☰
RAG数据导入实战:txt与Markdown结构化解析的关键技术
2026/10/6 6:35:03 网站建设 项目流程

先说一个我自己踩过的坑。早两年做 RAG 项目,团队把精力全砸在选 embedding 模型和调向量库参数上,结果检索回来的内容经常张冠李戴——明明问的是 A 模块的接口规范,向量召回的前三篇全是 B 模块的配置说明。排查到最后才恍然大悟:问题压根不出在检索环节,而是入库的数据本身就没解析干净,语义是乱的,向量化之后自然更乱。从那以后我养成了一个习惯:任何 RAG 项目,花在数据导入与解析上的时间,不应该少于整个数据管线的一半。这个系列就是想把这些经验一条条捋清楚,第一篇先聊最基础也最容易被低估的环节——txt 和 Markdown 这类通用文本,如何做扎实的结构化解。

说实话,"读文件"这件事看着简单,做过一轮才知道里面全是暗坑。编码错乱、BOM 头、不可见字符、空行语义丢失、表格被拍平成大段文本……任何一步没处理干净,都会以"检索质量差"的形式在后面爆发。这篇文章我会从"为什么要先做结构化"讲起,再分别拆 txt 和 Markdown 的解析细节,最后给出一条完整的、可以直接抄走的解析链路以及配套的切片策略。

1. 为什么说数据导入是 RAG 的第一个质量关卡

很多人对 RAG 的想象是从"上传文档 → 输入问题 → 得到回答"开始的,中间的流程被简化成"向量化 + 检索"。但真正做过端到端项目的人都清楚,这条链路里有一个铁律:解析决定检索,检索决定生成。如果源头文本就是脏的、碎的、语义断裂的,后面再好的模型也救不回来。

1.1 "垃圾桶进,垃圾桶出"在 RAG 里有多严重

我见过一个很典型的案例:某团队要做一个内部知识库问答,数据源是一堆 Markdown 格式的技术方案文档。最初的实现很简单——读文件、去掉井号、按固定字符长度切成 512 的块,全塞进向量库。上线之后用户反馈"答案质量很差",找了一轮原因才发现,问题出在两处。第一,Markdown 里的标题层级被拍平了,原本"第三章 / 高可用设计 / 容灾切换"的从属关系全部丢失,向量化之后,三级标题下的内容片段可能和毫不相干的二级标题内容挨在一起。第二,固定长度切块时,恰好把一个代码块或表格拦腰截断,检索命中的内容只有半截,上下文全丢了。

这个案例的关键不在"Markdown 解析"本身,而在于一个认知:文本的结构本身就是语义的一部分。标题层级、段落边界、列表关系、代码块边界,这些都是比"字符序列"高维的信息。如果解析阶段把这些结构丢掉,后续无论用多贵的 embedding 模型,都是在信息残缺的基础上做压缩。

1.2 换个角度看解析:它决定了切块的上限

切块(chunking)策略是 RAG 项目里讨论最多的话题之一——每块多长、重叠多少、用什么方式切。但我想先给一个反直觉的结论:切片器只是划分边界的工具,真正决定边界质量的是解析器输出的"语义单元"。解析器如果能把文档还原成一棵有层级结构的节点树(标题是父节点、段落和表格是子节点),切片器就能沿着这棵树的边界去切,每一块都天然语义内聚。反之,如果解析器输出的是"去掉格式后的纯文本流",切片器只能用字符窗口硬切,语义断裂是必然的。

所以我的主张是:先别急着纠结 chunk size 是多少,先回头看数据导入这层。你解析出来的东西到底是什么?是"一行一行的字符串",还是"一份有结构的文档对象"?这两种输入,直接决定了 RAG 效果的天花板。

1.3 这个系列会覆盖什么,本篇先解决什么

这个系列我打算按"数据格式家族"拆开讲。本篇是第一篇,聚焦通用文本:txt、Markdown,以及由它们衍生的轻量结构。后续会单独聊 HTML 转文、PDF 里那些"看似是文本实则是图形"的坑、表格文档的结构化识别,以及表格结构在向量化时怎么保留行列语义。之所以把 txt 和 Markdown 放在最前面,是因为它们是最常见的存量知识库载体,同时也是最容易被人"一眼觉得自己会解析、实际上总在出问题"的格式。

