Hybrid RAG 落地:向量 + BM25 + Rerank 的工程选择

13 阅读11分钟

Hybrid RAG 落地:向量 + BM25 + Rerank 的工程选择

纯向量检索的召回率只有 72%——每 10 个相关文档漏掉近 3 个。我们用 Hybrid Search(向量 + BM25 + Rerank)把召回率提升到 91%。本文记录技术选型、架构设计和实测数据。


一、问题:纯向量为什么不够

1.1 一个典型的失败案例

用户问:"SID 2.6 的 License 校验在网关层怎么实现的?"

纯向量检索返回的 Top 5:

  1. ✓ "License 管理服务设计文档"(相关)
  2. ✓ "rg-domain-license 服务说明"(相关)
  3. ✗ "软件许可证合规指南"(语义相似但不相关——"License"被理解为版权许可)
  4. ✗ "Gateway 限流设计"(包含"网关"但不是 License 相关)
  5. ✗ "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@5Recall@10MRR
纯向量(text-embedding-3-small)72%84%0.61
纯 BM2565%78%0.54
Hybrid + Rerank91%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@5MRR
仅向量72%0.61
向量 + BM25 合并(加权)82%0.71
向量 + BM25 + Rerank91%0.82

Rerank 在合并基础上又提升了 9% 的召回率。


三、技术选型

3.1 向量数据库:Milvus

对比项MilvusChromaPineconepgvector
生产就绪✓ 成熟开发/测试✓ 但是 SaaS需要 PG 运维
私有部署✗(仅云)
性能(百万级)✓ HNSW/IVF性能一般百万级吃力
多租户✓ Collection/Partition手动隔离NamespaceSchema 隔离
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需外部 APINDCG@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 StreamRabbitMQKafka
部署复杂度低(已有 Redis)
消费模式Consumer GroupExchange+QueuePartition
持久化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 Embedding50ms
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 分场景召回率

查询类型纯向量HybridHybrid+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延迟
1085%90ms
2089%130ms
30-4091%180ms
5091.5%240ms
10092%480ms

30-40 条是最佳平衡点:再多增加召回率微乎其微,但延迟线性增长。

7.3 切片大小的影响

Chunk Size (tokens)Recall@5精度感受
12888%回答太碎片化
25690%较好
51291%最佳平衡
102489%噪音增多

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

十、设计原则总结

  1. Hybrid > Single — 单一检索方式必有盲区,组合互补
  2. Rerank 是性价比最高的提升 — 在召回基础上,用精排模型再提 9%
  3. 候选集 30-40 条是甜区 — 再多边际收益递减
  4. 异步入库,同步查询 — 入库不急,查询要快
  5. content_hash 幂等 — 避免重复 Embedding 浪费资源
  6. 私有部署优先 — 企业文档不能发公网

RAG 的终极目标不是"检索到文档",而是"让 Agent 获得回答问题所需的精准上下文"。检索只是手段,精准注入才是目的。