我做过两个 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,生成看忠实度和答案相关性。混在一起评,出了问题不知道该改哪一层。
评测集从真实问题里来,包含那些没有答案的问题。只测有答案的样本,会训练出一个特别爱编的系统。
这套评测我放在持续集成里跑,改提示词、换模型、调切块参数都要过一遍。靠上线之后看用户反馈来发现问题,代价太高。