2. 从 txt 开始:通用文本解析的隐藏难点

txt 是数字世界最早的文档格式,看起来毫无门槛。但你真拿一个真实环境里的 txt 文件来做 RAG 数据导入,会发现它的不确定性比想象中大得多。我把 txt 解析的难点分成四层,每一层都值得单独处理。

2.1 编码问题:乱码不是"加个参数"就能解决的

txt 没有自我声明编码的能力。同样一个文件,可能是 UTF-8、GBK(简体中文环境里尤其常见)、GB18030、Big5 甚至 UTF-16。直接用open(file_path, encoding="utf-8")去读,遇到 GBK 文件就是一片乱码。

我最常用的一套处理方案是"先检测、后兜底":

import chardet def read_text_with_encoding(file_path): # 先读原始字节,用 chardet 做编码检测 with open(file_path, "rb") as f: raw_data = f.read(1024 * 1024) # 先读前 1MB 做检测,足够了 encoding_guess = chardet.detect(raw_data) encoding = encoding_guess.get("encoding", "utf-8") # 常见兜底顺序:先按检测结果试,失败再逐级回退 for enc in [encoding, "utf-8", "gbk", "gb18030", "latin-1"]: try: with open(file_path, "r", encoding=enc, errors="strict") as f: return f.read(), enc except UnicodeDecodeError: continue # 最后兜底:lossy 读取,至少不崩 with open(file_path, "r", encoding="utf-8", errors="ignore") as f: return f.read(), "utf-8-ignore"

这里有个细节值得说明:chardet的检测结果不一定准,尤其对短文件、混合编码文本,所以必须配一套回退机制。我曾经碰到过一批从老系统导出的文件,文件头几百字节是 GBK,后半部分却是 UTF-8 的——这种情况靠"单一编码"已经无解了,只能在预处理环节先清洗、再尝试拼接。实际项目中,我不会让这套逻辑无限复杂,而是用"检测 + 回退"保证流程不中断,再用事后抽检来发现异常文件。

2.2 BOM 头和不可见字符:解析器最容易忽略的一层

很多 txt 是从 Windows 环境导出的,文件开头可能带一个 UTF-8 BOM(\xef\xbb\xbf)。它不可见,但会被当成文本内容读进来。如果解析时不处理,最直接的后果是:第一个 chunk 的第一个字符前面永远挂着一个不可见符号,向量化时它可能被当成合法 token,也可能被忽略掉,取决于分词器的实现——但无论如何,这是不该有的不确定性。

另一个坑是"不可见格式控制字符",比如零宽空格(Zero Width Space)、零宽连接符,以及 Windows 的\r\n和 Unix 的\n混用。我的处理策略很简单:

def clean_text(raw_text: str) -> str: # 去掉 BOM if raw_text.startswith("\ufeff"): raw_text = raw_text[1:] # 统一换行符 raw_text = raw_text.replace("\r\n", "\n").replace("\r", "\n") # 去掉零宽字符(保留普通空格和制表符) zero_width_chars = ["\u200b", "\u200c", "\u200d", "\ufeff"] for ch in zero_width_chars: raw_text = raw_text.replace(ch, "") return raw_text

不要小看这些不可见字符。它们不会让程序报错,也不会让肉眼立刻发现问题,但会导致切块时出现奇怪的"空块"、检索时命中莫名其妙的片段、甚至让同一份文档的多个片段被映射到相近的向量空间,造成冗余。我做 RAG 导入时有一条纪律:任何进入向量库的文本,都必须经过逐字符清洗。

2.3 段落语义:txt 的"空行"是比内容更重要的信息

纯文本不像 Markdown 有标题级别,它表达结构的方式很朴素:空行分隔段落,缩进暗示层级,编号列表表达顺序。解析 txt 时,最容易犯的错误是"按行读取之后直接拼接成一个超长字符串"。这么做等于告诉后续的切块器:这份文档没有结构,你随便切吧。

我的做法是:先把 txt 解析成"块序列",块的边界由空行和缩进共同决定。

