Hybrid RAG 落地:向量 + BM25 + Rerank 的工程选择
纯向量检索的召回率只有 72%——每 10 个相关文档漏掉近 3 个。我们用 Hybrid Search(向量 + BM25 + Rerank)把召回率提升到 91%。本文记录技术选型、架构设计和实测数据。
一、问题:纯向量为什么不够
1.1 一个典型的失败案例
用户问:"SID 2.6 的 License 校验在网关层怎么实现的?"
纯向量检索返回的 Top 5:
- ✓ "License 管理服务设计文档"(相关)
- ✓ "rg-domain-license 服务说明"(相关)
- ✗ "软件许可证合规指南"(语义相似但不相关——"License"被理解为版权许可)
- ✗ "Gateway 限流设计"(包含"网关"但不是 License 相关)
- ✗ "SID 2.5 版本发布说明"(包含"SID"但不是 2.6)
真正相关的文档"rg-gate 中的 LicenseStateUtils 实现"排在第 8 位——因为它的标题和内容没有"License 校验"这个语义表述,而是用了具体的类名。
1.2 纯向量检索的系统性问题
| 问题 | 原因 | 示例 |
|---|---|---|
| 精确词匹配差 | Embedding 做语义相似度,不做精确匹配 | 搜"LicenseStateUtils"可能匹配不到含这个类名的文档 |
| 专业术语混淆 | 通用模型对领域术语理解不足 | "License"被理解为版权而非业务功能 |
| 长尾查询召回低 | 罕见表述的 Embedding 质量差 | 团队内部的缩写/黑话命中率低 |
| 多条件组合差 | 向量空间不擅长 AND/OR 组合 | "SID 2.6 + 网关 + License" 三个条件同时满足 |
1.3 量化对比
用 50 个标注好的 QA 对做评测:
| 方案 | Recall@5 | Recall@10 | MRR |
|---|---|---|---|
| 纯向量(text-embedding-3-small) | 72% | 84% | 0.61 |
| 纯 BM25 | 65% | 78% | 0.54 |
| Hybrid + Rerank | 91% | 96% | 0.82 |
结论明确:单一检索方式都有盲区,组合才能覆盖全面。
二、Hybrid Search 架构设计
2.1 整体流程
用户 Query
│
▼
┌───────────────────────────────────────────────────┐
│ Stage 1: 并行检索(Recall) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 向量检索 │ │ BM25 检索 │ │
│ │ Milvus │ │ Elasticsearch │ │
│ │ Top-K = 30 │ │ Top-K = 30 │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ └────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ 合并去重(Union) │ │
│ │ 保留两路各自的分数 │ │
│ │ 候选集: 30-50 条 │ │
│ └──────────────┬───────────┘ │
│ │ │
└──────────────────┼──────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Stage 2: 重排序(Rerank) │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Cross-Encoder Reranker │ │
│ │ · 输入: (query, document) pair │ │
│ │ · 输出: relevance score [0, 1] │ │
│ │ · 模型: bge-reranker-v2-m3 │ │
│ │ · 对候选集全部打分,按分数重新排序 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 输出: Top-5 最终结果 │
└──────────────────────────────────────────────────────┘
2.2 为什么是"向量 + BM25"而不是其他组合
| 组合 | 优势 | 劣势 |
|---|---|---|
| 向量 + BM25 | 语义互补 + 精确匹配互补 | 需要两套存储 |
| 向量 + 向量(不同模型) | 多视角语义 | 盲区重叠度高 |
| BM25 + BM25(不同分词) | 精确匹配强 | 完全没有语义理解 |
| 向量 + 知识图谱 | 结构化关系 | 知识图谱维护成本高 |
向量和 BM25 的优势/劣势正好互补:
- 向量擅长语义相似("License 校验" ≈ "许可证验证"),BM25 擅长精确匹配("LicenseStateUtils" 精确命中)
- 向量在长文本表述差异时仍能匹配,BM25 在专业术语/类名时精确召回
- 两者盲区几乎不重叠
2.3 为什么还需要 Rerank
向量和 BM25 各自给出的分数不可比较——向量用余弦相似度(01),BM25 用 TF-IDF 分数(0∞)。
简单加权合并(如 0.6*向量 + 0.4*BM25)效果有限,因为:
- 分数分布不同(向量分数集中在 0.6-0.9,BM25 分数范围大)
- 最优权重因 query 类型而异(精确搜索 BM25 权重应高,模糊搜索向量权重应高)
Rerank 用 Cross-Encoder 模型对每个 (query, document) pair 做精细打分,从头判断相关性,不依赖之前的分数。
实测效果:
| 阶段 | Recall@5 | MRR |
|---|---|---|
| 仅向量 | 72% | 0.61 |
| 向量 + BM25 合并(加权) | 82% | 0.71 |
| 向量 + BM25 + Rerank | 91% | 0.82 |
Rerank 在合并基础上又提升了 9% 的召回率。
三、技术选型
3.1 向量数据库:Milvus
| 对比项 | Milvus | Chroma | Pinecone | pgvector |
|---|---|---|---|---|
| 生产就绪 | ✓ 成熟 | 开发/测试 | ✓ 但是 SaaS | 需要 PG 运维 |
| 私有部署 | ✓ | ✓ | ✗(仅云) | ✓ |
| 性能(百万级) | ✓ HNSW/IVF | 性能一般 | ✓ | 百万级吃力 |
| 多租户 | ✓ Collection/Partition | 手动隔离 | Namespace | Schema 隔离 |
| GPU 加速 | ✓ | ✗ | — | ✗ |
选 Milvus 的理由:私有部署 + 百万级性能 + 成熟的多租户支持。
3.2 BM25:Elasticsearch
BM25 检索可以用 ES 或独立实现。我们选 ES 因为:
- 已有 ES 集群(日志收集)
- 分词器成熟(IK 中文分词 + 英文标准分词)
- 支持高亮(前端展示检索命中片段)
3.3 Reranker:bge-reranker-v2-m3
| 模型 | 多语言 | 延迟 | 精度 |
|---|---|---|---|
| bge-reranker-v2-m3 | ✓ 中英 | ~50ms/条 | NDCG@10: 0.72 |
| cohere-rerank-v3 | ✓ | 需外部 API | NDCG@10: 0.74 |
| cross-encoder/ms-marco | 英文为主 | ~30ms/条 | NDCG@10: 0.68 |
选 bge-reranker-v2-m3:私有部署 + 中英双语 + 精度够用。
3.4 Embedding 模型
# 我们的 Embedding 配置
EMBEDDING_CONFIG = {
"model": "text-embedding-3-small", # OpenAI(通过 Model Gateway 代理)
"dimension": 1536,
"batch_size": 100,
"fallback_model": "bge-m3", # 本地 fallback
}
为什么用 OpenAI 的 Embedding 而非本地模型?
- 质量更高(在我们的数据集上 Recall 高 8%)
- 通过 Model Gateway 代理,降级时自动切本地模型
- Embedding 是一次性操作(入库时),不影响查询延迟
四、异步处理流水线
4.1 文档入库流程
数据源(飞书文档 / 代码仓库 / 需求文档)
│
▼ Webhook / 定时拉取
│
▼ Redis Stream(消息队列)
│
▼ Worker 流水线
│
├── Stage 1: Parse(文档解析)
│ · PDF → 文本
│ · Markdown → 结构化段落
│ · 代码 → 文件级 / 函数级切片
│
├── Stage 2: Chunk(语义切片)
│ · 按段落/标题自然切分
│ · 重叠窗口(overlap = 100 tokens)
│ · 保留元数据(来源、章节、作者)
│
├── Stage 3: Embed(向量化)
│ · 批量调用 Embedding API
│ · 写入 Milvus
│
└── Stage 4: Index(关键词索引)
· 写入 Elasticsearch
· IK 分词 + 英文 standard 分词
4.2 为什么用 Redis Stream 而非 RabbitMQ/Kafka
| 对比 | Redis Stream | RabbitMQ | Kafka |
|---|---|---|---|
| 部署复杂度 | 低(已有 Redis) | 中 | 高 |
| 消费模式 | Consumer Group | Exchange+Queue | Partition |
| 持久化 | AOF 持久化 | 持久化 | 日志持久化 |
| 适合场景 | 中等吞吐 | 复杂路由 | 超高吞吐 |
我们的场景:日均新增文档 50-200 篇,峰值不超过 1000 篇/小时。Redis Stream 完全足够,且不引入新组件。
4.3 Worker 代码示例
class RAGIngestionWorker:
"""文档入库 Worker(从 Redis Stream 消费)"""
async def run(self):
while True:
# 从 Stream 读取任务
messages = await self.redis.xreadgroup(
groupname="rag-workers",
consumername=f"worker-{self.worker_id}",
streams={"doc-ingestion": ">"},
count=10,
block=5000 # 5s 无消息则返回
)
for msg_id, data in messages:
try:
await self.process_document(data)
await self.redis.xack("doc-ingestion", "rag-workers", msg_id)
except Exception as e:
logger.error(f"处理失败: {e}, msg_id={msg_id}")
# 失败的消息会在 pending 列表中,可重试
async def process_document(self, data: dict):
doc_id = data["doc_id"]
source = data["source"] # feishu / gitlab / manual
content = data["content"]
# Stage 1: 解析
parsed = await self.parser.parse(content, source_type=source)
# Stage 2: 切片
chunks = self.chunker.chunk(
parsed,
max_tokens=512,
overlap_tokens=100,
metadata={"doc_id": doc_id, "source": source}
)
# Stage 3: 向量化(批量)
embeddings = await self.embedder.embed_batch(
[chunk.text for chunk in chunks]
)
# Stage 4: 写入存储
await self.milvus.insert(
collection="knowledge_base",
vectors=embeddings,
metadata=[chunk.metadata for chunk in chunks]
)
await self.es.bulk_index(
index="knowledge_base",
documents=[{"text": chunk.text, **chunk.metadata} for chunk in chunks]
)
logger.info(f"文档入库完成: {doc_id}, {len(chunks)} 个切片")
五、查询流程实现
5.1 Hybrid Search 核心代码
class HybridSearcher:
"""混合检索 + Rerank"""
async def search(self, query: str, top_k: int = 5, tenant_id: str = None) -> list[SearchResult]:
# 并行执行向量检索和 BM25 检索
vector_task = self._vector_search(query, top_k=30, tenant_id=tenant_id)
bm25_task = self._bm25_search(query, top_k=30, tenant_id=tenant_id)
vector_results, bm25_results = await asyncio.gather(vector_task, bm25_task)
# 合并去重(基于 doc_id)
merged = self._merge_results(vector_results, bm25_results)
# 此时候选集约 30-50 条
# Rerank
if len(merged) > top_k:
reranked = await self._rerank(query, merged, top_k=top_k)
return reranked
return merged[:top_k]
async def _vector_search(self, query: str, top_k: int, tenant_id: str):
# 生成查询向量
query_embedding = await self.embedder.embed(query)
# Milvus 检索
results = await self.milvus.search(
collection="knowledge_base",
vector=query_embedding,
top_k=top_k,
filter=f"tenant_id == '{tenant_id}'" if tenant_id else None,
output_fields=["text", "doc_id", "source", "metadata"]
)
return [SearchResult(
doc_id=r.entity.get("doc_id"),
text=r.entity.get("text"),
score=r.score,
source="vector",
metadata=r.entity.get("metadata")
) for r in results]
async def _bm25_search(self, query: str, top_k: int, tenant_id: str):
# Elasticsearch BM25 检索
body = {
"query": {
"bool": {
"must": [{"match": {"text": query}}],
"filter": [{"term": {"tenant_id": tenant_id}}] if tenant_id else []
}
},
"size": top_k
}
results = await self.es.search(index="knowledge_base", body=body)
return [SearchResult(
doc_id=hit["_source"]["doc_id"],
text=hit["_source"]["text"],
score=hit["_score"],
source="bm25",
metadata=hit["_source"].get("metadata")
) for hit in results["hits"]["hits"]]
async def _rerank(self, query: str, candidates: list[SearchResult], top_k: int):
# Cross-Encoder Rerank
pairs = [(query, c.text) for c in candidates]
scores = await self.reranker.predict(pairs)
# 按 rerank 分数排序
scored_results = list(zip(candidates, scores))
scored_results.sort(key=lambda x: x[1], reverse=True)
return [r for r, _ in scored_results[:top_k]]
def _merge_results(self, vector_results, bm25_results):
"""合并去重,保留两路来源标记"""
seen = {}
for r in vector_results + bm25_results:
if r.doc_id not in seen:
seen[r.doc_id] = r
else:
# 同一文档被两路命中,标记为"双路命中"(通常更相关)
seen[r.doc_id].source = "both"
return list(seen.values())
5.2 查询延迟分解
| 阶段 | 延迟 |
|---|---|
| Query Embedding | 50ms |
| Milvus 向量检索 | 30ms |
| ES BM25 检索 | 25ms |
| 合并去重 | < 1ms |
| Rerank(40 条候选) | 180ms |
| 总计 | ~235ms |
由于向量检索和 BM25 并行执行,实际总延迟约 230-250ms,满足实时交互需求。
六、多数据源接入
6.1 数据源 Parser 设计
class ParserFactory:
"""按来源类型选择 Parser"""
parsers = {
"feishu": FeishuDocParser, # 飞书文档 API → Markdown
"gitlab": GitLabFileParser, # 代码文件 → 函数级切片
"confluence": ConfluenceParser, # Confluence → 清洗后文本
"markdown": MarkdownParser, # 本地 .md 文件
"pdf": PDFParser, # PDF → 文本提取
}
def get_parser(self, source_type: str) -> BaseParser:
return self.parsers[source_type]()
6.2 飞书文档接入
class FeishuDocParser(BaseParser):
"""飞书文档解析器"""
async def parse(self, doc_token: str) -> ParsedDocument:
# 调用飞书 Open API 获取文档内容
content = await self.feishu_client.get_doc_content(
doc_token=doc_token,
format="markdown" # 飞书支持导出 Markdown
)
# 提取元数据
meta = await self.feishu_client.get_doc_meta(doc_token)
return ParsedDocument(
text=content,
metadata={
"title": meta.title,
"author": meta.owner_name,
"updated_at": meta.updated_at,
"url": f"https://xxx.feishu.cn/docx/{doc_token}",
"source": "feishu",
}
)
6.3 避免重复 Embedding
同一文档可能从多个渠道触发入库(Webhook + 定时拉取)。用 content_hash 做幂等:
async def process_document(self, data: dict):
content_hash = hashlib.sha256(data["content"].encode()).hexdigest()
# 检查是否已入库(且内容未变)
existing = await self.store.get_by_hash(content_hash)
if existing:
logger.info(f"文档未变化,跳过: {data['doc_id']}")
return
# 内容有变化,重新入库
await self._do_ingest(data)
await self.store.update_hash(data["doc_id"], content_hash)
七、实测数据与优化
7.1 分场景召回率
| 查询类型 | 纯向量 | Hybrid | Hybrid+Rerank |
|---|---|---|---|
| 语义查询("用户认证流程") | 85% | 87% | 93% |
| 精确查询("LicenseStateUtils") | 45% | 88% | 92% |
| 混合查询("SID 2.6 网关 License") | 68% | 85% | 90% |
| 代码相关("Feign 超时配置") | 60% | 82% | 89% |
| 平均 | 72% | 85% | 91% |
最大收益在精确查询:BM25 把类名/配置名的精确匹配从 45% 拉到 88%。
7.2 Rerank 候选集大小的影响
| Rerank 候选数 | Recall@5 | 延迟 |
|---|---|---|
| 10 | 85% | 90ms |
| 20 | 89% | 130ms |
| 30-40 | 91% | 180ms |
| 50 | 91.5% | 240ms |
| 100 | 92% | 480ms |
30-40 条是最佳平衡点:再多增加召回率微乎其微,但延迟线性增长。
7.3 切片大小的影响
| Chunk Size (tokens) | Recall@5 | 精度感受 |
|---|---|---|
| 128 | 88% | 回答太碎片化 |
| 256 | 90% | 较好 |
| 512 | 91% | 最佳平衡 |
| 1024 | 89% | 噪音增多 |
512 tokens 的切片在"信息完整性"和"检索精度"之间最优。
八、踩坑记录
8.1 向量维度与性能
初期用 1536 维向量,Milvus 查询延迟 50-80ms。改为 1024 维后降到 25-35ms,Recall 只下降 1%。
教训:不要无脑用最大维度。实测数据说话。
8.2 BM25 中文分词
ES 默认的 standard 分词器对中文按字切分,效果极差。必须配 IK 分词器:
{
"settings": {
"analysis": {
"analyzer": {
"ik_smart_analyzer": {
"type": "custom",
"tokenizer": "ik_smart",
"filter": ["lowercase"]
}
}
}
},
"mappings": {
"properties": {
"text": {
"type": "text",
"analyzer": "ik_smart_analyzer",
"search_analyzer": "ik_smart_analyzer"
}
}
}
}
8.3 Reranker OOM
bge-reranker-v2-m3 加载后占用 ~2GB 显存。初期和 Embedding 模型共享 GPU,高并发时 OOM。
解决方案:Reranker 独立部署,用 CPU 推理(延迟从 120ms 增加到 180ms,但稳定性大幅提升)。
8.4 统一知识入口的去重
不同数据源可能引用同一份文档(飞书文档被多个知识库关联)。用 (doc_id, source) 做唯一键,避免同一内容存多份向量。
九、与 Agent 的集成
9.1 RAG 作为 Agent 的工具
@mcp.tool()
async def knowledge_search(
query: str,
source_filter: str | None = None,
top_k: int = 5
) -> str:
"""从知识库检索相关文档。
Args:
query: 检索查询(自然语言)
source_filter: 可选,限定来源(feishu/gitlab/manual)
top_k: 返回结果数量
"""
results = await hybrid_searcher.search(
query=query,
top_k=top_k,
source_filter=source_filter
)
lines = [f"## 知识库检索结果 ({len(results)} 条)\n"]
for i, r in enumerate(results, 1):
lines.append(f"### 结果 {i} (来源: {r.metadata.get('source', 'unknown')})")
lines.append(f"**标题**: {r.metadata.get('title', '无标题')}")
lines.append(f"**内容**: {r.text[:500]}...")
if r.metadata.get("url"):
lines.append(f"**链接**: {r.metadata['url']}")
lines.append("")
return "\n".join(lines)
9.2 上下文增强注入
Copilot 在组装 Agent 上下文时,自动触发 RAG 检索:
class ContextAssembler:
async def assemble(self, user_msg: str, agent_config: AgentConfig):
context = []
# 如果 Agent 配置了知识库增强
if agent_config.rag_enabled:
rag_results = await self.rag_service.search(
query=user_msg,
knowledge_base_ids=agent_config.knowledge_base_ids,
top_k=3
)
if rag_results:
context.append(SystemMessage(
content=f"以下是从知识库检索到的相关信息,请参考:\n\n"
+ "\n---\n".join(r.text for r in rag_results)
))
# 其他上下文组装...
return context
十、设计原则总结
- Hybrid > Single — 单一检索方式必有盲区,组合互补
- Rerank 是性价比最高的提升 — 在召回基础上,用精排模型再提 9%
- 候选集 30-40 条是甜区 — 再多边际收益递减
- 异步入库,同步查询 — 入库不急,查询要快
- content_hash 幂等 — 避免重复 Embedding 浪费资源
- 私有部署优先 — 企业文档不能发公网
RAG 的终极目标不是"检索到文档",而是"让 Agent 获得回答问题所需的精准上下文"。检索只是手段,精准注入才是目的。