检索增强——混合检索、Re-rank 与 Query 优化

05 · 检索增强------混合检索、Re-rank 与 Query 优化

向量检索不是唯一的路。关键词匹配、Query 改写、重排序,三管齐下才能把召回率提到 95%+。


1. 为什么需要检索增强?

纯粹的向量检索有两个致命盲区:

盲区 例子 原因
专有名词 "React 19 的 useOptimistic 怎么用?" Embedding 对函数名的语义理解弱
精确匹配 错误码 ERR_CERT_AUTHORITY_INVALID 向量检索天然模糊,可能返回不相关
缩写 "PA 审批流怎么配置?" "PA"=Policy Approval,Embedding 无法联想

解决之道:混合检索 = 关键词 + 向量,互补短板。


2. 混合检索架构

scss 复制代码
用户 Query
    │
    ├──→ [关键词检索] → BM25 结果集
    │
    └──→ [向量检索]   → 语义结果集
              │
              ▼
         [结果融合]
       RRF (Reciprocal Rank Fusion)
              │
              ▼
         [重排序 Re-rank] → Top-N
              │
              ▼
         [上下文拼接] → LLM

2.1 关键词检索:BM25

BM25 是经典的全文搜索算法,比简单的 LIKE '%keyword%' 精度高得多。

csharp 复制代码
// retrieval/bm25.ts
import { BM25Retriever } from "@langchain/community/retrievers/bm25";

// LangChain.js 内置 BM25
const bm25Retriever = BM25Retriever.fromDocuments(documents, {
  k: 10,
});

// 或使用 PostgreSQL 全文搜索
const pgSearch = async (query: string, limit: number) => {
  const result = await pool.query(`
    SELECT id, content,
           ts_rank(to_tsvector('chinese', content), plainto_tsquery('chinese', $1)) AS rank
    FROM documents
    WHERE to_tsvector('chinese', content) @@ plainto_tsquery('chinese', $1)
    ORDER BY rank DESC
    LIMIT $2
  `, [query, limit]);
  return result.rows;
};

为什么选 BM25 而不是 TF-IDF:BM25 引入了文档长度归一化,对长短文档混合的场景更公平。

2.2 结果融合:RRF(Reciprocal Rank Fusion)

typescript 复制代码
// retrieval/fusion.ts
interface SearchResult {
  id: string;
  content: string;
  source: "keyword" | "vector";
  score: number;
  rank: number;
}

function reciprocalRankFusion(
  keywordResults: SearchResult[],
  vectorResults: SearchResult[],
  k: number = 60,   // RRF 平滑因子
  topN: number = 10
): SearchResult[] {
  const scores = new Map<string, number>();

  // 关键词结果:rank 1 → score 1/(60+1)
  keywordResults.forEach((r, i) => {
    scores.set(r.id, 1 / (k + i + 1));
  });

  // 向量结果:rank 1 → score 1/(60+1),累加到已有分数
  vectorResults.forEach((r, i) => {
    const existing = scores.get(r.id) || 0;
    scores.set(r.id, existing + 1 / (k + i + 1));
  });

  // 按融合分数降序排列
  return Array.from(scores.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, topN)
    .map(([id, _score]) => {
      return (
        keywordResults.find(r => r.id === id) ||
        vectorResults.find(r => r.id === id)!
      );
    });
}

RRF 为什么好

  • 不需要归一化两个不同分布的分数(关键词分数和向量相似度不可比)
  • 只关心排序位置,天然抗异常值
  • k=60 是学术界公认的较优参数

2.3 混合检索完整流程

typescript 复制代码
// retrieval/hybrid.ts
class HybridRetriever {
  async search(query: string, topN: number): Promise<SearchResult[]> {
    // 1. 并行执行两种检索
    const [keywordResults, vectorResults] = await Promise.all([
      this.bm25Search(query, topN * 2),
      this.vectorSearch(query, topN * 2),
    ]);

    // 2. RRF 融合
    const fused = reciprocalRankFusion(keywordResults, vectorResults, 60, topN * 2);

    // 3. Re-rank 精选
    const reranked = await this.rerank(query, fused, topN);

    return reranked;
  }
}

3. Re-rank 重排序

