乐于分享
好东西不私藏

rag检索的本质不是向量搜索:从文档表示、混合排序到多跳证据图

rag检索的本质不是向量搜索:从文档表示、混合排序到多跳证据图


  今日碎碎念

一套rag系统可能把正确政策排在第一名,最后仍然给出错误答案,这个现象并不矛盾,因为“找到一段相似文本”和“收集足以支持结论的完整证据”是两个不同目标,前者只需要某个chunk与问题接近,后者还要求条件完整、版本正确、权限合法,并且不同证据之间可以建立推理关系

仍然用这个问题作为例子:

比较2024和2025年a产品退款政策的变化,并说明新规则是否适用于已激活设备

它表面上是一句问话,内部却包含两份时间不同的制度、一个设备状态条件和一个比较操作,如果系统只返回2025年政策中“14天内可退”的片段,模型很可能补出“退款期从7天统一延长到14天”,即使这段文字来自正确文档,结论依然可能错,因为2024年的7天针对未激活设备,2025年的14天只针对企业客户,两条证据没有在同一条件维度上对齐

要解决这类问题,不能继续把rag理解成“切块后做向量搜索”,更准确的理解是:rag在权限、时间和上下文预算约束下,构造一个能够支持答案的证据集合。

先定义目标:我们要找的不是相似文本,而是最小充分证据集

设用户问题为,用户权限和查询时间组成安全上下文,知识库中真正支持答案的证据集合为,检索器返回的前个chunk记为,那么召回阶段最基本的目标不是最大化平均相似度,而是提高证据覆盖率:

这个定义揭示了一个常见误区:如果问题需要2024政策、2025政策和激活条件三份证据,系统只找到其中两份,即使它们都排在前两名,覆盖率也只有;模型可以根据两份材料写出一段流畅回答,但缺失的第三份证据会被模型记忆、语言先验或错误类比填补。

进一步看,最理想的证据集合还应该“充分但不过量”,证据太少无法支持结论,证据太多又会引入旧版本、重复段落和无关条件,因此上下文选择可以写成一个受约束优化问题:

约束是总token数不能超过预算、每份文档不能占据过多位置、所有chunk必须满足租户、acl和有效期要求,这也是为什么“把top_k从5调到50”通常不会直接改善答案,候选变多只扩大了搜索空间,并没有完成证据选择

chunk不是长度参数,而是四种边界的共同原子

很多系统把chunk大小当作一个通过实验调节的超参数,例如500字符、100字符重叠,这个视角太窄,因为chunk同时承担四种职责:它是bm25计算词频与文档长度的统计边界,是向量模型压缩语义的表示边界,是acl和有效期过滤的安全边界,也是最终citation回到原文的引用边界

固定长度切块的问题不只是“可能把一句话切开”,它还会改变后续每一层的行为:标题和正文分离后,bm25失去产品名;条件句被切走后,向量表示只保留“14天退款”的中心语义;新旧政策落入同一chunk后,时间过滤无法选择其中一个版本;引用回到原文时,用户只能看到结论的一半

更合理的做法是先恢复文档结构,再建立parent-child两级表示,child足够小,用于提高召回定位精度,parent保留完整章节,用于回答时恢复条件上下文:

@dataclassclassChunk:    chunk_id: str    doc_id: str    parent_id: str    text: str    heading_path: tuple[str, ...]    metadata: dict[strobject]classParentChildChunker:defchunk(self, *, doc_id, markdown, version, valid_from, valid_to):        parents, children = [], []for index, section inenumerate(MarkdownStructureParser().parse(markdown)):            heading = " > ".join(section.heading_path) or"正文"            parent_id = self._id(doc_id, version, str(index), heading)            parent = Chunk(                chunk_id=parent_id,                doc_id=doc_id,                parent_id=parent_id,                text=f"标题路径:{heading}\n{section.body}",                heading_path=section.heading_path,                metadata={"version": version,"valid_from": valid_from.isoformat(),"valid_to": valid_to.isoformat() if valid_to elseNone,                },            )            parents.append(parent)for child_index, window inenumerate(self._windows(section)):                children.append(Chunk(                    chunk_id=self._id(parent_id, str(child_index), window),                    doc_id=doc_id,                    parent_id=parent_id,                    text=f"标题路径:{heading}\n{window}",                    heading_path=section.heading_path,                    metadata={**parent.metadata, "child_index": child_index},                ))return parents, children