def parse_txt_blocks(text: str): lines = text.split("\n") blocks = [] current_block_lines = [] def flush_block(): nonlocal current_block_lines if not current_block_lines: return block_text = "\n".join(current_block_lines).strip() if block_text: blocks.append({ "type": "paragraph", "content": block_text, "indent": detect_indent(current_block_lines), }) current_block_lines = [] for line in lines: if line.strip() == "": flush_block() else: current_block_lines.append(line) flush_block() return blocks

这里的indent(缩进)信息很重要,它可以用来识别"疑似列表"或"疑似层级"的段落。比如缩进一致、以数字或短横线开头的一组行,很可能是一个列表项;缩进逐级加深的段落,可能是层级结构。虽然这些推断不一定 100% 准确,但保留"缩进"这个特征,至少不会让后续的结构化丢失线索。

2.4 噪点文本和"假 txt":数据导入前的最后一关

真实业务里的 txt 往往不是干净的。可能混入了表单导出的制表符分隔文本,可能夹杂着网页复制下来的导航链接、广告文案,甚至有些"txt"实际上是代码文件、日志文件、CSV 改名而来。我在导入前一般会做一轮启发式检查:

  • 如果文本里\t数量明显偏多,按 TSV/表格解析而不是按普通段落。
  • 如果大量连续行都匹配<...>标签前导,先按 HTML 清洗。
  • 如果文本里有明显的"上一篇 / 下一篇 / 本文地址"等噪点短语,考虑抽稀或过滤。

这篇先不做深挖,但它属于"通用文本导入"最有价值的投入方向——因为脏数据在向量库里的破坏力是乘法级别的:一段垃圾文本一旦被向量化,就会在检索时反复命中,污染来源。

3. Markdown 解析的核心价值:把结构从格式里剥离出来

Markdown 比 txt 多了一层"轻量结构",这也正是它极具价值的地方。同样的内容,如果用 txt 方式解析 Markdown,等于把井号、星号、反引号全去掉,只留下纯文本——那这份 Markdown 最有价值的信息就全丢了。反过来,如果能正确解析 Markdown,得到的是一棵带语义标签的节点树,它对 RAG 的切片和检索非常友好。

3.1 从"去格式"到"提结构":解析观念的转变

我在 1.1 里说过"结构是语义的一部分",Markdown 是最能体现这句话的格式。举个简单的例子:

## 故障排查 ### 场景一:接口超时 当接口响应超过 3 秒时,触发超时告警。此时应检查: - 上游服务是否存活 - 网络链路是否存在丢包 > 注意:排查时先看日志,再动配置。

如果把它"去格式"成纯文本,得到的是一个线性字符串。标题层级、列表、引用块的语义关系全部丢失,"场景一"和"接口超时"之间的关系也变成普通的"相邻字符"。但如果保留结构,我们会得到一棵明确的树:

  • 文档节点
    • H2:故障排查
      • H3:场景一:接口超时
        • 段落:当接口响应超过 3 秒时……
        • 列表:上游服务是否存活 / 网络链路是否存在丢包
        • 引用块:注意:排查时先看日志……

这棵树的每一层都可以作为切块的边界参考。比如"场景一:接口超时"下面的所有内容,天然是一个语义完整的块,不需要再担心切到一半丢掉上下文。

3.2 用 markdown-it-py 提取 AST,而不是正则硬匹配

市面上的 Markdown 解析器很多,但我在 RAG 场景推荐用markdown-it-py。它的好处是能输出完整的 token 流,token 里包含准确的类型、层级和嵌套关系。正则匹配是个大坑——Markdown 语法组合方式太多,##可能出现在代码块的字符串里,也可能出现在行内代码里,正则很难区分这些上下文。用解析器,这些边界由词法引擎处理。

基本用法如下:

from markdown_it import MarkdownIt md = MarkdownIt("commonmark") text = """## 故障排查 ### 场景一:接口超时 当接口响应超过 3 秒时,触发超时告警。 """ tokens = md.parse(text) for token in tokens: print(token.type, token.tag, token.level, token.map)

token.type会区分heading_open、inline、list_item_open、fence(代码块)、table_open(表格)等。token.map还能拿到该节点在原文中的行号范围,这个信息对切片特别有用——你知道每个块的精确起止位置,可以做无损的边界裁剪。

我的建议是:在 RAG 导入管线里,不要自己维护正则解析规则,直接用现成的解析器。原因很简单:Markdown 的边界情况太多,自己写正则意味着你必须覆盖所有边界情况,而那是一个无底洞。解析器的 token 流可能有点繁琐,但可靠性和可维护性远胜自研方案。

