ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RAG数据导入实战:txt与Markdown结构化解析的关键技术

RAG数据导入实战:txt与Markdown结构化解析的关键技术 先说一个我自己踩过的坑。早两年做 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, encodingutf-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, encodingenc, errorsstrict) as f: return f.read(), enc except UnicodeDecodeError: continue # 最后兜底lossy 读取至少不崩 with open(file_path, r, encodingutf-8, errorsignore) 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 结构感知切片以块为单位设置组装规则我现在的做法是把切片当成一个组装问题而不是切割问题。流程如下解析器输出块序列带层级和类型。设定目标 chunk 体积比如 800 token按字符估算约 2000-3000 个中文字符。沿文档顺序遍历块。如果当前块本身超过目标体积单独成块。如果当前块 下一个块不超过目标体积把下一个块加进来直到接近上限。遇到硬边界比如 H1/H2 标题时即使当前 chunk 没满也在此截止——保证不同大章节的内容不会混进同一个 chunk。大概伪代码如下def build_chunks(blocks, max_tokens800): 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(fUnsupported format: {ext}) # 给每个块补上行号我这里直接用了解析结果不再重新扫描 for i, block in enumerate(blocks): block[id] i chunks build_chunks(blocks, max_tokens800) 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, encodingutf-8) as f: json.dump(all_docs, f, ensure_asciiFalse, indent2) print(fProcessed {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 2chunk 自带故障处理 服务宕机的上下文检索准确率和回答质量显然会高很多。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 的地基就算夯实了一半。
返回列表