☰
RAG分层召回实战:目录相关性评估与TOC Relevance User Prompt设计
2026/10/23 1:29:22 网站建设 项目流程

1. 从一次召回跑偏说起:目录相关性评估到底在解决什么

做过 RAG 的人大概率都遇到过这种场景:用户问的是"第三章里那个实验的对照组设置",检索器却把整本书的目录页、版权页、前言、附录一股脑全召回了。向量相似度看着都不低,因为目录里确实密集出现了"实验""对照组""第三章"这些词,但真正有用的正文段落反而被挤到了后面。这个问题在长文档、结构化文档(手册、白皮书、教材、规范文件)里尤其致命,因为这类文档的目录本身就是一份"关键词浓缩表",天然容易和用户 query 产生高相似度。

MemoryBear 这套 RAG 方案里,专门设计了一个叫TOC Relevance User Prompt的环节,用来在分层召回的过程中对"目录节点"做相关性评估。它的核心思路不是简单地把目录丢掉,而是把目录当成一个路由层:先判断用户的问题和目录里的哪个章节真正相关,再决定往哪个分支去深挖正文。这跟传统"一把梭全库向量检索"是两种完全不同的召回哲学。

这篇文章我想聊的就是这个环节的实战细节:TOC Relevance User Prompt 到底长什么样、它评估的是什么、在分层召回里怎么落地、参数怎么调、踩过哪些坑。适合正在做长文档 RAG、知识库问答、结构化文档检索的同行参考,也适合刚接触分层召回、想搞清楚"目录到底该不该进向量库"这个问题的朋友。全文基于我在实际项目里对这类方案的复现和调优经验,涉及具体实现的地方会给出可直接抄的思路和伪代码。

先说结论性的判断:目录不是噪声,但目录不能和正文平权地参与向量召回。把它单独拎出来做一层相关性评估,是性价比最高的做法。下面拆开讲。

2. 分层召回的整体设计:为什么目录要单独成层

2.1 传统扁平召回在长文档上的三个硬伤

先说说为什么不能偷懒。扁平召回就是把文档切成一堆 chunk,全部塞进向量库,query 来了做 top-k 相似度检索。这套在短文本、FAQ、聊天记录上没问题,但放到几百页的结构化文档上,问题立刻暴露:

第一个硬伤是粒度失配。目录节点往往只有一行字,比如"3.2 实验设计与对照组",而正文 chunk 可能是 500 字。两者语义密度完全不同,用同一个 embedding 模型、同一个相似度阈值去比,本身就不公平。目录行短、关键词密集,余弦相似度天然偏高。

第二个硬伤是层级信息丢失。目录是有树形结构的:章、节、小节。扁平化之后,"3.2"和"3.2.1"变成两个互不相关的向量,父子关系没了。用户问的是"3.2 这一节讲了什么",检索器却可能召回 3.2.1 的某个细节段落,答非所问。

第三个硬伤是召回预算被目录吃掉。top-k 里如果混进了目录页、索引页、参考文献列表,真正有用的正文 chunk 名额就被挤占了。k 越小越明显,k=5 的时候可能 3 个都是目录。

2.2 分层召回的三层结构

MemoryBear 这套方案把召回拆成三层,逻辑上是从粗到细的漏斗:

层级内容作用召回方式
L1 目录层章/节标题、层级路径路由,定位相关章节目录相关性评估
L2 段落层章节内的段落 chunk主体召回向量 + 关键词混合
L3 细节层表格、图注、代码块精确补充按需触发

关键点在于L1 不是用普通向量检索做的,而是用 TOC Relevance User Prompt 驱动一次轻量的相关性判断。为什么?因为目录节点数量通常不多(几十到几百个),完全可以用一次 LLM 调用或者一次专门的打分模型,把"query 和每个目录节点的相关性"算清楚,而不是丢进向量库让它和正文抢名额。

提示:目录节点数量如果超过 500,建议先做一次粗筛(比如按章节标题的关键词命中),再进 LLM 精排,否则单次 prompt 太长,成本和延迟都吃不消。

2.3 目录相关性评估在漏斗里的位置

