513 分钟

Hybrid Retrieval + RRF + Query Rewrite —— 从 60 分到 90 分的检索

掌握 FTS 全文检索、RRF 双路融合、Query Rewrite 三种改写策略,以及中文分词的工程实战。

RAGHybridRRFFTSQuery RewritePython
进度保存在本机浏览器;验收通过后再点更稳妥

第 5 课:Hybrid Retrieval + RRF + Query Rewrite —— 从 60 分到 90 分的检索

本节目标:掌握 FTS 全文检索、RRF 双路融合、Query Rewrite 三种改写策略,以及中文分词的工程实战。把第4课的纯向量检索升级为生产级的 hybrid retrieval。

学完本课后,推荐阅读 附05:RRF 融合推导 + Rerank 三层原理 —— RRF 公式推导与 Java 实现、Bi-Encoder vs Cross-Encoder 核心区别、Rerank 升级路径。


1. 回顾:纯向量检索的 3 个翻车场景

翻车原因需要的武器
"产品代号 X9-2024A 的售价?"embedding 难区分 X9-2024A vs X9-2024B精确字符串匹配
"QHSE 合规要求?"低频缩写在 embedding 语料里少关键词搜索
"帮我找下那个销假的规定"口语"销假" vs 文档"复职手续"同义词扩展 / HyDE

对应三件武器:FTS(BM25全文检索)→ RRF(双路融合)→ Query Rewrite(修复用户表述)


2. FTS:关键词搜索——"笨但稳"

BM25 直觉:一个词在文档里出现多、在全库出现少 → 对这个文档的"代表性"强。

向量 vs FTS

场景向量检索FTS
"请假流程是什么"能找到"休假制度"能找到"请假流程"
"X9-2024A 售价"禁止:可能返回 X9-2024B精确命中
"QHSE 合规"禁止:低频词向量不准精确命中
"我饿了" vs "想吃东西"语义桥接禁止:字面不同

互补关系——这就是为什么要 hybrid。

课程版用 SQLite FTS5

python
def init_fts(db_path=None):
    conn.execute("""
        CREATE VIRTUAL TABLE IF NOT EXISTS fts_chunks
        USING fts5(doc_id UNINDEXED, content, source UNINDEXED, indexed_at UNINDEXED)
    """)

只有 content 字段参与索引,其他标记 UNINDEXED——只存不搜。

中文分词陷阱(极其重要)

SQLite FTS5 默认 unicode61 分词器对中文是灾难——"请假流程"被当成一个 token,搜"请假"匹配不上。

课程版按字切

python
def _tokenize_for_fts(text):
    for ch in text:
        if "一" <= ch <= "鿿":   # CJK 字符
            out.append(" " + ch + " ")    # 前后加空格,让每个字成为独立 token
        else:
            out.append(ch)

代价:BM25 统计退化到字级别,"请"出现 5000 次导致 IDF 极低。生产用 jieba/HanLP。

BM25 负数陷阱

SQLite FTS5 的 bm25() 返回负数,越小越相关。转换成正向分数:

Code
"score": max(0.0, -float(bm25_score))   # -5.3 → 5.3,-2.1 → 2.1

3. Hybrid Retrieval:两路并跑 + 融合

Code
用户问题
   │
   ├──────────────┐
   ↓              ↓
向量检索(FTS5)  关键词检索(ChromaDB)
   │              │
   ↓              ↓
v_hits(10条)   k_hits(10条)
   │              │
   └──────┬───────┘
          ↓
   RRF 融合(按排名打分)
          ↓
   Rerank(α·向量 + β·关键词)
          ↓
   top-5 最终结果

关键词检索的回填机制:FTS 只存 doc_id + content → FTS5 搜到 chunk ids → 回 ChromaDB get_by_ids 拿原文 + metadata。如果 FTS 命中但 ChromaDB 没有 → 跳过(双写不一致的兜底)。


4. RRF 融合:为什么不直接比分数

核心问题:向量 score 范围 [0, 1],FTS BM25 分数没有上界。两个度量衡完全不同。

RRF 公式score(d) = Σ 1/(k + rank_i(d))

  • 只看排名不看分数,规避量纲问题
  • k 是平滑常数(默认 60),越大排名差距越"压平"
  • 两路都被命中的 chunk 分数翻倍,天然奖励"共识"
