混合检索与重排实践
向量检索不是银弹:知识库问答中的混合检索与重排实践
摘要:很多 RAG 系统把“文本转向量后取 Top-K”当成完整检索方案,结果在编码、缩写、数字和专有名词上表现不稳定。本文解释混合检索、融合排序和重排的职责边界。
标签:Elasticsearch 向量检索 RAG 搜索 重排
一、向量相似不等于答案相关
向量检索擅长处理语义接近但措辞不同的问题。例如用户问“如何处理知识文档更新”,它能够找到包含“版本维护”或“增量索引”的段落。
但在以下场景中,向量检索往往不够稳定:
- 精确编码、药名、型号和缩写;
- 数字只差一位的条目;
- 含义相反但上下文相似的句子;
- 需要同时满足多个限定条件的问题;
- 很长的段落中只有一句真正相关。
这不是向量模型“太小”,而是单一相似度无法表达所有检索意图。
二、混合检索的基本分工
一个实用的候选召回层可以包含三部分:
- 关键词检索:BM25 对稀有词、精确代码和数字更敏感;
- 向量检索:补充同义表达和自然语言改写;
- 元数据过滤:限制版本、日期、文档类型、权限和业务范围。
注意过滤最好发生在召回阶段。如果先取全库 Top-K 再过滤,很可能过滤完一个候选都不剩。
三、如何融合两路结果
BM25 分数与向量相似度的量纲不同,直接相加往往不稳。一个简单可靠的方法是 RRF(Reciprocal Rank Fusion,倒数排名融合):
def rrf(rank_lists, k=60):
scores = {}
for items in rank_lists:
for rank, doc_id in enumerate(items, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
RRF 关注排名而不是原始分数,能降低不同检索器分数分布差异带来的影响。它不是唯一方案,却很适合作为第一版基线。
四、重排模型解决什么问题
召回阶段强调“不要漏”,通常会取几十个候选;重排阶段强调“把最相关的放前面”,使用交叉编码器或专用 reranker 同时阅读问题与候选片段。
典型流程是:
问题
├─ BM25 Top 30
├─ 向量 Top 30
└─ 元数据过滤
↓
RRF 合并约 40 条
↓
重排模型 Top 8
↓
去重与上下文拼装
重排比向量召回计算量大,因此不适合扫描全库。它应处理一个已经较小的候选集合。
五、切分策略会决定检索上限
同一套检索模型,在不同切分方式下可能有完全不同的效果。固定按 500 字切分虽然简单,但可能把标题与正文分开,或切断表格和定义。
更好的做法是优先按文档结构切分:标题、段落、列表、表格和条款。每个片段携带上级标题路径,并允许少量重叠。对于过短片段,可以与相邻内容合并;对于过长条款,则保留完整语义后再做子块。
上下文拼装时也不要机械塞入 Top-K。相邻片段可以合并,重复内容应去除,并为每个片段保留来源编号。
六、查询路由比统一权重更有效
很多系统为 BM25 和向量检索设置一组固定权重,但不同问题需要的检索策略不同。可以先做查询特征识别:
- 包含规范编码、版本号或精确短语时,提高关键词权重;
- 问题较长、表达口语化时,提高向量召回数量;
- 包含“区别、比较、分别”等词时,为多个子问题分别检索;
- 包含“最新、目前、今年”等时效词时,强制加入日期过滤;
- 问题过短或含义不清时,先请求补充信息,而不是扩大检索范围。
查询路由不一定需要大模型。正则、词典和轻量分类器就能覆盖大量情况。只有复杂问题拆解才需要更强的语义模型。
例如“编码 A 与编码 B 的适用范围有什么区别”,如果把整句话只做一次向量检索,结果可能集中在其中一个编码。更合理的方式是分别检索 A、B 的权威定义,再检索包含二者的比较材料,最后构建成分组上下文。
七、处理文档去重和近重复
知识库经常出现同一内容的 PDF、Word、网页和内部转发版本。如果不去重,Top-K 可能被同一段内容占满,表面上相关度很高,实际证据多样性很差。
去重可以分三层:
- 文件级:使用哈希识别完全相同文件;
- 段落级:对规范化文本计算指纹,识别复制段落;
- 语义级:对相似度极高的片段聚类,但保留来源和版本差异。
语义去重不能简单删除旧文档,因为相同正文可能有不同适用范围。更稳妥的是在检索结果中把近重复片段折叠为一组,并优先选择最新、有效且来源明确的版本。
八、重排后的分数如何使用
重排分数适合比较同一次请求中的候选,但未必能跨问题直接比较。不要轻易设定一个全局阈值,例如“低于 0.7 就拒答”,除非经过真实问题集校准。
可以结合多个信号决定是否生成答案:
- 最高分与第二名的差距;
- Top 结果是否来自有效版本;
- 多个结果是否互相支持或冲突;
- 是否覆盖问题中的全部子条件;
- 该类问题在评测集上的历史表现。
当证据只覆盖部分条件时,答案应明确指出已找到和未找到的内容,而不是把部分命中包装成完整回答。
九、性能与成本优化
混合检索和重排增加了调用链路,但可以通过工程手段控制延迟:
- 为高频、非敏感问题缓存检索结果;
- 对文档向量做离线计算,不在查询时生成;
- 限制重排候选数量,并根据问题类型动态调整;
- 关键词与向量检索并行执行;
- 对超时环节设置降级,例如重排超时后使用 RRF 结果;
- 将元数据过滤字段设计成适合索引的类型。
缓存必须包含知识版本、权限范围和检索配置版本。否则文档更新或用户权限变化后,旧缓存可能返回过期甚至越权结果。
可以把总延迟拆成查询理解、Embedding、关键词检索、向量检索、融合、重排和上下文拼装几个阶段。只有知道时间花在哪里,优化才不会变成盲目减少 Top-K。
十、直接使用 Elasticsearch RRF Retriever
较新的 Elasticsearch 检索接口可以把关键词和 kNN 子检索器放进一个 RRF Retriever。下面是简化示例:
POST medical_knowledge/_search
{
"size": 8,
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"bool": {
"must": {
"multi_match": {
"query": "ICD 编码版本升级如何校验",
"fields": ["title^3", "content", "heading_path^2"]
}
},
"filter": [
{"term": {"tenant_id": "tenant-a"}},
{"term": {"status": "active"}}
]
}
}
}
},
{
"knn": {
"field": "content_vector",
"query_vector": [0.012, -0.031, 0.044],
"k": 50,
"num_candidates": 200,
"filter": {
"bool": {
"filter": [
{"term": {"tenant_id": "tenant-a"}},
{"term": {"status": "active"}}
]
}
}
}
}
],
"rank_window_size": 50,
"rank_constant": 60
}
}
}
这里特意在两路召回中都加入权限与状态过滤,防止某一路返回越权内容。rank_window_size 决定每个子检索器参与融合的结果窗口;窗口越大,召回机会越多,但排序和网络开销也越高。
RRF 的核心公式为:
score(d) = Σ 1 / (k + rank_i(d))
其中 rank_i(d) 是文档在第 i 路结果中的名次,k 是排名常数。RRF 使用名次而不是直接相加 BM25 与向量分数,因此不要求两路分数处于同一尺度。
十一、在应用层做跨编码器重排
RRF 负责融合,交叉编码器负责读取“问题 + 候选片段”后重新判断相关性:
from dataclasses import dataclass
@dataclass
class Candidate:
chunk_id: str
text: str
rrf_rank: int
rerank_score: float | None = None
async def rerank(query: str, candidates: list[Candidate], client) -> list[Candidate]:
pairs = [{"query": query, "document": c.text} for c in candidates]
scores = await client.score(pairs)
if len(scores) != len(candidates):
raise RuntimeError("reranker 返回数量与候选数不一致")
for candidate, score in zip(candidates, scores, strict=True):
candidate.rerank_score = float(score)
return sorted(
candidates,
key=lambda item: (item.rerank_score, -item.rrf_rank),
reverse=True,
)
为了避免单个超长片段拖慢整批请求,重排前应截断到统一 token 上限,但保留标题路径。发生超时时,可以降级回 RRF 顺序,并在响应元数据中记录 rerank_applied=false。
十二、离线评测脚本的最小结构
def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
return float(bool(set(retrieved[:k]) & relevant))
def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float:
for rank, doc_id in enumerate(retrieved, start=1):
if doc_id in relevant:
return 1.0 / rank
return 0.0
async def evaluate(cases, searcher):
rows = []
for case in cases:
hits = await searcher.search(case.question, top_k=20)
ids = [hit.chunk_id for hit in hits]
rows.append({
"id": case.id,
"recall@5": recall_at_k(ids, case.relevant_ids, 5),
"recall@20": recall_at_k(ids, case.relevant_ids, 20),
"rr": reciprocal_rank(ids, case.relevant_ids),
})
return rows
对比实验时同时保存配置:Embedding 模型、索引版本、RRF 参数、重排模型和切分策略。否则同一份结果很难复现。
十三、用问题集调参数,不凭感觉
检索优化至少需要一组真实风格的问题和标准相关文档。建议分别观察:
- Recall@K:正确证据是否进入候选;
- MRR:第一个正确证据出现得有多靠前;
- nDCG:多个相关结果的整体排序质量;
- 无答案问题的误召回率;
- 过滤条件是否准确执行。
如果 Recall@30 很低,应改召回、词典或切分;如果 Recall 很高但 Top 5 很差,应改融合或重排。不要在召回都失败时反复调整生成提示词。
十四、一个容易忽略的原则
搜索系统应允许“没有足够相关的结果”。当最高相关度仍低、候选彼此矛盾或文档已过期时,返回“未找到可靠依据”比硬凑答案更有价值。
向量检索为知识库带来了语义能力,但它只是检索系统的一部分。精确匹配、元数据过滤、排序融合、重排和质量评测共同决定了最终效果。把这些环节拆开,系统才有机会被定位、调试和持续改进。

浙公网安备 33010602011771号