整个流程是这样的:用户 query 进来,先过 TOC Relevance 评估,得到一组"相关章节路径",比如["3.2", "3.2.1"]。然后 L2 召回时,把检索范围限制在这些章节的正文 chunk 里,而不是全库。这一步直接把候选集从几千缩到几十,召回精度和速度同时提升。

这个设计的精髓是:用一次便宜的判断,换掉一次昂贵的全库检索。目录评估哪怕用 LLM 做,也就几百 token 的输入,比在全库上跑向量检索再重排要划算得多。而且目录评估的结果是可解释的——你能明确知道为什么召回了 3.2 而不是 4.1,这对调试和给用户展示引用来源都友好。

3. TOC Relevance User Prompt 的拆解与设计要点

3.1 Prompt 到底要模型判断什么

很多人第一次写这个 prompt 会写成"请判断以下目录和用户问题是否相关",然后模型给你一个 yes/no。这不够用。实战里需要模型输出的是结构化的相关性判断,至少包含三样东西:

  • 相关章节的路径标识(比如3.2)
  • 相关程度(强相关 / 弱相关 / 不相关,或者 0-1 分数)
  • 简短的判断理由(一句话,用于调试和可解释性)

为什么要有程度分级?因为分层召回需要决定"往下挖多深"。强相关的章节,L2 召回时给更大的 top-k 预算;弱相关的,只取少量 chunk 甚至跳过。如果只有 yes/no,就没法做这种差异化分配。

3.2 一个可直接复用的 Prompt 骨架

下面是我实际用下来比较稳的一个骨架,做了脱敏和通用化处理:

你是一个文档目录相关性评估器。用户提出了一个问题,你需要判断文档目录中哪些章节与这个问题相关。 用户问题: {query} 文档目录(每行格式为:路径 | 标题): {toc_lines} 请按以下规则输出: 1. 只输出与问题相关的章节,按相关程度从高到低排序。 2. 每个章节输出一行,格式为:路径 | 相关程度(强/弱) | 一句话理由 3. 如果没有任何章节相关,输出:NONE 4. 不要输出目录中不存在的路径,不要编造章节。 输出:

这个骨架有几个刻意的设计。第一,目录以"路径 | 标题"的扁平格式喂进去,而不是嵌套 JSON,因为扁平格式 token 更省,模型也更容易对齐路径。第二,强制要求输出理由,这看起来是浪费 token,但实测能显著降低模型瞎编路径的概率——它得为每个判断找个说法。第三,明确禁止编造路径,这是目录评估里最常见的翻车点。

3.3 为什么用 User Prompt 而不是 System Prompt

标题里特意强调 "User Prompt",这不是随便叫的。把目录评估放在 user 轮而不是 system 轮,有两个实际考虑:

一是目录内容每次请求都不同,放 system 里会导致 prompt 缓存失效(如果用了 prompt caching),成本反而高。放 user 轮,system 部分可以固定成通用的评估指令,缓存友好。

二是便于多轮复用。有些场景下用户会追问,比如先问"第三章讲了什么",再问"那第四章呢"。目录本身没变,但 query 变了。如果目录在 system 里,每次都得重新组织;放 user 轮,目录和 query 一起传,逻辑更清晰。

注意:如果你的推理框架对 system/user 的缓存策略不同,这个选择要结合实际情况调整。核心原则是"变化的部分放 user,固定的指令放 system"。

3.4 目录预处理:喂给模型之前要做的三件事

原始目录往往很脏,直接喂给模型效果差。我一般会做三步预处理:

第一步,路径标准化。把"第三章 第二节"、"3.2"、"Chapter 3.2"统一成同一种格式,比如都用3.2。模型对格式一致性很敏感,混着来容易判断错。

第二步,层级补全。目录里经常只有叶子节点,比如只列了3.2.1,没有3.2。这时候要把父路径补上,因为用户的问题可能指向父章节。补全后每个节点都带完整路径,模型才能正确路由。

第三步,噪声过滤。把"目录""前言""致谢""参考文献""索引"这类结构性节点标记出来,评估时可以降权或直接排除。这些节点几乎和任何 query 都"相关",留着只会干扰。

预处理步骤处理前处理后
路径标准化第三章第二节3.2
层级补全3.2.1 实验步骤3.2 / 3.2.1 实验步骤
噪声过滤目录、前言、索引标记为 structural,降权

