RAG 检索工程问题记录
RAG 从 demo 到生产之间的差距,全在这些不起眼的问题里。
问题一:BM25 打 12 分、向量打 0.83,融合排序没法做
现象:混合检索(关键词 + 向量)结果合并后排序混乱,权重怎么调都不对。
原因:BM25 分数量纲是 0~20+,cosine 是 0~1,两路分数分布完全不同——分数不可比是融合的第一性矛盾,直接加权相加等于让 BM25 主导一切。
解决:用 RRF(Reciprocal Rank Fusion),只看排名不看分数,天然免疫分布差异:
def rrf_fusion(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
"""k 是平滑参数,经验值 60;rank 越靠前贡献越大"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
验证:某路检索挂了返回空列表时,RRF 不会把整体排序拉垮(空路贡献为 0);加权求和则会。记得监控各路召回贡献率。
问题二:手写余弦相似度,某些查询结果全错
现象:自己实现的 cosine similarity,同义查询的相似度忽高忽低。
原因:排查发现两个坑——
- 新换的 embedding 模型输出未归一化向量,必须先除以模长,否则点积 ≠ 余弦
- 零向量(空文本入库)导致除零
解决:
import math
def cosine_similarity(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(x * x for x in b))
if norm_a == 0 or norm_b == 0: # 零向量兜底
return 0.0
return dot / (norm_a * norm_b)
经验:入库流程里加一步强制 L2 归一化,归一化后余弦 = 点积,检索时省一次除法;pgvector 的 <=> 操作符默认就假设向量已归一化。
问题三:chunk 边界把"仅限 VIP"这个限定条件切丢了
现象:用户问合同条款,检索命中的片段恰好是被切开的下半截,上半截的适用条件丢了,答案答错。
原因:固定长度切分(500 token)不看语义边界,一句话、一个条件可以被拦腰斩断。overlap 设 20% 也只是降低概率,不解决问题。
解决:父子块(parent-child chunk)——用小块检索保证精准,回带父块保证上下文完整:
# 入库时:父块存原文,子块带 parent_id
{
"chunk_id": "child_042",
"parent_id": "parent_007", # 指向完整段落
"content": "违约金比例为5%...", # 小块,用于 embedding 检索
}
# 检索后:用子块命中,取父块内容进上下文
def retrieve_with_parents(hits: list[dict]) -> list[str]:
parent_ids = {h["parent_id"] for h in hits}
return fetch_parents(parent_ids) # 返回完整段落给 LLM
配套:结构感知切分(Markdown 按标题、PDF 按段落、代码按函数)比固定长度好得多;元数据(标题/页码/时间)随块入库,用于过滤和溯源。
问题四:embedding 模型升级,召回率一夜跌 30%
现象:换了新 embedding 模型(768 维 → 1024 维),线上 RAG 答非所问。
原因:不同模型的向量空间完全不兼容——旧向量和新查询向量不在同一个数学空间里,算出来的相似度是垃圾。这不是"重新归一化"能救的。
解决:embedding 升级 = 必须全量重灌索引。业务不停机的做法:
- 新写入直接用新模型(双写)
- 旧索引后台分片并行重建(多进程跑,几百万条 8 小时内能完)
- 重建完成后原子切换读流量
- 灰度期保留旧索引做 fallback 对比
教训:缓存 key 里必须带 模型名+维度 复合键,否则发版后旧缓存全部语义失效,等于上线即脏。
问题五:rerank 加了 200ms 延迟,值不值?
现象:给 RAG 加了 cross-encoder rerank,效果变好但首 token 延迟超标。
原因:embedding 是双塔(query 和 doc 独立编码,快但不精),rerank 是交互式(query+doc 拼一起过模型,精但慢——每个候选都要过一次前向传播)。
解决:控制漏斗比例,rerank 只看粗排后的少量候选:
500 万文档 → embedding 粗排 top-100 → rerank 精排 top-10 → LLM
~50ms ~150ms
验证方法:对比 rerank 前后的 Recall@10——如果从 0.71 涨到 0.83,200ms 换 12 个点的召回率,值;如果只涨 2 个点,先调 chunking 再考虑加 rerank。
小结
| 问题 | 一句话方案 |
|---|---|
| 分数不可比 | RRF 融合,只看排名不看分数 |
| 余弦算错 | 入库强制 L2 归一化 + 零向量兜底 |
| 信息被切断 | 父子块:小块检索、父块进上下文 |
| 模型升级失效 | 双写 + 后台重灌 + 原子切换 |
| rerank 延迟 | 缩小漏斗:只 rerank 粗排 top-100 |