parent-child并不意味着parent越大越好,parent如果是一整本80页手册,child命中后仍会把大量无关文本送给模型,实际系统通常把parent限制在一个二级或三级标题下,再设置最大token数;表格也不能直接沿用段落切法,应该让每个child重复表头并保留完整行,否则“客户类型”和“退款天数”会失去列关系。

chunk id同样不能使用简单自增编号,文档中间增加一段以后,后续编号全部变化,缓存、向量和citation会同时失效,稳定做法是把doc_id、版本、标题路径和内容摘要放入哈希,让相同版本的相同内容可以重复生成同一个id,而不同有效期的政策即使文字一样,也必须形成不同版本

bm25和向量召回解决的是两类不同的信息损失

理解混合检索之前,需要先理解两种检索器各自保留了什么信息

bm25的核心分数可以写成:

其中提高稀有词的权重,表示词在文档中的出现次数,控制词频饱和,控制文档长度归一化,这意味着一个词从出现0次变成1次很重要,从20次变成21次几乎不重要,同时长文档不会仅因为包含更多词就天然得高分

这个机制对err-4217a-pro-2025和具体日期非常有效,因为这些稀有字符串的很高,但它无法自然理解“撤销购买”和“申请退款”是相近意图,只要索引分词和查询词没有重合,分数就可能为0。

向量检索使用bi-encoder分别把问题和chunk压缩成固定维度向量,再计算余弦相似度:

它的优势是文档向量可以离线计算,再通过ann索引快速查找语义邻居,代价是几百个token的信息必须被压进一个固定长度向量,型号、否定词、精确数字和多个条件可能在压缩中变弱;“所有客户14天可退”和“企业客户未激活设备14天可退”在主题上非常接近,在规则含义上却完全不同。

因此混合检索不是为了让两个检索器互相投票,而是为了弥补两种表示的信息损失:bm25保存词项身份,向量保存语义邻近,一个偏向精确符号,一个偏向同义表达。

查询规划不是改写句子,而是为不同索引生成不同观测

同一个原问题直接复制给bm25和向量检索,只完成了接口复用,没有发挥混合检索的价值,查询规划器应该先提取不可丢失的实体、时间和条件,再生成适合不同索引的观测:bm25查询保留产品名、年份和状态词,dense查询则补充同义表达与意图描述

@dataclass(frozen=True)classQueryPlan:    original_query: str    dense_queries: tuple[str, ...]    lexical_queries: tuple[str, ...]    entities: tuple[str, ...]    requires_multihop: bool    requested_top_k: int    time_range: tuple[str | Nonestr | None]asyncdefplan(query: str, llm: LLM) -> QueryPlan:    raw = await llm.complete_json(        system=("只生成检索计划,不回答问题;保留产品名、年份、版本号、""错误码、否定条件和设备状态,分别生成关键词查询与语义查询。"        ),        user=query,        schema=PLAN_SCHEMA,    )return QueryPlan(        original_query=query,        dense_queries=tuple(raw["dense_queries"][:3]),        lexical_queries=tuple(raw["lexical_queries"][:3]),        entities=tuple(raw.get("entities", [])),        requires_multihop=bool(raw.get("requires_multihop")),        requested_top_k=min(max(int(raw.get("requested_top_k"20)), 8), 50),        time_range=tuple(raw.get("time_range", [NoneNone])),    )

这里必须限制查询数和top_k,因为规划器输出会直接放大下游成本,三条dense查询加三条bm25查询、每路50条候选,已经产生300个原始结果,如果没有边界,一个异常计划很容易把重排gpu和向量库同时拖入排队

rrf解决尺度问题,但不负责判断证据是否充分

bm25分数可能是12.8,余弦相似度可能是0.76,直接写0.5 * bm25 + 0.5 * cosine并没有真正实现公平加权,因为两个分数的范围和查询间分布都不同;即使先做min-max归一化,候选集合变化也会改变上下界,线上稳定性仍然有限

rrf只使用每一路中的排名:

一份政策在dense中排第8、bm25中排第2,会得到两路贡献,只在某一路排第1的宣传稿只有一路贡献,这种方法不要求原始分数可比,也不需要先训练校准器

defrrf_fuse(ranked_lists, k: int = 60):    merged, scores = {}, defaultdict(float)for result_list in ranked_lists:for rank, hit inenumerate(result_list, start=1):            scores[hit.chunk_id] += 1.0 / (k + rank)            merged.setdefault(hit.chunk_id, hit)            merged[hit.chunk_id].ranks[hit.source] = rankfor chunk_id, hit in merged.items():        hit.fusion_score = scores[chunk_id]returnsorted(merged.values(), key=lambda hit: hit.fusion_score, reverse=True)

rrf也有明确代价,它只看名次,不看第一名领先第二名多少,如果某一路的第一名置信度极高,这个间距会被丢失;它也不知道三份faq复制了同一条政策,因此融合之后仍要做完全重复和近重复去重,否则同一事实会通过多个chunk重复占据上下文。

cross-encoder为什么更准,也为什么只能放在后面

bi-encoder提前独立编码问题和文档,因此可以在百万级向量中做ann检索,但问题与文档在编码阶段没有直接交互;cross-encoder把二者拼成一个序列:

模型的每一层注意力都能比较问题词与文档词,因此更容易识别“企业客户”“未激活”“截至2025年”这些条件是否对应,代价是每一个候选都需要完整前向计算,无法像向量一样离线预编码。

这决定了正确顺序只能是高召回候选生成在前,昂贵的精排在后:bm25和dense从百万文档中拿回几十条,rrf与去重压缩候选,再让cross-encoder对问题—段落对逐一评分。

result_lists = await asyncio.gather(*sparse_tasks, *dense_tasks)fused = rrf_fuse(result_lists)unique = remove_near_duplicates(fused[: max(40, top_k * 3)])scores = await reranker.score(query, [hit.text for hit in unique])for hit, score inzip(unique, scores, strict=True):    hit.rerank_score = float(score)reranked = sorted(unique, key=lambda hit: hit.final_score, reverse=True)

重排器训练数据也会决定它学到什么,如果正样本只标注“主题相关”,模型仍可能把缺少条件的段落排在前面,高质量训练对应该包含难负例,例如同一产品、同一年份、退款主题一致,但客户类型或激活状态错误的chunk。

上下文打包更接近背包问题,而不是截取前n条

重排后的候选还不能直接全部放进prompt,因为每个chunk有不同token成本,不同文档之间存在重复,同一问题可能需要多个年份的覆盖,选择过程更接近带多样性约束的背包问题。

一个实用启发式是按重排分数遍历候选,在token预算内加入chunk,同时限制每份文档的最大数量:

defpack_context(hits, *, token_budget, max_chunks_per_doc=3):    packed, per_doc, used = [], defaultdict(int), 0for hit insorted(hits, key=lambda item: item.final_score, reverse=True):if per_doc[hit.doc_id] >= max_chunks_per_doc:continue        estimated = estimate_tokens(hit.text)if used + estimated > token_budget:continue        packed.append(hit)        per_doc[hit.doc_id] += 1        used += estimatedreturn packed

它不是全局最优,但至少阻止一份长faq占据全部上下文;更进一步可以为时间维度和实体维度设置覆盖槽位,先确保2024、2025和激活条件各有证据,再把剩余预算分给高分chunk,这比单纯追求总相关性更符合比较问题的需求。

多跳rag不是多搜几次,而是条件化检索

单跳检索假设所有必要证据都能由原问题直接召回,多跳问题则要求后续查询依赖前一步发现的实体或条件,从概率角度看,第二跳寻找的是,而不是再次计算

对于退款政策问题,可以生成一个有向无环图:2024政策与2025政策并行检索,设备适用范围依赖2025政策中的产品版本,最终比较节点依赖前三个节点;dag让依赖关系显式化,也使无依赖节点能够并行执行