向量检索返回 Top-K,但"相似"不等于"相关"。Re-rank 用更强的模型对候选集重新打分。

3.1 Cohere Rerank(API 方案,效果最好)

typescript 复制代码
// rerank/cohere.ts
import { CohereClient } from "cohere-ai";

const cohere = new CohereClient({ token: process.env.COHERE_API_KEY! });

async function rerankWithCohere(
  query: string,
  documents: { id: string; content: string }[]
): Promise<{ id: string; content: string; relevanceScore: number }[]> {
  const response = await cohere.v2.rerank({
    model: "rerank-v3.5",
    query,
    documents: documents.map(d => d.content),
    topN: 5,
    returnDocuments: false,
  });

  return response.results.map(r => ({
    ...documents[r.index],
    relevanceScore: r.relevanceScore,
  }));
}

3.2 bge-reranker(本地部署,中文最优)

typescript 复制代码
// rerank/local.ts
class LocalReranker {
  private endpoint = "http://localhost:8001/rerank";

  async rerank(query: string, documents: string[]): Promise<RerankResult[]> {
    const res = await fetch(this.endpoint, {
      method: "POST",
      body: JSON.stringify({ query, documents }),
      headers: { "Content-Type": "application/json" },
    });

    const data = await res.json();
    return data.scores
      .map((score: number, i: number) => ({ index: i, score }))
      .sort((a, b) => b.score - a.score);
  }
}
python 复制代码
# rerank_server.py
from fastapi import FastAPI
from FlagEmbedding import FlagReranker

app = FastAPI()
reranker = FlagReranker("BAAI/bge-reranker-large")

@app.post("/rerank")
async def rerank(query: str, documents: list[str]):
    pairs = [[query, doc] for doc in documents]
    scores = reranker.compute_score(pairs)
    return {"scores": scores}

3.3 Re-rank 的性能-精度权衡

方案 精度提升 延迟增加
不 Re-rank 基准 0ms
Cross-encoder 重排 Top-20 +8-12% NDCG +50-100ms
Cohere Rerank API Top-40 +10-15% NDCG +200-500ms

建议:先召回 20-50 个候选,再用 Re-rank 精选 Top-5,成本可控且精度提升显著。


4. Query 优化

用户提问的质量决定了检索的上限。Query 改写是 ROI 最高的优化。

4.1 HyDE(假设文档嵌入)

先让 LLM 生成一个假想的答案文档,再用这个假答案去做检索------神奇地有效。

typescript 复制代码
// query/hyde.ts
async function hydeRetrieval(query: string, llm: any, vectorStore: any) {
  // 1. 让 LLM 生成一个假想的答案
  const hypotheticalDoc = await llm.invoke(`
    请根据以下问题,生成一段可能包含答案的文档(200-300字)。
    不需要真的回答,只需要写一段看起来像答案的文本。

    问题:${query}
  `);

  // 2. 用假想文档做向量检索
  const results = await vectorStore.similaritySearch(hypotheticalDoc, 10);

  return results;
}

原理:用户的 Query 通常是口语化的,而文档是书面化的。HyDE 将 Query 转换为"文档风格",缩小了 Query 和 Document 之间的语义鸿沟。

4.2 多轮 Query 改写(对话场景)

在对话场景中,用户的"它"、"那个"省略了上文,需要改写为完整查询。

typescript 复制代码
// query/rewrite.ts
async function rewriteConversationalQuery(
  query: string,
  history: { role: string; content: string }[],
  llm: any
): Promise<string> {
  if (history.length === 0) return query;

  const rewritten = await llm.invoke(`
    根据对话历史,将用户的当前问题改写为独立可理解的查询。
    如果问题已经完整,直接返回原问题。

    历史:${history.map(m => `${m.role}: ${m.content}`).join("\n")}
    当前问题:${query}

    改写后的查询:
  `);

  return rewritten.trim();
}

// 示例
// 历史: user: "React 18 有哪些新特性?" assistant: "并发模式..."
// 当前: "它怎么用?"
// 改写: "React 18 并发模式怎么使用?"

4.3 Query 分解(复杂问题)

复杂问题拆成多个子问题,分别检索后合并结果。

