LLMNotes

第 11 章:混合检索

zjc 于 2026-01-11 发布

这是《LLM 应用开发 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 向量检索擅长语义相似,但对错误码、型号、人名、版本号等精确信号不稳定;关键词检索相反。混合检索把多种召回路径组合起来,提高复杂查询的覆盖率。

11.1 单路召回的盲区

查询 向量检索 关键词检索
如何申请退款 一般
错误 1045 怎么处理 可能偏语义
ORD-10001 状态 可能不准
账号被锁 一般
MySQL 8.0.36 参数 语义近似错版本
退货和换货区别 一般

因此生产 RAG 通常至少包含:

dense embedding
sparse keywords / BM25
metadata filter
database exact lookup

11.2 检索架构

Query Understanding
  |-- rewrite
  |-- keyword extraction
  |-- language / tenant / acl
  |
  +-- parallel retrieval
     |-- vector top K1
     |-- keyword top K2
     +-- exact lookup
        |
        +-- fusion
           -> dedupe
              -> rerank
                 -> context build

每路召回取更多候选,再交给 rerank 精排,通常优于只调大单路 top K。

11.3 BM25

BM25 是经典关键词排序算法,核心思想:

  1. 查询词在文档中出现越多越相关;
  2. 罕见词权重更高;
  3. 长文档做归一化;
  4. 词频收益有上限。
score(query, doc)
  = sum IDF(term) * TF(term, doc) / (TF + length normalization)

适合:

  1. 精确术语;
  2. 错误码;
  3. 产品型号;
  4. 函数名;
  5. 合同编号;
  6. 版本号。

11.4 关键词抽取

规则优先处理强信号:

import re

PATTERNS = {
    "order_id": r"\b[A-Z]{2,4}-\d{4,12}\b",
    "error_code": r"\b(?:0x)?[A-Z]{2,6}-?\d{3,6}\b",
    "version": r"\bv?\d+\.\d+(?:\.\d+)?\b",
}

def extract_keywords(text: str):
    found = {}
    for name, pattern in PATTERNS.items():
        values = re.findall(pattern, text)
        if values:
            found[name] = values
    return found

强信号可直接查数据库或倒排索引,不一定要经过语义模型。

11.5 融合策略

分数归一化

normalized = (score - min) / (max - min)

不同来源的分数分布不同,简单加权容易失真。

RRF

def reciprocal_rank_fusion(rankings, k=60):
    scores = {}
    for ranking in rankings:
        for rank, item in enumerate(ranking, start=1):
            scores[item] = scores.get(item, 0) + 1 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

RRF 只用排名,不用原始分数,工程上更常用。

加权

final_score = 0.6 * dense + 0.3 * sparse + 0.1 * recency

权重应通过评测集调优,不能拍脑袋固定。

11.6 过滤与排序

过滤分两类:

类型 示例 时机
硬过滤 租户、权限、状态 检索前或检索中
软排序 新鲜度、权威度、来源 融合或重排

示例:

def apply_business_score(item, now):
    score = item["retrieval_score"]
    if item["source"] == "official":
        score += 0.05
    if item["effective_time"] > now:
        return None
    age_days = (now - item["updated_at"]).days
    score -= min(age_days / 365, 0.05)
    return score

权威度调整要谨慎,避免让高权威但无关的文档压过真正答案。

11.7 多查询召回

复合问题应拆解:

用户:退款多久到账,运费谁承担?

子查询 1:退款到账时间
子查询 2:退货运费承担规则

实现方式:

  1. 规则拆分问句;
  2. 模型生成子查询;
  3. 保留原始查询;
  4. 每个子查询独立检索;
  5. 合并去重;
  6. 按子问题组织上下文;
  7. 生成时分别回答。

拆分要设置数量上限,防止查询爆炸。

11.8 Elasticsearch 示例

{
  "query": {
    "bool": {
      "must": {
        "match": {
          "content": "MySQL 1045 access denied"
        }
      },
      "filter": [
        {"term": {"tenant_id": "tenant-a"}},
        {"term": {"active": true}}
      ]
    }
  },
  "size": 20
}

向量检索与 BM25 可并行执行,再由应用层或引擎做融合。

11.9 评估

指标 说明
Dense Recall@K 向量召回
Sparse Recall@K 关键词召回
Hybrid Recall@K 融合召回
Exact Hit@1 精确信号命中
Latency 检索总延迟
Noise Rate 无关候选比例
Context Tokens 上下文成本

必须按查询类型分组评估,例如普通语义类、编号类、版本类、多子问题类。

11.10 常见问题

现象 原因 处理
精确编号查不到 向量弱化符号 关键词/数据库
关键词结果太死 同义表达缺失 加向量召回
融合后旧文档靠前 新鲜度未参与 时间软排序
多租户串数据 过滤缺失 硬过滤
召回多但答案差 候选噪声高 rerank
查询延迟高 串行检索 并行化

本章小结

混合检索通过向量、关键词、元数据和精确查询互补,覆盖语义相似与精确匹配两类需求。融合常用 RRF,硬过滤必须前置,业务权重需通过评测调优。召回阶段追求覆盖,排序质量交给 rerank。

思考题

  1. BM25 和向量检索各自适合什么查询?
  2. RRF 为什么比直接混合分数更稳定?
  3. 硬过滤和软排序的区别是什么?
  4. 多子问题检索如何控制成本?
  5. 如何评估混合检索是否优于单路召回?