【RAG实战】知识库 BGE-M3 稀疏向量混合检索方案

RAG 知识库 BGE-M3 稀疏向量混合检索方案 ------ 从选型到落地的全链路设计

作者 : gc

技术栈 : Python 3.12 + FastAPI + LlamaIndex + Milvus 2.3 + FlagEmbedding

适用场景: 企业级 RAG 知识库,需要高精度中文混合检索(Dense + Sparse + RRF 融合排序)


文章目录

  • [RAG 知识库 BGE-M3 稀疏向量混合检索方案 ------ 从选型到落地的全链路设计](#RAG 知识库 BGE-M3 稀疏向量混合检索方案 —— 从选型到落地的全链路设计)
    • 一、方案概述
      • [1.1 核心目标](#1.1 核心目标)
      • [1.2 技术选型总览](#1.2 技术选型总览)
      • [1.3 为什么选择 BGE-M3 本地模型?](#1.3 为什么选择 BGE-M3 本地模型?)
    • 二、系统架构设计
      • [2.1 整体架构图](#2.1 整体架构图)
      • [2.2 核心文件清单](#2.2 核心文件清单)
    • 三、完整执行链路详解
      • [3.1 入库链路(写入流程)](#3.1 入库链路(写入流程))
        • [第 1 步:文档解析任务启动](#第 1 步:文档解析任务启动)
        • [第 2 步:Pipeline 组件链(清洗 → 切片 → 上下文增强 → 元数据增强)](#第 2 步:Pipeline 组件链(清洗 → 切片 → 上下文增强 → 元数据增强))
        • [第 3 步:元数据增强策略链(核心步骤)](#第 3 步:元数据增强策略链(核心步骤))
        • [第 4 步:Milvus 入库(dense + sparse + scalar 三路写入)](#第 4 步:Milvus 入库(dense + sparse + scalar 三路写入))
        • [第 5 步:ScalarAwareMilvusVectorStore(标量字段修复)](#第 5 步:ScalarAwareMilvusVectorStore(标量字段修复))
        • [第 6 步:BGE-M3 稀疏向量编码](#第 6 步:BGE-M3 稀疏向量编码)
      • [3.2 检索链路(读取流程)](#3.2 检索链路(读取流程))
    • [四、Milvus Schema 设计](#四、Milvus Schema 设计)
      • [4.1 标量字段定义](#4.1 标量字段定义)
      • [4.2 完整 Collection Schema](#4.2 完整 Collection Schema)
    • 五、数据流转全链路图
    • 六、稀疏向量编码原理
      • [6.1 BGE-M3 Learned Sparse Vector](#6.1 BGE-M3 Learned Sparse Vector)
      • [6.2 与 BM25 的对比](#6.2 与 BM25 的对比)
    • 七、全局单例管理
      • [7.1 稀疏编码器单例](#7.1 稀疏编码器单例)
      • [7.2 BGE-M3 模型单例](#7.2 BGE-M3 模型单例)
    • 八、遇到的问题与解决方案
      • [问题 1:ONNX 量化模型输出 Dense 而非 Sparse](#问题 1:ONNX 量化模型输出 Dense 而非 Sparse)
      • [问题 2:HuggingFace 镜像站 xet 协议不兼容](#问题 2:HuggingFace 镜像站 xet 协议不兼容)
      • [问题 3:ignore_patterns 导致关键文件被忽略](#问题 3:ignore_patterns 导致关键文件被忽略)
      • [问题 4:LlamaIndex MilvusVectorStore 标量字段不写入](#问题 4:LlamaIndex MilvusVectorStore 标量字段不写入)
      • [问题 5:Milvus 不支持在 JSON 字段上创建索引](#问题 5:Milvus 不支持在 JSON 字段上创建索引)
      • [问题 6:keywords 类型转换](#问题 6:keywords 类型转换)
      • [问题 7:FP16 加载失败回退](#问题 7:FP16 加载失败回退)
      • [问题 8:代码冗余](#问题 8:代码冗余)
    • 九、性能指标
      • [9.1 稀疏向量编码性能](#9.1 稀疏向量编码性能)
      • [9.2 混合检索性能](#9.2 混合检索性能)
    • 十、策略模式架构设计
      • [10.1 元数据增强策略模式](#10.1 元数据增强策略模式)
      • [10.2 扩展新策略](#10.2 扩展新策略)
    • 十一、配置参考
      • [11.1 环境变量(.env.dev)](#11.1 环境变量(.env.dev))
      • [11.2 关键依赖](#11.2 关键依赖)
    • 十二、测试验证
      • [12.1 稀疏向量编码测试](#12.1 稀疏向量编码测试)
    • 十三、设计决策总结
    • 十四、附录:完整代码文件索引

一、方案概述

1.1 核心目标

在已有 Dense 向量检索(智谱 AI text-embedding-v4)基础上,引入 BGE-M3 稀疏向量(Learned Sparse Vector),实现 Milvus 服务端双路混合检索,提升关键词精确匹配能力。

1.2 技术选型总览

组件 方案 说明
稀疏向量模型 BAAI/bge-m3(本地 FlagEmbedding FP16) 智源研究院,单模型同时输出 dense + sparse + multi-vector
稠密向量模型 智谱 AI text-embedding-v4(在线 API) 2048 维,OpenAI 兼容接口
向量数据库 Milvus 2.3+ 支持 SPARSE_FLOAT_VECTOR + hybrid_search + RRFRanker
关键词提取 jieba TF-IDF 每个切片提取 top-10 关键词,存入标量字段
融合排序 Milvus RRFRanker 服务端双路融合,无需客户端手动合并

1.3 为什么选择 BGE-M3 本地模型?

方案 优点 缺点 结论
方案 A: ONNX 量化模型(bge-m3-sparse) 体积小、推理快 实际输出 dense 而非 sparse(README 误导) ❌ 弃用
方案 B: farming789/bge-m3-sparse ONNX 专为稀疏设计 模型输出维度异常,与官方不一致 ❌ 弃用
方案 C: 纯 BM25 文本拟合(jieba + 手动计算) 无模型依赖 需要 fit 语料库,跨知识库无法复用 IDF ❌ 弃用
方案 D: FlagEmbedding FP16 本地 BGE-M3 ✅ 原生 learned sparse,精度高 模型约 1.1GB(FP16) ✅ 采用

二、系统架构设计

2.1 整体架构图

复制代码
┌──────────────────────────────────────────────────────────────────────┐
│                        文档入库链路(写入)                           │
│                                                                      │
│  文件上传 → 文件读取 → 文档清洗 → 文档切片 → 上下文增强              │
│                                                    ↓                 │
│                              元数据增强(策略链: basic → bm25)       │
│                              ├─ basic: 注入 doc_id/kb_id 等          │
│                              └─ bm25: jieba 关键词 + 稀疏编码器      │
│                                                    ↓                 │
│                              PipelineContext.state["sparse_func"]     │
│                                                    ↓                 │
│                              MilvusNode.add_nodes(enable_sparse=True) │
│                                                    ↓                 │
│                    ScalarAwareMilvusVectorStore.add()                  │
│                    ├─ dense: 智谱 API → 2048 维浮点向量               │
│                    ├─ sparse: BGE-M3 → dict[int, float]              │
│                    └─ scalar: keywords/file_name/kb_id 等            │
│                                                    ↓                 │
│                              Milvus Collection (SPARSE_FLOAT_VECTOR)  │
└──────────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────────┐
│                        检索链路(读取)                               │
│                                                                      │
│  用户查询 → MilvusSearchByRetrieve.search(hybrid=True)               │
│                              ↓                                       │
│                    execute_hybrid_retrieve()                          │
│                    ├─ dense: 智谱 API → dense_query_vec              │
│                    ├─ sparse: BGE-M3 → sparse_query_vec              │
│                    ├─ AnnSearchRequest(dense, vector, COSINE)        │
│                    ├─ AnnSearchRequest(sparse, sparse_vector, IP)    │
│                    └─ hybrid_search(RRFRanker) → Top-K 结果          │
│                                                    ↓                 │
│                              _hits_to_node_with_scores()             │
│                              └─ 恢复 keywords 为列表                 │
│                                                    ↓                 │
│                              List[NodeWithScore]                     │
└──────────────────────────────────────────────────────────────────────┘

2.2 核心文件清单

文件路径 职责
module_rag/config/settings.py 全局配置 + get_sparse_encoder() 单例
module_rag/rag_common/metadata_enrichment/bge3_embedding.py BGE-M3 稀疏向量编码器
module_rag/rag_common/metadata_enrichment/strategies/bm25.py BM25 元数据增强策略
module_rag/rag_common/metadata_enrichment/service.py 元数据增强服务(策略链式执行)
module_rag/rag_common/metadata_enrichment/factory.py 策略注册工厂
module_rag/rag_common/utils/milvus/scalar_vector_store.py 标量字段感知的 MilvusVectorStore
module_rag/rag_common/utils/milvus/client.py Milvus 集合管理(Schema 定义)
module_rag/rag_common/utils/milvus/node.py Milvus Node 入库/查询入口
module_rag/rag_common/utils/milvus/search_by_retrieve.py 混合检索入口
module_rag/rag_common/pipeline/pipeline.py RAGIngestionPipeline 组装
module_rag/rag_common/pipeline/context.py PipelineContext 上下文
module_rag/tasks/document_parse_task.py 文档解析任务(串联全流程)

三、完整执行链路详解

3.1 入库链路(写入流程)

第 1 步:文档解析任务启动
python 复制代码
# module_rag/tasks/document_parse_task.py

async def process_single_document(document: RagDocument) -> tuple[bool, str]:
    """处理单个文档的完整流程"""
    doc_id = document.id
    kb_id = document.kb_id
    
    # 1. 文件读取(同步阻塞操作,放入线程池)
    documents = await asyncio.to_thread(common_read_file_content, absolute_dir)
    
    # 2. 构建 PipelineContext
    pipeline_context = PipelineContext(
        chunking_strategy_type=strategy_type,
        chunking_params=strategy_params,
        meta_doc_id=document.id,
        meta_kb_id=document.kb_id,
        meta_doc_type=document.doc_type,
        meta_file_name=document.file_name,
        meta_file_type=document.file_type,
        meta_file_size=document.file_size or 0,
        rag_document=document,
        documents=documents,
    )
    
    # 3. 执行 Pipeline(清洗 → 切片 → 增强 → 收集)
    nodes = await _run_ingestion_pipeline(documents, pipeline_context)
    
    # 4. 从 PipelineContext.state 中提取稀疏编码器
    sparse_func = pipeline_context.state.get("sparse_embedding_function")
    
    # 5. 向量化入库(dense + sparse + scalar)
    await MilvusNode.add_nodes(
        nodes,
        collection_name=kb_info.milvus_collection,
        doc_id=doc_id,
        scalar_field_names=RAG_SCALAR_FIELD_NAMES,
        scalar_field_types=RAG_SCALAR_FIELD_TYPES,
        enable_sparse=True,
        sparse_embedding_function=sparse_func,
    )
第 2 步:Pipeline 组件链(清洗 → 切片 → 上下文增强 → 元数据增强)

说明:元数据增强之前的链路(清洗、切片、上下文增强)采用标准 LlamaIndex IngestionPipeline 组件串接,此处以简化描述替代。

python 复制代码
# module_rag/rag_common/pipeline/pipeline.py

class RAGIngestionPipeline:
    """RAG 文档解析 Pipeline"""
    
    def __init__(self, context: PipelineContext):
        self.context = context
        transformations: list[TransformComponent] = [
            DocumentCleanerComponent(),       # 1. 文档清洗
            ChunkingComponent(),              # 2. 文档切片
            ChunkPostProcessorComponent(),    # 3. 切片后处理
        ]
        if getattr(context, 'enhancement_enabled', True):
            transformations.append(ContextEnhancementComponent())  # 4. 上下文增强(可选)
        
        transformations.extend([
            MetadataEnrichmentComponent(),    # 5. 元数据增强(核心)
            StorageContextCollectorComponent(),# 6. 收集待入库 Nodes
        ])
        
        self.pipeline = IngestionPipeline(transformations=transformations, disable_cache=True)
    
    async def arun(self, documents: list[Document], **kwargs) -> Sequence[BaseNode]:
        return await self.pipeline.arun(documents=documents, pipeline_context=self.context, **kwargs)

各组件职责简述

序号 组件 职责 输入 → 输出
1 DocumentCleanerComponent 去除页眉页脚、水印、多余空白 List[Document]List[Document]
2 ChunkingComponent 按策略切片(Sentence/Token/Hierarchical) List[Document]List[TextNode]
3 ChunkPostProcessorComponent 注入 NodeRelationship、设置 start/end_char_idx List[TextNode]List[TextNode]
4 ContextEnhancementComponent HyDE 假设性问答 + 摘要 + 标题注入 List[TextNode]List[TextNode]
5 MetadataEnrichmentComponent 核心:注入业务元数据 + 关键词 + 稀疏编码器 List[TextNode]List[TextNode]
6 StorageContextCollectorComponent 收集最终 Node 列表返回 List[TextNode]List[TextNode]
第 3 步:元数据增强策略链(核心步骤)
python 复制代码
# module_rag/rag_common/pipeline/components/metadata_enrichment.py

class MetadataEnrichmentComponent(TransformComponent):
    def __call__(self, nodes, **kwargs):
        ctx = get_pipeline_context(kwargs)
        metadata_context = ctx.to_metadata_context()
        
        # 策略链: ["basic", "bm25"]
        strategy_names = list(ctx.meta_enrichment_strategies)
        
        # 透传参数 + 注入 pipeline_context
        params = dict(ctx.meta_enrichment_params)
        params["pipeline_context"] = ctx
        
        return MetadataEnrichmentService.process_enrichment(
            node_list, context=metadata_context,
            params=params, strategy_names=strategy_names,
        )

策略链执行顺序

复制代码
basic 策略 → 注入 doc_id/kb_id/file_name/file_type/chunk_index 等基础元数据
    ↓
bm25 策略 → jieba 关键词提取 → metadata["keywords"]
          → get_sparse_encoder() → PipelineContext.state["sparse_embedding_function"]

BM25 策略核心代码

python 复制代码
# module_rag/rag_common/metadata_enrichment/strategies/bm25.py

class BM25MetadataEnrichmentStrategy(BasicMetadataEnrichmentStrategy):
    def process(self, nodes, context, params):
        # 1. 先执行 basic 策略
        nodes = super().process(nodes, context, params)
        
        # 2. jieba 关键词提取
        keyword_top_k = params.get("keyword_top_k", rag_settings.BM25_KEYWORD_TOP_K)
        for node in nodes:
            keywords = self._extract_keywords(node.text, keyword_top_k)
            node.metadata["keywords"] = keywords  # list[str]
        
        # 3. 获取 BGE-M3 稀疏编码器(全局单例)
        sparse_func = get_sparse_encoder()
        
        # 4. 存入 PipelineContext.state,供后续入库使用
        pipeline_context = params.get("pipeline_context")
        if pipeline_context is not None:
            pipeline_context.state["sparse_embedding_function"] = sparse_func
        
        return nodes
    
    @staticmethod
    def _extract_keywords(text: str, top_k: int) -> list[str]:
        import jieba.analyse
        return jieba.analyse.extract_tags(text, topK=top_k)
第 4 步:Milvus 入库(dense + sparse + scalar 三路写入)
python 复制代码
# module_rag/rag_common/utils/milvus/node.py

class MilvusNode:
    @staticmethod
    async def add_nodes(collection_name, nodes, doc_id=None,
                        enable_sparse=False, sparse_embedding_function=None, ...):
        # 1. Schema 校验
        missing_fields = await MyMilvusClient.check_collection_schema(...)
        
        # 2. 幂等处理:先删旧数据
        if doc_id:
            await MilvusNode.delete_nodes_by_doc_id(collection_name, doc_id)
        
        # 3. 构建 ScalarAwareMilvusVectorStore 并写入
        def _insert():
            vector_store = MyMilvusClient.get_vector_store(
                collection_name,
                scalar_field_names=RAG_SCALAR_FIELD_NAMES,
                scalar_field_types=RAG_SCALAR_FIELD_TYPES,
                enable_sparse=enable_sparse,
                sparse_embedding_function=sparse_embedding_function,
            )
            storage_context = StorageContext.from_defaults(vector_store=vector_store)
            VectorStoreIndex(nodes=nodes, storage_context=storage_context,
                           embed_model=get_dense_embed_model(), show_progress=False)
        
        await asyncio.get_running_loop().run_in_executor(None, _insert)
第 5 步:ScalarAwareMilvusVectorStore(标量字段修复)

核心修复 :LlamaIndex 原生 MilvusVectorStore.add() 在写入数据时不把 metadata 字段提取到 entry 顶层,导致标量列始终为 NULL。本类通过重写 add() 修复该缺陷。

python 复制代码
# module_rag/rag_common/utils/milvus/scalar_vector_store.py

class ScalarAwareMilvusVectorStore(MilvusVectorStore):
    def add(self, nodes: List[BaseNode], **add_kwargs) -> List[str]:
        for node in nodes:
            entry = node_to_metadata_dict(node, remove_text=True, text_field=self.text_key)
            entry[self.text_key] = node.dict()[self.text_key]
            entry[MILVUS_ID_FIELD] = node.node_id
            
            # Dense 向量
            if self.enable_dense:
                entry[self.embedding_field] = node.embedding
            
            # Sparse 向量:调用 BGE-M3 编码器
            if self.enable_sparse:
                if isinstance(self.sparse_embedding_function, BaseSparseEmbeddingFunction):
                    entry[self.sparse_embedding_field] = (
                        self.sparse_embedding_function.encode_documents([node.text])[0]
                    )
            
            # 标量字段提取(核心修复)
            for field_name in self.scalar_field_names:
                if field_name in node.metadata:
                    value = node.metadata[field_name]
                    if isinstance(value, list):
                        value = ",".join(str(v) for v in value)  # keywords 列表 → 逗号分隔字符串
                    entry[field_name] = value
            
            # doc_id 字段填充
            if self.doc_id_field and self.doc_id_field not in self.scalar_field_names:
                ref_doc_id = node.ref_doc_id or node.metadata.get(self.doc_id_field)
                if ref_doc_id:
                    entry[self.doc_id_field] = ref_doc_id
第 6 步:BGE-M3 稀疏向量编码
python 复制代码
# module_rag/rag_common/metadata_enrichment/bge3_embedding.py

class BGE3SparseEmbeddingFunction(BaseSparseEmbeddingFunction):
    def encode_documents(self, documents: list[str]) -> list[dict[int, float]]:
        model = _LocalBGE3Model().get_model()
        output = model.encode(documents, return_dense=False, return_sparse=True)
        return self._lexical_weights_to_dicts(output['lexical_weights'])
    
    def encode_queries(self, queries: list[str]) -> list[dict[int, float]]:
        model = _LocalBGE3Model().get_model()
        output = model.encode(queries, return_dense=False, return_sparse=True)
        return self._lexical_weights_to_dicts(output['lexical_weights'])
    
    @staticmethod
    def _lexical_weights_to_dicts(lexical_weights):
        # BGEM3FlagModel 返回 list[np.ndarray],转为 dict[int, float]
        return [{int(k): float(v) for k, v in lw.items()} for lw in lexical_weights]

模型管理器(单例 + 自动下载 + FP16 加载):

python 复制代码
class _LocalBGE3Model:
    _instance = None
    _model = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance
    
    def get_model(self):
        if _LocalBGE3Model._model is not None:
            return _LocalBGE3Model._model
        self._ensure_model_downloaded()
        self._load_model()
        return _LocalBGE3Model._model
    
    @staticmethod
    def _ensure_model_downloaded():
        if LOCAL_MODEL_PATH.exists() and any(LOCAL_MODEL_PATH.iterdir()):
            return
        # 从 HuggingFace 镜像站下载
        os.environ.setdefault("HF_ENDPOINT", "https://hf-mirror.com")
        os.environ.setdefault("HF_HUB_DISABLE_XET", "1")  # 禁用 xet 协议
        snapshot_download(repo_id="BAAI/bge-m3", local_dir=str(LOCAL_MODEL_PATH),
                         ignore_patterns=["*.DS_Store", "imgs/*", "README.md"])
    
    @staticmethod
    def _load_model():
        from FlagEmbedding import BGEM3FlagModel
        _LocalBGE3Model._model = BGEM3FlagModel(str(LOCAL_MODEL_PATH), use_fp16=True)

3.2 检索链路(读取流程)

python 复制代码
# module_rag/rag_common/utils/milvus/search_by_retrieve.py

class MilvusSearchByRetrieve:
    @staticmethod
    async def search(query, collection_name, top_k=3, hybrid=True, ...):
        if hybrid:
            return await MilvusSearchByRetrieve.execute_hybrid_retrieve(...)
        return await MilvusSearchByRetrieve.execute_retrieve(...)
    
    @staticmethod
    async def execute_hybrid_retrieve(query, collection_name, top_k=3, ...):
        from pymilvus import AnnSearchRequest, RRFRanker
        
        def _hybrid_search():
            collection_obj = MyMilvusClient._get_collection_internal(collection_name)
            collection_obj.load()
            
            # 1. 生成 dense 查询向量(智谱 API)
            dense_query_vec = get_dense_embed_model().get_query_embedding(query)
            
            # 2. 生成 sparse 查询向量(BGE-M3 本地模型)
            sparse_func = get_sparse_encoder()
            sparse_query_vec = sparse_func.encode_queries([query])[0]
            
            # 3. 构建双路 AnnSearchRequest
            dense_req = AnnSearchRequest(
                data=[dense_query_vec], anns_field="vector",
                param={"metric_type": "COSINE"}, limit=top_k * 2, expr=milvus_expr,
            )
            sparse_req = AnnSearchRequest(
                data=[sparse_query_vec], anns_field="sparse_vector",
                param={"metric_type": "IP"}, limit=top_k * 2, expr=milvus_expr,
            )
            
            # 4. 服务端 RRF 融合排序
            results = collection_obj.hybrid_search(
                reqs=[dense_req, sparse_req],
                ranker=RRFRanker(), limit=top_k, output_fields=["*"],
            )
            
            # 5. 转换结果
            return MilvusSearchByRetrieve._hits_to_node_with_scores(results)
        
        return await asyncio.get_running_loop().run_in_executor(None, _hybrid_search)
    
    @staticmethod
    def _hits_to_node_with_scores(hits):
        results = []
        for hit in hits:
            entity = hit.entity
            metadata = {}
            for field_name, value in entity.items():
                if field_name in ("id", "vector", "sparse_vector", "text", "metadata"):
                    continue
                if value is not None:
                    metadata[field_name] = value
            
            # 恢复 keywords 为列表(入库时逗号分隔存储)
            if "keywords" in metadata and isinstance(metadata["keywords"], str):
                metadata["keywords"] = [k.strip() for k in metadata["keywords"].split(",") if k.strip()]
            
            node = TextNode(id_=entity.get("id", ""), text=entity.get("text", ""), metadata=metadata)
            results.append(NodeWithScore(node=node, score=hit.score))
        return results

四、Milvus Schema 设计

4.1 标量字段定义

python 复制代码
# module_rag/rag_common/utils/milvus/client.py

RAG_SCALAR_FIELD_NAMES = [
    "file_name",      # VARCHAR - 原始文件名
    "file_type",      # VARCHAR - 文件类型
    "kb_id",          # VARCHAR - 知识库 ID
    "doc_type",       # VARCHAR - 文档类型
    "chunk_index",    # INT64   - 切片序号
    "file_size",      # INT64   - 文件大小(字节)
    "strategy_type",  # VARCHAR - 切片策略类型
    "keywords",       # VARCHAR - BM25 关键词(逗号分隔字符串)
]
RAG_SCALAR_FIELD_TYPES = [
    DataType.VARCHAR, DataType.VARCHAR, DataType.VARCHAR, DataType.VARCHAR,
    DataType.INT64,   DataType.INT64,   DataType.VARCHAR, DataType.VARCHAR,
]

4.2 完整 Collection Schema

复制代码
┌─────────────────────────────────────────────────────────────────┐
│  rag_{collection_name}                                          │
├─────────────────────────────────────────────────────────────────┤
│  id              VARCHAR(65535)   PK                            │
│  vector          FLOAT_VECTOR(2048)  Dense 向量(智谱 API)      │
│  sparse_vector   SPARSE_FLOAT_VECTOR  Sparse 向量(BGE-M3)     │
│  text            VARCHAR(65535)  原文文本                        │
│  metadata        JSON              LlamaIndex 内部元数据         │
│  doc_id          VARCHAR(65535)  文档 ID(内置字段)             │
│  ─── 以下为自定义标量字段 ───                                    │
│  file_name       VARCHAR           原始文件名                    │
│  file_type       VARCHAR           文件类型                      │
│  kb_id           VARCHAR           知识库 ID                     │
│  doc_type        VARCHAR           文档类型                      │
│  chunk_index     INT64             切片序号                      │
│  file_size       INT64             文件大小                      │
│  strategy_type   VARCHAR           切片策略类型                  │
│  keywords        VARCHAR           BM25 关键词(逗号分隔)       │
└─────────────────────────────────────────────────────────────────┘

五、数据流转全链路图

复制代码
用户上传文档
    │
    ▼
┌──────────────────────────────────────────────────────────────────┐
│  process_single_document()                                       │
│                                                                  │
│  ① common_read_file_content() → List[Document]                   │
│                                                                  │
│  ② RAGIngestionPipeline.arun()                                   │
│     ├─ DocumentCleanerComponent: 清洗                            │
│     ├─ ChunkingComponent: 切片 → List[TextNode]                  │
│     ├─ ChunkPostProcessorComponent: 注入关系                     │
│     ├─ ContextEnhancementComponent: HyDE/摘要/标题               │
│     └─ MetadataEnrichmentComponent:                              │
│         ├─ basic 策略: doc_id, kb_id, file_name, chunk_index...  │
│         └─ bm25 策略:                                            │
│             ├─ jieba.extract_tags(text) → keywords: list[str]    │
│             │   → node.metadata["keywords"] = ["合同法", ...]    │
│             └─ get_sparse_encoder() → BGE3SparseEmbeddingFunction │
│                 → pipeline_context.state["sparse_embedding_function"] │
│                                                                  │
│  ③ sparse_func = pipeline_context.state.get("sparse_embedding_function") │
│                                                                  │
│  ④ MilvusNode.add_nodes(enable_sparse=True, sparse_embedding_function=sparse_func) │
│     └─ ScalarAwareMilvusVectorStore.add()                        │
│         for each node:                                           │
│         ├─ dense: embed_model.get_text_embedding(node.text)      │
│         │   → [0.012, -0.034, ...] (2048 维)                    │
│         ├─ sparse: sparse_func.encode_documents([node.text])[0]  │
│         │   → model.encode(texts, return_sparse=True)            │
│         │   → lexical_weights: [{1847: 2.31, 592: 1.87, ...}]   │
│         │   → dict[int, float]: {1847: 2.31, 592: 1.87, ...}    │
│         ├─ scalar: keywords → "合同法,违约,赔偿,第584条"         │
│         └─ scalar: file_name, kb_id, doc_type, chunk_index...    │
│                                                                  │
│  ⑤ save_chunks_to_db() → PostgreSQL rag_chunk 表                 │
└──────────────────────────────────────────────────────────────────┘

六、稀疏向量编码原理

6.1 BGE-M3 Learned Sparse Vector

BGE-M3 不同于传统 BM25 的词典匹配,它通过稀疏注意力头学习每个 token 的重要性权重:

复制代码
输入文本: "合同法第584条违约赔偿"
    │
    ▼
Transformer Encoder (BGE-M3)
    │
    ▼
Sparse Projection Head (线性投影)
    │
    ▼
lexical_weights: {token_id: weight}
    → {2847: 3.12, 1095: 2.45, 5842: 1.87, ...}
    (约 7~30 个非零 token)

6.2 与 BM25 的对比

特性 BM25 BGE-M3 Learned Sparse
词汇表 语料库拟合(IDF) 模型预训练(固定)
分词 jieba 分词 Transformer Tokenizer
权重计算 TF-IDF + 长度归一化 神经网络学习
跨语言 不支持 支持(中英混合)
是否需要 fit 是(每个知识库独立 fit) 否(模型权重固定)
语义理解 无(纯词面匹配) 有(上下文感知)

七、全局单例管理

7.1 稀疏编码器单例

python 复制代码
# module_rag/config/settings.py

_sparse_encoder_instance = None

def get_sparse_encoder():
    global _sparse_encoder_instance
    if _sparse_encoder_instance is not None:
        return _sparse_encoder_instance
    from module_rag.rag_common.metadata_enrichment.bge3_embedding import BGE3SparseEmbeddingFunction
    _sparse_encoder_instance = BGE3SparseEmbeddingFunction()
    print("[OK] BGE-M3 Sparse Encoder Initialized!")
    return _sparse_encoder_instance

7.2 BGE-M3 模型单例

python 复制代码
class _LocalBGE3Model:
    _instance = None
    _model = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance
    
    def get_model(self):
        # 双重检查锁定
        if _LocalBGE3Model._model is not None:
            return _LocalBGE3Model._model
        self._ensure_model_downloaded()
        self._load_model()
        return _LocalBGE3Model._model

单例层级关系

复制代码
get_sparse_encoder()          → BGE3SparseEmbeddingFunction 单例
    └── _LocalBGE3Model()     → 模型管理器单例
            └── BGEM3FlagModel → BGE-M3 模型实例(FP16,~1.1GB)

八、遇到的问题与解决方案

问题 1:ONNX 量化模型输出 Dense 而非 Sparse

现象 :使用 farming789/bge-m3-sparse ONNX 模型,调用 encode() 后输出 2048 维稠密向量,而非稀疏向量。

排查过程

  1. 检查模型 README → 声称支持 sparse output
  2. 实际测试 → 输出维度 = 2048(dense 维度),非稀疏
  3. 尝试多个 ONNX 版本 → 均输出 dense

结论:该 ONNX 模型的 README 存在误导,实际只输出 dense vector。

解决:弃用 ONNX 方案,改用 FlagEmbedding 库加载原始 BGE-M3 模型。

问题 2:HuggingFace 镜像站 xet 协议不兼容

现象snapshot_download() 报错 xet protocol not supported

原因:HuggingFace 新版传输协议 xet 在镜像站(hf-mirror.com)的 CAS 存储上不兼容。

解决

python 复制代码
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")  # 禁用 xet 协议

问题 3:ignore_patterns 导致关键文件被忽略

现象 :模型下载后加载失败,缺少 config.json

原因ignore_patterns=["*.md"] 过于宽泛,误忽略了包含 .md 路径的文件。

解决:精确指定忽略模式:

python 复制代码
ignore_patterns=["*.DS_Store", "imgs/*", "README.md", "*.md"]

问题 4:LlamaIndex MilvusVectorStore 标量字段不写入

现象:创建 Collection 时 Schema 包含标量字段,但数据写入后标量列全部为 NULL。

根因 :LlamaIndex MilvusVectorStore.add() 在构建 entry 时只写入 id/text/metadata/vector,不提取 scalar_field_names 对应的 metadata 字段到 entry 顶层。

解决 :重写 ScalarAwareMilvusVectorStore.add()async_add(),手动将标量字段从 node.metadata 提取到 entry 顶层。

问题 5:Milvus 不支持在 JSON 字段上创建索引

现象create_collection 报错 create index on json field is not supported(code=1100)。

原因:Milvus 2.3 不支持在 JSON 类型字段上创建标量索引。

解决:将需要索引的字段定义为 VARCHAR/INT64 等基础类型,而非 JSON。metadata 字段保留为 JSON(不创建索引)。

问题 6:keywords 类型转换

现象node.metadata["keywords"]list[str],Milvus VARCHAR 字段不接受列表。

解决 :在 ScalarAwareMilvusVectorStore 写入时自动转换:

python 复制代码
if isinstance(value, list):
    value = ",".join(str(v) for v in value)

检索时反向恢复:

python 复制代码
if "keywords" in metadata and isinstance(metadata["keywords"], str):
    metadata["keywords"] = [k.strip() for k in metadata["keywords"].split(",") if k.strip()]

问题 7:FP16 加载失败回退

现象:部分环境不支持 FP16(如 CPU-only 环境)。

解决:try-except 回退机制:

python 复制代码
try:
    model = BGEM3FlagModel(path, use_fp16=True)
except Exception:
    model = BGEM3FlagModel(path)  # 回退到 FP32

问题 8:代码冗余

现象ScalarAwareMilvusVectorStoreadd()async_add() 有约 80% 重复代码。

建议优化 :提取 _build_entry() 公共方法:

python 复制代码
def _build_entry(self, node: BaseNode) -> dict:
    entry = node_to_metadata_dict(node, remove_text=True, text_field=self.text_key)
    entry[self.text_key] = node.dict()[self.text_key]
    entry[MILVUS_ID_FIELD] = node.node_id
    if self.enable_dense:
        entry[self.embedding_field] = node.embedding
    if self.enable_sparse and isinstance(self.sparse_embedding_function, BaseSparseEmbeddingFunction):
        entry[self.sparse_embedding_field] = (
            self.sparse_embedding_function.encode_documents([node.text])[0]
        )
    for field_name in (self.scalar_field_names or []):
        if field_name in node.metadata:
            value = node.metadata[field_name]
            if isinstance(value, list):
                value = ",".join(str(v) for v in value)
            entry[field_name] = value
    return entry

九、性能指标

9.1 稀疏向量编码性能

操作 耗时 说明
BGE-M3 模型加载(FP16) ~15s(首次) 后续复用单例,无需重复加载
单条文档编码(encode_documents) ~50ms 约 30 个非零 token
单条查询编码(encode_queries) ~20ms 约 5-7 个非零 token
内存占用 ~1.1GB FP16 模型权重

9.2 混合检索性能

指标 Dense Only Dense + Sparse (RRF)
关键词精确匹配 一般 显著提升
语义模糊匹配 好(保持)
检索延迟 ~100ms ~150ms(+50ms 稀疏编码)
Top-5 准确率 72% 85%(预估)

十、策略模式架构设计

10.1 元数据增强策略模式

复制代码
MetadataEnrichmentStrategy (ABC)          ← 抽象基类
    ├── BasicMetadataEnrichmentStrategy   ← 基础策略(注入 doc_id/kb_id 等)
    │       ↑ 继承
    └── BM25MetadataEnrichmentStrategy    ← BM25 策略(关键词 + 稀疏编码器)

MetadataEnrichmentStrategyFactory         ← 注册式工厂
    └── _strategies: {"basic": BasicStrategy, "bm25": BM25Strategy}

MetadataEnrichmentService                 ← 策略链式执行
    └── process_enrichment(): basic → bm25 顺序执行

10.2 扩展新策略

python 复制代码
# 新增策略只需 3 步:

# 1. 实现策略类
class CustomMetadataEnrichmentStrategy(BasicMetadataEnrichmentStrategy):
    @property
    def name(self) -> str:
        return "custom"
    
    def process(self, nodes, context, params):
        nodes = super().process(nodes, context, params)
        # 自定义增强逻辑
        return nodes

# 2. 注册到工厂
MetadataEnrichmentStrategyFactory.register("custom", CustomMetadataEnrichmentStrategy)

# 3. 在 PipelineContext 中配置策略链
pipeline_context = PipelineContext(
    meta_enrichment_strategies=["basic", "bm25", "custom"],
)

十一、配置参考

11.1 环境变量(.env.dev)

bash 复制代码
# Dense 向量(智谱 API)
RAG_EMBED_PROVIDER=openai_compatible
RAG_EMBED_API_BASE=https://open.bigmodel.cn/api/paas/v4
RAG_EMBED_API_KEY=your-api-key
RAG_EMBED_MODEL=text-embedding-v4
RAG_EMBED_DIMENSION=2048
RAG_EMBED_BATCH_SIZE=100

# Milvus
RAG_MILVUS_HOST=xxxx
RAG_MILVUS_PORT=19530
RAG_MILVUS_USER=root
RAG_MILVUS_PASSWORD=your-password

# BM25 关键词配置
RAG_BM25_KEYWORD_TOP_K=10

11.2 关键依赖

复制代码
FlagEmbedding>=1.2.0       # BGE-M3 模型推理
huggingface_hub>=0.20.0    # 模型下载
jieba>=0.42.1              # 中文分词 + 关键词提取
pymilvus>=2.3.0            # Milvus Python SDK
llama-index-vector-stores-milvus>=0.1.0  # LlamaIndex Milvus 集成

十二、测试验证

12.1 稀疏向量编码测试

python 复制代码
from module_rag.rag_common.metadata_enrichment.bge3_embedding import BGE3SparseEmbeddingFunction

func = BGE3SparseEmbeddingFunction()

# 测试 encode_queries
result = func.encode_queries(["合同法第584条违约赔偿", "民法典侵权责任"])
print(f"query[0] 非零 token 数: {len(result[0])}")  # 7
print(f"query[1] 非零 token 数: {len(result[1])}")  # 5

# 测试 encode_documents
result = func.encode_documents(["这是一段关于合同违约赔偿的法律条文..."])
print(f"doc[0] 非零 token 数: {len(result[0])}")  # 30

# 测试空输入
assert func.encode_queries([]) == []
assert func.encode_documents([]) == []

# 类型验证
assert isinstance(result[0], dict)
assert all(isinstance(k, int) for k in result[0].keys())
assert all(isinstance(v, float) for v in result[0].values())

测试结果

复制代码
=== encode_queries ===
  [0] "合同法第584条违约赔偿" -> 7 个非零token
  [1] "民法典侵权责任" -> 5 个非零token

=== encode_documents ===
  [0] 30 个非零token

=== 空输入测试 ===
encode_queries([]) -> []
encode_documents([]) -> []

=== 类型检查 ===
返回类型: <class 'dict'>
key 类型: <class 'int'>
value 类型: <class 'float'>

十三、设计决策总结

决策点 选择 理由
稀疏向量方案 BGE-M3 本地 FP16 精度高、无需 fit、跨知识库复用
模型加载方式 懒加载 + 自动下载 首次调用时触发,不影响启动速度
编码器传递方式 PipelineContext.state 解耦元数据增强和入库步骤
标量字段写入 重写 VectorStore.add() 修复 LlamaIndex 原生缺陷
检索融合方式 Milvus 服务端 RRF 无需客户端手动合并,性能最优
keywords 存储 VARCHAR 逗号分隔 兼容 Milvus 标量索引
策略模式 注册式工厂 + 链式执行 扩展性好,新增策略无需修改工厂

十四、附录:完整代码文件索引

序号 文件 行数 核心功能
1 module_rag/config/settings.py 374 全局配置 + 单例管理
2 module_rag/rag_common/metadata_enrichment/bge3_embedding.py 148 BGE-M3 编码器
3 module_rag/rag_common/metadata_enrichment/strategies/bm25.py 117 BM25 增强策略
4 module_rag/rag_common/metadata_enrichment/strategies/basic.py 118 基础增强策略
5 module_rag/rag_common/metadata_enrichment/service.py 63 策略链式执行
6 module_rag/rag_common/metadata_enrichment/factory.py 28 策略注册工厂
7 module_rag/rag_common/metadata_enrichment/schemas.py 36 数据模型
8 module_rag/rag_common/metadata_enrichment/bm25_sparse_embedding.py 174 BM25 文本稀疏编码(备选方案)
9 module_rag/rag_common/utils/milvus/scalar_vector_store.py 206 标量字段修复
10 module_rag/rag_common/utils/milvus/client.py 501 Milvus 集合管理
11 module_rag/rag_common/utils/milvus/node.py 482 Node 入库/查询
12 module_rag/rag_common/utils/milvus/search_by_retrieve.py 348 混合检索
13 module_rag/rag_common/pipeline/pipeline.py 78 Pipeline 组装
14 module_rag/rag_common/pipeline/context.py 111 Pipeline 上下文
15 module_rag/rag_common/pipeline/components/metadata_enrichment.py 64 元数据增强组件
16 module_rag/tasks/document_parse_task.py 361 文档解析任务

文档版本 : v1.0

最后更新 : 2026-08-28

适用 Milvus 版本 : 2.3+

适用 Python 版本: 3.10+

相关推荐
l12586523 分钟前
# LangGraph 核心架构入门:State、Node、Edge 与 Reducer 的工程理解
大数据·人工智能·python·架构·langchain·edge
圆圆讲门店1 小时前
实体获客团队|基础概念与核心要点整理
人工智能·python
砚底藏山河1 小时前
【量化纯GET实战 #04】批量快照:一次 HTTP GET 取多只股票
java·python·金融·eclipse
烂蜻蜓1 小时前
Flask入门教程(九):模板渲染——用Jinja2构建动态页面
后端·python·flask
2601_966871401 小时前
AE影视后期特效-遮罩/调色/抠像/MG动画/3D/Vlog制作(完结)jzit
python
circuitsosk2 小时前
大模型本体安全防护实践:提示词注入防御、输出合规过滤与敏感信息脱敏
python·安全·数据脱敏·纵深防御·提示词注入·llmsecurity
工业一体机老司机2 小时前
Python实现工业一体机Modbus-TCP通信-从协议解析到多设备轮询实战
开发语言·python·tcp/ip
2401_885885042 小时前
国际语音php接口代码示例:PHP使用cURL快速调用语音发送API
android·开发语言·前端·人工智能·python·php·语音识别
EW Frontier2 小时前
基于 Python + NumPy 的雷达信号处理仿真平台设计与实现——从 LFM 波形到 CFAR 检测的完整信号链
python·雷达信号处理·cfar\]·mti·mtd