一、问题背景:为什么61%的命中率无法接受
我们团队在做一个面向企业内部文档的智能问答系统,文档类型包括产品手册、技术方案、会议纪要等,总量约2万+文本块。初版系统上线后,业务方反馈“答非所问”的情况频繁出现。我们排查后发现,问题集中在两个环节:
- 召回阶段:chunk切分不合理导致语义碎片化。比如一段关于“支付超时处理流程”的文本,被硬生生切成了两半,导致检索时上下文信息丢失。
- 排序阶段:单纯依赖向量相似度排序,噪声太大。有些语义相近但实际不相关的文本块排到了前面。
当时线上指标:Hit Rate@5(Top-5内包含正确答案的比例)仅为61%,MRR(平均倒数排名)为0.47。这个数据意味着近四成的问题,系统根本找不到相关文档块。
二、环境与版本:技术栈一览
- Python 3.10
- LlamaIndex 0.9.3
- ChromaDB 0.4.15
- Embedding模型:
moka-ai/m3e-base(初版)→BAAI/bge-large-zh-v1.5(优化后) - Rerank模型:
BAAI/bge-reranker-base - LLM:
Qwen-14B-Chat(部署在本地V100上,vLLM推理)
说明一下,我们最初选m3e-base是因为它体积小、推理快,但后来发现它对长文本的语义捕捉能力一般。
三、方案设计:三管齐下
3.1 Chunk策略调整:从固定长度到语义段落
初版使用的是固定256个字符的chunk,重叠50个字符。这种方式对于中文这种无空格分隔的语言来说,极易切断语义。我们改用了递归字符文本分割器,优先按段落(\n\n)切分,其次按句子(。!?)切分,最后按逗号切分。
关键参数配置:
from llama_index.node_parser import HierarchicalNodeParser
parser = HierarchicalNodeParser.from_defaults(
chunk_sizes=[512, 256, 128], # 三级层级:父块512,子块256,孙块128
chunk_overlap=50,
separators=["\n\n", "。", "!", "?", ";", ",", " ", ""]
)
这里用了层级节点解析器,父块保持完整段落,子块用于检索,孙块用于精细化定位。这样做的好处是:检索到子块后,可以回溯到父块,拿完整的上下文喂给LLM。
3.2 Embedding模型切换:m3e → bge-large-zh
m3e-base的向量维度是768,bge-large-zh-v1.5是1024。后者在中文语义匹配上明显更强,特别是对于相似但不同义的句子区分度更高。
切换后的对比实验(在1000条标注测试集上):
| 模型 | Hit Rate@5 | MRR@5 | 隐式向量维度 |
|---|---|---|---|
| m3e-base | 61% | 0.47 | 768 |
| bge-large-zh-v1.5 | 78% | 0.65 | 1024 |
注意,bge-large-zh-v1.5在使用时需要给query加上指令前缀"为这个句子生成表示以用于检索相关文章:",否则效果会打折扣。
加载方式:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
query_instruction = "为这个句子生成表示以用于检索相关文章:"
query_emb = model.encode(query_instruction + query, normalize_embeddings=True)
3.3 引入Rerank:双阶段排序
单纯靠向量相似度排序,噪声太大。我们引入了bge-reranker-base做精排,将向量检索返回的Top-50结果重排为Top-5。
Rerank的核心逻辑:
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def rerank(query, candidates, top_k=5):
pairs = [[query, doc] for doc in candidates]
scores = reranker.compute_score(pairs, normalize=True)
sorted_results = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, score in sorted_results[:top_k]]
注意,FlagReranker的compute_score支持批量计算,但需要一次性传入所有pairs。如果候选数量太大(比如超过100),建议分批处理,否则显存容易爆。
四、核心实现:完整检索流程
下面是我们最终落地的检索Pipeline代码(简化版):
import chromadb
from llama_index.embeddings import HuggingFaceEmbedding
from llama_index.vector_stores import ChromaVectorStore
from llama_index.schema import NodeWithScore
class RAGRetriever:
def __init__(self):
self.embed_model = HuggingFaceEmbedding(
model_name="BAAI/bge-large-zh-v1.5",
query_instruction="为这个句子生成表示以用于检索相关文章:"
)
self.chroma_client = chromadb.PersistentClient(path="./chroma_db")
self.collection = self.chroma_client.get_collection("docs_v2")
self.reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def retrieve(self, query, top_k=5):
# Step 1: 向量召回 Top-50
query_emb = self.embed_model.get_query_embedding(query)
results = self.collection.query(
query_embeddings=[query_emb],
n_results=50,
include=["documents", "metadatas"]
)
candidates = results["documents"][0]
# Step 2: Rerank 精排取 Top-5
pairs = [[query, doc] for doc in candidates]
scores = self.reranker.compute_score(pairs, normalize=True)
sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
final_docs = [candidates[i] for i in sorted_idx]
return final_docs
五、踩坑与优化:那些文档没说的事
5.1 层级chunk的“父块回溯”问题
用HierarchicalNodeParser后,检索返回的是子块节点。但如果你只把子块喂给LLM,上下文信息依然不完整。我们最初忘了做父块回溯,导致Hit Rate反而下降了3个百分点。后来通过parent_id查询父块,拼接到子块内容后面:
def get_full_context(node_id):
node = index.docstore.get_node(node_id)
parent_node = index.docstore.get_node(node.parent_node.node_id)
return parent_node.text + "\n---\n" + node.text
5.2 Rerank模型的显存优化
bge-reranker-base虽然只有278M参数,但批处理时显存占用不小。我们一开始一次性传入50对pairs,结果V100(16G)直接OOM。后来改成每次10对,循环处理。另外,use_fp16=True能省一半显存,但需要GPU支持半精度计算。
5.3 ChromaDB的元数据过滤
我们文档都有来源、日期等元数据。在检索时加上元数据过滤能显著提升精度。比如只检索某个部门近一年的文档:
results = self.collection.query(
query_embeddings=[query_emb],
n_results=50,
where={"$and": [{"dept": "研发部"}, {"date": {"$gte": "2024-01-01"}}]},
include=["documents", "metadatas"]
)
六、效果数据:最终指标对比
经过三轮优化后,在相同的1000条测试集上重新评测:
| 版本 | Hit Rate@5 | MRR@5 | 平均响应时间 |
|---|---|---|---|
| 初版(m3e + 固定chunk) | 61% | 0.47 | 1.2s |
| + bge-large-zh | 78% | 0.65 | 1.5s |
| + 语义chunk | 83% | 0.72 | 1.6s |
| + rerank | 89% | 0.82 | 2.1s |
响应时间从1.2s增加到2.1s,主要是因为rerank阶段需要额外推理。但用户侧感知到的“废话率”大幅下降,业务方表示可以接受。
七、总结与反思
这次优化的核心结论:
- Embedding模型的选择比chunk策略影响更大。从m3e到bge-large-zh,Hit Rate直接提升了17个百分点。如果预算允许,直接用
bge-m3或gte-large-zh效果会更好。 - Rerank是性价比最高的优化手段。额外的0.5s延迟换来了6%的命中率提升,值得。
- 层级chunk比固定长度chunk更适合中文文档。但注意父块回溯,否则子块上下文不全。
- 不要迷信单一指标。Hit Rate提升可能只是把简单问题答对了,要结合MRR看排序质量,最好再人工抽检几个典型case。
最后提一句,RAG优化是系统工程,数据清洗和索引质量同样重要。我们后来发现,文档里大量扫描件OCR错误导致的乱码,严重干扰了向量检索。如果你也遇到瓶颈,先检查数据质量,再调模型。
以上,纯手打,有问题评论区见。