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 维稠密向量,而非稀疏向量。
排查过程:
- 检查模型 README → 声称支持 sparse output
- 实际测试 → 输出维度 = 2048(dense 维度),非稀疏
- 尝试多个 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:代码冗余
现象 :ScalarAwareMilvusVectorStore 的 add() 和 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+