☰
RAG 文本导入实战:LangChain 解析 txt 与 Markdown 文档
2026/10/5 8:35:05 网站建设 项目流程

1. 为什么文本导入是 RAG 系统的第一道生死关

做过 RAG 项目的人都有一个共识:检索效果差,八成不是模型的问题,而是数据导入环节就已经埋了雷。我见过太多团队花大力气调 embedding 模型、换 rerank 策略、折腾向量数据库参数,最后发现原始文档在 Loader 阶段就被切得七零八落,表格变成乱码,标题层级全丢,检索出来的 chunk 驴唇不对马嘴。这不是模型不行,是喂进去的料本身就是碎的。

RAG 的全称是 Retrieval-Augmented Generation,检索增强生成。整个链路可以粗暴地分成两段:离线阶段的数据导入与索引,和在线阶段的检索与生成。绝大多数教程和分享都盯着后半段,讲怎么调 prompt、怎么选向量库、怎么做混合检索,但真正决定系统上限的,是前半段——你的文档是怎么被读进来、怎么被解析、怎么被切分的。这部分做不好,后面全是空中楼阁。

这篇要聊的,就是离线阶段最基础也最容易被轻视的一环:通用文本与结构化文档的导入与解析。具体来说,是从最朴素的 txt 文件,到带层级结构的 Markdown 文件,怎么用 LangChain 的 Document Loader 体系把它们干净地读进来,怎么处理编码、换行、元数据、结构保留这些细节问题。适合正在搭 RAG 知识库的开发者、需要批量处理文档的数据工程师,以及任何想让自己的 RAG 系统"下地干活"而不是停留在 demo 阶段的人。

LangChain 在这个环节提供的核心抽象是Document Loader和Document 对象。Document 是 LangChain 里表示一段文本及其元数据的基本单元,它有两个核心字段:page_content存文本内容,metadata存来源、页码、标题路径等附加信息。Loader 负责把各种格式的文件转成 Document 列表。这个设计看起来简单,但用好用透需要理解不少细节。下面我会从整体设计思路讲起,然后逐个拆解 txt 和 Markdown 的解析要点,再给出可直接复现的实操流程和踩坑记录。

2. 数据导入的整体设计与 Loader 选型思路

2.1 先想清楚:你的文档到底长什么样

在动手写代码之前,我强烈建议先做一件事:把你手头所有要入库的文档类型列一张清单。别急着打开编辑器,先分类。常见的文档类型大致可以分成这么几档:

  • 纯文本类:txt、log、csv 里的文本字段
  • 轻量结构化类:Markdown、HTML、JSON、YAML
  • 办公文档类:Word、PDF、PPT、Excel
  • 扫描件与图片类:需要 OCR 的 PDF、图片
  • 代码与配置文件类:各种源码、配置文件

为什么要先分类?因为不同类型的文档,解析策略完全不同。纯文本你只需要关心编码和分段;Markdown 你要关心标题层级和代码块;PDF 你要关心是文本层还是扫描件;Word 你要关心表格和样式。如果一上来就无脑用UnstructuredFileLoader一把梭,结果往往是该保留的结构没保留,该拆的地方没拆开。

我个人的经验是:能用结构化 Loader 就别用通用 Loader。LangChain 提供了针对 Markdown、HTML、JSON 的专用 Loader,它们能识别文档本身的结构信息,把这些信息写进 metadata,这对后续的切分和检索帮助极大。通用 Loader 虽然省事,但它把文档当成一坨纯文本,结构信息全丢了。

2.2 Document 对象:RAG 里的"集装箱"

理解 Document 对象是理解整个导入环节的钥匙。你可以把它想象成物流里的标准集装箱——不管里面装的是衣服还是电器,外面都是统一规格的箱子,方便后续的运输和分拣。

from langchain_core.documents import Document doc = Document( page_content="这是正文内容", metadata={ "source": "docs/intro.md", "title_path": ["第一章", "1.1 概述"], "page": 1, } )