4. 分层召回中的落地实现与参数调优

4.1 从评估结果到召回范围的映射

TOC 评估输出的是章节路径列表,接下来要把它翻译成 L2 的检索范围。这里有个容易忽略的细节:路径匹配是前缀匹配还是精确匹配?

假设评估结果是3.2,而正文 chunk 的元数据里存的是3.2.1、3.2.2。这时候应该用前缀匹配,把3.2开头的所有 chunk 都纳入范围。但如果评估结果是3.2.1,就只取3.2.1的 chunk,不要往上扩到3.2,否则范围会失控。

我的做法是给每个评估结果打一个"展开策略":

  • 强相关 + 路径是章节级 → 前缀展开,纳入所有子节点
  • 强相关 + 路径是叶子级 → 精确匹配
  • 弱相关 → 只取该节点自身,不展开

这样既保证了强相关章节的召回充分性,又避免了弱相关章节把范围撑大。

4.2 召回预算的分配

L2 召回的总预算(比如 top-20)怎么在多个相关章节间分配?平均分是最省事但最差的做法。我一般按相关程度加权:

# 伪代码:按相关程度分配召回预算 total_budget = 20 weights = {"强": 3, "弱": 1} sections = [("3.2", "强"), ("3.2.1", "强"), ("4.1", "弱")] total_weight = sum(weights[level] for _, level in sections) budget_map = {} for path, level in sections: budget_map[path] = max(2, int(total_budget * weights[level] / total_weight))

注意那个max(2, ...),是防止某个章节因为权重低被分到 0 个名额。哪怕弱相关,也至少给 2 个 chunk 的机会,避免漏召回。这个下限值可以根据文档粒度调,chunk 越小,下限可以设得越高。

4.3 阈值与兜底策略

目录评估不可能永远准。模型可能返回 NONE,也可能返回一堆弱相关。这时候要有兜底:

情况一:返回 NONE。不要直接放弃,退化成全库向量检索,但把目录节点排除掉。相当于"目录评估没帮上忙,那就走传统路线"。

情况二:全是弱相关。说明 query 可能比较泛,或者文档目录和 query 的表述差异大。这时候可以放宽到"取所有弱相关章节 + 全库检索的 top-k 做并集",用重排来收口。

情况三:强相关章节过多(比如超过 5 个)。这通常意味着 query 太宽泛,或者目录评估过于宽松。可以对强相关结果再做一次精排,或者直接截断到 top-3。

提示:兜底策略一定要有,而且要在日志里记录触发频率。如果 NONE 的比例长期超过 20%,说明你的目录预处理或者 prompt 有问题,得回头调。

4.4 一个完整的调用链路示例

把上面的东西串起来,一次完整的请求大概是这样:

def hierarchical_retrieve(query, toc, vector_store): # L1: 目录相关性评估 toc_lines = format_toc(toc) # 预处理后的目录 prompt = build_toc_prompt(query, toc_lines) toc_result = llm_call(prompt) # 返回 [(path, level, reason), ...] # 兜底:评估失败则全库检索 if not toc_result: return vector_store.search(query, top_k=20, exclude=["toc", "index"]) # 预算分配 budget_map = allocate_budget(toc_result, total_budget=20) # L2: 按章节范围召回 candidates = [] for path, budget in budget_map.items(): chunks = vector_store.search( query, top_k=budget, filter={"section_path": {"$prefix": path}} ) candidates.extend(chunks) # 重排收口 return rerank(query, candidates, top_k=8)

这个链路里,L1 是一次 LLM 调用,L2 是若干次带过滤的向量检索,最后统一重排。实测下来,相比全库检索 + 重排,端到端延迟能降 30% 到 50%,因为 L2 的候选集小了一个数量级。

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

5.1 模型编造不存在的章节路径

这是最高频的问题。模型看到目录里有3.2,用户问的东西又和3.2沾边,它可能顺手输出一个3.2.5,而目录里根本没有3.2.5。

排查思路:在解析输出时做一次路径白名单校验,凡是不在原始目录里的路径,直接丢弃并记日志。如果丢弃率很高,说明 prompt 里的"不要编造"约束不够强,可以在 prompt 里加一句"输出前请逐一核对路径是否存在于上方目录中"。

