我做过两个 RAG 项目,第一个失败了。失败的原因不是向量库选错,而是我从头到尾都在优化检索,没有认真对待回答。用户问的是问题,系统给的是一堆相关段落,中间那一步被我想当然了。

切块决定上限

切块策略比嵌入模型影响更大。按固定字数切,会把一个完整条件拆到两个块里,检索命中上半句,生成时缺了下半句。

我现在的做法是按结构切:Markdown 按标题层级,HTML 按语义标签,代码文档按函数边界。每块保留标题路径作为上下文前缀,块大小控制在 300 到 600 字,重叠 10% 左右。

切块之前先清洗。导航栏、页脚、每页重复的免责声明留在块里,会稀释向量,也会让生成把无关内容当成依据。

  • 块太大,向量被稀释,召回不准
  • 块太小,缺少上下文,生成时容易编
  • 表格和列表单独处理,不要和正文混切
  • 每块都带上来源标识,后面引用要用

混合检索与重排

纯向量检索对语义相近的表述很友好,但对型号、错误码、专有名词这类精确匹配很弱。用户搜 ERR_CONN_REFUSED,在向量空间里它和一句笼统的网络错误描述靠得很近,实际想要的却是那一页排障手册。

所以我把 BM25 和向量检索并行跑,用 RRF 融合排名。

function rrf(lists, k = 60) {
  const scores = new Map()
  for (const list of lists) {
    list.forEach((id, index) => {
      const prev = scores.get(id) ?? 0
      scores.set(id, prev + 1 / (k + index + 1))
    })
  }
  return [...scores.entries()]
    .sort((a, b) => b[1] - a[1])
    .map(([id]) => id)
}

RRF 的好处是不需要把两边的分数归一化,工程上省事,效果也够用。

召回二十条,重排取前五条,是我目前最划算的一步。交叉编码器慢,但只对少量候选跑,延迟可以接受。上线后最明显的变化是无关段落变少了,回答里瞎编的比例跟着下降。

拒答和引用

这两件事决定了系统可不可信。

  • 检索分数低于阈值时直接说没找到,不要硬答
  • 提示词里明确允许回答不知道
  • 每个结论后面带来源编号,前端能点开原文
  • 引用要指向具体段落,不是整个文档

我见过一个系统,回答准确率不低,但用户不敢用,因为看不出哪句话有依据。加上引用之后,同一个系统的采纳率明显变了。人对不确定的东西需要抓手。

评测要分层

检索和生成要分开评。检索看召回率和 MRR,生成看忠实度和答案相关性。混在一起评,出了问题不知道该改哪一层。

评测集从真实问题里来,包含那些没有答案的问题。只测有答案的样本,会训练出一个特别爱编的系统。

这套评测我放在持续集成里跑,改提示词、换模型、调切块参数都要过一遍。靠上线之后看用户反馈来发现问题,代价太高。