page_content是真正会被 embedding 和检索的文本,metadata是附加信息。这里有个关键点很多人忽略:metadata 不只是给检索结果展示用的,它还能参与过滤和重排。比如你可以用 metadata 里的source字段做来源过滤,用title_path做上下文补全,用page做引用定位。所以在 Loader 阶段就把 metadata 填好,后面能省很多事。

2.3 Loader 选型的三个判断维度

面对一个文档,我通常按三个维度决定用什么 Loader:

判断维度问题影响
格式是否结构化文档本身有没有明确的层级、段落、表格结构决定用专用 Loader 还是通用 Loader
是否需要保留结构检索时是否需要知道"这段话属于哪个标题下"决定是否要解析标题路径
体量大小单文件是几 KB 还是几百 MB决定用一次性加载还是懒加载

举个具体例子:一份 200 页的产品手册 PDF,如果它有清晰的目录和标题层级,我会优先考虑用能提取结构的方案,把标题路径写进 metadata;如果它是一份扫描件,那就得先走 OCR,再按段落切分。而一份 5KB 的 README.md,直接用UnstructuredMarkdownLoader就够了,没必要上重型方案。

2.4 为什么从 txt 和 Markdown 讲起

有人可能会问,现在文档格式这么多,为什么偏偏从 txt 和 Markdown 开始讲?原因很实在:这两种格式是理解整个导入体系的最佳切入点。txt 是最简单的纯文本,没有任何结构,正好用来讲清楚编码、换行、分段这些基础问题;Markdown 是"轻量结构化"的典型代表,它用极简的语法表达了标题、列表、代码块、表格等结构,正好用来讲清楚结构解析和 metadata 提取。把这两个搞明白了,再去看 PDF、Word 的解析,思路是相通的,只是多了格式转换的复杂度。

而且在实际项目里,txt 和 Markdown 的占比往往被低估。技术文档、内部 wiki、代码仓库里的说明文件,大量都是这两种格式。把它们处理好,RAG 知识库的基础质量就有了保障。

3. txt 文本导入:看似简单,坑都在细节里

3.1 编码问题:第一道坎

txt 文件最大的坑就是编码。中文环境下,你永远不知道一个 txt 文件到底是 UTF-8、GBK、GB2312 还是 GB18030。用错编码读进来,轻则乱码,重则直接抛异常。

LangChain 的TextLoader默认用 UTF-8 读取,遇到非 UTF-8 文件就会报UnicodeDecodeError。我踩过的坑是:一批从老系统导出的 txt 文件,混着 UTF-8 和 GBK 两种编码,直接批量读的时候一半成功一半失败。

解决办法是先探测编码,再指定编码读取。可以用chardet库做编码探测:

import chardet def detect_encoding(file_path): with open(file_path, "rb") as f: raw = f.read(10000) # 读前 10KB 足够判断 result = chardet.detect(raw) return result["encoding"] encoding = detect_encoding("data/legacy.txt") print(f"探测到的编码: {encoding}")

探测出来之后,传给TextLoader的encoding参数:

from langchain_community.document_loaders import TextLoader loader = TextLoader("data/legacy.txt", encoding="gbk") docs = loader.load()

注意:chardet对短文本的探测准确率有限,如果文件很短(比如几百字节),探测结果可能不准。这种情况建议手动确认,或者用charset-normalizer这个更现代的替代库,它对中文编码的识别更稳。

还有一个细节:有些 txt 文件带 BOM 头(字节顺序标记),尤其是 Windows 记事本保存的 UTF-8 文件。BOM 会在文本开头插入一个不可见字符,导致第一个 chunk 的内容莫名其妙多出一个\ufeff。解决办法是用utf-8-sig编码读取,它会自动去掉 BOM:

loader = TextLoader("data/notepad_saved.txt", encoding="utf-8-sig")

3.2 换行符:跨平台的隐形杀手

换行符的问题同样隐蔽。Windows 用\r\n,Linux 和 macOS 用\n,老 Mac 用\r。如果文件在不同系统间流转过,可能混着好几种换行符。

