RAG 检索调优:先检查分块与召回
文章分析小块检索精度与大块上下文完整性的取舍,主张按文档结构切分并保留元数据。调参前先测量召回率,避免将检索遗漏误判为模型或提示词问题。
AI 深度解读
RAG 答错了,先别急着换模型或改提示词。作者提出的排查顺序是:先确认正确段落有没有进入检索结果,再检查它是否被切断、混进了无关内容,或者丢掉了标题和章节信息。答案没送到模型面前,后面的优化就很难奏效。
这篇最有用的结论不是某个最佳 chunk_size,而是一套可验证的做法:先按文档结构切分,再限制长度;给片段补上可检索的上下文;用真实问题测召回,最后才调整参数。
chunk_size 调的是精度与上下文,数字不能直接照搬
分块(chunking)决定了哪些内容会作为一个单元进入索引。小块主题集中,查询更容易匹配,也能减少送给模型的无关 token;但切得太碎,指代、条件和适用范围可能消失。大块保留了上下文,却容易把多个主题揉进同一个嵌入向量,稀释真正相关的内容。
原文引用了 Chroma 研究团队在 2024 年 7 月发布的评估:覆盖五个语料库,按 token 计算召回率、精确率和交并比(IoU),不同切分策略的召回率差异最高达到 9%。
使用 text-embedding-3-large、检索 5 个片段时,RecursiveCharacterTextSplitter 的两组结果很直观:
| 切分配置,单位为 token | 召回率 | 精确率 |
|---|---|---|
| 200,无重叠 | 88.1% | 7.0% |
| 800,重叠 400 | 85.4% | 1.5% |
片段扩大后,召回没有提高,无关内容却明显增多。原文还指出,OpenAI file search 的 800-token 分块、400-token 重叠默认配置,在那次实验中的召回略低于平均值,其他指标得分最低。这只能说明该配置在那套评估里的表现,不能据此判定它适合或不适合所有业务。
调参前,先确认你比较的是 token 还是字符。
LangChain 的 chunk_size=1000 默认表示 1,000 个字符;只有传入基于 token 的 length_function,才按 token 计数。跨工具比较时,数字相同不代表输入规模相同。
重叠也要单独看。它把上一块末尾复制到下一块开头,能缓解边界切断信息的问题,代价是重复存储、重复检索和重复占用模型上下文。Chroma 的实验中,减少重叠改善了会惩罚重复 token 的 IoU;但对较小的 all-MiniLM-L6-v2,250-token 分块、125-token 重叠获得了 82.4% 的最佳召回,零重叠时降到 77.1%。
因此,这些数据支持的是「需要按模型和语料测量」,并不支持一律取消重叠。
先保住章节边界,再让片段带上身份
固定长度窗口不知道一句话在哪里结束,更不知道「退款政策」和「配送说明」属于两个主题。一个 500 字符窗口,可能切开表格,也可能把两个章节的段落拼在一起。
作者采用两阶段切分:先沿 Markdown 标题、HTML 分区、PDF 标题或代码函数等结构拆开,再对过长的章节施加长度限制。LangChain 的 MarkdownHeaderTextSplitter 会把指定标题写入元数据;LlamaIndex 提供 MarkdownNodeParser,处理代码则有按语言切分、支持 chunk_lines 和 max_chars 限制的 CodeSplitter。递归切分器优先尝试段落、行、词,也是在尽量保留自然边界。
边界完整还不够。「期限是 30 天」即使没有被截断,也缺少对象和适用范围。
原文引用 Anthropic 在 2024 年 9 月介绍的 Contextual Retrieval:用 Claude 为片段生成通常为 50-100 tokens 的专属上下文,在生成嵌入和建立 BM25 索引之前前置。其测试中,仅上下文嵌入就让 top-20 检索失败率相对下降 35%,从 5.7% 降到 3.7%。
作者给出的低成本方案是,把文档标题和章节路径一起送去生成嵌入,同时保存为元数据。前者帮助匹配,后者支持按产品、版本、租户过滤,以及追溯来源。两件事要分别落实:只把标题存在元数据里,并不会自动改变嵌入内容。
下面这段代码把结构切分、长度限制和标题上下文串在了一起,也提供了后续评估入口:
from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
def chunk_markdown(doc_title: str, markdown: str):
# 第一阶段:按真实结构切分,将标题写入元数据
by_section = MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
).split_text(markdown)
# 第二阶段:仅在长章节内部限制长度,此处 chunk_size 的单位是字符
sized = RecursiveCharacterTextSplitter(
chunk_size=1200, chunk_overlap=100
).split_documents(by_section)
for d in sized:
section = " > ".join(d.metadata[k] for k in ("h1", "h2", "h3") if k in d.metadata)
d.metadata.update({"doc_title": doc_title, "section": section})
# 将这段文本用于嵌入:标题、章节路径与正文
d.metadata["embed_text"] = f"{doc_title} | {section}\n{d.page_content}"
return sized
def recall_at_k(eval_set, search, k=10):
"""eval_set: [(question, expected_section)], search(q, k) -> list of chunks."""
hits = sum(
any(c.metadata["section"] == expected for c in search(q, k))
for q, expected in eval_set
)
return hits / len(eval_set)
要盯住 d.metadata["embed_text"] 这一行:它只是构造了待嵌入文本,实际索引流程仍需读取这个字段。chunk_size=1200 和 chunk_overlap=100 都是假设起点,不是推荐值。原文还说明,使用 split_documents 时,重叠发生在章节内部,不跨章节边界。
怎么证明切分改好了,而不是碰巧答对一次
作者建议收集 50-100 个真实问题,来源可以是日志、支持工单或团队提问,再为每个问题标注真正能回答它的章节。
随后测 recall@k:有多少问题的正确章节出现在前 k 个检索结果中。上面代码默认 k=10,只要结果中存在目标章节,就记为命中。
每次只改一个变量,例如长度、重叠或切分器,然后重建索引、重新测量。改动会影响索引里的嵌入,不能只修改配置就宣称优化完成。没有标注集时,原文提到可以让 LLM 基于语料生成评估问题,但仍优先推荐真实查询。
这里有两个边界。第一,Chroma 的 token 级召回与示例里的问题级章节命中率不是同一口径,不能直接横向比较。第二,检索命中只能证明证据被找到了,不能证明模型最终正确使用了证据。
原文的报销机器人也只是一个假设案例:固定 1,000 字符分块中的「30 天内提交」,补上 HR Handbook | Expenses > Deadlines 后更容易被识别。作者要求用 recall@10 验证,并未提供这次改造的真实前后数据。
我的判断
我赞同把切分和召回放到换模型之前排查。它把「回答感觉不对」变成了一个可定位的问题:正确证据究竟在哪一步丢了。
但「结构切分需要更少重叠」在文中主要是作者经验,没有给出他们团队的对照数据。它也依赖文档本身有可信的结构;标题缺失、章节过长,或答案横跨多个章节时,仍需要额外处理。标题前置是便宜的上下文补充,也不能直接视为 Anthropic 生成式上下文方案的等价替代。
示例评估还有一个工程细节:只比较 section 字符串,多份文档出现相同章节名时可能误判。正式评估应使用能唯一定位来源的标识。若一个问题需要多个章节才能回答,单次命中也不足以代表证据完整。
固定长度和递归切分依然适合作为基线。结构切分是否值得增加解析工作,应由自己的查询集回答;原文最可靠的主张,正是先测量再选择。
能拿来做什么
- 为团队文档问答整理 50-100 个真实问题,标注答案章节,先记录当前配置的
recall@10,再开始修改切分方式。 - 对 Markdown 技术文档先按标题拆分,再限制长章节;把标题与章节路径加入实际嵌入输入,并核对参数使用字符还是 token。
- 在 Node 或其他检索服务的入库流程中保留来源、章节、产品和版本信息,让过滤与引用能够追溯到具体文档。
- 如果正在做代码检索,可以把语言结构切分与现有递归切分做对照,每次只改变一个变量,用相同问题集判断是否值得替换。