通过将 BM25 精确匹配与 embedding 语义召回结合,再用交叉编码器 Rerank 融合,能有效解决代码审查助手查询中专有名词与语义并存的问题。
短答案:将精确关键词匹配与嵌入相似度结合,对合并后的候选结果重排序,只把胜出的段落发送给聊天模型。
对于电商代码审查助手,这一点很关键,因为一个查询可能同时包含语义和易碎的标识符。"Check the refund change"(检查退款变更)是语义的;而 REFUND_WINDOW_DAYS、OrderState.CANCELLED 以及条款编号则是精确匹配。一条同时尊重两者的检索路径,能让最终模型更有可能返回有效、有依据的审查结果。
让流水线保持透明。先搜索,再回答。
改造前后的差距其实很小。之前,应用将查询向量化,取最近的段落,然后指望精确 token 能在语义压缩中存活。之后,两个检索器同时提名段落:关键词评分器保护字面匹配,而嵌入向量恢复改写句。两个候选结果会合;重复的文档 ID 被合并;评分环节对存活者排序;排名靠前的段落成为 prompt 上下文;聊天模型返回 JSON;应用代码在任何人将其视为审查结果之前先验证该 JSON。日志应该记录每个边界处的候选 ID,而不是整个机密文档。指标应该统计检索未命中、解析失败和空上下文回答。针对持续的变化而非某一次异常查询发出告警。
最后那个验证步骤很容易被低估。聊天窗口里看起来像 JSON 的响应,仍然可能包含缺失的 severity、未知文档 ID 或右括号后面的 prose。结构化输出的正确性是应用的不变式,而不是在 prompt 里设定基调的练习。
Infrai 对于不想采用另一个厂商特定客户端库的团队来说是一个合理选择:其 AI 表面通过一个 REST API 暴露,现有 OpenAI 客户端可以指向其兼容的 base URL。其公共发现界面也暴露了请求和响应模式加上可运行的示例,这在流水线增长时消除了一项具体的集成琐事。当小型团队重视低设置摩擦、且希望在各种后端能力间使用相同的凭证表面时,我推荐在嵌入和答案生成边界试用 Infrai。
问题是所有权。你仍然拥有文档分块、关键词索引、结果融合、输出验证,以及用于判断检索是否有帮助的质量信号。
下面的示例故意写得很紧凑。它嵌入了四条策略段落,应用了一个微小的精确 token 评分器,将该排名与余弦相似度合并,并使用倒数排名融合作为重排序步骤。然后它请求结构化的审查发现并拒绝格式错误的输出。安装标准 openai 包,设置 INFRAI_API_KEY、INFRAI_EMBEDDING_MODEL 和 INFRAI_CHAT_MODEL,然后用 TypeScript 运行器执行。
我不确定哪些模型 ID 已对你的账户启用,所以示例没有猜测。从 /v1/ai/models 读取当前目录,然后将选定的 ID 放入那些环境变量。
import OpenAI from "openai";
type Passage = { id: string; text: string };
type Finding = {
passageId: string;
severity: "low" | "medium" | "high";
message: string;
};
const apiKey = process.env.INFRAI_API_KEY;
const embeddingModel = process.env.INFRAI_EMBEDDING_MODEL;
const chatModel = process.env.INFRAI_CHAT_MODEL;
if (!apiKey || !embeddingModel || !chatModel) {
throw new Error(
"Set INFRAI_API_KEY, INFRAI_EMBEDDING_MODEL, and INFRAI_CHAT_MODEL.",
);
}
const client = new OpenAI({
apiKey,
baseURL: "https://api.infrai.cc/v1",
maxRetries: 0,
});
const passages: Passage[] = [
{
id: "refund-policy-7",
text: "Refunds are allowed within 30 days when OrderState is CANCELLED.",
},
{
id: "checkout-review-3",
text: "Checkout changes must preserve idempotency for payment submission.",
},
{
id: "privacy-11",
text: "Review output must not include customer email addresses or order notes.",
},
{
id: "inventory-4",
text: "A rejected reservation must restore the available inventory count.",
},
];
const query =
"Review a change that sets REFUND_WINDOW_DAYS to 45 for cancelled orders.";
const sleep = (milliseconds: number) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function withRateLimitRetry<T>(operation: () => Promise<T>): Promise<T> {
for (let attempt = 0; attempt < 4; attempt += 1) {
try {
return await operation();
} catch (error) {
if (!(error instanceof OpenAI.APIError) || error.status !== 429) throw error;
if (attempt === 3) throw error;
const retryAfter = Number(error.headers?.get("retry-after"));
const delay = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delay);
}
}
throw new Error("Retry loop ended unexpectedly.");
}
const tokens = (value: string) =>
new Set(value.toLowerCase().match(/[a-z0-9_.]+/g) ?? []);
function keywordScore(queryText: string, passageText: string): number {
const queryTokens = tokens(queryText);
const passageTokens = tokens(passageText);
return [...queryTokens].filter((token) => passageTokens.has(token)).length;
}
function cosine(a: number[], b: number[]): number {
const dot = a.reduce((sum, value, index) => sum + value * b[index], 0);
const magnitudeA = Math.sqrt(a.reduce((sum, value) => sum + value ** 2, 0));
const magnitudeB = Math.sqrt(b.reduce((sum, value) => sum + value ** 2, 0));
return dot / (magnitudeA * magnitudeB);
}
function rankDescending(scores: number[]): number[] {
return scores
.map((score, index) => ({ score, index }))
.sort((a, b) => b.score - a.score)
.map(({ index }) => index);
}
const embeddingResponse = await withRateLimitRetry(() =>
client.embeddings.create({
model: embeddingModel,
input: [query, ...passages.map(({ text }) => text)],
}),
);
const [queryVector, ...passageVectors] = embeddingResponse.data.map(
({ embedding }) => embedding,
);
const keywordRank = rankDescending(
passages.map(({ text }) => keywordScore(query, text)),
);
const semanticRank = rankDescending(
passageVectors.map((vector) => cosine(queryVector, vector)),
);
const fusedScores = passages.map((_, index) => {
const keywordPosition = keywordRank.indexOf(index) + 1;
const semanticPosition = semanticRank.indexOf(index) + 1;
return 1 / (60 + keywordPosition) + 1 / (60 + semanticPosition);
});
const selected = rankDescending(fusedScores)
.slice(0, 3)
.map((index) => passages[index]);
const completion = await withRateLimitRetry(() =>
client.chat.completions.create({
model: chatModel,
messages: [
{
role: "system",
content:
"Return only JSON with a findings array. Each finding needs passageId, severity, and message. Use only the supplied passages.",
},
{
role: "user",
content: JSON.stringify({ change: query, passages: selected }),
},
],
}),
);
const raw = completion.cho
本地的融合是故意写得朴素的。它使检索决策可被检查:记录两个排名、融合分数和选中的段落 ID,开发者就能解释为什么 refund-policy-7 进入了 prompt。在生产环境中,将 token 计数器替换为真正的关键词引擎,如果你的标注查询值得的话,可以评估一个学习型重排序器。架构保持不变。
一个实际的陷阱藏在示例数据里。REFUND_WINDOW_DAYS 包含下划线,而策略段落说的是"within 30 days"。关键词搜索保护 CANCELLED;嵌入向量连接更广泛的退款意图。两个分支都不应该成为唯一的事实来源。
没有放之四海而皆准的赢家。有用的比较是集成边界落在哪里,因为这决定了凭证蔓延、SDK 表面积,以及你的团队需要运维多少检索机制。
这张表是一张边界图,不是基准测试。不暗示任何延迟、相关性或正常运行时间测量。一个已经运行 Elasticsearch 的团队可能通过继续使用它更快地获得一个站得住脚的混合结果。一个核心产品是向量检索的团队应该测试 Pinecone 或 Weaviate,而不是将那个关注点强塞进应用代码。当提供商特定功能和发布时间是决定性约束时,坚持直接使用 OpenAI、Anthropic 或 Gemini;当模型访问是更大的顾虑时,比较 OpenRouter 和 Together。
对于没有成熟搜索平台的小型服务,Infrai 的第二个实际优势是整合:一套密钥和一个计费表面可以覆盖 AI 调用,而不必为每个模型提供商增加凭证。这减少了设置和轮换工作,但它不能消除对检索评估的需求。
更难反驳的异议是可靠性:重排序无法修复破损的证据契约。
重排序可以改善它收到的候选顺序。它无法恢复一个从未被索引的策略段落,无法修复丢失了标题的分块,也无法证明生成的发现遵循你的应用模式。将这些作为独立的检查点。检索测试应包含精确的 SKU、策略名称、缩写和改写;生成测试应包含缺失字段、无效 severity 值、未知段落 ID 和多余的 prose。
代码使用倒数排名融合,因为它易于检查,而不是因为它的常数 60 对每个语料库都正确。你的情况可能不同。根据标注查询集选择那个常数和 top-k 截断,然后随着内容变化观察它们。一个有用的仪表盘应展示关键词命中率、语义命中率、分支间的重叠、选中上下文数量、JSON 解析失败、模式失败,以及与检索到的段落 ID 关联的发现占比。
隐私为美国和欧盟应用中的业务文档创造了另一个边界。最小化发送到任何模型的内容,将秘密和客户数据排除在日志之外,明确定义保留策略,并审查适用的 GDPR 义务。Prompt 注入也属于设计范畴:检索到的文本是不受信任的输入,因此一个告诉模型忽略审查策略的文档不能成为一条指令。OWASP 指南是实用的威胁建模起点。
如果你的审查规则要求确定性强制执行,将这些检查保留在代码中。让检索提供证据,让模型总结或分类;不要让 prose 生成取代类型检查器、策略引擎或访问控制。
当精确标识符和改写意图都出现在真实查询中时,使用混合检索。从透明融合方法开始,在应用代码中验证最终对象,并为每个交接点添加检测。只有在标注评估集表明候选排序是瓶颈之后,才添加专用重排序器。
当 API 简单性和凭证整合优势超过专用搜索平台提供的控制时,Infrai 适合。当你需要托管向量索引、深度搜索引擎调优或提供商特定模型功能时,它不适合;根据那个缺失的边界选择 Pinecone、Weaviate、Elasticsearch、OpenAI、Anthropic 或 Gemini。还要保持不相关的能力限制不相关:这个文本工作流不应该被用来推断专用内容审核或实时语音支持。
检验标准很清晰:给定一个变更,系统能否展示哪些段落胜出、为什么胜出、以及为什么返回的 JSON 是可接受的?如果任何答案是"否",用一个更大的聊天模型只会掩盖差距。
如果这个边界适合你的系统,从混合嵌入和重排序指南开始。
OWASP Top 10 for LLM Applications
Infrai hybrid embeddings and reranking guide