python
def _fuse_rrf(self, v_hits, k_hits, *, k=60):
    rrf_scores = {}
    for rank, h in enumerate(v_hits, start=1):
        rrf_scores[h["id"]] = rrf_scores.get(h["id"], 0) + 1.0 / (k + rank)
    for rank, h in enumerate(k_hits, start=1):
        rrf_scores[h["id"]] = rrf_scores.get(h["id"], 0) + 1.0 / (k + rank)
    # 合并元数据 + 按 RRF 降序输出

k 值的调参直觉

k行为适合
小 (1-10)排名极敏感,top-1 碾压你非常信任某一路
中 (30-100)平衡,推荐两路质量差不多
大 (1000+)排名不敏感,变成"命中计数器"只关心是否被多路同时命中

实际上 k 几乎不用调。融合效果不好先查单路检索质量——通常不是 RRF 的锅。


5. Rerank:RRF 之后再做加权

RRF 不看分数——向量 top-1 的 0.99 和 0.51 在 RRF 里贡献相同。所以加一轮 rerank:

python
def _rerank(self, fused, *, alpha, beta):
    max_kw = max((h["keyword_score"] for h in fused), default=1.0) or 1.0
    for h in fused:
        vec = float(h.get("vector_score", 0))
        kw = float(h.get("keyword_score", 0)) / max_kw   # 归一化到 [0,1]
        h["rerank_score"] = round(alpha * vec + beta * kw, 4)
        h["score"] = h["rerank_score"]
    fused.sort(key=lambda x: x["score"], reverse=True)
    return fused

为什么先 RRF 再 rerank:RRF 负责去重合并 + 初步排序;Rerank 利用原始分数做精细调整。两步分离,可独立开关和调参。


6. Query Rewrite:修复用户的"口语问题"

3 种策略串联执行,每步都有兜底和可观测性:

策略 1:Normalize(清洗)

python
def normalize(query):
    q = _PUNCT_RE.sub(" ", query)      # 去标点
    q = _MULTI_SPACE_RE.sub(" ", q)     # 合并空格
    return q.strip()

去标点对 FTS 特别重要——FTS MATCH 语法里引号、问号是特殊字符。

策略 2:Expand(同义词扩展)

python
_SYNONYMS = {
    "请假": ["休假", "年假", "请假流程"],
    "报销": ["费用报销", "发票", "报销流程"],
    "RAG": ["检索增强生成", "retrieval augmented"],
}

def expand_synonyms(query):
    for key, syns in _SYNONYMS.items():
        if key in query:
            query += " " + " ".join(syns)
    return query

效果:"请假怎么办" → "请假怎么办 休假 年假 请假流程"——FTS 能精确命中扩展词,向量能通过更丰富的语义找到更多文档。

策略 3:HyDE(最强也最贵,默认关闭)

python
async def hyde_generate(query):
    """让 LLM 先生成一个'假设答案',用它去 embed 检索."""
    try:
        result = await llm_service.chat_messages(hyde_prompt.render(question=query))
        return result["answer"]
    except LLMError:
        return query   # 兜底:LLM 失败退回原始 query

为什么默认关闭:多一次 LLM 调用(300-500ms + 费用)、LLM 假设答案可能偏。适合口语化严重、领域术语差距大的场景。HyDE 失败时退回原始 query,不让增强功能搞崩主流程。


7. 完整数据流

Code
用户: "那个销假的规定在哪"
   ↓
QueryRewriter: normalize → expand → [HyDE]
   ↓
final_query
   ├→ 向量检索 (ChromaDB) → v_hits
   └→ FTS 检索 (SQLite) → k_hits
          ↓
   _fuse_rrf(v_hits, k_hits)
          ↓
   _rerank(α·vector + β·keyword)
          ↓
   top-K chunks(每个带诊断字段:vector_score, keyword_score, rrf_score, rerank_score)

8. 工程教训

  1. 先跑两路再融合优于只跑一路——hybrid 的额外成本极低(FTS 几乎不耗时),但 5% 的精确匹配尾部 case 兜住了
  2. RRF 的美在于"无参数"——k=60 几乎不用调,规避了两路分数量纲不同
  3. Query rewrite 每一步记录中间结果——召回不准时能立刻定位是哪一步错了
  4. 增强功能优雅退化——HyDE 挂了退回原 query、FTS 表不存在返回空、双写不一致跳过
  5. 中文分词不是可选项——不解决分词,FTS 等于摆设

