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);
}
}
}