第 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
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,搜"请假"匹配不上。
课程版按字切:
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() 返回负数,越小越相关。转换成正向分数:
"score": max(0.0, -float(bm25_score)) # -5.3 → 5.3,-2.1 → 2.1
3. Hybrid Retrieval:两路并跑 + 融合
用户问题
│
├──────────────┐
↓ ↓
向量检索(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 分数翻倍,天然奖励"共识"
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:
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(清洗)
def normalize(query):
q = _PUNCT_RE.sub(" ", query) # 去标点
q = _MULTI_SPACE_RE.sub(" ", q) # 合并空格
return q.strip()
去标点对 FTS 特别重要——FTS MATCH 语法里引号、问号是特殊字符。
策略 2:Expand(同义词扩展)
_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(最强也最贵,默认关闭)
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. 完整数据流
用户: "那个销假的规定在哪"
↓
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. 工程教训
- 先跑两路再融合优于只跑一路——hybrid 的额外成本极低(FTS 几乎不耗时),但 5% 的精确匹配尾部 case 兜住了
- RRF 的美在于"无参数"——k=60 几乎不用调,规避了两路分数量纲不同
- Query rewrite 每一步记录中间结果——召回不准时能立刻定位是哪一步错了
- 增强功能优雅退化——HyDE 挂了退回原 query、FTS 表不存在返回空、双写不一致跳过
- 中文分词不是可选项——不解决分词,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 凑数的三个隐患
- 文本失真:FTS 存的是分词后内容("请 假 流 程"),塞进 LLM prompt → 生成质量严重下降
- metadata 缺失:没有 heading_path、source → 引用绑定做不了
- 掩盖一致性问题:双写不一致被静默吞掉,运维永远不知道
永远不要用"残缺数据"凑数。看起来完整但错误的结果比直接说"没找到"更危险。
问题 3:HyDE 编错型号
错误假设答案 → embedding 偏向 X9-2024C → 检索返回错误产品的文档。
三重缓解:
- HyDE + 原始 query 双路:两个 embedding 结果 RRF 融合,原始 query 保底
- FTS 路始终用原始 query:不受 HyDE 影响,精确匹配用户原话
- HyDE prompt 约束:"保留原始问题中的精确术语,不要编造具体型号"
任何用 LLM 输出驱动下游搜索的流程,都要防御幻觉扩散。增强路和兜底路并行,永远保留原始信号。
问题 4:静态词表 vs LLM 动态
| 静态词表 | LLM 动态 | |
|---|---|---|
| 覆盖面 | 有限,需人工维护 | 无限,任何领域 |
| 延迟 | O(1),零开销 | 300-800ms |
| 准确性 | 可控,错的删一行 | 可能扩展错方向 |
| 可复现性 | 永远一致 | temperature > 0 会变 |
课程版选静态的理由:教学透明、零延迟、可复现、可控。
生产升级:静态打底 + LLM 叠加 + Redis 缓存(同一 query 24 小时内不重复调 LLM)。
问题 5:中文分词改进
核心铁律:写入和查询必须用同一个分词器。
# 写入时
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%。
本节要点
- 向量懂语义不精确,FTS 精确不懂语义——hybrid 两路并跑 + RRF 融合,最小成本堵住尾部翻车
- RRF 只看排名不看分数——规避量纲问题,k=60 几乎不用调
- Query Rewrite 三步:normalize(清洗)→ expand(同义词)→ HyDE(假设答案),每步可开关可兜底可观测
- 增强功能优雅退化:HyDE 失败退原 query、FTS 挂退空结果、双写不一致跳过
- 中文分词是 RAG 的生命线——不解决分词,FTS 等于摆设