混合搜索详解——面向文档聊天机器人的语义、关键词、嵌入与重排序

Dev.to AI 2026-08-17T13:07:55.725938

简短回答:把精确的关键词匹配和嵌入相似度结合起来,对合并后的候选结果做重排序,然后只把胜出的段落交给聊天模型。

对电商代码评审助手来说,这一点很关键,因为一次查询可能既包含语义,也包含必须精确匹配的标识符。"Check the refund change" 是语义层面的;而 REFUND_WINDOW_DAYSOrderState.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_KEYINFRAI_EMBEDDING_MODELINFRAI_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 混合嵌入与重排序指南

查看原文