这对 RAG 的影响是什么?切分的时候,如果你的分隔符只认\n,那\r\n就会被当成一个普通字符留在文本里,导致 chunk 里出现多余的\r,影响 embedding 质量,也会让检索出来的文本看起来怪怪的。

我的处理习惯是在 Loader 之后、切分之前,统一做一次换行符归一化:

def normalize_newlines(text): return text.replace("\r\n", "\n").replace("\r", "\n") docs = loader.load() for doc in docs: doc.page_content = normalize_newlines(doc.page_content)

这一步看起来微不足道,但实测下来,对检索结果的整洁度提升很明显。尤其是从 Windows 环境批量导入的文档,不做这一步后面会一直膈应。

3.3 大文件处理:别一次性全读进内存

TextLoader默认是一次性把整个文件读进内存。对于几 KB 到几 MB 的文件,这没问题。但如果你要处理的是几百 MB 的日志文件或者超长文本,一次性加载会直接把内存打爆。

LangChain 提供了TextLoader的懒加载模式,通过lazy_load()方法逐行或分块读取:

loader = TextLoader("data/huge_log.txt", encoding="utf-8") for doc in loader.lazy_load(): # 逐条处理,不用一次性全加载 process(doc)

不过要注意,lazy_load()对TextLoader来说仍然是按整个文件返回一个 Document,只是延迟了加载时机。如果你需要真正的大文件分块读取,得自己写一个生成器,或者用DirectoryLoader配合文件级别的切分。

我处理超大文本的经验是:先在文件层面切分,再进 Loader。比如一个 500MB 的日志,先按天或按大小切成若干个小文件,再用DirectoryLoader批量加载。这样既避免了内存问题,也方便后续做增量更新。

3.4 元数据补充:别让来源信息丢了

TextLoader默认只会往 metadata 里塞一个source字段,值是文件路径。这远远不够。实际项目里,我通常会在 Loader 之后手动补充元数据:

