分析RAG系统最常见的质量失败根因——文档版本管理缺失导致过时chunk被误召回,提出"无scope查询应为类型错误"的修复思路。
用户问如何轮换 API key。你的助手信心满满地给出了回答,还附上了引用,但答案描述的是十八个月前就已废弃的 v1 轮换流程。
检索是成功的。它找到的那个 chunk 确实讲的是 API key 轮换。只是讲的是不同版本的产品,而整个 pipeline 没有任何机制知道这很重要。
这是 RAG 系统最常见的质量失败,而且几乎从来不是 embedding 的问题。
文档站点会积累版本。v1/auth.md 和 v2/auth.md 用相似的措辞讲相似的事情,所以它们 embedding 到几乎同一点。余弦相似度无法偏好其中一个——它们在主题上同等"相关"。
谁胜出由噪声决定。
解决办法不是更好的排序。根本是不要把过期内容放入索引:
const rows = await db.query(
`SELECT id, text FROM chunks
WHERE deprecated_at IS NULL
AND doc_version = $2
ORDER BY embedding <=> $1
LIMIT $3`,
[queryVec, CURRENT_VERSION, k],
);
如果旧版本必须保持可搜索,要让它显式而不是偶然:
type Scope = { version: string; includeDeprecated: boolean };
没有 scope 的查询应该是类型错误。这就是全部的修复——bug 存在是因为"哪个版本"从来不是一个参数。
大多数团队在 chunk 行上存储了 docType、product、locale、tenantId,然后在查询时从不使用它们。
// 这个查询导致了整篇文章的问题
ORDER BY embedding <=> $1 LIMIT 10
"如何轮换 key"的 embedding 不携带关于用户使用哪个产品线的任何信号。那不在文本里。它在你的 session 里。
export async function search(q: string, ctx: RequestCtx, k = 10) {
return db.query(
`SELECT id, text, doc_id FROM chunks
WHERE tenant_id = $2
AND product = $3
AND locale = $4
AND deprecated_at IS NULL
ORDER BY embedding <=> $1
LIMIT $5`,
[await embed(q), ctx.tenantId, ctx.product, ctx.locale, k],
);
}
先过滤,再相似度。你确定知道的所有东西都应该在让向量去猜之前约束候选集。
一个 chunk 读起来是"点击 Rotate,然后确认。旧 key 在 24 小时内仍然有效。"这确实是模糊的。哪个产品?哪个版本?往上两层的标题说清楚了,但 chunking 把它丢掉了。
把标题路径带入 embedding 的文本:
const embedText = [
doc.product,
doc.version,
...chunk.headingPath, // ["Authentication", "API keys", "Rotation"]
chunk.text,
].join("\n");
await store(chunk.id, await embed(embedText), chunk.text);
embedding 丰富后的文本;存储原始文本用于显示。标题路径只花几个 token,但它往往是区分两个原本相同的流程的唯一东西。

Top-k 总是返回 k 个结果。没有"没有好的匹配"这个结果——问一个你的语料库中没有的东西,你仍然会得到 10 个 chunk,而且它们会是 10 个最不差的。
然后模型根据它们来回答,因为这是你让它做的。
加一个底限和拒绝路径:
const MIN_RELEVANCE = 0.35;
const scored = await rerank(q, candidates);
const usable = scored.filter((c) => c.relevance >= MIN_RELEVANCE);
if (usable.length === 0) {
return {
kind: "no_answer" as const,
message: "I could not find anything about that in the documentation.",
};
}
对原始余弦相似度设绝对阈值是脆弱的——分布会随 embedding 模型变化。对 reranker 分数设阈值,它校准的是"这是否回答了问题",或者对第一名和中间值的差距设阈值。
然后在 prompt 里说明这一点,并把拒绝做成一个真实的选项:
const system = `
Answer only from the provided sources. If they do not contain the answer,
say so plainly. Do not fill gaps with general knowledge — a wrong specific
answer is worse than "not documented".`.trim();
更长的 chunk 经常只是因为包含更多与查询重叠的词而检索得更好。一个简洁的三行当前答案输给了冗长的过时页面。
把新鲜度和权威性作为显式信号纳入排序,而不是指望它:
const score = (c: Scored) =>
0.65 * c.relevance +
0.15 * Math.exp(-ageDays(c) / 365) +
0.10 * AUTHORITY[c.docType] + // guide > reference > changelog
0.10 * (c.isCanonical ? 1 : 0);
权重在一个可见的地方。另一种方式——指望 embedding"知道"2024 年的 changelog 没有当前指南有用——不是一个机制。
在改变任何东西之前,先搞清楚你遇到的是五种中的哪一种。取 20 个真实的问题——它们的答案是错的——然后记录本该被使用的 chunk 的 id。
for (const c of GOLDEN) {
const got = await search(c.question, ctx, 20);
const rank = got.findIndex((r) => r.id === c.expectedChunkId);
console.log(c.question, rank === -1 ? "NOT RETRIEVED" : `rank ${rank + 1}`);
}
两种结果,两种不同的问题:
根本没被检索到——召回问题。chunking、embedding 或者过滤器太窄。这无法通过重排序修复,因为正确的 chunk 从未进入候选集。
排在第 8 位——排序问题。正确的 chunk 在那里,只是其他东西排在了前面。重排序和分数组合可以修复它,而且代价不高。
团队通常会花一周调优排序,而实际问题是召回失败。20 个带标签的问题可以在一小时内告诉你你遇到的是哪一种。

用你已经知道的一切做过滤,embedding 标题路径让 chunk 自我区分,让系统返回"未记录",并把新鲜度和权威性作为显式项纳入排序。
余弦相似度回答的是"这是关于什么的"。这些失败都是你的 pipeline 指望它回答"这些中哪一个是对的",而它从来做不到。
AI That Reads 从端到端讲透了检索质量——元数据过滤、chunk 丰富化、重排序、拒绝路径,以及告诉你实际在面对哪种失败的小型标注集。
