一、问题背景:用户问“报销”,系统给“考勤”
我们做的是企业内部文档问答系统,底层是标准的RAG流程:文档切块 → embedding向量化 → 存入Milvus → 查询时向量召回Top-K → LLM生成答案。
线上跑了一个月,用户反馈最多的不是“答案不对”,而是“根本找不到对应文档”。我们拉取了近一周的线上query日志,随机抽了500条做人工标注,发现召回率(Top-5内包含正确答案的比例)只有61.2%。具体表现为三类问题:
- 语义混淆:用户问“加班餐费怎么报销”,系统召回的是“加班补贴标准”,因为都含有“加班”字样,但实质内容差很远。
- 专有名词衰减:比如“ERP系统应付模块操作手册”,embedding后跟“财务系统付款流程”的相似度反而更高。
- 长文本稀释:我们把整个章节(约2000-3000字)作为一个chunk,结果向量被平均化,跟query相关的部分被无关内容稀释了。
二、环境与版本
先交代一下当时的运行环境,方便你复现对比:
- Python 3.10.12
- LangChain 0.2.11(当时最新版)
- Milvus 2.4.1(用于向量存储,collection用了HNSW索引,metric类型为IP)
- 语言模型:Qwen-14B-Chat(通过vLLM 0.6.3部署)
- 原embedding模型:
BAAI/bge-large-zh-v1.5(1024维) - 替换后embedding模型:
shibing624/text2vec-base-chinese-paraphrase(768维,后文说明为什么不用large)
离线评测集:从知识库中取2000个问题-文档对(由业务方手工标注),用Recall@5和MRR@5作为指标。
三、方案设计:三步走,每步单独验证
我们决定按顺序优化,每一步都在同一个评测集上跑离线数据,避免多个变量同时变化导致无法归因。
Step 1:chunk策略调整。原方案是固定256字切分,无重叠。我们改成“语义标题滑动窗口”:先按Markdown标题拆成二级段落,再对每个段落内部如果超过512字,按句号切分并保留前后1个句子的重叠(约30-50字)。
Step 2:embedding模型切换。从bge-large-zh-v1.5(1024维)换到text2vec系列。为什么换?因为我们的测试集里包含大量如“应付暂估”“资产转固”这类财务专业词,评测后发现text2vec在这些词上的相似度分布更分散,而bge把所有跟“财务”沾边的query都挤到一个高密度区域。另外,我们最终线上用的是text2vec-base-chinese-paraphrase,不是large——因为large的推理延迟在CPU上要80ms,base只要25ms,而精度差距只有1.1%。
Step 3:引入rerank。粗排(embedding召回Top-50)之后,接一个交叉编码器BAAI/bge-reranker-base对50条重新打分,取Top-5。这个模型是单塔结构,query和document拼在一起输入,能建模细粒度交互。
四、核心实现:每一步的代码与关键参数
4.1 chunk策略:从固定长度到语义滑动窗口
原来代码很简单,split_text按字符数硬切,导致句子被切断。改成下面的逻辑:
from langchain.text_splitter import MarkdownHeaderTextSplitter
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 1. 先按Markdown标题结构切一级
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on, strip_headers=False)
md_chunks = md_splitter.split_text(markdown_doc)
# 2. 对每个标题块内部,用递归字符切分器做语义滑动
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=60, # 保留约1-2个完整句子
separators=["\n\n", "。", "!", "?", "\n", ";", ",", " ", ""],
length_function=len,
)
final_chunks = []
for chunk in md_chunks:
sub_chunks = text_splitter.split_text(chunk.page_content)
for sub in sub_chunks:
# 注入元数据:标题路径 + 原始文档ID
final_chunks.append({
"text": sub,
"metadata": {
"doc_id": "xxx",
"header_h1": chunk.metadata.get("H1", ""),
"header_h2": chunk.metadata.get("H2", ""),
"header_h3": chunk.metadata.get("H3", ""),
}
})
关键参数:chunk_size=512(不是越大越好,后面踩坑会细说),chunk_overlap=60。这里用句号、感叹号作为优先分隔符,保证语义完整性。
4.2 embedding替换与向量库迁移
原向量是1024维,换成text2vec后是768维,Milvus里的collection需要重建。我们写了迁移脚本,但注意一个坑——维度变了,索引必须重建,而且要用新的collection名,否则线上服务会短暂不可用。
from sentence_transformers import SentenceTransformer
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType
# 加载新版embedding模型
model = SentenceTransformer("shibing624/text2vec-base-chinese-paraphrase")
print(model.encode(["测试"]).shape) # (768,)
# 创建新collection
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=768),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=2000),
]
schema = CollectionSchema(fields, "kb_v2")
collection = Collection("kb_chunk_v2", schema)
# 批量写入(伪代码)
for batch in doc_batches:
vectors = model.encode(batch["texts"], normalize_embeddings=True)
collection.insert([batch["ids"], vectors, batch["texts"]])
# 创建HNSW索引,重点:M值调到32,efConstruction设为200
index_params = {
"index_type": "HNSW",
"metric_type": "IP",
"params": {"M": 32, "efConstruction": 200}
}
collection.create_index("embedding", index_params)
注意:normalize_embeddings=True必须开,因为我们用的是IP内积,不是余弦距离。否则召回结果会乱。
4.3 rerank集成
粗排召回Top-50,然后用bge-reranker-base打分,取Top-5。这里用的是FlagEmbedding库:
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def rag_search(query, top_k=50, rerank_top=5):
# 1. embedding召回
q_vec = embed_model.encode(query, normalize_embeddings=True)
results = collection.search(
data=[q_vec],
anns_field="embedding",
param={"metric_type": "IP", "params": {"ef": 200}},
limit=top_k,
output_fields=["text"]
)
hits = results[0]
# 2. rerank精排
pairs = [[query, hit.entity.get("text")] for hit in hits]
scores = reranker.compute_score(pairs, normalize=True)
# 3. 按新分数排序取前5
scored = sorted(zip(hits, scores), key=lambda x: x[1], reverse=True)
return [hit.entity.get("text") for hit, score in scored[:rerank_top]]
这里的ef参数是查询时HNSW的探索宽度,设成200能提高召回率,但延迟会增加约5ms,可接受。
五、踩坑与优化:你以为改完就完了?
坑1:chunk重叠率过高导致重复内容刷屏
第一次把chunk_overlap设成120,结果两个相邻chunk几乎一半内容重复,向量召回时同一个文档的内容占了Top-5的3个位置,答案冗余严重。后来改成重叠区间只包含“前一个句子的结尾 + 后一个句子的开头”,即大约60字,重复率就降到5%以下。
坑2:bge-large换text2vec后,Milvus旧collection没删干净
我们直接复用旧collection名,结果数据维度对不上,报错。后来强制删了旧collection,用新名字kb_chunk_v2。这里建议:embedding模型升级一定要换collection名,不要原地重建,便于灰度回滚。
坑3:rerank模型的batch推理显存溢出
在GPU上跑rerank,如果一次性把50条query-doc对全塞进去,12G显存会爆。改成每次处理16对,用循环累加。实际延迟影响不大,50条总共增加约180ms。
坑4:离线评测和线上效果不一致
离线Recall@5到了89%,但线上用户满意度只提升了10%。后来查了线上日志,发现很多用户query是“口语化”的,比如“钱多久能到账”,评测集里写的是“报销到账时间”。解决办法:我们在query侧加了一个轻量改写模块(基于规则+同义词表),把口语转成书面语再进向量检索。这一步又提升了3.2%的线上命中率。
六、效果数据:每个阶段的变化轨迹
我们在同一份2000条人工评测集上跑分,保证可对比性:
| 阶段 | Recall@5 | MRR@5 | 平均响应延迟 |
|---|---|---|---|
| 初始(固定256字chunk + bge-large) | 61.2% | 0.458 | 310ms |
| Step1(语义滑动chunk) | 68.7% | 0.512 | 325ms |
| Step2(换text2vec-base) | 74.5% | 0.567 | 305ms |
| Step3(引入rerank) | 82.3% | 0.634 | 495ms |
| 最终(+口语改写) | 89.1% | 0.692 | 510ms |
线上AB测试(连续7天,各50%流量):
- 用户点“有帮助”按钮的比例:从31.4%提升到44.7%
- 平均每query召回的文档数(去重后):从4.2个降低到2.8个,意味着答案更聚焦
- 端到端响应时间:因为加了rerank,从800ms涨到1.2s,但用户可接受(p95 < 2.5s)
七、总结与建议
回顾整个优化过程,我想说三点:
- chunk策略是性价比最高的优化点。我们只改切分逻辑,没换模型,召回率就涨了7.5个点。不要迷信embedding模型,先检查你的chunk有没有切断关键实体。
- embedding模型不要盲追最新。bge-large-zh-v1.5很强,但它在垂直领域(财务/ERP)上可能不如一个在中文通用语料上训练得更充分的模型。你最好自己拉一个200条查询+人工标注的小数据集,跑一遍Recall@5再决定。
- rerank值得上,但要注意延迟预算。如果你的query量不大(<10 QPS),完全可以用cross-encoder做精排。如果QPS很高,建议把粗排Top-50缩减到Top-20,能省一半rerank时间,召回率损失不到1%。
最后提醒一句:向量库索引的ef和M参数对召回率影响很大,不要用默认值。我们实测M=16时Recall@5只有77%,调到M=32后直接到81%,代价是索引构建时间从3分钟变成11分钟,但这是一次性的,值得。