3.3 把 AST 转成"语义块":建立统一的块结构

拿到 token 流之后,如果直接用 token 列表去切片,体验还是不够好。我的做法是再做一层转换:把 token 流拍平成一份统一的块序列,每块记录类型、层级、内容。这个块结构在后面统一的数据模型里会复用。

def tokens_to_blocks(tokens): blocks = [] current_heading_level = 0 buffer = [] def flush_buffer(): nonlocal buffer text = "".join(buffer).strip() if text: blocks.append({ "type": "paragraph", "level": current_heading_level, "content": text, }) buffer = [] for token in tokens: if token.type == "heading_open": flush_buffer() current_heading_level = int(token.tag[-1]) # h1 -> 1 elif token.type == "inline": buffer.append(token.content) elif token.type == "fence": flush_buffer() blocks.append({ "type": "code_block", "level": current_heading_level, "content": token.content, "language": token.info, # 代码语言标记 }) elif token.type == "table_open": # 表格需要专门处理,见 3.4 pass flush_buffer() return blocks

这个"块序列"的好处是:它不再依赖 Markdown 的具体语法,后续不管你后续接的是 OpenAI embedding、本地向量库还是某种大模型 API,处理的都是同一套中间结构。

3.4 表格、数学公式、代码块的"不可拍平"原则

Markdown 里有三类元素,我在解析时坚持"不拍平",因为拍平后语义损失极大。

表格。一个 Markdown 表格本质是一个二维结构。拍平成一行字符串,表头和表体的对应关系就没了一半。检索"某列的含义"时,如果向量只存了拼接后的字符串,模型很难知道哪个词是表头、哪个词是单元格值。我的处理思路是两种方案并行:如果表格体积小,就转成"表头+行记录"的描述式文本,比如"列名: 省份, 省会; 数据: 广东, 广州";如果表格体积大,就把整个表格单独作为一个块,保持原始 Markdown 原文,由后续结构感知的切片器决定怎么处理。

数学公式。有些 Markdown 文档包含 LaTeX 公式,比如$$\int_a^b f(x) dx$$。如果按普通文本拍平,公式里的_、^、\会被当成乱七八糟的符号,向量化效果很差。我的做法是:把公式块识别出来,保留原始 LaTeX 字符串,并在块类型上标记为math_block,这样后续不管是用专门的数学检索方案,还是拼接描述文本,都有据可依。

代码块。代码块的问题更明显——它的换行、缩进、语言类型都是语义本身。拍平后,代码结构被破坏,检索命中一段代码却不知道它属于什么语言、什么函数。我的方案是:代码块单独成块,保留language元信息,如果代码块太长,切片时整体保留,不强行拆分。

3.5 数学公式、软换行和 Callout:三个"想当然"的坑

关于 Markdown,有三个非常容易被"想当然"处理的细节,我分别说一下。

数学公式。在 GitHub 风格 Markdown 里,行内公式用$...$,块级公式用$$...$$。但普通文本里也可能有大量美元符号(比如价格)。如果解析器不支持数学公式语法,$就只是一个普通字符。这时我的经验是:不要试图让解析器"智能识别"公式边界,而是同时保留原始文本和解析后文本:解析后的文本用于语义切块,原始文本留在 metadata 里做溯源。两套文本互相补位,检索质量更稳。

软换行(softbreak)。Markdown 里一个普通换行(没有两个尾随空格)在渲染时不会产生新段落,它只是"软换行"。解析时如果不注意,两个没有空行的行,可能被拼成一句完整的话。这个细节本身不影响进程,但它决定了你的切片器会不会出现"断句断在中间"的情况。我的处理是:对于软换行,统一按普通空格拼接(符合 Markdown 语义),而不是保留\n。

GitHub Callout。就是那种> [!NOTE]开头的引用块,现在很多技术文档用它在渲染时生成醒目的提示框。这类块有很强的语义色彩(NOTE、WARNING、TIP),解析时最好保留这个标记,放进 metadata 里。这样检索时,如果用户问"这个操作有什么注意事项",WARNING 块就能被优先召回。

4. 统一数据模型:所有格式都收敛到一个结构

