文章解释向量相似度只能衡量主题接近程度,可能把真正能回答问题的片段排在后面。建议第一阶段扩大候选召回,再结合重排、文档类型与时效等信号筛选最终上下文。
本系列:AI in TypeScript——共 5 本书,从第一次调用 LLM 到在生产环境中运行 Agent——五本均在这里
我的项目:Hermes IDE | GitHub——一款面向使用 Claude Code 和其他 AI 编程工具交付产品的开发者 IDE
关于我:xgabriel.com | GitHub
向一个支持助手提问:“如何轮换 API key?”然后看看普通向量搜索会返回什么。十个 chunk,全都确实与 API key 有关:宣布支持 key 轮换的 changelog、提到 key 数量限制的定价页面,以及解释轮换为何重要的安全概述。
而分步骤讲解轮换操作的文档,排在第十一位。
系统没有出故障。Cosine similarity 衡量的是主题相关性,它返回了主题上最相关的十个 chunk。它从未被要求判断哪个 chunk 真正回答了问题,因为单次 embedding 比较无法表达这种差异。
Embedding 会把一个 chunk 压缩成一个点。两个点之间的 cosine distance 衡量的是它们整体语义的接近程度。
这是一个确实有用、但同时存在信息损失的信号。压缩过程中丢失的信息包括:chunk 是说明性的还是描述性的、内容是最新的还是已被取代、是否明确提到了查询中的某个实体,以及它究竟是否包含操作步骤。
查询通常简短,并带有疑问语气。优质的答案 chunk 通常更长,而且使用陈述语气。二者在语义上可以非常接近,却属于不同类型的文本——而“文本类型不同”,恰恰是区分“真正的答案”和“仅仅提到相关内容”的关键。
因此,第一阶段不应该追求精准,而应该以 recall 为导向:尽可能多地获取候选内容,然后再投入计算资源,判断其中哪些内容真正回答了问题。
export type Candidate = {
id: string;
text: string;
vectorScore: number;
updatedAt: Date;
docType: "guide" | "reference" | "changelog" | "marketing";
};
export async function retrieve(q: string, k = 8) {
const candidates = await vectorSearch(q, { limit: k * 8 });
const reranked = await rerank(q, candidates);
return reranked.slice(0, k);
}
第一阶段的诀窍全在 k * 8。原本排在第十一位的轮换操作步骤,现在进入了候选集合;随后,第二阶段不再比较点,而是直接阅读文本,因此可以把它提升到更靠前的位置。
这个倍数是一个真正需要调节的参数。设置得太小,正确答案根本无法进入候选池;设置得太大,reranking 的成本又会变高。以 k 的五到十倍作为起始范围比较合理,但应该根据带标签的查询进行测量,而不是选定一次后就不再调整。
Bi-encoder——也就是向量搜索所使用的模型——会分别对查询和文档进行 embedding,然后比较两者。查询与文档从未真正一起参与计算。
Cross-encoder 则会把这对文本一起输入模型,直接评估它们之间的关系。它能够关注到查询里写的是 “how do I”,而这个 chunk 的开头是 “To rotate a key:”。这种交互信息,正是分别生成 embedding 时丢失的内容。
export async function crossEncode(
query: string,
cands: Candidate[],
): Promise<Scored[]> {
const scores = await reranker.rank({
query,
documents: cands.map((c) => c.text),
});
return cands.map((c, i) => ({ ...c, relevance: scores[i] }));
}
它对每一对文本进行计算的成本更高,因此只会处理六十个候选项,而不是整个语料库。这就是整个模式的结构:先以低成本进行宽泛筛选,再以高成本进行精细筛选。
如果没有可用的 reranker,也可以让 LLM 配合受约束的输出来完成同样的工作:
const Rated = z.object({
ratings: z.array(z.object({
id: z.string(),
answers: z.number().min(0).max(3),
})),
});
async function llmRerank(query: string, cands: Candidate[]) {
const res = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
system:
"Rate how directly each passage ANSWERS the question. " +
"3 = contains the answer. 2 = partial. " +
"1 = related topic only. 0 = irrelevant.",
messages: [{ role: "user", content: render(query, cands) }],
});
return Rated.parse(JSON.parse(textOf(res.content)));
}
真正发挥作用的是评分标准的措辞。使用 “answers” 而不是 “relevant”,明确指出了向量阶段无法识别的区别;而显式表达这种区别,基本就是你为此付费所购买的核心能力。

