去年我接了一个企业知识库的 RAG 项目,文档不到两百篇,embedding 模型换了好几个,向量库也从开源切到托管,结果一个很基础的问题始终答不圆:员工出差回来,餐饮发票报销到底要附哪些材料、单次上限是多少?答案分散在三份制度文件里,单路向量检索每次只召回其中一段。我把 TopK 调到 10、调到 20,召回率反而更难看。后来我把检索层重做成了“结构化知识条目 + 原始切片”的双层 RAG,这类问题才算真正解决。
这篇文章不聊空概念,直接讲三件事:为什么单一向量库在知识割裂面前扛不住;双层 RAG 的完整设计逻辑和落地方案;以及我在真实知识库上做的对比实测。如果你也在做 RAG 知识库,尤其是制度问答、产品文档问答、医疗或法律文本这类对精确引用要求高的场景,这篇应该能给你一些直接能用的思路。
1. 单一向量库 RAG 的“知识割裂”困局:问题出在哪
1.1 向量检索本质上是“形似”匹配,不是“理解”
向量检索的流程大家都很熟:文档切成块,embedding 成向量,存进向量库,用户问题也 embedding 一次,然后算余弦相似度,取 TopK 返回。这个流程能成立的前提是:答案所在的文本片段,和问题的语义向量足够接近。
这个前提在简单事实问答里成立。问“公司年假怎么计算”,答案段落里大概率有“年假”这个词或者相近语义,向量一拉就出来。可一旦问题需要把几段分散的信息拼起来,单靠相似度就很容易漏。
打个比方:单一向量库像一个把整本百科全书按页码撕碎、再按字面相关性排列的档案柜。每张纸片都能被找到,但纸片与纸片之间的逻辑关系是丢失的。“张三出差,餐饮发票要附审批单”这件事,可能在《差旅制度》里写了前半句,在《报销细则》里写了后半句,在《审批权限表》里规定了超过多少金额需要总经理批。三张纸片各自都能被相关问题召唤出来,但当你没有把它们组织成一张关系网时,召回哪张靠的是运气,不是逻辑。
这不是 embedding 模型不够好,而是单一向量库这种组织形式本身就不承载“知识之间的连接”。我见过不少团队在 Hit Rate 上不去的时候,第一反应是换更强的 embedding 模型,或者把 chunk 调小调大,折腾一圈发现提升有限,就是这个原因——你在优化一个“形似匹配器”,却希望它能完成“逻辑推理”的活。
1.2 一例多跳问答的完整翻车现场
说一个我当时印象特别深的失败案例。知识库里有《差旅管理办法》《费用报销制度》《销售中心授权手册》三份文档,用户问:
“销售部门员工出差拜访客户,回来报销住宿费,需要走审批流程吗?如果需要,审批人是谁?”
正确答案要求把三处信息拼起来:
- 《差旅管理办法》规定:员工出差需提前提交出差申请,由部门负责人审批;
- 《费用报销制度》规定:住宿费报销需附发票、银行流水单和出差申请单;
- 《销售中心授权手册》规定:销售部门住宿费单笔 5000 元以下由销售总监审批,以上由总经理审批。
我当时用的单一向量库,TopK=5 时,召回了第二条“住宿费报销需附发票、流水单”相关内容,第一条和第三条都没进候选。这还不是最惨的,最惨的是“审批人是谁”这个信息点,在向量空间里跟“5000 元以上需总经理审批”这句话的相似度并不高,它很容易排到十名开外。
我一开始以为这是 TopK 太小,直接把 TopK 调到 20。结果第三条确实出现了,但上下文也被大量无关片段塞满,LLM 反而被噪声带偏,最后给出一句“住宿费报销需要总经理审批”——完全忽略了单笔 5000 元以下应该是销售总监审批。这就是典型的“召回不全导致答错,召回太杂也导致答错”。
1.3 把向量数量堆上去,Hit Rate 反而下降
为了确认“是不是切片越多效果越好”,我当时做了一个小实验:同一个知识库,分别切成 300、800、1500 个切片,用 20 道多跳题测 Hit Rate(题干所需的证据片段是否全部被召回)。结果非常反直觉:
| 切片数量 | 多跳题 Hit Rate |
|---|---|
| 300 | 12 / 20 |
| 800 | 10 / 20 |
| 1500 | 8 / 20 |
切片越多,单个片段的语义越窄,被问题向量命中的概率越低;同时无关片段数量暴涨,向量检索的精度被噪声稀释。哪怕上下文窗口能塞下更多 token,真正关键的证据也会因为排名靠后被挤出窗口。
所以“把更多文档塞进向量库”看起来是让知识更全面,实际上是在放大语义噪声。知识库不是越全越好,而是组织得越清楚越好。这也是我开始认真考虑双层架构的直接原因。
2. 双层 RAG 的设计逻辑:先让“地图”定位,再让“现场”说话
2.1 结构化知识条目:把散落信息钉在关系网上
既然问题出在“知识割裂”,那解决方向就很明确:先让知识之间产生连接。我引入的第一层是“结构化知识条目”,它包含三类信息:
- 实体:员工、部门、制度、条款、流程节点、表单、金额阈值等;
- 关系:适用、规定、需要、提交、审批、例外等;
- 属性:单笔上限、最短时长、例外条件等。
这些条目组成一张知识图,它不依赖向量相似度定位信息,而是直接按逻辑链走。比如前面那个报销问题,图里会有这样的路径:
销售部 →(适用)→ 差旅管理办法 →(规定)→ 出差申请单 →(审批)→ 部门负责人
销售部 →(适用)→ 销售中心授权手册 →(规定)→ 住宿费单笔 5000 元阈值 → 审批人(销售总监 / 总经理)
这种设计思路跟 GraphRAG 和本体 RAG 是一脉相承的。GraphRAG 先用 LLM 抽取实体关系再构图,方向跟我做的很像;但我的做法更轻量——“本体”不追求学术级的完备,只覆盖问答场景真正需要的实体类型和关系类型。核心是把“文本相似”升级为“逻辑连通”。
2.2 原始切片:保留上下文完整性的“现场证据”
结构化层擅长定位,但它不能替代原文。原因有两个:
一是抽取会丢细节。比如“报销时必须提供增值税专用发票,餐饮类发票需备注实际消费人数”这种话,很难完整塞进一个三元组。无论关系抽取做得再好,三元组都是对原文的压缩,压缩必然有损。
二是用户需要看原文。制度问答、医疗问答、法律问答,天然要求答案附出处。如果回答里说“按《费用报销制度》第五条规定”,那用户一定要能点开原文核对。没有原始切片支撑的结构化层,就像一张没有门牌号的地图——知道位置,但进不了门。
所以第二层保留原始切片,它的职责是“现场取证”:结构化层定位到具体章节、条款之后,回到原始文档读取完整段落,把上下文补回来,保证答案里每一句话都能在原文找到依据。
2.3 两层协作:粗筛定位再精读原文
两层不是各查各的,而是有明确的主从顺序:
- 把问题解析出实体、关系和查询意图;
- 结构化层做“窄而准”的定位:根据实体和图谱关系找到候选知识条目,以及它们关联的原始切片 ID;
- 原始切片层做“广而全”的补全:基于切片 ID 读取完整上下文,同时用向量召回补充可能遗漏的相关片段;
- 合并候选,去重、排序,连同原文切片一起交给 LLM 生成答案。
和单一向量库最大的差别在于:检索的第一个动作不是算相似度,而是先在逻辑图上问一句:这问题涉及谁、牵涉什么关系、落到哪些条款。这一步做好之后,向量层只负责兜底和扩展,而不是唱主角。
3. 结构化层落地细节:本体设计、知识抽取与增量维护
3.1 从真实问题反推本体设计
很多团队做知识图谱,一上来就参考公开本体,什么 FOAF、Schema.org 都往项目里套,最后建出来的结构很“完整”,但问答根本用不上。我踩过这个坑,后来改成一套笨但有效的办法:从真实问题反推本体。
拿 50 道制度类问答逐个拆解,看它们涉及哪些实体和关系。最后我沉淀下来的本体结构长得像这样:
| 实体类型 | 常见实例 | 关键属性 |
|---|---|---|
| 部门 | 销售部、财务部、行政部 | 负责人、是否一线部门 |
| 员工 | 张三、销售专员 | 所属部门、职级 |
| 制度/文档 | 差旅管理办法、费用报销制度 | 版本号、生效日期 |
| 条款 | 3.2 条、5.1 条 | 所属制度、原文切片 ID |
| 流程节点 | 出差申请、费用报销、审批 | 前置条件、后置节点 |
| 表单 | 出差申请单、报销单 | 必填字段、附件要求 |
| 金额阈值 | 单笔 5000 元 | 适用部门、审批角色 |
实体类型控制在 20 到 30 个之间,关系类型控制在 10 到 15 个之间,足够覆盖绝大多数制度问答。设计本体时有三个原则值得记住:
- 只建问答需要的结构,不要建百科全书式的结构;
- 关系和属性宁可少而准,不要多而偏,关系一多,抽取错误率会指数上升;
- 每条知识条目必须记录来源切片 ID,这是两层架构能协作的前提。
3.2 LLM 抽取 + 规则校准的双保险
结构化层的构建我用的是“LLM 离线批处理抽取 + 规则校验 + 人工抽检”的组合。
离线阶段,把每篇文档切片后,交给 LLM 批量抽取 JSON 结构,格式大概是:
{ "head": "销售部", "relation": "适用", "tail": "差旅管理办法", "source_chunk_id": "doc-032-chunk-007", "confidence": 0.91 }LLM 抽取有个必须防的问题:编造关系。它经常抽出一个看起来合理、但原文根本没提的关系。我加了三道规则校验:
- head 和 tail 必须在原文切片中出现过,或者能通过同义词映射对齐到原文;
- relation 必须在预定义关系集合内,集合之外的直接丢弃;
- 如果 head 和 tail 只有一个在原文中出现,标记为“待人工复核”,不直接入库。
整个抽取跑完,我还会抽 20% 人工核对。有一次抽出来关系准确率只有 78%,我回头把 prompt 改了一版,要求模型必须引用原文片段作为抽取依据,准确率立刻提到 92%。所以抽取 prompt 里一定要有“依据”字段,没有依据的抽取结果宁可弃掉。
3.3 增量更新:不要让结构化层变成新的信息孤岛
结构化层最大的维护风险是“陈旧”。制度文档经常修订,如果旧条目还留在图里,新条目又加了进来,答案就会新旧混杂。我在设计里加了三个机制:
- 版本字段:每条知识条目带 document_version,查询时只取当前有效版本;
- 文档级重建:某篇文档更新时,先删掉所有 source 指向该文档的知识条目,再重新抽取入库,不做局部补丁式修改;
- 失效标记:文档被删除时,关联的知识条目和切片全部置为 inactive。
这里有个隐蔽的坑:如果知识条目不带版本信息,你只看图结构很难发现它已经过时。好比地图上还标着一座已经拆掉的桥,不导航过去你根本不知道。单一向量库也有类似问题,但至少你能看到原文修改时间;在结构化层,过期条目和新条目长得一模一样,版本管理是硬要求。
4. 原始切片层的工程取舍:分块、链接与召回
4.1 分块策略选择:结构感知优先于固定窗口
单一向量库时代我常用固定窗口分块,比如每 512 字符一块、重叠 128 字符,效果只能说凑合。后来改成“结构感知分块”:按文档标题层级切出章节,章节太大再按段落和列表项向下切。理由很简单——制度文档的章节结构本身就是最强的语义边界,标题和段落的切分点通常正好是逻辑的起止点。
我最终用的是“结构感知 + 父子分块”的组合:
| 分块策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定窗口 | 实现简单,长度可控 | 频繁切断语义完整的段落 | 弱结构化文本 |
| 父子分块 | 兼顾上下文和精确性 | 索引量翻倍 | 需要精确引用+上下文扩展的问答 |
| 结构感知分块 | 贴合文档逻辑边界 | 依赖文档原始结构质量 | 制度、手册、论文等层级分明的文档 |
实际操作中,我用章节级切片作为“父块”,段落级切片作为“子块”,两者都建索引。段落级切片用于精确引用,章节级切片用于上下文扩展。召回一个段落时,可以顺带把它的父章节也捞出来,这样 LLM 生成答案时既有细节又不会断章取义。
4.2 切片与知识条目的双向链接
结构化层和原始切片层必须能互相跳转,这里我说“双向”是有原因的:
- 正向链接:每条知识条目记录 source_chunk_id,指向段落级切片,这是前面设计本体时就强制留好的字段;
- 反向链接:每个切片的 metadata 里记录 entity_refs,即该切片提到了哪些实体和知识条目 ID。
只做正向链接会漏掉一种情况:用户的问题比较泛,没有明确的实体名,向量层先召回了某个切片,但它对应的知识条目在图里还有相邻证据。没有反向链接,就倒不回去查这些相邻证据。双向链接让两层变成真正的协作关系,而不是两个独立检索器。
4.3 双路召回后的重排:把“候选池”变成“答案证据”
两路召回会得到两个候选集合,融合策略很大程度上决定了最终质量。我用过三种方案:
- 加权相加:结构化层召回的切片权重设 1.3,向量层设 1.0。经验是结构化层的命中属于“强证据”,向量层只能当补充;权重太低会漏,太高则会对抽取错误过度自信。
- RRF(Reciprocal Rank Fusion):把两路候选按各自排名算倒序分再求和。稳,但对“结构化层命中”没有体现任何额外信任。
- 交叉编码器重排:把候选和问题拼接成对,跑一个精排模型,取 TopK 送 LLM。效果最好,代价是多一次模型推理。
我的项目最终是“加权相加 + 交叉编码器”:先按权重合并两路候选,取 Top25,再用交叉编码器精排,最后取 Top5 作为生成依据。另外注意一个细节:不同检索器的分数尺度不同,直接相加前要归一化,否则向量层的 0.8 分会把图层的 1.3 权重压下去。
5. 可复用的实现骨架:检索流程与核心代码
5.1 两层召回的主流程
下面这段代码我把关键逻辑都简化成可读的形式,方便你照着搭原型。完整项目里我用的是图数据库存结构化层、向量库存切片,这里为了讲清楚逻辑,先用 networkx 和向量库接口代替。
def double_layer_retrieve(question: str, top_k: int = 5): # 第一步:解析问题中的实体和关系 entities, relations = parse_question(question) # 第二步:结构化层定位 graph_candidates = structured_search(entities, relations) # 第三步:原始切片层召回 vector_candidates = vector_search(question, top_k=20) # 第四步:链接扩展与融合重排 chunk_pool = merge_candidates(graph_candidates, vector_candidates) ranked_chunks = rerank(question, chunk_pool, top_k=top_k) return ranked_chunks主流程看着简单,真正的细节都藏在这几个函数里。
5.2 结构化层:从实体出发的图遍历查询
假设知识条目已经以“头实体 + 关系 + 尾实体”的三元组形式存进有向图。给定问题解析出的实体集合,我从这些种子节点出发,沿着关系边扩展邻居节点,把命中的原始切片 ID 聚合起来。
import networkx as nx def structured_search(G, entities, relations): # candidate_chunks: 切片ID -> 权重 candidate_chunks = {} seed_nodes = [e for e in entities if G.has_node(e)] for node in seed_nodes: # 沿关系边找邻居 for neighbor in G.neighbors(node): edge_rel = G[node][neighbor].get("relation", "") if relations and edge_rel not in relations: continue chunk_id = G[node][neighbor].get("source_chunk_id") if chunk_id: # 图直接命中的切片给强权重 candidate_chunks[chunk_id] = candidate_chunks.get(chunk_id, 0) + 1.3 # 实体的属性也可能指向证据切片 for attr in G.nodes[node].get("attrs", {}).values(): chunk_id = attr.get("source_chunk_id") if chunk_id: candidate_chunks[chunk_id] = candidate_chunks.get(chunk_id, 0) + 1.0 return candidate_chunks实际项目里数据量大了之后,networkx 会吃力,我换成了图数据库,用类 Cypher 查询做同样的多跳遍历。上面这段 networkx 版本的好处是能让你彻底理解“从实体出发,沿关系找证据”的逻辑,换成图数据库只是语法差异。
5.3 原始切片层:向量召回与链接扩展
向量层先做常规召回,然后做一步“链接扩展”:根据候选切片里的 entity_refs,反查这些实体在图里的相邻切片,把它们也拉进候选池。
def vector_search(question, top_k=20): q_vec = embed(question) hits = vector_db.search(q_vec, top_k=top_k) return {hit["chunk_id"]: hit["score"] for hit in hits} def expand_with_links(vector_scores, chunk_to_entities, G): expanded = dict(vector_scores) # 第一轮:向量命中的切片关联的知识条目实体 related_entities = set() for cid in vector_scores: related_entities.update(chunk_to_entities.get(cid, [])) # 第二轮:从这些实体出发,找它们在图里的相邻切片 for ent in related_entities: if not G.has_node(ent): continue for neighbor in G.neighbors(ent): chunk_id = G[ent][neighbor].get("source_chunk_id") if chunk_id and chunk_id not in expanded: # 新的扩展证据,基础权重给少一点 expanded[chunk_id] = 0.6 return expanded这一步的价值在于:向量召回只要“蹭到”一个切片,实体链接就能把同一逻辑链上的邻居切片全部带出来。相当于向量负责开门,图负责把屋子里所有相关的抽屉都打开。
5.4 融合排序与答案组装
最后是融合重排。先对两路分数做归一化,合并成一个候选池,再用交叉编码器精排:
def normalize_scores(scores): if not scores: return scores max_s = max(scores.values()) min_s = min(scores.values()) denom = max_s - min_s if max_s != min_s else 1.0 return {k: (v - min_s) / denom for k, v in scores.items()} def merge_and_rerank(question, graph_scores, vector_scores, reranker, top_k=5): graph_scores = normalize_scores(graph_scores) vector_scores = normalize_scores(vector_scores) combined = {} for cid, score in graph_scores.items(): combined[cid] = combined.get(cid, 0) + score * 1.3 for cid, score in vector_scores.items(): combined[cid] = combined.get(cid, 0) + score # 取前 25 做精排 ranked = sorted(combined.items(), key=lambda x: x[1], reverse=True)[:25] pairs = [(question, load_chunk_text(cid)) for cid, _ in ranked] rerank_scores = reranker.predict(pairs) final = sorted(zip([cid for cid, _ in ranked], rerank_scores), key=lambda x: x[1], reverse=True)[:top_k] return [cid for cid, _ in final]送进 LLM 之前的 prompt,我会把原始切片按证据顺序排在前面,并明确要求模型为每句话标注来源切片编号:
请根据以下材料回答问题。材料中已标注 [1][2][3] 等编号,回答的每个关键结论必须标明依据编号。若材料不足,直接说明无法回答。 [1] 差旅管理办法第3.2条:员工出差需提前提交出差申请,由部门负责人审批。 [2] 费用报销制度第5.1条:住宿费报销需附发票、银行流水单和出差申请单。 [3] 销售中心授权手册第2.1条:销售部门住宿费单笔5000元以下由销售总监审批,以上由总经理审批。 问题:销售部门员工出差回来报销住宿费,需要审批流程吗?审批人是谁?这个 prompt 结构看着普通,但它隐含着两层架构的成果:材料 1、2、3 不是随机 TopK,而是结构化层先锁定了“销售部 - 差旅管理办法 - 住宿费 - 审批人”这条逻辑链,再由切片层把三份文档的原文完整捞出来。
6. 实测对比:在真实知识库上,双层 RAG 到底强在哪
6.1 评测集与指标设计
为了验证“双层 RAG 更好用”不是自我感觉,我在一个真实制度型知识库上做了一轮对比评测。知识库包括员工手册、差旅管理办法、费用报销制度、销售中心授权手册、采购管理制度等,约 180 篇文档,测试集 60 道题,分成三类:
- 单跳题 20 道:答案在一处文本,比如“入职满一年年假多少天”;
- 多跳题 20 道:答案需要两段以上信息拼接,比如文章开头那个报销审批问题;
- 精确引用题 20 道:答案必须引用原文条款,不能自由改写。
指标也设了三个维度:
| 指标 | 含义 |
|---|---|
| Hit Rate | 生成答案所需的全部证据切片是否出现在 TopK 中 |
| 答案准确率 | 人工打分的完整回答正确率 |
| 引用准确率 | 模型标注的依据编号与实际证据切片是否对得上 |
对比的基线就是当初的单一向量库方案,同样的 embedding、同样的分块、同样的生成模型,只是没有结构化层。
6.2 单路向量库 vs 双层 RAG 的实测数据
结果如下:
| 指标 | 单一向量库 | 双层 RAG |
|---|---|---|
| Hit Rate | 31 / 60(51.7%) | 53 / 60(88.3%) |
| 答案准确率 | 27 / 60(45.0%) | 52 / 60(86.7%) |
| 引用准确率 | 23 / 60(38.3%) | 55 / 60(91.7%) |
逐类拆解看一下:
单跳题两者差距不大,单一向量库靠相似度就能解决,双层 RAG 的优势不明显。这也是为什么我建议简单 FAQ 别上双层架构,性价比太低。
多跳题差距最大。单一向量库经常只召回一半证据,LLM 只能基于残缺材料作答;双层 RAG 因为结构化层先定义了逻辑链,证据切片是一组一组出现的,很少出现漏掉中间环节的情况。
精确引用题是双层 RAG 最舒服的赛道。知识条目本身就带 source_chunk_id,答案引用直接落在原文切片上,引用准确率从 38.3% 拉到 91.7%。这个提升在制度问答场景里是决定性的——用户不怕模型答得不全,怕的是答错了还一本正经引用一个不存在的条款。
6.3 成本代价与适用边界
当然,双层 RAG 不是免费的。我在这轮实测里付出的额外成本是:
- 离线抽取:180 篇文档用中等模型做知识抽取,约 1 小时;加上人工抽检和修正,大半天;
- 存储:图结构、向量库双份数据,多出的存储可以忽略,但多了一套要维护的索引;
- 在线延迟:多了结构化查询一轮,实测单次问答增加约 80 到 150 毫秒,对产品体验影响不大。
所以我的结论是:先判断你的问答场景是不是真的存在“知识割裂”。如果每个答案都能从单一文档的单一段落里找到,老老实实用向量库就行;如果问题的答案普遍需要跨文档拼接、需要引用具体条款,那么花一天时间做结构化层,回收的 Hit Rate 和引用准确率提升是值得的。
另一方面,也要提醒一句:结构化层做出来后不能放着不管。文档一更新就要触发重抽取,否则旧条目和新条款混在同一个图里,双层架构反而会比单一向量库更容易给出“看着有依据、实际上过时”的回答。
最后聊两句落地体会
做完这次改造,我最大的感受是:RAG 的瓶颈常常不在生成模型,而在检索层对知识组织方式的理解。你先把知识连成一张网,召回自然变准;你放任知识摊成一片纸屑,换什么模型都救不回来。
另外一点很实际:双层架构不要一上来就追求大而全的本体。从 50 条真实问题反推出来的 30 个实体类型,比精雕细琢的 1000 类本体有用得多。先让结构化层覆盖你最高频的 80% 问题,跑通整个链路,再慢慢补关系、补实体,才是最稳的节奏。我后来每接手一个 RAG 项目,第一句建议都是:先别急着换 embedding、调 chunk size,去看看你的知识是不是还摊在平面上,有没有可能先把它钉成一张网。