typescript 复制代码
// query/decompose.ts
async function decomposeAndRetrieve(query: string, llm: any, retriever: any) {
  // 1. 判断是否需要分解
  const plan = await llm.invoke(`
    如果问题简单直接,返回 "SIMPLE"。
    如果需要多个信息才能回答,分解为子问题(每行一个):
    问题:${query}
  `);

  if (plan.trim() === "SIMPLE") {
    return retriever.search(query);
  }

  // 2. 分别检索每个子问题
  const subQueries = plan.split("\n").filter(Boolean);
  const allResults = await Promise.all(
    subQueries.map(q => retriever.search(q))
  );

  // 3. 去重合并
  return deduplicate(allResults.flat());
}

5. 检索增强的完整编排

typescript 复制代码
// retrieval/pipeline.ts
class RetrievalPipeline {
  async retrieve(query: string, history?: any[]): Promise<Document[]> {
    // Step 1: Query 优化
    let optimizedQuery = query;
    if (history?.length) {
      optimizedQuery = await this.rewriteConversationalQuery(query, history);
    }

    // Step 2: 混合检索
    const candidates = await this.hybridRetriever.search(optimizedQuery, 30);

    // Step 3: Re-rank
    const reranked = await this.reranker.rerank(optimizedQuery, candidates);

    // Step 4: 上下文压缩(可选)
    // 如果检索到的文档太长,用 LLM 提取关键句
    const compressed = await this.compressContext(optimizedQuery, reranked.slice(0, 5));

    return compressed;
  }

  private async compressContext(query: string, docs: Document[]): Promise<Document[]> {
    // 如果所有文档加起来超过 token 限制,用 LLM 提取和 query 最相关的句子
    const totalTokens = docs.reduce((s, d) => s + d.pageContent.length / 4, 0);
    if (totalTokens < 3000) return docs;

    const llm = new ChatOpenAI({ model: "gpt-4o-mini" });
    const compressed = await llm.invoke(`
      从以下文档中提取与问题最相关的关键信息(保留原文):

      问题:${query}

      文档:
      ${docs.map((d, i) => `[${i}]: ${d.pageContent}`).join("\n\n")}

      提取的关键信息:
    `);

    return [{ pageContent: compressed.content as string, metadata: {} }];
  }
}

6. 检索质量监控

typescript 复制代码
// monitoring/retrieval.ts
class RetrievalMonitor {
  private metrics = {
    avgLatency: 0,
    recallRate: 0,        // 人工标注的相关文档被召回的比例
    emptyRate: 0,         // 检索结果为空的比例
    rerankEffectiveness: 0, // Re-rank 前后 Top-1 一致性
  };

  async logQuery(query: string, results: Document[], latency: number) {
    if (results.length === 0) {
      this.metrics.emptyRate++;
      console.warn(`Empty results for: ${query}`);
    }

    // 定期采样做人工评估
    if (Math.random() < 0.01) {  // 1% 采样
      await this.queueForHumanEval(query, results);
    }
  }
}

上一篇:04 · 向量数据库选型与生产级实战 下一篇:06 · 从开发到生产:生成优化、监控、安全与成本

相关推荐
用户298698530145 小时前
在 React 中使用 JavaScript 合并 Excel 文件
前端·javascript·react.js
橘子星5 小时前
JavaScript this 指向全解实战指南
前端·javascript
何出无名之师5 小时前
AIDL的一次调用链路追踪之二,如何和驱动打交道
前端
weedsfly5 小时前
JS垃圾回收:从原理到项目实战,彻底根治内存泄漏
前端·javascript·面试
Jcc5 小时前
虚拟 DOM 是什么?从 Snabbdom 理解 Vue 的 DOM 更新机制
前端
user62229864925815 小时前
Vue 常用技术知识全景:从响应式到组件通信的系统理解
前端
feiyu_gao5 小时前
一个人 + AI:246 commits 做出设计系统 CLI 的故事
前端·ai编程·交互设计
奶油mm5 小时前
从 0 到 1 搭建高可用 Redis Cluster:踩坑、优化与生产实践
前端
掘金安东尼5 小时前
Agent Loop 深度调研:把决定权交给模型的一次换代,为什么发生在现在
前端