Relevance 并不是你掌握的唯一信息。Recency 和 document type 都是行记录中现成的结构化字段,将它们作为纯函数纳入评分,可以让这些逻辑保持可测试性。
export type Signal = (c: Scored, q: string) => number;
export const recency: Signal = (c) => {
const days = (Date.now() - c.updatedAt.getTime()) / 86_400_000;
return Math.exp(-days / 365); // ~0.37 at one year
};
export const authority: Signal = (c) =>
({ guide: 1, reference: 0.9, changelog: 0.5, marketing: 0.2 })[
c.docType
];
export const exactTerm: Signal = (c, q) => {
const terms = q.toLowerCase().match(/\b[a-z_]{4,}\b/g) ?? [];
const hits = terms.filter((t) => c.text.toLowerCase().includes(t));
return terms.length ? hits.length / terms.length : 0;
};
每一个信号都只是一个以候选项为输入的普通函数。无需模型、数据库或网络调用,就可以分别对它们进行单元测试——这一点很重要,因为如果不这样做,排序逻辑往往会成为 RAG 系统中最难测试的部分。
使用显式权重组合这些信号:
const WEIGHTS = [
[relevanceSignal, 0.6],
[exactTerm, 0.2],
[recency, 0.1],
[authority, 0.1],
] as const;
export function finalScore(c: Scored, q: string): number {
return WEIGHTS.reduce((sum, [f, w]) => sum + f(c, q) * w, 0);
}
把权重集中放在一个数组中,清晰可见、方便调整,也能直接查看 diff。另一种做法,是让评分逻辑散落在一个很长的函数里,并在代码中到处内联 magic number——这种版本最终会变得无人敢动。
exactTerm 的价值往往比人们预想的更高。Embedding 不擅长处理罕见的字面量 token,例如错误码、配置 key 或版本字符串。如果用户输入了 ERR_KEY_ROTATION_FAILED,那么包含这个精确字符串的 chunk 几乎肯定就是正确的 chunk,而向量阶段通常无法对此给出足够明确的判断。
不同来源的分数处于不同的数值尺度。Cosine similarity 通常集中在接近上限的一小段范围内;reranker 可能输出 logits;而你自己定义的信号已经处于 [0, 1] 区间。
如果直接把它们相加,取值范围最宽的信号就会在不知不觉中主导最终结果。
function normalise(xs: number[]): number[] {
const lo = Math.min(...xs);
const hi = Math.max(...xs);
if (hi === lo) return xs.map(() => 0.5);
return xs.map((x) => (x - lo) / (hi - lo));
}
应该针对每次查询,在候选集合内部进行 min-max 归一化,而不是进行全局归一化——我们的目标是确定这些候选项之间的相对顺序;如果使用全局尺度,一个拥有十个优质答案的简单查询,可能会与一个完全没有答案的困难查询表现得一模一样。

准备三十个真实查询,并为每个查询标注能够回答该问题的 chunk id。这已经足以观察指标变化。
it("ranks the answering chunk in the top 3", async () => {
let hits = 0;
for (const { query, answerId } of GOLDEN) {
const top = (await retrieve(query, 3)).map((c) => c.id);
if (top.includes(answerId)) hits++;
}
expect(hits / GOLDEN.length).toBeGreaterThan(BASELINE);
});
将其作为一个必须持续提升的数值指标进行跟踪。如果没有这个指标,所谓的权重调优就会变成:某个人因为亲自尝试的一条查询效果变好了,就把 0.6 改成 0.7,却悄无声息地让另外三十条查询的效果变得更差。
Retrieval 要回答的其实是两个问题,而不是一个。第一,这段内容在讨论什么——低成本、近似、宽泛。第二,这段内容是否回答了问题——高成本、精确、收窄。
Cosine similarity 很擅长回答第一个问题,却完全无法回答第二个问题。大多数效果“差一点就对了”的 RAG 系统,缺少的正是完整的第二阶段。
《AI That Reads》完整讲解了 retrieval quality 的各个环节——候选项生成、reranking、分数组合,以及如何构建一个小规模带标签的数据集,用来判断某次改动是否真正带来了改善。

完整系列可在 xgabriel.com/ai-in-typescript 查看。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。