05-Rerank 与上下文工程:把候选片段整理成证据
上一篇讲到混合检索:Query Rewrite、BM25、向量检索、RRF 和 metadata filter 共同负责把正确证据送进候选池。
05-Rerank 和上下文工程:把候选片段整理成证据
上一篇讲到混合检索:Query Rewrite、BM25、向量检索、RRF 和 metadata filter 共同负责把正确证据送进候选池。
候选池之后,还要形成最终上下文。
RAG 项目里一个很常见的问题是:检索结果看起来不少,但真正交给模型时仍然混乱。可能有重复 chunk,可能有相邻片段被拆开,可能 topK 太长挤爆 prompt,也可能候选里只有一两段真正有用,其余都是噪声。如果把候选池原样塞给大模型,模型就要在噪声里自己判断证据,幻觉风险会变高。
所以 NexaRAG 在候选池之后又做了两件事:rerank 和上下文工程。
这篇的核心判断是:
检索负责找候选,rerank 负责重排候选,上下文工程负责把候选整理成可引用的证据。
rerank 是候选池里的再排序
BM25、向量检索、RRF 已经能得到一批候选,但这批候选的顺序未必适合回答当前问题。
比如用户问“屏幕碎裂是否保修”,候选池里可能同时有:
- 屏幕参数。
- 售后政策。
- 意外损坏说明。
- 维修价格。
- 产品介绍。
这些片段都可能和问题有关系,但最该排在前面的是售后政策和意外损坏说明。rerank 的作用就是在候选池里再做一次更贴近问题的排序。
NexaRAG 的 rerank 接入点在 rag/_retrieval/rerank.py:
selected, rerank_trace = self._apply_rerank(query, fused_ranked, top_k)
具体实现是 apply_rerank()。它会根据配置判断是否启用 rerank,记录 provider、input_count、output_count、rank_changes 和 errors。如果 rerank 失败,就 fallback 到 fused 结果。
这点很重要。rerank 是增强环节,整个 RAG 链路应该在外部模型失败时继续可用。
三种 reranker 的定位
Rerank 的构建逻辑在 rag/_rerank/factory.py。当前支持几类 provider:
NoopRerankerKeywordOverlapRerankerDashScopeReranker
NoopReranker 用于关闭 rerank 或保持基线。
KeywordOverlapReranker 是规则版 rerank,适合本地测试和无外部依赖时的冒烟验证。
DashScopeReranker 是模型版 rerank,适合更真实的语义重排。
KeywordOverlapReranker 的逻辑很直观:把 query 和候选内容 tokenize,计算重叠覆盖,再叠加一点原始分数:
overlap = len(query_tokens & content_tokens)
coverage = overlap / max(len(query_tokens), 1)
score = coverage + min(candidate.original_score, 1.0) * 0.01
它的定位是给系统一个可控的本地策略。这样在缺少远程 rerank 服务时,评测和 trace 仍然能跑。
rerank 必须进入 trace
Rerank 只要改变排序,就应该方便调试。NexaRAG 会把 rerank 的变化写进 trace:
enabled
provider
fallback_used
input_count
output_count
rank_changes
errors
其中 rank_changes 会记录:
chunk_id
original_rank
rerank_rank
original_score
rerank_score
这对排查很有价值。假设正确证据在 fused 里排第 2,rerank 后掉到第 8,那问题可能出在 rerank。反过来,如果正确证据在 fused 里排第 12,rerank 后升到第 2,说明 rerank 确实带来了收益。
有了这层 trace,就能判断 rerank 到底是否发挥作用,减少只看最终答案好坏的误判。
ContextBuilder 才是 final context 的入口
Rerank 之后,还要把候选列表整理成 final context。
上下文工程入口在 rag/_context/builder.py 的 ContextBuilder:
def build(self, candidates: list[dict]) -> ContextBuildResult:
source_count = len(candidates or [])
deduped, dedup_trace = self._dedup(candidates or [])
merged, merge_trace = self._merge_adjacent(deduped)
evidences, budget_trace = self._build_evidences(merged)
context_text = self._format_context(evidences)
这条链路很清楚:
candidates
-> dedup
-> merge adjacent
-> build evidences
-> format context
也就是说,ContextBuilder 的目标是把候选片段整理成 evidence list。
去重:避免同一证据重复占预算
去重逻辑在 rag/_context/selection.py。它优先用 chunk_id 作为去重 key;缺少 chunk_id 时用 content_hash;最后再退到 source、title_path、position。
if candidate_chunk_id:
return ("chunk_id", str(candidate_chunk_id))
if content_hash:
return ("content_hash", str(content_hash))
RAG 上下文预算很宝贵。重复片段进入 prompt,会浪费 token,也会让模型误以为某个证据更重要。去重是上下文工程里最基础的一步。
预算:证据数量和长度都要受控
ContextBuilder 有三个预算:
max_evidence_countmax_context_charsmax_chunk_chars
build_evidences() 会按候选顺序生成 evidence。如果单个 chunk 太长,会截断;如果整体 context 超过预算,会停止继续加入。
这一步解决的是 prompt 可控性。RAG 需要控制进入模型的候选规模。证据必须足够,也要短、清楚、可引用。
预算 trace 里会记录:
max_evidence_count
max_context_chars
max_chunk_chars
used_chars
truncated_evidence_ids
context_truncated
后面如果答案漏掉信息,就可以看是否 context budget 太小;如果 prompt 太长,也能看是哪几个证据占了大量空间。
evidence_id 把上下文、答案和校验连起来
ContextBuilder 最关键的输出是 Evidence,每条证据都有一个 evidence_id:
evidence_id=f"E{index}"
最终格式化时,rag/_context/formatting.py 会生成这样的上下文块:
[E1]
source: ...
doc_id: ...
chunk_id: ...
version: ...
title_path: ...
content:
...
这一步让上下文从“几段文本”变成了带编号的证据。后面答案生成时可以引用 [E1]、[E2],citation checker 可以检查引用是否存在,verifier 可以把答案中的数字和型号回到对应 evidence 里核对。
有了 evidence_id,引用治理才有抓手。模型说“根据资料”时,系统可以判断它到底根据了哪段资料。
检索工具如何接上 ContextBuilder
在请求链路里,tools/_retrieval/builder.py 会把检索 payload 交给 ContextBuilder:
context_result = ContextBuilder().build(results)
如果构造出了 context,就会把 context_result.evidences 写入 trace:
trace["final_context"] = final_context
retrieval["final_context"] = final_context
retrieval["context_builder"] = context_result.trace
这说明 final context 是 trace 的一部分。前端诊断面板能同时看到检索候选和最终进入 prompt 的证据。
这对排查非常关键。候选池里有正确证据之后,还要看它是否进入 final context;final context 里有正确证据,才说明证据真正进入生成阶段。
这一步解决的内容和下一步
Rerank 和上下文工程解决的是“候选整理”问题。
它让候选池经过重排、去重、合并、预算控制,最后变成 [E1]、[E2] 这样的可引用证据块。它还把 rerank 和 context builder 的细节写进 trace,方便判断排序变化和上下文截断。
下一步继续约束模型按证据回答。即使 final context 很好,模型仍然可能漏引用、编造数字、把冲突信息合并成一个确定结论。所以后面要进入引用、拒答和答案校验。
小结
NexaRAG 的上下文工程会把检索候选整理成受控、可引用、可追踪的 evidence list。
apply_rerank() 负责重排候选并记录 rank changes;ContextBuilder 负责去重、合并、预算和 evidence 编号;tools/_retrieval/builder.py 把 final context 写回 trace。这样,RAG 链路从“召回了一堆文本”走到了“形成一组证据”。
下一篇继续看:有了 [E1]、[E2] 以后,怎样要求模型留在证据边界内,并用 citation、refusal 和 verifier 做兜底。
参考材料:
- NexaRAG
rag/_retrieval/rerank.py - NexaRAG
rag/_rerank/factory.py - NexaRAG
rag/_rerank/keyword.py - NexaRAG
rag/_context/builder.py - NexaRAG
rag/_context/selection.py - NexaRAG
rag/_context/formatting.py - NexaRAG
tools/_retrieval/builder.py - 原 RAG 系列
07-Rerank与上下文工程-怎么把候选证据整理给大模型.md