9. 自测

问题 1:RRF 的 k=60 改成 k=1 或 k=10000 分别会怎样?

问题 2:FTS 命中但 ChromaDB 没有时为什么必须跳过?用 FTS 的 content 凑数有什么隐患?

问题 3:HyDE 生成的假设答案如果编错了产品型号(用户问 X9-2024A,LLM 编了 X9-2024C),检索会怎样?怎么缓解?

问题 4:expand_synonyms 用静态词表 vs 让 LLM 动态生成,各自的优劣?课程版为什么选静态?

问题 5(最重要):90% 中文用户,FTS 按字切导致 BM25 统计意义弱。设计改进方案——写入/查询分词一致性、存量数据迁移、对 ingestion_service 的影响。


答案与解析

问题 1:k 值的影响

k=1:排名 1 和 2 的分差巨大(0.167)。赢家通吃——单路 top-1 碾压所有,另一路翻不了盘。

k=10000:排名 1 和 10 几乎一样。变成"命中计数器"——被两路同时命中的 chunk 分数翻倍,排名高低不重要。

调参技巧:实际上 k 几乎不用调。融合效果不好先检查单路检索质量——通常不是 RRF 的锅。

问题 2:FTS 凑数的三个隐患

  1. 文本失真:FTS 存的是分词后内容("请 假 流 程"),塞进 LLM prompt → 生成质量严重下降
  2. metadata 缺失:没有 heading_path、source → 引用绑定做不了
  3. 掩盖一致性问题:双写不一致被静默吞掉,运维永远不知道

永远不要用"残缺数据"凑数。看起来完整但错误的结果比直接说"没找到"更危险。

问题 3:HyDE 编错型号

错误假设答案 → embedding 偏向 X9-2024C → 检索返回错误产品的文档。

三重缓解

  1. HyDE + 原始 query 双路:两个 embedding 结果 RRF 融合,原始 query 保底
  2. FTS 路始终用原始 query:不受 HyDE 影响,精确匹配用户原话
  3. HyDE prompt 约束:"保留原始问题中的精确术语,不要编造具体型号"

任何用 LLM 输出驱动下游搜索的流程,都要防御幻觉扩散。增强路和兜底路并行,永远保留原始信号。

问题 4:静态词表 vs LLM 动态

静态词表LLM 动态
覆盖面有限,需人工维护无限,任何领域
延迟O(1),零开销300-800ms
准确性可控,错的删一行可能扩展错方向
可复现性永远一致temperature > 0 会变

课程版选静态的理由:教学透明、零延迟、可复现、可控。

生产升级:静态打底 + LLM 叠加 + Redis 缓存(同一 query 24 小时内不重复调 LLM)。

问题 5:中文分词改进

核心铁律:写入和查询必须用同一个分词器。

Code
# 写入时
content = jieba.cut("请假流程需要在OA系统提交")  # → "请假 流程 需要 在 OA 系统 提交"
fts_db.upsert(content=content)

# 查询时
query = jieba.cut("请假流程")  # → "请假 流程"
fts_db.search(query)

4 处改造_tokenize_for_fts 用 jieba 替换按字切(写入/查询两处自动生效)+ 存量数据重建 FTS 索引 + 加自定义词典(jieba.load_userdict)。

ingestion_service 不用改——分词在 FTS 层内部,上层无感。分层红利。

中文全文检索的质量 = 分词器质量。写入查询同一分词器 + 加业务词典 = 召回率从 30% 跳到 85%。


本节要点

  1. 向量懂语义不精确,FTS 精确不懂语义——hybrid 两路并跑 + RRF 融合,最小成本堵住尾部翻车
  2. RRF 只看排名不看分数——规避量纲问题,k=60 几乎不用调
  3. Query Rewrite 三步:normalize(清洗)→ expand(同义词)→ HyDE(假设答案),每步可开关可兜底可观测
  4. 增强功能优雅退化:HyDE 失败退原 query、FTS 挂退空结果、双写不一致跳过
  5. 中文分词是 RAG 的生命线——不解决分词,FTS 等于摆设