1. 从目录树到召回入口:TOC相关性评估到底在解决什么
做过RAG(检索增强生成)的人大概都有过这种体验:文档切了一堆chunk,向量库也建好了,用户问一个问题,检索出来的片段单看每一段都挺相关,但拼在一起就是答非所问。问题往往不出在向量模型上,而是出在召回入口选错了——系统压根没意识到用户的问题应该落在文档的哪个章节分支下。
MemoryBear这套RAG实现里,有一个容易被忽略但极其关键的环节:TOC Relevance(目录相关性评估)。它做的事情说起来很朴素——在正式做向量召回之前,先拿用户的问题去和文档的目录结构做一次相关性打分,判断这个问题最可能属于目录树里的哪几个节点,然后再把召回范围收敛到这些节点对应的子树里。
这个思路的价值在于:一份几百页的技术手册或者产品文档,它的目录本身就是作者对知识的一次高质量聚类。目录节点名(比如"3.2 认证与鉴权"、"5.1 数据导出")浓缩了该章节下所有内容的语义主题。与其让向量检索在整个文档空间里大海捞针,不如先用一次轻量的LLM调用,让模型判断"用户这个问题和哪些目录节点相关",把搜索空间从"全文档"缩小到"若干子树"。
我实测下来,这种分层召回在长文档、强结构、章节主题区分明显的场景下,召回准确率的提升非常明显。尤其是那种"答案只存在于某一个特定章节"的问题,扁平向量检索经常被其他章节里语义相近但主题不同的片段干扰,而TOC相关性评估能有效把这类干扰挡在门外。
这篇文章我会把MemoryBear里TOC Relevance User Prompt的设计逻辑、它在分层召回链路里的位置、以及实际落地时会踩的坑,完整拆一遍。适合已经在做RAG、但召回质量卡在瓶颈的开发者,也适合刚接触分层检索、想搞清楚"目录到底怎么用起来"的同学。
2. TOC Relevance User Prompt 的构造逻辑拆解
2.1 为什么不能直接把整个目录塞给模型
很多人第一反应是:把文档目录整个拼进prompt,让模型输出相关章节编号不就行了?理论上可行,但实际会出问题。
一份中等规模文档的目录,展开到三级标题可能有上百个节点。全部塞进prompt有两个直接后果:一是token消耗大,每次查询都要带上完整目录,成本随文档规模线性上涨;二是模型注意力被稀释,节点一多,模型对每个节点的判断精度会下降,容易出现"选了一堆看起来都沾边"的模糊结果。
MemoryBear的做法是分层递进:先只给模型一级目录(章),让它选出相关的章;再在选中的章下面,给二级目录(节),继续收敛。这样每一轮模型面对的候选节点数量都控制在十几个以内,判断精度高,token也可控。
提示:分层递进的层数不是越多越好。我试过展开到四级标题,结果发现模型在细粒度节点上的判断反而不如二级稳定,因为越细的节点名信息量越少,语义区分度下降。一般两到三层是比较舒服的平衡点。
2.2 Prompt里的三个核心要素
拆开MemoryBear的TOC Relevance User Prompt,结构上其实就三块:任务指令、目录候选列表、输出格式约束。但每一块的措辞都有讲究。
任务指令部分,它没有用"请判断相关性"这种模糊表述,而是明确告诉模型"你要做的是筛选,不是排序,也不是解释"。这个约束很重要——如果不限制,模型很容易输出一段分析文字,后面还得再解析,增加链路复杂度。
目录候选列表部分,每个节点带上编号和标题,格式类似:
1. 系统概述 2. 安装与部署 3. 核心概念 4. 配置管理 5. 数据操作编号是必须的,因为模型输出时用编号比用标题更稳定,标题里可能有特殊字符或者重复词,编号是唯一且简洁的引用方式。
输出格式约束部分,要求模型返回一个编号数组,比如[2, 4],并且明确"如果没有相关节点,返回空数组"。这个空数组的约定非常关键,它给了模型一个"弃权"的出口。如果不给这个出口,模型面对完全不相关的问题时,会被迫硬选一个,反而引入噪声。
2.3 一个容易被忽略的细节:节点标题的预处理
原始目录里的节点标题往往带着编号前缀,比如"3.2.1 认证流程"。如果直接把这些标题喂给模型,编号前缀会干扰语义判断——模型可能会因为"3.2.1"和用户问题里的某个数字巧合而对齐,产生误判。
MemoryBear在构造候选列表时,会把标题里的层级编号剥离掉,只保留纯语义部分。同时对于过长的标题做截断,对于包含特殊符号的标题做清洗。这些预处理看起来琐碎,但直接影响相关性判断的准确率。
我自己的经验是,节点标题里如果包含"概述""简介""其他"这类信息量极低的词,可以在预处理阶段标记出来,在prompt里提示模型"这类节点优先级较低"。因为这类章节往往是杂项汇总,语义边界模糊,容易成为误召回的来源。
3. 分层召回链路里,TOC评估处在哪个位置
3.1 完整链路的分步拆解
把TOC Relevance放进整个召回链路看,它的位置在查询理解之后、向量检索之前。完整流程大致是这样:
- 用户输入query
- 查询预处理(改写、扩展、意图识别)
- TOC相关性评估:确定相关目录节点集合
- 根据节点集合,映射到对应的chunk子集
- 在子集内做向量检索
- 检索结果重排、拼接
- 送入生成模型
第3步和第4步之间的"映射"是很多人会卡住的地方。目录节点和chunk之间不是天然对应的,需要在建索引阶段就建立好映射关系。MemoryBear的做法是:每个chunk在入库时,记录它所属的目录路径(比如["配置管理", "环境变量"]),这样当TOC评估选出节点后,可以直接用路径前缀匹配筛出候选chunk。
3.2 为什么这个位置是最优的
有人会问:为什么不先做向量检索,再对结果做目录过滤?这样不是更简单吗?
区别在于召回空间的大小。先检索再过滤,意味着向量检索仍然在全量chunk上进行,那些属于不相关章节、但语义相近的chunk依然会进入候选池,占用top-k名额。而先做TOC评估再检索,是把不相关章节的chunk在检索前就排除掉,top-k名额全部留给相关子树内的chunk,召回精度自然更高。
代价是TOC评估本身有一次LLM调用开销。但这次调用的输入是目录(几十个短标题),输出是几个编号,token量很小,延迟通常在几百毫秒级别。相比它带来的召回质量提升,这个成本是划算的。
3.3 与向量检索的协同关系
TOC评估不是要取代向量检索,而是给它划定战场。目录节点解决的是"大方向对不对"的问题,向量检索解决的是"子树内部哪一段最相关"的问题。两者是粗筛和精筛的关系。
这里有个实践中的坑:如果TOC评估选出的节点集合太小(比如只选了一个节点),而正确答案恰好在该节点的兄弟节点下,就会直接漏召回。所以MemoryBear在实现里做了一个召回兜底:TOC评估选出的节点集合,会额外扩展一层兄弟节点作为候选,避免因为目录划分粒度和问题边界不完全对齐导致的漏召。
注意:兜底扩展的幅度要控制。扩一层兄弟节点通常没问题,扩两层以上就接近全量检索了,TOC评估的意义会被稀释。我一般把扩展控制在"选中节点 + 其直接兄弟"这个范围。
4. 实操落地:从Prompt调试到效果验证
4.1 构造测试集:怎么判断TOC评估准不准
要调优TOC评估,首先得有一套能衡量它的测试集。我的做法是人工标注一批"问题-正确目录节点"的对应关系,规模不用大,几十条就能看出趋势。
标注的时候有个技巧:不要只标一个"唯一正确节点",而是标出"必须命中的节点"和"可以接受的节点"。因为很多问题的答案确实跨章节,硬性要求命中唯一节点会低估评估效果。比如问"如何修改默认端口",正确答案可能在"配置管理"下,但"安装部署"章节里也可能提到,这两个都算可接受。
评估指标上,我主要看两个:命中率(必须命中的节点是否被选中)和精确率(选中的节点里有多少是真正相关的)。命中率低说明漏召回,精确率低说明引入了噪声。两者要平衡看,不能只盯一个。
4.2 Prompt迭代中的典型问题与修法
调试TOC Relevance User Prompt的过程中,我遇到过几类高频问题,列出来供参考:
| 问题现象 | 根本原因 | 修法 |
|---|---|---|
| 模型总是选很多节点 | 指令里没有数量约束 | 加一句"最多选择3个最相关的节点" |
| 模型输出解释文字而非编号 | 输出格式约束不够强硬 | 在指令末尾强调"只输出编号数组,不要任何其他文字" |
| 完全不相关的问题也硬选 | 缺少弃权机制 | 明确"无相关节点时返回空数组" |
| 相近节点反复混淆 | 节点标题语义区分度低 | 预处理时补充节点描述或关键词 |
其中"相近节点混淆"是最难处理的。比如"用户管理"和"权限管理"两个节点,如果文档里这两块内容有交叉,模型很容易选错。这种情况下,光靠节点标题不够,需要在候选列表里给每个节点补一句简短的描述,帮助模型区分。这个描述可以从该节点下chunk的摘要里自动生成,不需要人工写。
4.3 端到端效果怎么验证
TOC评估本身的准确率是一回事,它对最终回答质量的影响是另一回事。我建议做A/B对比:一组走完整的TOC分层召回,一组走扁平向量召回,用同一批问题跑,对比最终答案的准确率和召回片段的命中情况。
实测下来,在结构化文档场景下,分层召回的答案准确率通常有明显优势,尤其是那些"答案藏在特定章节"的问题。但在非结构化文档(比如会议纪要、聊天记录)场景下,目录本身就不清晰,TOC评估的价值会大打折扣,这时候扁平检索反而更稳。
所以是否引入TOC评估,要先判断你的文档有没有清晰的、语义化的目录结构。这是前提条件,没有这个前提,后面的优化都是空中楼阁。
5. 踩过的坑与性能权衡
5.1 目录质量决定上限
我踩过最深的坑,是拿一份目录结构混乱的文档去做TOC评估。那份文档的目录里充斥着"其他""杂项""待整理"这类节点,还有大量层级错乱的情况。结果就是模型怎么调都调不准,因为输入本身就是噪声。
后来我的做法是:在入库前加一道目录清洗。把信息量过低的节点合并或剔除,把层级错乱的节点重新归位,对于确实无法归类的零散内容,单独放到一个"未分类"节点下,并在prompt里告诉模型这个节点是兜底用的,优先级最低。
目录清洗这一步,投入产出比极高。花半天时间整理目录,可能比花一周调prompt的效果还好。
5.2 延迟与精度的取舍
TOC评估引入了一次额外的LLM调用,这是实打实的延迟。如果做多层递进(先选章再选节),就是两次甚至三次调用,延迟会叠加。
我的优化思路是:能一次搞定的不要分层。如果文档目录节点总数在可接受范围内(比如50个以内),直接一次评估选到二级节点,比先选章再选节要快。只有当目录规模确实很大时,才值得用分层递进。
另一个优化是缓存。对于高频重复的query,TOC评估结果可以缓存复用。很多实际场景下,用户的问题集中在少数几个主题上,缓存命中率不低。
5.3 什么时候该放弃TOC评估
不是所有场景都适合上TOC评估。以下几种情况,我建议直接用扁平检索:
- 文档本身没有目录,或者目录是自动生成的、语义质量差
- 文档规模很小(比如就几十个chunk),扁平检索已经够用
- 问题类型高度发散,用户什么都问,目录覆盖不全
- 对延迟极度敏感,无法接受额外的一次LLM调用
技术选型要看场景,不要因为一个方案在某个场景下效果好,就无脑套用到所有场景。TOC评估解决的是"结构化长文档的召回精度"问题,脱离这个前提,它的价值就不成立了。
6. 几个能直接抄的Prompt片段与配置建议
6.1 目录相关性评估的核心指令模板
下面这段是我在MemoryBear基础上调整过的指令模板,可以直接拿去改:
你是一个文档目录相关性评估器。 用户问题:{query} 以下是文档的目录节点列表: {toc_nodes} 请从上述节点中,选出与用户问题最相关的节点,最多选择{n}个。 判断标准:用户问题的答案最可能出现在哪个节点所代表的章节内容中。 如果没有任何节点与问题相关,返回空数组。 只输出节点编号组成的JSON数组,不要输出任何解释文字。关键点在于"答案最可能出现在哪个节点"这句,它把判断标准从"语义相似"拉到了"答案归属",这个视角的转换能显著提升准确率。因为语义相似的节点可能很多,但答案真正归属的节点通常只有一个或少数几个。
6.2 节点描述的自动生成思路
对于语义区分度低的节点,可以在候选列表里给每个节点补一句描述。生成方式很简单:取该节点下若干chunk的开头片段,让模型总结成一句话。这个总结可以离线做好,存进索引,评估时直接拼进prompt。
格式上建议这样组织:
1. 配置管理 —— 涉及系统参数设置、环境变量、配置文件格式等内容 2. 数据操作 —— 涉及数据的导入导出、批量处理、格式转换等内容描述控制在20字以内,太长会稀释注意力,太短又起不到区分作用。
6.3 参数配置的参考值
几个我实测下来比较稳的参数:
- 单次评估最多选中的节点数:3个(超过3个基本等于没筛)
- 兜底扩展的兄弟节点层数:1层
- 目录节点总数超过50时启用分层递进
- 节点描述长度上限:20字
- 评估结果缓存有效期:按query分布决定,高频场景可以设长一些
这些值不是绝对的,要根据你的文档特点和问题分布去调。但作为起点,它们能帮你少走一些弯路。
7. 我对这套方案的真实体会
TOC相关性评估这套东西,本质上是在用文档作者已经做好的知识组织工作,来辅助检索。目录是作者对内容的一次人工聚类,这个聚类信息比向量空间里的距离更贴近人类的语义理解。把它用起来,等于站在了作者的肩膀上。
但它不是银弹。我见过有人把TOC评估吹成召回质量的万能解药,实际用下来,它在结构化文档上是利器,在非结构化场景下可能还不如老老实实调向量模型。判断标准很简单:你的文档目录,是不是一个人类看了能快速定位知识的结构?如果是,TOC评估值得上;如果不是,先把目录整理好再说。
最后分享一个小技巧:TOC评估的日志一定要留全。每次评估的query、候选节点、模型选中的节点、最终召回结果,都记下来。积累一段时间后,你会发现哪些节点经常被误选、哪些query经常评估失败,这些都是优化prompt和调整目录结构的一手依据。光靠拍脑袋调prompt,效率太低了。