import os from datetime import datetime docs = loader.load() for doc in docs: doc.metadata.update({ "file_name": os.path.basename(doc.metadata["source"]), "file_type": "txt", "load_time": datetime.now().isoformat(), "category": "技术文档", # 根据业务自定义 })

这些元数据在后面做检索过滤、结果展示、增量更新时都会派上用场。尤其是category这种业务标签,越早打上越好,等到入库后再补就麻烦了。

3.5 空行与空白字符的清理

txt 文件里经常有连续空行、行尾空格、制表符混用的情况。这些噪声如果不清理,会稀释 embedding 的信息密度。我的做法是在 Loader 之后做一轮轻量清洗:

import re def clean_text(text): # 多个连续空行压成一个 text = re.sub(r"\n{3,}", "\n\n", text) # 去掉行尾空格 text = re.sub(r"[ \t]+\n", "\n", text) # 去掉首尾空白 return text.strip() for doc in docs: doc.page_content = clean_text(doc.page_content)

提示:清洗要适度。有些文档里的缩进和空行是有语义的(比如代码块、诗歌),过度清洗会破坏原意。我的原则是只清理"明显是噪声"的部分,比如三个以上连续空行、行尾多余空格,其余保持原样。

4. Markdown 解析:把结构信息榨干

4.1 为什么 Markdown 值得单独对待

Markdown 是 RAG 知识库里的"优质食材"。它用极简的语法表达了丰富的结构:#表示标题层级,-和1.表示列表,``` 包裹代码块,|表示表格,>表示引用。这些结构信息如果能在导入阶段提取出来,对检索质量的提升是立竿见影的。

举个场景:用户问"怎么配置数据库连接",如果你的 chunk 里带着title_path: ["部署指南", "数据库配置"]这样的元数据,检索时就能精准命中相关章节,而不是从一堆无关段落里碰运气。这就是结构信息的价值。

LangChain 提供了UnstructuredMarkdownLoader,它底层依赖unstructured库来解析 Markdown。但说实话,这个 Loader 的默认行为有时候不够理想——它会把 Markdown 拆成一个个元素(标题、段落、列表项),每个元素变成一个 Document,粒度偏细,而且标题层级信息需要额外处理才能串起来。

4.2 标题层级提取:自己动手更可控

我试过几种方案后,最终倾向于自己写一个轻量的 Markdown 解析器,专门用来提取标题路径。原因很简单:需求明确,逻辑不复杂,自己写反而更可控。

核心思路是逐行扫描,维护一个标题栈:

import re def extract_markdown_structure(text): """把 Markdown 按标题层级切成带 title_path 的块""" lines = text.split("\n") chunks = [] title_stack = [] # 维护当前标题路径 current_content = [] header_pattern = re.compile(r"^(#{1,6})\s+(.+)$") def flush(): if current_content: content = "\n".join(current_content).strip() if content: chunks.append({ "content": content, "title_path": list(title_stack), }) current_content.clear() for line in lines: match = header_pattern.match(line) if match: flush() level = len(match.group(1)) title = match.group(2).strip() # 调整标题栈:弹出比当前层级深的 title_stack = title_stack[:level - 1] title_stack.append(title) else: current_content.append(line) flush() return chunks

这段代码的逻辑是:遇到标题就先把之前累积的内容"结算"成一个 chunk,然后更新标题栈;遇到普通内容就累积起来。最终每个 chunk 都带着它所属的完整标题路径。

实测下来,这个方案对标准 Markdown 文档的解析准确率很高,而且逻辑透明,出问题好排查。比依赖第三方库的黑盒行为要踏实。

4.3 代码块保护:别让代码被切碎

Markdown 里的代码块是个特殊存在。它用 ``` 包裹,内部可能包含任意字符,包括看起来像标题的#。如果你在解析时不做保护,代码块里的# 这是注释会被误判成一级标题,整个结构就乱了。

处理办法是在扫描时维护一个"是否在代码块内"的状态:

def extract_markdown_structure_safe(text): lines = text.split("\n") chunks = [] title_stack = [] current_content = [] in_code_block = False header_pattern = re.compile(r"^(#{1,6})\s+(.+)$") code_fence_pattern = re.compile(r"^```") def flush(): if current_content: content = "\n".join(current_content).strip() if content: chunks.append({ "content": content, "title_path": list(title_stack), }) current_content.clear() for line in lines: if code_fence_pattern.match(line): in_code_block = not in_code_block current_content.append(line) continue if not in_code_block: match = header_pattern.match(line) if match: flush() level = len(match.group(1)) title = match.group(2).strip() title_stack = title_stack[:level - 1] title_stack.append(title) continue current_content.append(line) flush() return chunks

这个版本加了in_code_block状态,代码块内的内容一律当普通文本处理,不会被误判成标题。这个细节看起来小,但处理技术文档时非常关键——技术文档里代码块占比很高,不保护的话结构全乱。

4.4 表格与列表的处理策略

Markdown 表格和列表在 RAG 里是个老大难。表格的二维结构在纯文本里很难保留,列表的层级关系也容易丢。

我的处理策略分两种情况:

表格:如果表格不大(比如 10 行以内),我会把整个表格作为一个 chunk,保留 Markdown 原始格式。embedding 模型对 Markdown 表格的语法有一定理解能力,保留原格式比强行转成自然语言效果好。如果表格很大,就按行拆,但每行都要带上表头,否则单行数据没有意义。

列表:列表项通常比较短,单独成 chunk 信息量不足。我的做法是把同一个标题下的列表项合并成一个 chunk,保留列表的层级缩进。这样既保证了信息密度,又保留了结构。

def merge_short_chunks(chunks, min_length=100): """把过短的 chunk 合并到相邻 chunk""" merged = [] buffer = None for chunk in chunks: if buffer is None: buffer = chunk elif len(buffer["content"]) < min_length: buffer["content"] += "\n\n" + chunk["content"] else: merged.append(buffer) buffer = chunk if buffer: merged.append(buffer) return merged

注意:合并 chunk 时要小心不要跨越标题边界。如果两个 chunk 的title_path不同,强行合并会让元数据失真。上面的简化版没做这个检查,实际用的时候要加上。

4.5 用 UnstructuredMarkdownLoader 的注意事项

如果你不想自己写解析器,用UnstructuredMarkdownLoader也行,但有几个点要注意:

from langchain_community.document_loaders import UnstructuredMarkdownLoader loader = UnstructuredMarkdownLoader( "docs/guide.md", mode="single", # single 返回单个 Document,elements 返回元素列表 ) docs = loader.load()

mode="single"会把整个文档作为一个 Document 返回,mode="elements"会拆成元素列表。我一般用elements模式,然后自己根据元素类型(Title、NarrativeText、ListItem 等)做二次组装。这样比single模式灵活,比完全自己写省事。

但unstructured库有个问题:它对中文 Markdown 的支持不如英文,有时候会把中文标题识别成普通文本。如果你的文档以中文为主,我建议还是自己写解析器,或者用markdown-it-py这类专门的 Markdown 解析库,它对语法的解析更规范。

5. 完整实操流程:从文件到可入库的 Document

5.1 环境准备与依赖安装

先把环境搭起来。我用的 Python 版本是 3.10,LangChain 生态更新很快,建议用较新的版本:

pip install langchain langchain-community langchain-core pip install chardet charset-normalizer pip install markdown-it-py

如果你要用UnstructuredMarkdownLoader,还需要装unstructured:

pip install unstructured markdown

提示:unstructured的依赖比较重,装的时候可能会拉一堆东西。如果只是处理 Markdown,其实用markdown-it-py就够了,没必要上unstructured。

5.2 目录结构设计

我习惯把数据导入相关的代码组织成这样的结构:

rag-ingest/ ├── loaders/ │ ├── txt_loader.py # txt 加载与清洗 │ └── md_loader.py # Markdown 解析 ├── utils/ │ ├── encoding.py # 编码探测 │ └── text_clean.py # 文本清洗 ├── data/ │ ├── raw/ # 原始文件 │ └── processed/ # 处理后的中间结果 └── main.py # 入口

这样分层的好处是:Loader 逻辑和清洗逻辑解耦,换格式的时候不用动清洗代码,改清洗规则的时候也不用碰 Loader。

5.3 txt 加载的完整实现

把前面讲的点串起来,一个完整的 txt 加载函数长这样:

import os import re import chardet from datetime import datetime from langchain_community.document_loaders import TextLoader def detect_encoding(file_path, sample_size=10000): with open(file_path, "rb") as f: raw = f.read(sample_size) result = chardet.detect(raw) return result["encoding"] or "utf-8" def clean_text(text): text = text.replace("\r\n", "\n").replace("\r", "\n") text = re.sub(r"\n{3,}", "\n\n", text) text = re.sub(r"[ \t]+\n", "\n", text) return text.strip() def load_txt(file_path, category="default"): encoding = detect_encoding(file_path) # BOM 处理 if encoding and encoding.lower() == "utf-8": with open(file_path, "rb") as f: if f.read(3) == b"\xef\xbb\xbf": encoding = "utf-8-sig" loader = TextLoader(file_path, encoding=encoding) docs = loader.load() for doc in docs: doc.page_content = clean_text(doc.page_content) doc.metadata.update({ "file_name": os.path.basename(file_path), "file_type": "txt", "encoding": encoding, "category": category, "load_time": datetime.now().isoformat(), }) return docs

这个函数处理了编码探测、BOM、换行归一化、空白清理、元数据补充,基本覆盖了 txt 导入的所有常见问题。

5.4 Markdown 加载的完整实现

Markdown 的完整实现,把结构提取和 Document 组装串起来:

import os import re from datetime import datetime from langchain_core.documents import Document def parse_markdown(text): lines = text.split("\n") chunks = [] title_stack = [] current_content = [] in_code_block = False header_pattern = re.compile(r"^(#{1,6})\s+(.+)$") code_fence_pattern = re.compile(r"^```") def flush(): if current_content: content = "\n".join(current_content).strip() if content: chunks.append({ "content": content, "title_path": list(title_stack), }) current_content.clear() for line in lines: if code_fence_pattern.match(line): in_code_block = not in_code_block current_content.append(line) continue if not in_code_block: match = header_pattern.match(line) if match: flush() level = len(match.group(1)) title = match.group(2).strip() title_stack = title_stack[:level - 1] title_stack.append(title) continue current_content.append(line) flush() return chunks def load_markdown(file_path, category="default"): with open(file_path, "r", encoding="utf-8") as f: text = f.read() chunks = parse_markdown(text) docs = [] for i, chunk in enumerate(chunks): doc = Document( page_content=chunk["content"], metadata={ "source": file_path, "file_name": os.path.basename(file_path), "file_type": "markdown", "title_path": chunk["title_path"], "title_str": " > ".join(chunk["title_path"]), "chunk_index": i, "category": category, "load_time": datetime.now().isoformat(), } ) docs.append(doc) return docs

注意title_str这个字段,它是把标题路径用>拼起来的字符串。这个字段在检索结果展示和上下文补全时特别好用——你可以直接把它拼到 chunk 内容前面,给 embedding 模型提供额外的上下文信息。

5.5 批量处理与增量更新

实际项目里,文档是批量来的,而且会不断更新。我通常用DirectoryLoader做批量加载,配合文件哈希做增量判断:

import hashlib from langchain_community.document_loaders import DirectoryLoader def file_hash(file_path): h = hashlib.md5() with open(file_path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest() def load_directory(dir_path, glob_pattern="**/*.md"): loader = DirectoryLoader( dir_path, glob=glob_pattern, loader_cls=None, # 自定义处理 use_multithreading=True, ) # 实际用的时候,遍历文件自己调 load_markdown ...

增量更新的核心是:记录每个文件的哈希值,只有哈希变了才重新解析。这样避免每次全量重跑,节省时间和算力。哈希值可以存在一个简单的 JSON 文件里,或者存到数据库。

5.6 处理结果验证

导入完成后,一定要做验证。我通常检查这几项:

检查项方法合格标准
文档数量统计 Document 总数与源文件数量匹配
空内容检查 page_content 为空的应该为 0
元数据完整性检查关键字段是否存在source、file_type 必须有
标题路径抽查 Markdown 的 title_path层级正确,无缺失
编码正确性抽查中文内容无乱码
def validate_docs(docs): issues = [] for i, doc in enumerate(docs): if not doc.page_content.strip(): issues.append(f"第 {i} 个文档内容为空") if "source" not in doc.metadata: issues.append(f"第 {i} 个文档缺少 source") if "file_type" not in doc.metadata: issues.append(f"第 {i} 个文档缺少 file_type") return issues

这一步花不了几分钟,但能提前发现 90% 的导入问题,避免脏数据进库后再回头排查。

6. 常见问题与排查技巧实录

6.1 编码乱码问题速查

编码问题是最高频的。整理成速查表:

现象可能原因解决办法
中文全是问号用 UTF-8 读了 GBK 文件探测编码后指定
开头多出奇怪字符BOM 头未处理用 utf-8-sig 读取
部分字符乱码文件混合编码分段探测,或统一转码
读取直接报错编码完全不匹配用 errors="replace" 容错

提示:TextLoader的encoding参数如果传错,不会自动降级,会直接抛异常。批量处理时建议加 try-except,把失败的文件单独记录,不要因为一个文件失败中断整批。

6.2 Markdown 结构解析的典型坑

坑一:标题里有特殊字符。比如## 1.1 配置 [重要],方括号在正则里是特殊字符,如果标题提取的正则没处理好,可能匹配失败。我的做法是标题提取用宽松匹配,只认#开头的行,后面的内容原样保留。

坑二:Setext 风格标题。Markdown 除了#风格,还有用===和---下划线表示的标题。这种在技术文档里不常见,但遇到了会漏解析。如果需要支持,得额外加逻辑判断。

坑三:HTML 混入。有些 Markdown 里嵌了 HTML 标签,比如<br>、<div>。这些标签在纯文本检索里是噪声,建议在清洗阶段去掉,或者转成对应的 Markdown 语法。

坑四:代码块语言标识。```python 这种带语言标识的代码块,解析时要注意别把语言标识当成内容。我的处理是保留整个代码块原样,包括语言标识,因为 embedding 模型能从中获取"这是代码"的信号。

6.3 大文件与性能问题

处理大批量文档时,性能问题会冒出来。几个实测有效的优化点:

  • 多线程加载:DirectoryLoader的use_multithreading=True能显著加速,但要注意线程安全,尤其是共享的元数据字典。
  • 懒加载:能用lazy_load()就别用load(),内存占用差好几倍。
  • 批量写入:Document 生成后批量写入向量库,别一条一条写,网络往返开销太大。
  • 缓存解析结果:Markdown 解析比较耗时,可以把解析结果缓存成 JSON,下次直接读。

我处理过一个 5000 个 Markdown 文件的项目,单线程解析要 20 多分钟,开了 8 线程后降到 3 分钟左右。但线程数不是越多越好,超过 CPU 核心数反而会因为上下文切换变慢。

6.4 元数据设计的经验之谈

元数据设计有几个我踩过坑才明白的道理:

第一,字段名要统一。不同 Loader 返回的 metadata 字段名可能不一样,比如有的叫source,有的叫file_path。入库前一定要统一,否则后面过滤的时候要对着一堆别名写兼容代码。

第二,值类型要稳定。page字段有时候是整数,有时候是字符串,这种不一致会让过滤查询出问题。建议在导入阶段就做类型转换。

第三,别塞太多。metadata 会跟着每个 chunk 存储,字段太多会显著增加存储和传输开销。只保留真正会用到的字段,比如来源、类型、标题路径、时间。

第四,预留扩展字段。我习惯留一个extra字段,类型是 dict,用来放一些临时的、实验性的元数据。这样加字段不用改表结构。

6.5 一个真实的排查案例

分享一个我实际遇到的案例。有个项目的 RAG 检索效果一直不好,用户问"如何申请报销",检索出来的却是"报销标准"相关的内容。排查过程是这样的:

先看检索结果,发现命中的 chunk 内容确实和"报销"相关,但都是标准说明,不是流程说明。然后去看这些 chunk 的 metadata,发现它们的title_path都是空的。再去看原始 Markdown,发现这份文档的标题用的是 Setext 风格(下划线),而我的解析器只认#风格,所以所有标题都没被识别,title_path全是空。

修复方案是给解析器加上 Setext 风格的支持。改完之后,检索准确率明显提升,因为现在每个 chunk 都带着正确的标题路径,检索时能区分"报销标准"和"报销流程"了。

这个案例的教训是:解析器的兼容性要提前测试,别假设所有 Markdown 都是标准#风格。上线前拿真实文档跑一遍,比事后排查省事得多。

6.6 导入环节的自检清单

最后给一份我常用的自检清单,每次导入新数据前过一遍:

  • 编码是否探测并正确处理
  • 换行符是否归一化
  • BOM 是否处理
  • 空内容 chunk 是否过滤
  • 元数据字段是否完整且类型一致
  • Markdown 标题路径是否正确提取
  • 代码块是否被保护
  • 过短 chunk 是否合并
  • 文件哈希是否记录(用于增量)
  • 抽样验证中文内容无乱码

这份清单看起来啰嗦,但每一条都是踩过坑总结出来的。导入环节做扎实了,后面的检索和生成才有发挥空间。我个人在实际操作中的体会是,RAG 项目里最值得投入时间的就是数据导入这一环,它不像调模型那样有即时反馈,但它是整个系统的地基,地基不牢,上面盖得再漂亮也白搭。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询