ATRULE 技术博客ATRULE 技术博客
首页
博客
文档
关于
首页
博客
文档
关于
  • 技术文章

    • VuePress 2.x 完全指南
    • Gemini3 & GPT5 & DeepSeek3:AI时代程序员的身份转变
    • Agent 架构选型指南:单智能体 vs 多智能体
    • Agent 工程师转型学习路线图 (Full-Stack to Agent Engineer)
    • Agent 工程师学习笔记
    • 📖 Agent 系统架构与工程实践面试速记卡片
    • Agent 流式对话前端踩坑记录
    • RAG 检索工程问题记录
    • Agent 后端问题记录(Koa / MongoDB)
    • LLM 调用与 Agent 编排问题记录
    • Electron 桌面端问题排查记录
    • AI Agent 全栈开发问题排查记录
    • ES2026 与 TypeScript 进阶问题记录
    • 浏览器渲染与 V8 内存问题记录
    • React 19 与 Vue 4 新内核问题记录
    • 前端工程化与微前端问题记录
    • 性能、安全与可观测性问题记录
    • 前端架构实战问题记录
  • 项目实战

    • Node.js + Koa 抖音直播弹幕Agent 五大核心模块(面试项目完整版,TS技术栈)★★★★★
    • Poiclaw 项目蓝图:自主编程实体
    • AI旅行助手

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,同义查询的相似度忽高忽低。

原因:排查发现两个坑——

  1. 新换的 embedding 模型输出未归一化向量,必须先除以模长,否则点积 ≠ 余弦
  2. 零向量(空文本入库)导致除零

解决:

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 升级 = 必须全量重灌索引。业务不停机的做法:

  1. 新写入直接用新模型(双写)
  2. 旧索引后台分片并行重建(多进程跑,几百万条 8 小时内能完)
  3. 重建完成后原子切换读流量
  4. 灰度期保留旧索引做 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
最后更新: 2026/9/5 17:19
Prev
Agent 流式对话前端踩坑记录
Next
Agent 后端问题记录(Koa / MongoDB)