混合检索、Query Rewrite、Rerank 什么时候有用
“退款多久到账”和“PAY-REFUND-042 怎么处理”都在查售后知识,却适合不同的检索方式。前者依赖语义相近,向量检索更占优势;后者包含精确错误码,关键词检索通常更稳。
项目做到这里,很容易顺手把向量、关键词、查询改写(Query Rewrite)和重排(Rerank)全部串上。链路确实更完整了,候选分数却无法直接比较,改写可能偏离原问题,重排也会增加延迟和外部调用。一项能力是否值得加入,要看它解决了哪类真实召回问题。
下面继续使用退款政策,先实现 pgvector、用于模糊关键词匹配的 pg_trgm,以及按名次合并结果的 RRF,再说明 Query Rewrite 与 Rerank 适合放在哪里。重点是检索计划、候选预算和失败后的处理方式,而不是把链路堆得更长。
语义查询和精确词各有所长
假设知识库里有以下三段内容:
A. 退款审核通过后,款项将在一到五个工作日内原路退回。
B. 支付平台返回错误码 PAY-REFUND-042 时,转人工核对退款单。
C. 售后政策第十条适用于银行卡及第三方支付退款。
用户问“退款多久到账”,向量检索容易命中 A,因为“多久到账”和“一到五个工作日”在语义上接近。用户改问“PAY-REFUND-042 怎么处理”,关键词检索通常更可靠:错误码几乎没有同义表达,任何模糊匹配都可能把它带到别的支付故障。用户问“第十条支持哪些支付方式”,条款编号与“银行卡”同时出现,关键词信号也很有价值。
向量检索把查询和 chunk 映射到同一个向量空间,再按距离或相似度取近邻。它对同义表达、口语、省略主语比较友好,但有几个天然弱点:
- 型号、工单号、政策编号等低频词可能没有可靠语义表示;
- 两段话主题相近,不代表其中包含回答问题所需的具体条件;
- Embedding 模型更换后,距离分布和召回顺序可能变化;
- 长 chunk 中的次要事实容易被整体语义稀释。
关键词检索根据词项、词频、字段权重等信号排序。BM25 一类算法会考虑词在当前文档中的出现次数,也会降低全库高频词的区分度。它适合精确术语、名称、编号和原文措辞,却很难仅凭字面匹配理解“多久到账”与“到账时限”说的是同一件事。中文还涉及分词词典、同义词表和领域词维护,不能把英文默认分词配置原样搬进生产环境。
两种通道互补的前提,是它们独立执行相同的业务过滤。租户、部门、文档发布状态和生效时间必须在各自检索语句中生效,而不是两边召回完成后再统一删除越权结果。否则无权限内容会占掉候选名额,RRF 和 Rerank 都无法把已经丢失的合规候选补回来。
图里的 candidateK 是每个召回通道的候选上限,topK 是最终交给回答链路的证据上限。两者必须分开。直接让向量和关键词各返回 topK,融合后再做重排,候选面往往太窄;无限放大 candidateK 又会增加搜索、网络传输和重排成本。
三个增强步骤各自解决一个问题
Query Rewrite 只生成检索表达
真实咨询经常包含代词、省略和聊天语气:
用户上一轮:我的退款已经审核通过了。
用户这一轮:那一般还要等多久?
只检索“那一般还要等多久”几乎没有业务信息。Query Rewrite 可以结合受控的会话上下文,把它改写成“退款审核通过后的到账时限”。检索表达更完整后,向量和关键词通道都有机会命中正确条款。
改写会改变原始表达,存在明确风险。模型可能把“银行卡退款”改成“第三方支付退款”,把“试用期员工”改成“正式员工”,或者补进原问题从未出现的产品名称。查询看起来更顺,检索意图已经漂了。知识库更难发现的坏案例,是系统带着错误限定条件命中一条措辞很像的制度,而非简单返回空结果。
工程上应把原问题和检索表达保留成两个字段,并让它们承担不同职责:
| 信息 | 用途 |
|---|---|
| 原问题 | 展示、受控审计、Rerank、回答生成和最终评测 |
| 改写查询 | 向量化、关键词召回和检索日志 |
| 改写使用的上下文 | 解释代词和省略,必须经过长度与权限控制 |
| 改写版本 | 定位 Prompt 或模型变化引起的召回结果变化 |
当前 HybridKnowledgeRetrievalService 正是这样处理的。改写结果进入 Embedding 与关键词通道,Rerank 收到的仍是原始问题 query.question()。即使改写补充了检索术语,最终相关性判断也要回到用户真正问的内容。
改写适合以下场景:多轮对话存在明确指代,用户表达非常口语化,或者企业内部术语与用户语言差异很大。短而精确的查询未必需要改写,例如错误码、订单号、产品型号和条款编号。对这些内容,模型“润色”反而容易破坏精确词。
Query Rewrite 还应遵守两条硬边界。第一,它不能扩大访问范围。租户和权限来自经过校验的身份,不允许模型通过改写查询决定访问哪些部门。第二,它不能把改写文本当成事实。改写只帮助召回,回答仍然要以检索到的已发布证据为准。
当前最小实现要求改写结果非空,空白结果会抛出参数异常,模型或网络异常则继续向上返回。生产适配器可以选择“改写失败时使用原问题”这一降级语义,但要记录 rewrite_fallback=true、失败类型和耗时。悄悄回退而不留痕,会让离线评测与线上表现对不上。
用排名融合代替直接比较分数
向量通道可能返回余弦相似度 0.86,关键词通道可能返回 BM25 分数 21.4。如果把结果放进一个列表按原始 score 排序,关键词结果几乎总在前面;换一种搜索引擎或相似度函数,结论又可能反过来。分数的数值范围、分布和方向都由算法实现决定,不是跨通道通用的相关性单位。
可以先对两边分数做归一化,再加权求和,但这要求分数分布相对稳定。数据量、查询类型、索引版本或模型变化都会影响归一化参数。线上查询只有少量候选时,用当前结果的最大最小值归一化也很脆弱:第一名和第二名的真实差距可能很小,归一化后却被放大成 1 与 0。
RRF(Reciprocal Rank Fusion)绕开了这个问题。它不比较原始分数,只使用候选在每个通道中的名次。对文档 d,融合分数可以写成:
RRF(d) = Σ 1 / (k + rank_i(d))
rank_i(d) 是 d 在第 i 个排名中的位置,从 1 开始;没有进入某个排名,就不贡献该通道分数。k 是平滑常数,用来控制前几名与后续名次的差距。当前实现使用 k = 60。
假设向量通道排序为 [semantic, shared],关键词通道排序为 [lexical, shared]:
| chunk | 向量名次 | 关键词名次 | RRF 分数 |
|---|---|---|---|
shared |
2 | 2 | 1/62 + 1/62 |
semantic |
1 | - | 1/61 |
lexical |
- | 1 | 1/61 |
shared 同时被两个通道认可,所以排在只被单个通道命中的候选之前。RRF 不知道哪个通道的原始分数更大,也不需要知道。它表达的是一种相当实用的判断:多个独立检索器都把某段证据排在前面时,这段证据更值得进入下一步。
ReciprocalRankFusion 以 chunkId 去重并累加排名贡献。融合分数相同时,再按 chunkId 升序排列:
return scoresById.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue(Comparator.reverseOrder())
.thenComparing(Map.Entry::getKey))
.limit(topK)
.map(entry -> withScore(chunksById.get(entry.getKey()), entry.getValue()))
.toList();
第二排序条件用于保证确定性,与相关性无关。同一批输入不能因为 HashMap 遍历顺序或运行环境差异而随机交换位置。稳定排序会直接影响缓存、评测复现和分页结果,也是线上排查“同样问题为什么偶尔引用不同条款”的基础。
RRF 的局限也很明确。它只看名次,会丢掉通道内部的置信差异。向量第一名远高于第二名,与两者几乎并列,在 RRF 中都会被视为名次 1 和 2。它也不会理解用户问题和证据之间的细粒度关系。RRF 适合做异构召回的稳健融合,不是最终相关性模型。
把 Rerank 留给少量高价值候选
Embedding 检索通常采用双塔思路:查询和 chunk 分别编码,索引中的 chunk 向量可以预先计算,因此召回速度快。Rerank 常用交叉编码或大模型判断,让查询与每个候选一起参与计算,能够看到更细的词序、条件和否定关系,但无法像向量检索那样对全库预计算。
这决定了合理顺序只能是“先召回,再重排”。如果全库有一百万个 chunk,不可能让重排模型逐个判断;先用向量与关键词缩小到几十条,再选出最终几条,成本才可控。
候选预算至少要同时看四个量:
- 最终
topK:回答链路真正需要几段证据; - 每通道
candidateK:为了不漏掉正确证据,召回面要放大多少; - 候选去重率:两个通道重叠越高,融合后的实际数量越少;
- Rerank 单候选成本:模型输入长度、批处理能力、并发和计费方式。
当前 RetrievalPlan 把 rewriteQuery、lexicalSearch、rerank 和 candidateK 显式放在计划中。candidateK 被限制在 1 到 100,应用服务还会检查它不能小于查询的 topK。这个限制挡住了一个很常见的配置错误:想返回 8 条最终证据,却只让召回通道各找 5 条。
if (plan.candidateK() < query.topK()) {
throw new IllegalArgumentException(
"candidateK must be greater than or equal to topK"
);
}
链路延迟不能只看 Rerank 接口的平均耗时。按当前顺序执行的实现,粗略总延迟是:
T ≈ T_rewrite + T_embedding + T_vector + T_lexical + T_fusion + T_rerank
如果向量与关键词查询在生产适配层并行执行,检索耗时可以接近两者中的较慢值,但前提是两边使用同一个改写查询、权限范围和生效时间,取消和超时也能同时结束两个请求,并保留在同一 Trace 中。当前应用服务是顺序调用,不能按并行链路估算延迟。
Rerank 的超时应该明显小于整个问答请求剩余预算。举例来说,接口总预算只有 2 秒,前面改写和两路检索已经消耗 1.4 秒,再给重排单独设置 2 秒超时没有意义。超时预算应由入口向下传递,而不是每层各配一个看起来合理的固定值。
降级也要按步骤定义,不能笼统写成“检索失败就返回默认结果”:
| 失败位置 | 可接受的读路径 | 必须保留的观测信息 |
|---|---|---|
| Query Rewrite | 使用原问题继续检索 | 异常类型、是否回退;脱敏原问题只进入受控明细 |
| 关键词检索 | 使用向量候选 | 失败通道、剩余候选数量 |
| 向量检索 | 仅在关键词足以支撑业务时继续 | Embedding/向量库错误、策略名 |
| Rerank | 使用 RRF 排名截取 topK |
重排超时或错误、融合版本 |
| 两个召回通道都失败 | 返回明确的检索不可用 | Trace、错误码、重试建议 |
这张表给出生产决策规则,当前代码不会自动执行这些回退。HybridKnowledgeRetrievalService 让端口异常向上抛出,避免在应用层无声吞错。公司项目若允许读请求降级,应把规则放进可测试的适配器或编排策略,同时把降级状态传到 Trace 和回答元数据。证据不足时若让大模型凭自身知识继续回答,产品的事实边界已经发生改变,不能按普通降级处理。
按查询类型选择三档策略
向量、关键词和重排不是固定套餐。先按查询分布和坏案例选最短链路,再用同一套 Golden Set 比较增量收益。
只用向量检索
适合自然语言问题为主、文档措辞差异大、专有编号较少的知识库。链路短,维护成本最低。第一个版本没有关键词索引与重排模型时,向量检索也更容易把权限过滤、版本过滤、引用和评测先做正确。
不要因为“混合检索更高级”就立刻加组件。如果向量 Recall@K 已经覆盖主要问题,线上坏案例集中在文档切分或权限,增加 Rerank 不会修复根因。
向量加关键词,再做 RRF
适合知识库同时包含自然语言规则和大量精确术语,例如错误码、产品型号、条款号、接口名。RRF 不要求校准两种分数,实现和解释成本都比学习排序低。
这档方案首先改善召回。它能把不同通道发现的候选放进同一列表,但不会理解复杂条件之间的关系。若正确证据已经进入候选集却总排不到前面,再考虑重排。
混合检索加 Rerank
适合候选内容高度相似、需要区分适用条件、否定条款和版本差异的场景。例如多份退款政策都写着“一到五个工作日”,只有渠道、地区或生效日期不同。重排模型可以结合原问题比较候选,但要承担模型质量、延迟、配额和降级成本。
Rerank 的价值应该体现在指标上。固定 candidateK 后,要比较首个正确证据排名的倒数均值(MRR)、整段排序质量(nDCG)和 TopK 证据准确率,还要记录坏案例、P95 延迟与单次成本。只展示一个“重排后看起来更准”的例子,不能支持默认开启。
从轻量关键词通道到 Java 编排
pg_trgm 适合作为轻量起点
PostgresTrigramKnowledgeChunkSearchRepository 作为关键词通道,通过 pg_trgm 的相似度和 ILIKE 补充字面召回。Flyway 的 V2__knowledge_hybrid_search.sql 创建扩展与 GIN 索引。数据库账号没有 CREATE EXTENSION 权限时,应由 DBA 预装扩展,再让应用账号只负责业务表迁移。
这套方案适合数据量有限、团队已经使用 PostgreSQL、希望先验证混合检索增量收益的场景。它按字符片段工作,不理解中文词边界、字段权重、同义词和领域词典;大规模知识库、复杂字段检索或专有词维护通常更适合公司已有的 Elasticsearch、OpenSearch 或搜索中台。
无论关键词通道落在哪个组件,租户、当前有效的业务版本、当前检索版本和 ACL 都必须在 TopK 之前过滤。不能先让搜索引擎返回全局候选,再由 Java 删除越权结果。更换关键词基础设施时,应替换 KnowledgeLexicalSearchRepository 适配器,而不是改写混合检索用例。
Java 应用层的检索编排
混合检索最容易写成一个依赖搜索引擎 SDK、Embedding SDK 和重排 SDK 的大 Service。这样短期代码少,后面切模型、做本地测试或增加降级时,每个分支都会碰到基础设施细节。
应用层用六个端口隔开变化来源:
| 端口 | 责任 |
|---|---|
KnowledgeQueryRewriter |
把原问题改成检索表达 |
KnowledgeEmbeddingModel |
生成查询向量并返回模型标识 |
KnowledgeChunkSearchRepository |
在权限与时间范围内做向量召回 |
KnowledgeLexicalSearchRepository |
使用同一范围做关键词召回 |
KnowledgeReranker |
根据原问题重排候选 |
RetrievalPlanProvider |
根据服务端策略选择候选预算与可选步骤 |
应用服务只编排顺序和预算:
String retrievalQuery = plan.rewriteQuery()
? requireText(queryRewriter.rewrite(query.question()), "rewritten query")
: query.question();
KnowledgeEmbedding embedding = embeddingModel.embed(retrievalQuery);
List<RetrievedKnowledgeChunk> vectorCandidates = vectorSearch.search(
embedding.vector(), embedding.model(),
query.accessScope(), query.effectiveAt(), plan.candidateK()
);
List<RetrievedKnowledgeChunk> candidates = vectorCandidates;
if (plan.lexicalSearch()) {
List<RetrievedKnowledgeChunk> lexicalCandidates = lexicalSearch.search(
retrievalQuery, query.accessScope(), query.effectiveAt(), plan.candidateK()
);
candidates = rankFusion.fuse(
vectorCandidates, lexicalCandidates, plan.candidateK()
);
}
List<RetrievedKnowledgeChunk> result = plan.rerank()
? restoreRerankedCandidates(
candidates,
reranker.rerank(query.question(), candidates, query.topK()),
query.topK()
)
: candidates.stream().limit(query.topK()).toList();
这里有几个值得保留的细节。
第一,向量查询携带 Embedding 模型标识,避免升级期间混排不兼容向量。第二,两个召回通道收到相同的 KnowledgeAccessScope 和 effectiveAt。检索策略可以变化,身份与时效边界不能变化。
第三,RRF 输出受 candidateK 限制,不会无限放大候选集。第四,Rerank 只返回 Chunk ID。应用层拒绝空 ID、重复 ID、未知 ID 和数量异常,再从原始授权候选恢复对象。重排适配器只能改变顺序,不能丢候选或注入未经过 ACL 的内容。
RetrievalPlan 让同一个用例可以按场景选择链路,但它不应该直接暴露给前端,让用户随意打开昂贵能力。计划应由服务端根据业务入口、租户配置、实验分组和容量策略决定。外部请求只表达业务问题、身份与期望证据数量。
项目默认使用单路向量检索。把 java-ai.knowledge.retrieval.mode 设为 hybrid 后,应用才会组装 pg_trgm 关键词通道和 RRF。该模式下 rewrite-query 与 rerank 仍默认关闭。
当前仓库没有真实的 Rewrite 和 Rerank 适配器,误开开关会直接报错,不会返回固定结果或静默降级。接入公司的改写或重排服务后,再打开对应开关,并用固定数据集验证收益、超时和回退策略。
固定融合顺序、预算和稳定性
连续实验会在同一次启动中分别请求 VECTOR 与 HYBRID,控制台并列打印两组 TopK,最终报告也分别计算 Recall、HitRate 和 MRR。普通问答仍按 application.yml 中的检索计划运行;这个模式参数只属于受保护的内部评测接口,避免终端用户随意打开昂贵链路。
确定性代码继续锁住什么文本进入哪个端口、权限范围是否原样传递、候选预算是否一致,以及最终顺序是否可重复。
HybridKnowledgeRetrievalServiceTest 固化了两条路径。完整路径把“退款多久到账?”改写为“退款到账时效”,向量和关键词通道都拿到 8 条候选预算,Rerank 收到原问题与融合后的候选;纯向量路径则确认 Rewrite、关键词和 Rerank 端口都不会被调用。
ReciprocalRankFusionTest 验证同一 chunk 被两个通道命中时排在单通道候选之前,还验证融合分数相同后按 chunkId 得到稳定顺序。
PostgresLexicalSearchQueryTest 锁住关键词 SQL 的租户、状态、版本、时效与 ACL 前置过滤。KnowledgeRetrievalConfigurationTest 则确认 hybrid 配置真正组装出混合检索器,避免运行时仍走旧实现。
这些测试检查编排与融合算法。接入公司环境时,还要用同一套业务问题分别运行三档策略,保存原问题、改写查询、各通道排名、融合排名、重排结果和耗时。指标发生变化时,才能据此判断是改写结果发生变化、某一路漏召回,还是重排压低了正确证据。
迁入既有检索系统时保留什么
可以直接复用的是应用层边界:原问题与检索查询分离、candidateK 与 topK 分离、两路召回使用相同权限和时效条件、异构分数通过排名融合、同分排序保持稳定、重排只处理有限候选。
关键词检索、Query Rewrite 和 Rerank 这三个适配点要按公司基础设施接入。关键词检索可以落在已有搜索引擎或数据库全文检索能力上,但中文分词、字段权重和同义词必须使用业务数据验证;Query Rewrite 与 Rerank 可以调用独立模型服务,也可以在效果收益不足时关闭。代码中有端口,不代表每个环境都必须部署对应组件。
生产观测至少应回答这些问题:
- 当前使用哪种
RetrievalPlan,candidateK与topK分别是多少; - 原问题是否改写,改写失败是否回退;
- 两个通道分别返回多少候选,重合率是多少;
- RRF 和 Rerank 前后的正确证据名次怎样变化;
- 每一步耗时、超时、错误和降级原因是什么;
- 最终证据属于哪个租户、文档版本、标题和条款。
混合检索值得引入的判断标准很朴素:它能否在固定权限、固定候选预算和可接受延迟下,稳定找回单通道遗漏的证据。Query Rewrite 只负责补全检索表达,RRF 负责合并不同评分空间,Rerank 负责在小候选集里判断细粒度相关性。三者各自解决一个问题,任何一步没有独立收益,都可以从计划中关掉。