混合检索与重排实践

向量检索不是银弹:知识库问答中的混合检索与重排实践

摘要:很多 RAG 系统把“文本转向量后取 Top-K”当成完整检索方案,结果在编码、缩写、数字和专有名词上表现不稳定。本文解释混合检索、融合排序和重排的职责边界。

标签:Elasticsearch 向量检索 RAG 搜索 重排

一、向量相似不等于答案相关

向量检索擅长处理语义接近但措辞不同的问题。例如用户问“如何处理知识文档更新”,它能够找到包含“版本维护”或“增量索引”的段落。

但在以下场景中,向量检索往往不够稳定:

  • 精确编码、药名、型号和缩写;
  • 数字只差一位的条目;
  • 含义相反但上下文相似的句子;
  • 需要同时满足多个限定条件的问题;
  • 很长的段落中只有一句真正相关。

这不是向量模型“太小”,而是单一相似度无法表达所有检索意图。

二、混合检索的基本分工

一个实用的候选召回层可以包含三部分:

  1. 关键词检索:BM25 对稀有词、精确代码和数字更敏感;
  2. 向量检索:补充同义表达和自然语言改写;
  3. 元数据过滤:限制版本、日期、文档类型、权限和业务范围。

注意过滤最好发生在召回阶段。如果先取全库 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 可能被同一段内容占满,表面上相关度很高,实际证据多样性很差。

去重可以分三层:

  1. 文件级:使用哈希识别完全相同文件;
  2. 段落级:对规范化文本计算指纹,识别复制段落;
  3. 语义级:对相似度极高的片段聚类,但保留来源和版本差异。

语义去重不能简单删除旧文档,因为相同正文可能有不同适用范围。更稳妥的是在检索结果中把近重复片段折叠为一组,并优先选择最新、有效且来源明确的版本。

八、重排后的分数如何使用

重排分数适合比较同一次请求中的候选,但未必能跨问题直接比较。不要轻易设定一个全局阈值,例如“低于 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 很差,应改融合或重排。不要在召回都失败时反复调整生成提示词。

十四、一个容易忽略的原则

搜索系统应允许“没有足够相关的结果”。当最高相关度仍低、候选彼此矛盾或文档已过期时,返回“未找到可靠依据”比硬凑答案更有价值。

向量检索为知识库带来了语义能力,但它只是检索系统的一部分。精确匹配、元数据过滤、排序融合、重排和质量评测共同决定了最终效果。把这些环节拆开,系统才有机会被定位、调试和持续改进。

参考资料

posted @ 2026-09-20 15:47  楼主好菜啊  阅读(1)  评论(0)    收藏  举报