前两章分别讲了 txt 和 Markdown 的解析,但真正的工程化,需要把这两种格式(以及后续的 PDF、HTML)统一到一个"中间表示"。如果每种格式输出不同的结构,下游的切片逻辑、向量化逻辑、入库逻辑都要分别写,维护成本会成倍增加。统一模型之后,每个解析器只负责"把源格式转成中间表示"这一件事,下游无差别消费。

4.1 用 JSON 结构表达文档:元数据、语义块、正文三分离

我最常用的中间表示是这个结构:

{ "source": "docs/guide.md", "format": "markdown", "title": "故障排查手册", "metadata": { "author": "王工", "created": "2024-05-01", "language": "zh-CN" }, "blocks": [ { "type": "heading_h2", "level": 2, "content": "故障排查", "start_line": 1, "end_line": 1 }, { "type": "heading_h3", "level": 3, "content": "场景一:接口超时", "start_line": 3, "end_line": 3 }, { "type": "paragraph", "level": 3, "content": "当接口响应超过 3 秒时……", "start_line": 5, "end_line": 5 } ] }

这个结构有三层信息:文档级 metadata、语义块序列、每个块的起止行号。前两者服务于语义理解和检索,行号服务于溯源和原文映射。我只会在解析阶段填充前两层,行号用于最后校验——比如切片之后,可以准确知道"这个 chunk 对应原文的哪几行",对排查问题非常有用。

4.2 粒度怎么选:块和切片的边界不要混淆

一个常见误区是:把"解析后的块"直接当成"向量化的 chunk"。其实两者是不同的粒度——块是最小语义单元,chunk 是喂给向量模型的实际文本片断。块可以很小(一段只有两行字),而 chunk 通常需要一定体积(几百到上千 token)。正确的流程是:解析产出块 → 按规则把相邻块组装成 chunk → 向量化入库。

我建议把"块"和"chunk"这两个概念在数据模型里明确分开。如果混在一起,当你需要调 chunk 大小时,就只能回头改解析器;分开了,则只需要改组装规则,解析器不用动。

4.3 保留原文的"溯源能力":结构化之后不能丢原文

这一点是我踩过最深的坑。早期做结构化解析时,我总想把内容"优化"一下再存入知识库——去重、改写、摘要。后来发现,一旦切片之后的内容和原文对不上,用户看到答案时想核对原文都核对不了,信任感大打折扣。所以我现在铁律一条:结构化后的每个块,必须保留对应原文的行号或字符偏移。最终检索返回的答案,可以引用块内容,但一定要能给用户指向原文档的出处。没有溯源能力的 RAG,在企业场景里基本不可用。

5. 从结构到切片:让边界落在语义完整的位置

数据做好了结构化,切片就变成了一件"沿着结构走"的活。我看过很多 RAG 项目,切片器的实现就是text[window_start:window_end]一个循环搞定。不能说这种方案一定错,但它在处理长段落、跨层级文档时,语义损失非常明显。这一章我讲一下如何基于前面的块结构做高质量的切片。

5.1 为什么"固定窗口"切片会切坏语义

固定窗口切片的典型实现是:设定一个chunk_size(比如 500 字符)和一个overlap(比如 50 字符),然后像割草机一样在字符串上等距切割。它最大的问题有两个。

第一,边界随机。一个句子可能被从中间切开,前半个 chunk 是"接口返回超时后应检查以下三个环节:上游服务、网络链路、数据库连接池",后半个 chunk 是"配置。在实际操作中,还要注意…",语义断了。

第二,结构无效。固定窗口完全不知道标题层级的存在。假设一个三级标题下的正文有 3000 字,固定窗口会把它切成 6 段,这 6 段之间的"从属关系"只能靠向量模型"悟"出来。检索时如果命中第 4 段,模型可能不知道这段属于"场景二:数据库连接池耗尽",因为那段标题在很远的另一个 chunk 里。

5.2 结构感知切片:以块为单位,设置组装规则

