RAG 系统落地时,最容易被低估的环节不是向量检索,也不是大模型选型,而是数据导入与解析。很多人一上来就调 embedding 模型、搭向量库,结果发现检索出来的内容驴唇不对马嘴,回头一查,原始文档在解析阶段就已经碎成了渣。我做过好几个 RAG 项目,踩过最多的坑几乎都集中在文档加载和结构化处理这一段。这篇就从最基础的 txt 和 Markdown 入手,把通用文本和结构化文本的导入解析逻辑讲透,后面再延伸到 PDF、Word、HTML 等格式就有了参照系。
1. 为什么 txt 和 Markdown 是 RAG 数据管道的起点
1.1 纯文本是检验解析链路的最小闭环
很多人觉得 txt 太简单,不值得花时间。但我的经验恰恰相反:txt 是验证整条 RAG 数据管道是否通畅的最佳试验品。它没有复杂的格式嵌套,没有二进制编码问题,没有表格和图片的干扰,如果连 txt 导入后检索效果都不好,那问题一定出在切分策略或 embedding 环节,而不是解析器本身。
从工程角度看,先用 txt 跑通"加载 → 切分 → 向量化 → 检索 → 生成"这条完整链路,相当于给自己建立了一个基线。后面接入 Markdown、PDF 时,任何效果波动都可以和这个基线对比,快速定位是解析引入的噪声还是检索本身的瓶颈。
LangChain 的Document对象是整个数据管道的基本单元,它只有两个核心字段:page_content存放文本内容,metadata存放来源、页码、标题等附加信息。理解这个结构非常关键,因为后续所有的切分、过滤、检索都围绕它展开。txt 加载器返回的就是最纯粹的Document列表,没有多余的元数据干扰,适合用来观察管道的基础行为。
1.2 Markdown 是结构化文本的天然试验场
Markdown 在 RAG 场景里的地位被严重低估了。它用极轻量的语法表达了标题层级、列表、代码块、表格、引用等结构信息,而这些结构恰恰是切分策略最需要的"语义边界信号"。
举个例子:一篇技术文档用##分隔不同主题,用###分隔子主题。如果切分器能识别这些标题层级,就可以按照语义单元切分,而不是机械地按字符数截断。按字符数截断最典型的翻车场景是:一个完整的代码示例被从中间切开,前半段在 chunk A,后半段在 chunk B,检索时只召回一半,大模型拿到的上下文残缺不全,生成的答案自然错漏百出。
Markdown 的另一个优势是它的纯文本本质。它不像 PDF 那样需要复杂的布局分析,也不像 Word 那样依赖二进制解析库,用 Python 直接读取就是完整内容。这意味着解析环节引入的噪声极低,可以把精力集中在切分策略的优化上。所以我的建议是:txt 用来验证管道通畅性,Markdown 用来打磨切分策略,这两个跑通了,再去碰 PDF 和 Word 会从容很多。
1.3 通用文本与结构化文本的边界在哪里
这里需要厘清一个概念:什么是"通用文本",什么是"结构化文本"。通用文本指的是没有显式层级标记的连续文本,比如小说、新闻稿、会议记录,段落之间只有换行,没有标题、列表这些语义标记。结构化文本则带有明确的组织标记,比如 Markdown 的标题符号、HTML 的标签、JSON 的键值对。
这个区分直接决定了切分策略的选择。通用文本只能依赖段落、句子边界和字符数来切分,而结构化文本可以利用其内在的层级结构做语义切分。很多 RAG 教程把这两类文本混在一起讲,导致读者不知道什么时候该用RecursiveCharacterTextSplitter,什么时候该用MarkdownHeaderTextSplitter。我的做法是:先判断文本有没有可识别的结构标记,有就用结构化切分器,没有就退回通用切分器,必要时两者组合使用。
2. LangChain Document 与 Loader 的协作机制
2.1 Document 对象的字段设计与元数据价值
Document对象看起来简单,但metadata字段的设计直接决定了后续检索的过滤能力和溯源能力。我在实际项目里会给每个Document至少塞进这几类元数据:
- 来源标识:文件路径或 URL,用于溯源和去重
- 标题层级:当前 chunk 所属的标题路径,比如"第三章 > 3.2 节 > 配置说明"
- 位置信息:在原始文档中的字符偏移或页码
- 时间戳:文档的创建或修改时间,用于时效性过滤
这些元数据在检索阶段能发挥大作用。比如用户问的是"最新版本的配置方法",你就可以在检索时加一个时间过滤条件,只召回最近半年的文档。又比如用户问的是某个具体章节的内容,你可以用标题路径做精确匹配。没有元数据的Document就是一坨没有出处的文本,检索效果和可维护性都会大打折扣。
需要特别注意的是,元数据的值必须是可序列化的基本类型(字符串、数字、布尔值),不能塞入复杂的嵌套对象。我见过有人把整个解析配置字典塞进 metadata,结果在向量库写入时报序列化错误。正确的做法是把关键信息扁平化,比如把{"config": {"model": "gpt", "temp": 0.7}}拆成config_model和config_temp两个独立字段。
2.2 Loader 的职责边界:只做加载,不做切分
这是新手最容易混淆的一点:Loader 只负责把原始文件读成Document列表,不负责切分。切分是 Splitter 的职责。LangChain 把这两个环节拆开是有道理的,因为加载和切分的策略是正交的:同一个 PDF 文件,你可以用不同的切分策略处理;同一种切分策略,也可以应用到不同格式的文件上。
以TextLoader为例,它的核心参数只有几个:
from langchain_community.document_loaders import TextLoader loader = TextLoader( file_path="./docs/example.txt", encoding="utf-8", autodetect_encoding=True ) documents = loader.load()encoding参数看似不起眼,实则是中文场景下最常见的翻车点。Windows 系统默认用 GBK 编码保存 txt,如果你不指定utf-8,读出来的就是乱码。autodetect_encoding=True可以让 LangChain 尝试自动检测编码,但这个检测不是百分百准确,尤其是短文本。我的建议是:能明确指定编码就明确指定,不要依赖自动检测。如果文档来源混杂,可以在加载前用chardet库先探测一遍编码。
load()和lazy_load()的区别也值得说一下。load()一次性把所有文档读进内存,返回一个列表;lazy_load()返回一个生成器,逐个产出Document。处理大批量文件时,lazy_load()能显著降低内存占用。我处理过一个包含上万个小文件的知识库,用load()直接把内存打满,换成lazy_load()后内存占用降到了原来的十分之一。
2.3 目录级批量加载与文件过滤策略
实际项目里很少只加载单个文件,更多是加载整个目录。DirectoryLoader就是干这个的,它支持用 glob 模式过滤文件类型:
from langchain_community.document_loaders import DirectoryLoader, TextLoader loader = DirectoryLoader( path="./knowledge_base", glob="**/*.md", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, recursive=True, show_progress=True, use_multithreading=True, max_concurrency=4 ) documents = loader.load()这里有几个参数值得展开说。glob="**/*.md"中的**表示递归匹配所有子目录,如果只写*.md就只匹配当前目录。loader_kwargs用来给底层的TextLoader传参,编码设置就是通过它传进去的。use_multithreading=True配合max_concurrency可以并行加载,但要注意:并行加载时如果底层 loader 不是线程安全的,可能会出问题。TextLoader是线程安全的,可以放心开多线程;但某些依赖外部服务的 loader 就不一定了。
还有一个坑:DirectoryLoader默认遇到加载失败的文件会直接抛异常,导致整个批量加载中断。生产环境里更稳妥的做法是设置silent_errors=True,让加载失败的文件被跳过并记录日志,而不是让整个任务崩掉。当然,跳过之后要记得检查日志,看看是哪些文件出了问题,不能放任不管。
3. 通用文本切分的参数计算与实测调优
3.1 chunk_size 与 chunk_overlap 的取值逻辑
切分参数没有万能值,但有一套推导逻辑。chunk_size决定了每个 chunk 的最大字符数,chunk_overlap决定了相邻 chunk 之间的重叠字符数。这两个参数直接影响检索粒度和上下文完整性。
先看chunk_size。它主要受两个因素制约:embedding 模型的最大输入长度和检索精度。大多数 embedding 模型支持 512 个 token 左右的输入,换算成中文大约是 300 到 400 个汉字。如果chunk_size设得太大,超出模型输入限制的部分会被截断,等于白切;设得太小,一个完整的语义单元被切碎,检索时召回的是残缺片段。
我的经验值是:中文文本 chunk_size 设在 300 到 500 字符之间,英文文本设在 500 到 1000 字符之间。这个范围是在检索精度和上下文完整性之间取的平衡。具体到某个项目,还要看文档的段落平均长度。如果文档段落普遍很短(比如 FAQ 问答对),chunk_size 可以设小一点,让每个 chunk 正好容纳一个完整问答;如果段落很长(比如技术白皮书),chunk_size 就要相应放大。
再看chunk_overlap。它的作用是防止语义在切分边界处断裂。比如一句话正好被切在中间,有了 overlap,前后两个 chunk 都能包含这句话的完整内容。overlap 的常见取值是chunk_size的 10% 到 20%。设得太小起不到保护作用,设得太大则会导致大量冗余内容被重复索引,浪费存储空间还降低检索效率。我一般从 15% 起步,然后根据实测效果微调。
| 文本类型 | chunk_size 建议值 | chunk_overlap 建议值 | 说明 |
|---|---|---|---|
| 中文技术文档 | 400 | 60 | 段落较长,需要较大 chunk |
| 中文 FAQ | 200 | 30 | 问答对短小,chunk 宜小 |
| 英文技术文档 | 800 | 120 | 英文 token 密度低于中文 |
| 中英混合 | 500 | 80 | 折中取值,兼顾两种语言 |
3.2 RecursiveCharacterTextSplitter 的分隔符优先级
RecursiveCharacterTextSplitter是通用文本切分的首选,它的核心思路是:按分隔符优先级从高到低依次尝试切分,直到每个 chunk 都小于 chunk_size。默认的分隔符列表是["\n\n", "\n", " ", ""],对应段落、行、空格、字符四个层级。
这个优先级设计的逻辑是:优先在语义边界处切分。段落边界(\n\n)是最强的语义边界,其次是行边界(\n),再次是空格,最后才是不管语义直接按字符切。中文场景下需要调整这个列表,因为中文句子之间用句号、问号、感叹号分隔,而不是空格:
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=60, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len, is_separator_regex=False ) chunks = splitter.split_documents(documents)把中文标点加入分隔符列表后,切分器会优先在句子边界处断开,而不是在句子中间硬切。这个改动对中文检索效果的提升非常明显,我实测下来召回内容的可读性提升了一个档次。
length_function参数默认是len,也就是按字符数计算长度。如果你用的是按 token 计费的 embedding 模型,可以换成 token 计数函数,让 chunk_size 直接对应 token 数。但要注意,token 计数需要加载 tokenizer,会增加一点计算开销。
3.3 切分效果的验证方法与常见问题
切分完不能直接往向量库里灌,必须先验证效果。我常用的验证方法有三种:
第一种是抽样目测。随机抽 10 到 20 个 chunk,看看它们是否语义完整、有没有被从中间切断。这个方法最直接,能发现大部分明显问题。
第二种是边界检查。专门看那些正好在 chunk_size 附近被切开的 chunk,检查切分点是否落在合理的位置。如果发现大量 chunk 在句子中间断开,说明分隔符列表需要调整。
第三种是检索回测。准备一批典型问题,跑一遍检索,看召回的 chunk 是否包含答案。这个方法最接近真实使用场景,但需要先有向量库和检索链路。
常见的切分问题有这么几类:chunk 过短导致语义不完整,通常是 chunk_size 设得太小或者分隔符过于激进;chunk 过长导致检索精度下降,通常是 chunk_size 设得太大;大量重复内容,通常是 chunk_overlap 设得太大。遇到这些问题,按"先调 chunk_size,再调 overlap,最后调分隔符"的顺序排查,基本都能解决。
提示:切分参数调优是个迭代过程,不要指望一次调好。建议把每次调整的参数和对应的检索效果记录下来,形成自己的参数经验库。
4. Markdown 结构化切分的层级利用
4.1 MarkdownHeaderTextSplitter 的工作原理
MarkdownHeaderTextSplitter的思路和通用切分器完全不同:它不按字符数切,而是按 Markdown 的标题层级切。你告诉它哪些标题级别需要保留,它就在这些标题处断开,并把标题内容写入每个 chunk 的 metadata。
from langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on = [ ("#", "h1"), ("##", "h2"), ("###", "h3"), ] markdown_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=headers_to_split_on, strip_headers=False ) with open("./docs/guide.md", "r", encoding="utf-8") as f: md_text = f.read() md_chunks = markdown_splitter.split_text(md_text)headers_to_split_on是一个元组列表,每个元组包含标题符号和对应的 metadata 键名。strip_headers=False表示保留标题文本在 chunk 内容里,设为True则会把标题从内容中移除、只保留在 metadata 里。我的建议是保留标题,因为标题本身携带了重要的语义信息,对 embedding 有正向帮助。
切分后每个 chunk 的 metadata 会长这样:
{ "h1": "RAG 数据导入指南", "h2": "Markdown 结构化切分", "h3": "标题层级利用" }这个 metadata 结构非常有用。检索时你可以根据标题路径做过滤,比如只检索某个章节下的内容;也可以在生成答案时把标题路径作为上下文提示给大模型,帮助它理解内容的归属。
4.2 标题层级与 chunk 粒度的映射关系
MarkdownHeaderTextSplitter有一个需要注意的行为:它只在标题处切分,不控制 chunk 大小。如果某个##标题下的内容特别长,切出来的 chunk 就会很大,可能超出 embedding 模型的输入限制。
解决这个问题需要两步走:先用MarkdownHeaderTextSplitter按标题切分,再用RecursiveCharacterTextSplitter对过大的 chunk 做二次切分。LangChain 官方推荐的做法是:
from langchain.text_splitter import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=60, separators=["\n\n", "\n", "。", " ", ""] ) final_chunks = text_splitter.split_documents(md_chunks)这样既保留了标题层级的语义边界,又保证了每个 chunk 的大小可控。二次切分时,md_chunks里每个 chunk 的 metadata 会被继承到切分后的子 chunk 上,标题路径信息不会丢失。
标题层级和 chunk 粒度的映射关系可以这样理解:#一级标题通常对应文档主题,粒度太粗,一般不用来切分;##二级标题对应主要章节,是切分的主力层级;###三级标题对应子章节,适合内容较细的文档。如果文档层级很深,切到###就够了,再往下切会导致 chunk 过碎。
4.3 代码块、表格、引用块的特殊处理
Markdown 里的代码块、表格、引用块是切分时的"雷区"。这些结构对完整性要求很高,一旦被切开就失去意义。
代码块用三个反引号包裹,RecursiveCharacterTextSplitter默认的分隔符列表里没有反引号,所以它可能会在代码块中间断开。解决办法是在分隔符列表里加入代码块标记,或者用正则表达式先把代码块提取出来单独处理。我通常的做法是:在切分前用正则把代码块替换成占位符,切分完再把代码块填回去,这样能保证代码块的完整性。
表格的问题类似。Markdown 表格用|分隔单元格,用---分隔表头和表体。如果表格被切开,检索出来的就是残缺的表格,大模型无法正确理解。对于表格,我建议把整个表格作为一个独立的 chunk,不要切分。如果表格特别大,可以按行切分,但每一行都要带上表头信息,否则数据就失去了列的含义。
引用块用>标记,通常是对正文的补充说明。引用块被切开的影响相对小一些,但最好也保持完整。可以在分隔符列表里加入\n>来识别引用块边界。
import re def protect_code_blocks(text): code_blocks = [] pattern = r'```[\s\S]*?```' def replace(match): code_blocks.append(match.group(0)) return f"__CODE_BLOCK_{len(code_blocks)-1}__" protected_text = re.sub(pattern, replace, text) return protected_text, code_blocks def restore_code_blocks(text, code_blocks): for i, block in enumerate(code_blocks): text = text.replace(f"__CODE_BLOCK_{i}__", block) return text这段代码展示了代码块保护的基本思路。实际使用时,占位符要足够独特,避免和正文内容冲突。切分完成后,再把占位符替换回原始代码块。
5. 从加载到入库的完整链路实操
5.1 环境准备与依赖安装
先把环境搭起来。Python 版本建议 3.9 以上,LangChain 的版本迭代很快,不同版本 API 有差异,建议锁定版本:
pip install langchain==0.1.0 pip install langchain-community==0.0.10 pip install chromadb==0.4.22 pip install sentence-transformers==2.2.2这里选了 Chroma 作为向量库,因为它轻量、本地可跑、和 LangChain 集成度高,适合做原型验证。embedding 模型用sentence-transformers的本地模型,不依赖外部 API,离线也能跑。如果追求更好的中文效果,可以换成BAAI/bge-large-zh-v1.5这类中文优化的模型。
注意:LangChain 的包结构在 0.1 版本后做了拆分,很多 loader 和 splitter 从主包移到了
langchain-community。如果导入报错,先检查是不是包路径变了。
5.2 加载、切分、向量化的串联代码
把前面讲的环节串起来,形成一个完整的处理函数:
from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain.text_splitter import ( RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter ) from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import Chroma def build_knowledge_base(source_dir, persist_dir): # 第一步:加载所有 Markdown 文件 loader = DirectoryLoader( path=source_dir, glob="**/*.md", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, recursive=True, silent_errors=True, show_progress=True ) raw_docs = loader.load() print(f"加载了 {len(raw_docs)} 个文档") # 第二步:按标题层级切分 headers_to_split_on = [ ("#", "h1"), ("##", "h2"), ("###", "h3"), ] md_splitter = MarkdownHeaderTextSplitter( headers_to_split_on=headers_to_split_on, strip_headers=False ) md_chunks = [] for doc in raw_docs: chunks = md_splitter.split_text(doc.page_content) for chunk in chunks: chunk.metadata["source"] = doc.metadata.get("source", "unknown") md_chunks.extend(chunks) print(f"标题切分后得到 {len(md_chunks)} 个片段") # 第三步:二次切分控制大小 text_splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=60, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] ) final_chunks = text_splitter.split_documents(md_chunks) print(f"二次切分后得到 {len(final_chunks)} 个片段") # 第四步:向量化并入库 embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True} ) vectorstore = Chroma.from_documents( documents=final_chunks, embedding=embeddings, persist_directory=persist_dir ) vectorstore.persist() print(f"向量库已持久化到 {persist_dir}") return vectorstore vectorstore = build_knowledge_base("./knowledge_base", "./chroma_db")这段代码把加载、切分、向量化、入库四个环节串成了一条流水线。每一步都有打印输出,方便观察中间结果。实际项目里可以把这些打印换成日志记录,便于排查问题。
5.3 入库后的检索验证与效果评估
向量库建好后,必须做检索验证。准备几个典型问题,看召回的 chunk 是否包含答案:
def test_retrieval(vectorstore, query, k=3): results = vectorstore.similarity_search_with_score(query, k=k) for i, (doc, score) in enumerate(results): print(f"--- 结果 {i+1} (相似度: {score:.4f}) ---") print(f"来源: {doc.metadata.get('source', 'unknown')}") print(f"标题路径: {doc.metadata.get('h1', '')} > {doc.metadata.get('h2', '')} > {doc.metadata.get('h3', '')}") print(f"内容: {doc.page_content[:200]}...") print() test_retrieval(vectorstore, "Markdown 代码块怎么处理")评估检索效果时,我关注三个指标:召回率(相关 chunk 有没有被召回)、准确率(召回的 chunk 有多少是相关的)、排序质量(最相关的 chunk 是不是排在前面)。如果召回率低,说明切分或 embedding 有问题;如果准确率低,说明 chunk 里混入了太多无关内容;如果排序质量差,说明 embedding 模型对这类语义的区分度不够。
相似度分数也值得关注。Chroma 默认用余弦距离,分数越低表示越相似。如果所有结果的分数都很接近,说明 embedding 模型没能很好地区分这些内容,可能需要换模型或者调整切分粒度。
6. 实操中踩过的坑与经验沉淀
6.1 编码问题导致的乱码与内容丢失
编码问题是中文 RAG 项目里最高频的坑。我遇到过一次:一批从 Windows 系统导出的 txt 文件,用TextLoader默认参数加载后,中文全部变成乱码。原因是这些文件用 GBK 编码保存,而TextLoader默认用 UTF-8 读取。
解决办法有两种:一是加载时显式指定encoding="gbk",二是用chardet先探测编码再加载。第二种更通用:
import chardet def detect_encoding(file_path): with open(file_path, "rb") as f: raw = f.read(10000) result = chardet.detect(raw) return result["encoding"] encoding = detect_encoding("./docs/example.txt") loader = TextLoader("./docs/example.txt", encoding=encoding)chardet的探测不是百分百准确,尤其是短文本。所以我的做法是:探测结果作为参考,如果探测置信度低于 0.8,就手动检查一下。另外,有些文件可能混合了多种编码,这种情况只能人工处理,没有通用解法。
还有一个隐蔽的坑:BOM 头。有些 UTF-8 文件开头带有 BOM 标记,读取后会在内容最前面多出一个不可见字符。这个字符会干扰 embedding,导致检索效果下降。解决办法是用utf-8-sig编码读取,Python 会自动去掉 BOM 头。
6.2 切分边界处的语义断裂修复
语义断裂是切分环节最头疼的问题。我遇到过一个典型案例:一份 API 文档里,某个接口的参数说明被切成了两个 chunk,第一个 chunk 只有参数名,第二个 chunk 只有参数说明,检索时只召回其中一个,大模型拿到的信息不完整,生成的答案就漏了参数。
修复这类问题,我总结了几个手段。第一个是增大 chunk_overlap,让相邻 chunk 有更多重叠内容,降低断裂概率。第二个是优化分隔符列表,把文档中常见的语义边界符号加进去。第三个是在 chunk 内容前拼接标题路径,让每个 chunk 都带上上下文信息:
def enrich_chunk_with_context(chunk): h1 = chunk.metadata.get("h1", "") h2 = chunk.metadata.get("h2", "") h3 = chunk.metadata.get("h3", "") context_parts = [p for p in [h1, h2, h3] if p] if context_parts: context = " > ".join(context_parts) chunk.page_content = f"[{context}]\n{chunk.page_content}" return chunk final_chunks = [enrich_chunk_with_context(c) for c in final_chunks]这个做法相当于给每个 chunk 加了一个"上下文标签",embedding 时会把这个标签一起编码进去,检索时就能利用标题信息做语义匹配。实测下来,这个改动对检索准确率的提升很明显,尤其是当用户的问题涉及具体章节时。
6.3 大批量文件处理时的内存与性能优化
处理大批量文件时,内存和性能是两个绕不开的问题。我处理过一个包含 5 万多个 Markdown 文件的知识库,一开始用load()一次性加载,内存直接飙到 8GB 以上,机器差点扛不住。
优化手段有这么几个。第一,用lazy_load()替代load(),逐个处理文件,内存占用降到几百 MB。第二,分批向量化,每处理 1000 个 chunk 就写入一次向量库,而不是全部处理完再一次性写入。第三,用多进程而非多线程做 CPU 密集型的切分和向量化,Python 的 GIL 会限制多线程在 CPU 密集型任务上的表现。
def batch_process(source_dir, batch_size=1000): loader = DirectoryLoader( path=source_dir, glob="**/*.md", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, recursive=True, silent_errors=True ) batch = [] for doc in loader.lazy_load(): chunks = process_single_doc(doc) batch.extend(chunks) if len(batch) >= batch_size: yield batch batch = [] if batch: yield batch这个生成器模式让内存占用始终保持在可控范围内。配合向量库的增量写入,整个处理过程就变得很稳。
提示:向量化是计算密集型任务,如果机器有 GPU,把 embedding 模型放到 GPU 上能提速十倍以上。没有 GPU 的话,考虑用更小的模型或者降低向量维度。
6.4 元数据丢失与溯源断链的预防
元数据丢失是另一个高频问题。最常见的情况是:加载时 metadata 里有source字段,经过几轮切分后,source字段不见了。原因是某些切分器在创建新Document时没有继承原始 metadata。
预防这个问题,我的做法是在每个处理环节后都检查一遍 metadata 完整性:
def check_metadata_integrity(chunks, required_keys): missing = [] for i, chunk in enumerate(chunks): for key in required_keys: if key not in chunk.metadata: missing.append((i, key)) if missing: print(f"发现 {len(missing)} 处元数据缺失") for idx, key in missing[:10]: print(f" chunk {idx} 缺少 {key}") else: print("元数据完整性检查通过") return missing check_metadata_integrity(final_chunks, ["source", "h1"])如果发现缺失,就在切分后手动补上。比如MarkdownHeaderTextSplitter切分后,原始source字段会丢失,需要手动从原始Document复制过来。这个补丁看起来麻烦,但能保证溯源链路不断,后期排查问题时能快速定位到原始文件。
溯源能力在 RAG 系统里非常重要。用户问了一个问题,系统给出了答案,如果用户追问"这个答案是从哪来的",你得能准确指出源文件和具体位置。没有完整的元数据,这个功能就无从谈起。
7. 从 txt 和 Markdown 延伸到其他格式的思路
txt 和 Markdown 跑通之后,接入其他格式就是替换 Loader 和调整切分策略的事。PDF 用PyPDFLoader或PDFPlumberLoader,Word 用Docx2txtLoader,HTML 用UnstructuredHTMLLoader。核心逻辑不变:加载成Document,按结构切分,向量化入库。
不同格式的特殊处理点在于:PDF 需要处理扫描件 OCR 和表格提取,Word 需要处理样式和批注,HTML 需要处理标签嵌套和导航栏噪声。这些内容展开讲篇幅太长,但只要你把 txt 和 Markdown 这条基础链路吃透了,理解这些格式的处理逻辑会快很多。
我在实际项目里的体会是:数据导入与解析环节投入的时间,和最终 RAG 系统的效果成正比。很多人急着调模型、换向量库,却在这个基础环节偷工减料,结果就是上层怎么调都调不好。把 txt 和 Markdown 这两个最简单的格式做到极致,建立起可靠的切分和元数据管理习惯,后面的复杂格式才有稳固的地基。