Home Projects Blog Resume Contact 中文
Back to list
2026年7月5日 1,562 words 4 min read

05-Rerank 与上下文工程:把候选片段整理成证据

上一篇讲到混合检索:Query Rewrite、BM25、向量检索、RRF 和 metadata filter 共同负责把正确证据送进候选池。

#Rerank#NexaRAG#证据链#上下文工程

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:

  • NoopReranker
  • KeywordOverlapReranker
  • DashScopeReranker

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.pyContextBuilder

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_count
  • max_context_chars
  • max_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