我现在的做法,是把切片当成一个"组装"问题,而不是"切割"问题。流程如下:

  1. 解析器输出块序列(带层级和类型)。
  2. 设定目标 chunk 体积(比如 800 token,按字符估算约 2000-3000 个中文字符)。
  3. 沿文档顺序遍历块。如果当前块本身超过目标体积,单独成块。
  4. 如果当前块 + 下一个块不超过目标体积,把下一个块加进来,直到接近上限。
  5. 遇到"硬边界"(比如 H1/H2 标题)时,即使当前 chunk 没满,也在此截止——保证不同大章节的内容不会混进同一个 chunk。

大概伪代码如下:

def build_chunks(blocks, max_tokens=800): chunks = [] current_parts = [] current_len = 0 def flush(): nonlocal current_parts, current_len if current_parts: text = "\n".join([b["content"] for b in current_parts]) chunks.append({ "text": text, "block_ids": [b["id"] or b["start_line"] for b in current_parts], }) current_parts = [] current_len = 0 for block in blocks: block_len = estimate_tokens(block["content"]) # H1/H2 是硬边界,截止当前块 if block["type"] in ("heading_h1", "heading_h2") and current_parts: flush() # 单个块超长:直接成为单独 chunk if block_len >= max_tokens: flush() chunks.append({ "text": block["content"], "block_ids": [block["start_line"]], }) continue # 超过上限:先截止,再开新块 if current_len + block_len > max_tokens: flush() current_parts.append(block) current_len += block_len flush() return chunks

这段代码的精髓在于:它永远不会把一个块劈成两半。就算某个块再长,也会整体保留为一个 chunk。有人说这样可能浪费 token,但在语义完整性面前,那点浪费完全值得。而且大多数时候不会触发——你只要在解析阶段多做一层"超大段落预切分",比如把一个 3000 字的无标题长文本,按"段落"进一步切小,就能很好地配合这个组装逻辑。

5.3 元数据注入:把上下文写进 chunk,而非依赖检索拼接

结构感知切片比固定窗口多出来的另一个优势是:我们可以把当前块所处的"上下文路径"写进 chunk 的文本或 metadata 里。比如:

{ "text": "当接口响应超过 3 秒时……", "metadata": { "heading_path": "故障排查 > 场景一:接口超时", "source": "docs/guide.md", "start_line": 5, "end_line": 5 } }

把这个heading_path拼进最终向量化文本的前缀,形式类似:

[上下文] 故障排查 > 场景一:接口超时 [正文] 当接口响应超过 3 秒时……

这样做的好处是:即使某个 chunk 被单独召回,向量模型也能感知它的章节位置,不会把它当成一个"无源无头的孤立片段"。这一点对多级文档效果非常显著。

6. 完整实战:一条从 txt/Markdown 到分块 JSON 的处理链路

前面五章把原理和坑都讲了,这一章我把所有环节串起来,给出一条可以完整落地的处理链路。我会用一个混合目录的示例目录来走一遍——既有 txt 文件,也有 Markdown 文件,统一处理成结构化 JSON,供下游向量化使用。

6.1 整体处理流程概览

我先给一张流程简图(用文字描述,不画图了):

读取原始文件 → 编码识别与清洗 → 格式分发(txt/Markdown) → 结构解析成块 → 块转 chunk(含上下文注入) → 输出 JSON。

每一步的产物都是下一步的输入,中间状态全部落盘。我在工程上还有一个习惯:每一步都要有统计输出(文件数、块数、chunk 数、平均 token 数),这样任何一步出问题,马上能定位。

6.2 主流程代码骨架

import json import os from pathlib import Path def process_file(file_path: str) -> dict: ext = Path(file_path).suffix.lower() raw_text, encoding = read_text_with_encoding(file_path) raw_text = clean_text(raw_text) if ext in (".txt", ".text", ".log"): blocks = parse_txt_blocks(raw_text) source_format = "txt" elif ext in (".md", ".markdown"): blocks = md_to_blocks(raw_text) source_format = "markdown" else: raise ValueError(f"Unsupported format: {ext}") # 给每个块补上行号(我这里直接用了解析结果,不再重新扫描) for i, block in enumerate(blocks): block["id"] = i chunks = build_chunks(blocks, max_tokens=800) return { "source": str(file_path), "format": source_format, "encoding": encoding, "metadata": extract_metadata(file_path), "blocks": blocks, "chunks": chunks, } def process_directory(input_dir: str, output_json: str): all_docs = [] for root, _, files in os.walk(input_dir): for fname in files: if Path(fname).suffix.lower() in (".txt", ".text", ".log", ".md", ".markdown"): all_docs.append(process_file(os.path.join(root, fname))) with open(output_json, "w", encoding="utf-8") as f: json.dump(all_docs, f, ensure_ascii=False, indent=2) print(f"Processed {len(all_docs)} files, {sum(len(d['blocks']) for d in all_docs)} blocks, " f"{sum(len(d['chunks']) for d in all_docs)} chunks")

