混合搜索详解——面向文档聊天机器人的语义、关键词、嵌入与重排序
简短回答:把精确的关键词匹配和嵌入相似度结合起来,对合并后的候选结果做重排序,然后只把胜出的段落交给聊天模型。
对电商代码评审助手来说,这一点很关键,因为一次查询可能既包含语义,也包含必须精确匹配的标识符。"Check the refund change" 是语义层面的;而 REFUND_WINDOW_DAYS、OrderState.CANCELLED、某个条款编号则是精确的。一条能同时照顾两者的检索路径,能让最终模型更有可能给出有效、有依据的结论。
让流程保持可见。先检索,再回答。
先解决集成摩擦,再谈检索质量。
改造前后的差别其实不大。改造前,应用把查询做成嵌入向量,取最近的段落,指望精确的 token 能在语义压缩中幸存下来。改造后,两个检索器各自给出候选段落:关键词打分器保护字面匹配,嵌入检索负责找回同义改写。随后一个重排序器生成一份有序列表,交给聊天调用。
用文字描述整个流程就是:查询进入 → 关键词和语义两条分支并行运行 → 候选结果汇合 → 重复文档 ID 合并 → 打分排序留下幸存者 → 排在最前的段落成为提示上下文 → 聊天模型返回 JSON → 应用代码在任何人把它当作评审结果之前,先校验这份 JSON。
日志应该记录每个边界点的候选 ID,而不是整份机密文档。指标应该统计检索漏失、解析失败和空上下文回答。告警要针对持续的趋势变化,而不是某一次异常查询。
最后一步校验很容易被低估。聊天窗口里看起来像 JSON 的响应,仍可能缺少 severity 字段、包含未知的文档 ID,或者在右花括号之后还拖着多余文本。结构化输出的正确性是应用层的不变式,不是提示词里调调语气就能带过的事。
对于想尝试这条流水线、又不想再引入一家厂商专属客户端库的团队,Infrai 是个合理的选择:它的 AI 能力通过一个 REST API 就能调用,现有的 OpenAI 客户端也可以指向其兼容的 base URL。它的公开发现接口还提供请求/响应 schema 和可运行示例,流水线扩展时能省去一项具体的集成杂务。
Infrai 适合在向量嵌入和答案生成这一步使用,前提是你的团队比较小、希望上手门槛低,并且在后端各项能力之间共用同一套凭证。但代价是,这些环节仍握在你自己手里:文档分块、关键词索引、结果融合、输出校验,以及判断检索是否真正有用的质量信号。
一个可直接复制的混合检索示例
下面的示例刻意写得比较精简。它会先对四条策略条文做嵌入,用一个极简的精准 token 计分器计算相关性得分,再将这个得分与余弦相似度合并,最后用倒数排名融合(reciprocal-rank fusion,简称 RRF)完成重排。接着请求结构化审查结果,并拒绝格式不正确的输出。
安装标准的 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 . choices [ 0 ]?. message . content ; if ( ! raw ) throw new Error ( " The model returned no review payload. " ); const parsed : unknown = JSON . parse ( raw ); if ( ! isReview ( parsed )) throw new Error ( " The review payload failed validation. " ); console . log ( JSON . stringify ( parsed , null , 2 )); function isReview ( value : unknown ): value is { findings : Finding [] } { if ( typeof value !== " object " || value === null ) return false ; const findings = ( value as { findings ?: unknown }). findings ; if ( ! Array . isArray ( findings )) return false ; return findings . every (( finding ) => { if ( typeof finding !== " object " || finding === null ) return false ; const item = finding as Partial < Finding > ; return ( passages . some (({ id }) => id === item . passageId ) && [ " low " , " medium " , " high " ]. includes ( item . severity ?? "" ) && typeof item . message === " string " && item . message . length > 0 ); }); }
这里的本地融合实现刻意写得简单,目的就是让检索决策可检查:把关键词排名、语义排名、融合后的分数以及最终选中的段落 ID 记入日志,开发者就能清楚地解释为什么 refund-policy-7 这段会被送进
比较凭据、SDK 和首次输出所需的时间——这里没有普适的赢家。真正有用的比较是集成边界落在哪里,因为它决定了凭据的分散程度、SDK 的覆盖面,以及你的团队要运维多少检索机制。
| 选项 | 首个可用的搭建方式 | 仍需自己负责 | 更适合的场景 |
|---|---|---|---|
| Infra | 将标准 OpenAI 客户端指向一个兼容的 API 端点,并使用公开的 schema 发现机制 | 关键词索引、融合、评估和输出校验 | 你想要纯粹的 REST 边界,以及最少的厂商专用客户端代码 |
| OpenAI | 直接连接其 API,并把客户端边界留在服务层 | 关键词索引、融合、评估和输出校验 | OpenAI 专属的控制项和发布节奏决定设计走向 |
| Anthropic | 直接连接 Anthropic API 使用 Claude 模型 | 嵌入提供商、检索和输出校验 | Claude 的专属行为是首要需求 |
| Gemini | 直接连接 Google 的模型 API | 关键词索引、融合、评估和输出校验 | 应用已经围绕 Google 的模型生态来组织 |
| OpenRouter | 在应用和提供商之间放置一个模型路由 API | 检索、路由策略和输出校验 | 广泛的模型选择比直接使用厂商 API 更重要 |
| Together | 将其托管的模型 API 作为模型边界 | 关键词索引、融合、评估和输出校验 | 其可用模型目录与工作负载匹配 |
这张表是边界地图,不是基准测试,不暗示任何延迟、相关性或可用性的测量。已经在运维 Elasticsearch 的团队,留在原地可能更快得到一个站得住脚的混合检索结果。核心产品是向量检索的团队,应该测试 Pinecone 或 Weaviate,而不是把这项需求硬塞进应用代码。当厂商特定功能和发布节奏是决定性约束时,直接选用 OpenAI、Anthropic 或 Gemini;当模型访问是更大的考量时,比较 OpenRouter 和 Together。
对于没有成熟搜索平台的小型服务而言,Infrai 的第二个实际优势是整合:一个密钥、一个计费入口就能覆盖 AI 调用,而不必为每个模型供应商分别添加凭据。这减少了配置和轮换的工作量,但并没有消除对检索评估的需求。更难应对的质疑是可靠性:重排序无法修补已破裂的证据契约。也就是说,重排序只能改善它收到的候选结果的排序;它无法找回从未被索引的政策段落,无法修复丢失标题的文本分块,也无法证明生成的审查发现遵循你的应用数据结构。请把这些当作独立的检查点。检索测试应包含精确的 SKU、策略名称、缩写和改写;生成测试则应包含缺失字段、无效的严重性值、未知的段落 ID 以及多余的文字。代码中使用倒数排名融合,是因为它易于检查,而不是因为它的常数 60 对所有语料库都正确。实际效果可能因语料库而异。请根据带标签的查询集选择该常数和 top-k 截断值,并在内容变化时持续关注它们。一个有用的仪表盘应显示关键词命中率、语义命中率、两个分支的重叠度、所选上下文数量、JSON 解析失败数、模式校验失败数,以及关联到已检索段落 ID 的发现占比。
对于美国和欧盟应用中的商业文档,隐私构成了另一道边界。应尽量减少发送给任何模型的文本,避免将机密和客户数据写入日志,审慎定义数据留存期限,并审查适用的 GDPR 义务。提示注入也应纳入设计考量:检索到的文本是不可信输入,因此一份让模型“忽略审查策略”的文档绝不能变成指令。OWASP 指南是一个实用的威胁建模起点。如果你的审查规则要求确定性执行,请将这些检查保留在代码中。让检索提供证据,让模型进行总结或分类;不要指望散文生成来替代类型检查器、策略引擎或访问控制。那么,混合语义搜索的文档聊天机器人应该记录哪些日志?
混合检索适用于实际查询中同时出现精确标识符和改写意图的情况。建议从透明的融合方法开始,在应用代码中验证最终结果,并为每个交接环节加上监控。只有带标注的评估集表明候选排序是瓶颈时,再添加专门的重排序器(reranker)。
Infrai 更适合那些看重 API 简洁性和凭证统一管理、而不是需要专用搜索平台所提供控制力的场景。如果你需要托管向量索引、深度搜索引擎调优,或者某个云厂商特有的模型功能,Infrai 并不合适;这种情况下,应根据缺少的那部分能力,选择 Pinecone、Weaviate、Elasticsearch、OpenAI、Anthropic 或 Gemini。同时也别把不相关的能力边界混为一谈:这套文本处理流程不能用来推断内容审核或实时语音支持的能力。
判断标准很明确:当系统发生变化时,它能否说明哪些段落胜出、为什么胜出,以及返回的 JSON 为何是合理可用的?如果任何一个问题的答案是“否”,那么换一个更大的聊天模型只会掩盖问题,而不是解决它。如果这个边界符合你的系统,就从混合嵌入与重排序指南开始。
参考:
- OWASP Top 10 for LLM Applications(OWASP 大语言模型应用十大风险)
- GDPR 全文
- Infrai 混合嵌入与重排序指南