asyncdefexecute(nodes, user, rag):    completed = {}    evidence = []whilelen(completed) < len(nodes):        ready = [            node for node in nodesif node.node_id notin completedandall(dep in completed for dep in node.depends_on)        ]ifnot ready:raise RuntimeError("unexecutable or cyclic plan")asyncdefrun(node):            dependency_evidence = [                hit.text[:500]for dep in node.depends_onfor hit in completed[dep][:3]            ]            query = node.questionif dependency_evidence:                query += "\n已知证据:\n" + "\n".join(dependency_evidence)            _, hits, trace = await rag.retrieve_evidence(query, user)return node, hits, tracefor node, hits, trace inawait asyncio.gather(*(run(n) for n in ready)):            completed[node.node_id] = hits            evidence.extend(build_evidence_nodes(node, hits, trace))return evidence

这里传递的是证据摘要,不是上一跳生成的自然语言答案,因为答案已经包含模型推断,把它当成下一跳事实会造成错误传播;同样需要限制最大节点数、验证未知依赖和循环,否则所谓agent规划可能生成一个成本不可控的搜索过程

多跳系统还有一个容易低估的上限:如果每一跳找到正确证据的概率都是0.85,三跳全部成功的概率近似为,因此增加跳数会快速降低端到端成功率,真正的优化重点不是让模型拆得更细,而是减少不必要的依赖、并行独立分支,并为关键节点增加可诊断的召回评测

证据图用来保存条件,不是用来画图

完成多跳以后,系统不能只留下一个chunk列表,还要记录每份证据由哪个问题节点发现、支持哪个claim、具有什么时间和适用范围,最终形成claim—evidence图。

{"claim":"企业客户退款期限为14天","dimensions":{"year":"2025","customer_type":"enterprise","activation_status":"unactivated"},"evidence_ids":["p2025:1"],"source_authority":"formal_policy"}

只有year之外的比较维度一致时,系统才能安全地产生“从7天变为14天”,如果客户类型、设备状态或政策权威等级不同,正确结果不是选择相似度更高的一条,而是明确说明现有证据不可直接比较。

评测必须把表示、召回、排序和生成拆开

只评最终答案会让所有错误汇聚成一个分数,无法指导系统修改,一套可诊断评测至少需要四类测试。

第一类是入库完整性,检查标题路径、表头、列表条件和代码块有没有被切断,child是否都能回到parent,新旧版本是否存在重叠有效期,这一层失败时,后续模型无论多强都无法恢复已经丢失的信息。

第二类是召回与排序,记录每一路独立的recall@k、融合后的recall@k、正确证据的mrr、重排前后名次和近重复率;如果融合提升recall但重排后正确证据下降,问题在reranker而不是embedding

第三类是多跳覆盖,对每个dag节点分别计算per-hop recall,并记录整个证据路径是否完整,不要只看最终节点,因为一条路径失败可能被模型猜对答案掩盖

第四类是oracle实验:把人工标注的正确证据直接交给answerer,如果答案仍错,问题在生成与验证;让真实检索器工作但用规则模板读取结果,如果证据缺失,问题在检索,两个oracle可以把“没找到”和“看到了却答错”分开

消融实验也应该按链路进行:只用bm25、只用dense、二者加rrf、再加去重、再加reranker、最后加parent回填,每一步记录质量增益、p95延迟和token成本,只有这样才能知道新增组件是否真的值得,而不是让系统复杂度在缺少证据的情况下持续增长。

最后回到那个政策问题

一条可靠链路会先按结构和版本建立parent-child索引,再由规划器生成2024政策、2025政策和激活条件的不同查询,bm25保留年份与产品符号,dense补充同义表达,rrf融合候选,去重和cross-encoder压缩结果,token打包器保证多个条件维度得到覆盖,多跳dag补全依赖证据,证据图最后检查两个年份是否处于可比较范围

这套系统仍然不能保证模型永远正确,但它把错误从一个无法解释的“rag效果不好”,拆成了可以观察和修复的具体阶段:信息是否在入库时丢失、正确证据是否进入候选、排序是否保留条件、上下文是否覆盖全部维度、多跳路径是否完整

rag检索真正要管理的不是向量,而是证据从文档结构进入候选集、再进入推理图的完整过程,只有先把这条链路建立起来,后面的引用、事实验证和拒答才有可靠基础

欢迎在评论区留言,发表你的观点!若对你有帮助,欢迎转发给身边有需要的人!欢迎点赞在看关注

HAPPY LABOR DAY

点个 下方名片 关注我们