这个骨架已经把前几章的关键函数全部串起来了。实际部署时,你可以把process_directory换成流式处理,或者接入 Celery/消息队列,处理大规模文档集。但处理逻辑本身,就是这个结构。

6.3 实测样例:同一份文档,两种方案的效果对比

为了让大家更直观地感受"结构化解"带来的差异,我拿一份简化版技术文档做了一次对比测试。原文档结构大概是:

# 系统维护手册 ## 1. 日常巡检 ### 1.1 检查服务状态 使用 `systemctl status` 查看服务状态。 ## 2. 故障处理 ### 2.1 服务宕机 先看日志,再重启服务。

用朴素固定窗口(500 字符、无 overlap)切出来的 chunk 大致是:

  • Chunk 1:# 系统维护手册 ## 1. 日常巡检 ### 1.1 检查服务状态 使用 \systemctl ...`
  • Chunk 2:...status\查看服务状态。 ## 2. 故障处理 ### 2.1 服务宕机 先看日志...`

这个结果里,systemctl status这个命令可能被切到第 1 块尾部,语义勉强能懂;但"故障处理"这个二级标题被切到了第 2 块中间,它和它下面的内容其实没有对齐。如果用户问"服务宕机怎么处理",检索器可能只命中 chunk 2 的"先看日志,再重启服务",但因为 chunk 2 混合了"日常巡检"的尾部内容和"故障处理"的内容,向量表示会变得模糊。

用结构感知切片(按标题硬边界 + 块组装)拿到的 chunk 则是:

  • Chunk 1:# 系统维护手册 > ## 1. 日常巡检 > ### 1.1 检查服务状态 > 使用 \systemctl status` 查看服务状态。`
  • Chunk 2:## 2. 故障处理 > ### 2.1 服务宕机 > 先看日志,再重启服务。

两个 chunk 的语义边界非常干净,而且每个 chunk 都带上了标题路径。用户问"服务宕机怎么处理"时,命中 chunk 2,chunk 自带"故障处理 > 服务宕机"的上下文,检索准确率和回答质量显然会高很多。

6.4 几个落地环节的注意点

最后补几个我在实际项目中反复碰到的细节:

上限保护。build_chunks里的max_tokens不是越大越好。过大的 chunk 会增加向量化的信息密度,反而稀释了关键语义;过小则碎片化严重。我的经验值:中文章节类文档,目标 500-800 token 之间;技术问答类数据,目标 300-500 token 之间。具体数值可以在你的数据上做一个简单的 A/B 测试——用同一组问题,分别跑两套 chunk 参数,对比召回答率。

重叠怎么加。结构感知切片可以不做重叠,因为语义边界是自然的。但如果你的场景特别依赖前后文(比如连续对话中的上下文继承),可以只在 chunk 之间叠加上一个块的结尾部分,而不是任意截断的 50 字符。这样既保住了过渡信息,又不破坏语义完整。

空块过滤。解析过程中会产生不少空块、纯符号块、只有链接内容的块。我在build_chunks前加了一道过滤逻辑:块内容字符数小于 2 且不含字母数字的,直接丢掉。

元数据不要塞正文。我看到有人把文件路径、作者、日期全拼进 chunk 文本去向量化,这其实是在浪费 token。metadata 建议单独存一列或一个字段,在检索时做过滤或排序,而不是参与正文向量化。文件名可以作为一种"弱语义前缀"留在向量文本里(类似heading_path的做法),但文件路径这类纯标识信息就留给结构化字段就好。

这个系列后面,我会继续写 HTML 文档转 Markdown 的保真处理、PDF 里"文字层与视觉排版错位"的解析方案,以及表格类文档怎么做行列语义的结构化。先把通用文本这条链路打磨好,RAG 的地基就算夯实了一半。

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

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

立即咨询