5.2 相关章节判断过宽

模型倾向于"宁可多判相关,不可漏判"。结果就是一次返回七八个章节,分层召回退化成全库召回。

解决办法有两个。一是在 prompt 里明确数量上限,比如"最多输出 3 个最相关的章节"。二是引入负例说明,在 prompt 里给一两个"看起来相关但其实不相关"的例子,帮模型建立边界感。实测加负例后,过宽判断能减少一半以上。

5.3 目录层级深导致路径匹配失败

有些文档层级特别深,比如1.2.3.4.5。这种路径在正文 chunk 的元数据里如果只存了叶子路径,前缀匹配就会出问题。

我的处理方式是在入库时给每个 chunk 存完整路径链,比如["1", "1.2", "1.2.3", "1.2.3.4", "1.2.3.4.5"],检索时用数组包含匹配。这样无论评估结果落在哪一层,都能正确匹配到子节点。

5.4 常见问题速查表

问题现象可能原因排查方向解决手段
返回不存在的路径prompt 约束弱统计丢弃率加白名单校验 + 强化 prompt
相关章节过多判断过宽看平均返回数量设数量上限 + 加负例
路径匹配不到 chunk元数据路径不全检查 chunk 元数据存完整路径链
NONE 比例过高目录预处理差看预处理日志标准化路径 + 补全层级
延迟没降下来L2 范围没缩小看候选集大小检查前缀匹配是否生效

5.5 几个踩过的坑

坑一:把目录评估结果直接当最终答案的引用。目录评估只告诉你"去哪找",不告诉你"答案是什么"。有次我图省事,直接把相关章节标题返回给用户当答案,结果用户问的是具体数值,标题里根本没有。目录评估和答案生成是两回事,别混。

坑二:忽略目录的版本变化。文档更新后目录变了,但缓存里的评估结果没失效,导致路由到已经不存在的章节。解决办法是给目录加版本号,版本变了就清缓存。

坑三:用同一个 prompt 处理所有文档类型。技术手册和文学作品的目录结构差异巨大,前者层级严谨,后者可能只有章节名。我后来按文档类型分了几个 prompt 模板,效果比一个通用模板好很多。

6. 效果验证与迭代方向

6.1 怎么衡量目录评估的好坏

不能只看最终答案对不对,要单独评估 L1 这一层。我一般看三个指标:

路由准确率:人工标注一批 query 对应的正确章节,看评估结果里有没有包含。这个指标直接反映 L1 的质量。

候选集压缩比:L2 的候选集大小相比全库检索缩小了多少倍。压缩比越高,说明路由越精准,但太高可能意味着漏召回,要结合路由准确率一起看。

端到端延迟:L1 的 LLM 调用加上 L2 的检索,总时间相比全库方案的变化。如果 L1 太慢,整体可能反而更慢,这时候要考虑用更小的模型或者规则兜底。

6.2 迭代时优先动哪里

如果路由准确率低,优先调 prompt 和目录预处理,这两块改动成本最低、收益最直接。如果准确率够但延迟高,考虑把 L1 的 LLM 换成小模型或者蒸馏一个分类器。如果压缩比高但漏召回多,检查前缀匹配和预算分配,往往是范围卡太死了。

我个人在实际操作中的体会是,目录相关性评估这个环节,七分靠预处理,三分靠 prompt。目录本身整理干净了,模型判断的准确率会高一大截。很多人一上来就猛调 prompt,其实问题出在喂进去的目录就是乱的。先把路径标准化、层级补全、噪声过滤做扎实,再谈 prompt 优化,顺序不能反。

最后分享一个小技巧:如果你的文档目录是动态生成的(比如从 PDF 解析出来的),一定要人工抽查前几十个节点的解析质量。PDF 目录解析经常把页码、点线、换行混进标题里,这些脏数据会直接污染评估结果。我一般会写个简单的清洗规则,把纯数字、连续点号、单字符的节点过滤掉,效果立竿见影。这个内容后续还可以往"多文档目录联合路由"的方向扩展,比如用户的问题跨了多个文档,怎么在文档级和章节级同时做相关性评估,那是另一个值得单独聊的话题了。

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

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

立即咨询