1. 问题背景:从“能用”到“没法用”的临界点
我们维护的是一款面向半导体行业的工艺问答系统。最初只用300份PDF构建时,QA表现良好。但当知识库扩充到5300份文档(包含SOP、专利、维修记录),问题开始爆发:
- 症状1:用户问“光刻胶涂布厚度均匀性如何控制”,返回的前5个片段里有3个是关于“刻蚀”的。
- 症状2:检索出的片段在BM25和向量混合分数上都很高,但语义上完全不相关——因为文档里反复出现“光刻胶”这个词,导致向量被“高频词”绑架。
- 症状3:回答有幻觉,引用了不存在的参数范围。
我们当时的架构是:LangChain + ChromaDB + bge-large-zh-v1.5 + OpenAI GPT-4o(仅做生成)。检索Top-5直接塞进Prompt。诊断后发现瓶颈不在生成,而在召回质量和排序逻辑。
2. 环境与版本:固定版本是复盘的前提
强烈建议任何优化工作前先冻结环境,否则排查问题会疯掉。我们的基准环境如下:
Python 3.10.13
langchain==0.2.17
langchain-community==0.2.17
chromadb==0.5.15
FlagEmbedding==1.2.10
sentence-transformers==3.3.1
torch==2.3.1+cu121
Embedding模型路径:/models/bge-large-zh-v1.5(本地挂载),后续切换为/models/Qwen3-Embedding-0.6B。Rerank模型使用BAAI/bge-reranker-v2-m3,从HuggingFace下载并转ONNX加速。
关键注意:ChromaDB的hnsw:space参数默认是l2,我们改成cosine后,召回效果有明显变化——这个细节后面展开。
3. 方案设计:三个变量,一个目标
我们定义优化目标:Top-5召回准确率(人工评判,是否包含标准答案)。控制变量如下:
| 阶段 | Chunk策略 | Embedding模型 | Rerank |
|---|---|---|---|
| A(基线) | 固定512字符,overlap=64 | bge-large-zh-v1.5 | 无 |
| B | 自适应分隔符切片 | bge-large-zh-v1.5 | 无 |
| C | 自适应分隔符切片 | Qwen3-Embedding-0.6B | 无 |
| D(最终) | 自适应分隔符切片 | Qwen3-Embedding-0.6B | bge-reranker-v2-m3 |
测试集:从真实用户日志中抽取200个问题,每个问题人工标注标准答案所在文档ID和片段ID。评估脚本为自研函数,计算Recall@5和MRR(Mean Reciprocal Rank)。
4. 核心实现:三步走的代码细节
4.1 Chunk策略调整:抛弃“一刀切”的512字符
基线chunk_size=512导致两个问题:长段落被切断,语义不完整;短段落被padding,噪声放大。我们改为基于结构化分隔符的递归切片:
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=768, # 比之前大,因为支持重叠语义
chunk_overlap=128, # 增加overlap,保持段落连贯
separators=[
"\n\n## ", # Markdown二级标题
"\n\n### ", # Markdown三级标题
"\n\n",
"\n",
"。", # 中文句号
";", # 中文分号
" ",
""
],
length_function=len,
is_separator_regex=False
)
# 对每份文档先做结构感知切片
chunks = text_splitter.split_documents(all_docs)
print(f"切片数量: {len(chunks)}")
# 之前42000个,现在变成51000个,多了21%,但每个片段语义更完整
核心逻辑:如果文档有标题/章节结构,优先按标题切;如果没有,退回按段落切;只有超长段落才按句号切。这样切出来的片段,平均长度从512字符变为约640字符,但片段内完整句子的比例从68%提升至91%。
4.2 Embedding模型切换:从854MB到192MB,精度反升
bge-large-zh-v1.5是1024维,精度不错,但有两个问题:1. 对长文本(>512 tokens)支持不好;2. 对专业领域术语(如“CMP抛光液pH值”)区分度不够。
我们测试了Qwen3-Embedding-0.6B(602M参数,输出维度1024,支持8192 tokens上下文)。切换代码很简单:
from FlagEmbedding import FlagModel
# 旧模型
model_old = FlagModel('/models/bge-large-zh-v1.5',
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True)
# 新模型:Qwen3-Embedding-0.6B,无需前缀指令
model_new = FlagModel('/models/Qwen3-Embedding-0.6B', use_fp16=True)
# 批量编码文档
docs_embeddings = model_new.encode(docs,
batch_size=32,
max_length=2048, # 关键:允许更长文本
normalize_embeddings=True)
切换后的即时影响:
- 显存占用:从5.8GB(bge-large)降至1.7GB(Qwen3-0.6B),批处理batch_size能开到128。
- 单次embedding耗时:平均45ms → 28ms(CPU推理,AMD EPYC 7K62)。
- 重要:去掉query指令。bge系列需要“为这个句子生成表示...”前缀,但Qwen3-Embedding不需要,加上反而降点约2%。
4.3 Rerank引入:最后一道闸门
Rerank不改变候选集,只重排。我们用bge-reranker-v2-m3(2.7GB,ONNX量化后860MB)对Top-20候选重排取Top-5。实现:
from FlagEmbedding import FlagReranker
reranker = FlagReranker('/models/bge-reranker-v2-m3', use_fp16=True)
def rerank(query, candidates, top_k=5):
pairs = [[query, doc.page_content] for doc in candidates]
scores = reranker.compute_score(pairs, normalize=True)
scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in scored[:top_k]]
# 在检索管道中嵌入
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
compressor = CrossEncoderReranker(
model=reranker,
top_n=5 # 保留最重要的5个
)
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=vectorstore.as_retriever(search_kwargs={"k": 20})
)
Rerank阶段单次推理约90ms(ONNX, CPU),整个查询Pipeline从210ms变为约320ms,但效果提升巨大。
5. 踩坑与优化:三个我们走了半天的弯路
坑1:ChromaDB的distance参数默认是l2,不是cosine。
我们用余弦相似度打分时,发现结果排序和人工预期完全不符。查了一天发现向量库默认用L2距离排序,L2对向量模长敏感,而bge和Qwen3的embedding模长并不固定。解决方案:
from chromadb.config import Settings
client = chromadb.PersistentClient(
path="./chroma_db",
settings=Settings(anonymized_telemetry=False)
)
collection = client.create_collection(
name="semiconductor_kb",
metadata={"hnsw:space": "cosine"} # 必须显式指定!
)
坑2:Qwen3-Embedding对中文标点敏感。
直接encode“如何控制光刻胶厚度?”和“如何控制光刻胶厚度”效果差异很大。处理方式:统一做标点归一化(全角转半角,去除尾部问号)。代码片段:
import unicodedata
def normalize_query(s: str) -> str:
s = unicodedata.normalize('NFKC', s)
s = s.replace('?', '?').replace('!', '!').strip()
return s
坑3:混合检索权重没调。
我们曾经试过BM25+向量混合,但权重固定为0.5:0.5。引入rerank后,发现混合检索反而干扰。最终决定:只保留向量召回Top-20,然后rerank取Top-5。因为我们的文档是强语义关联,关键词重叠高但语义偏远的场景,BM25有害无益。
6. 效果数据:对比表与结论
测试集200个问题,结果如下:
| 方法 | Recall@5 | MRR@10 | 平均检索耗时(CPU) | 显存峰值 |
|---|---|---|---|---|
| A:固定512切片 + bge-large | 67.3% | 0.51 | 180ms | 5.8GB |
| B:自适应切片 + bge-large | 76.2% | 0.58 | 185ms | 5.8GB |
| C:自适应切片 + Qwen3-Embedding | 84.5% | 0.65 | 220ms(因上下文更长) | 1.7GB |
| D:C + bge-reranker-v2-m3 | 92.1% | 0.74 | 320ms | 2.1GB |
人工评估(3名工程师,对答案相关性打分1-5,取均值):
- 阶段A:3.2分
- 阶段B:3.6分
- 阶段C:3.9分
- 阶段D:4.4分
错误案例分析:仍有8%的失败案例,集中在“参数查询”类型问题——比如“CMP设备下压力上限是多少?”。原因是多个工艺配方文档参数不同,且无上下文说明。当前策略是增加“配方版本号”作为元数据过滤条件,计划下一轮优化中实现。
7. 总结:优先顺序与建议
如果只做一件事,选chunk策略调整——成本最低,收益最明显(+9%)。如果做两件事,加上embedding切换(+8%),因为Qwen3-Embedding明显对长文档更友好。如果有资源做线上推理,再上rerank(+8%),但注意它不能让错误的候选变正确,只是把正确的排前面。
我们的经验:先确保召回候选集里有正确答案(靠chunk和embedding),再谈排序(靠rerank)。另外,所有优化都必须在固定测试集上量化评估,不要靠感觉调参。目前这套配置已上线两个多月,线上用户满意度(5星好评率)从71%升至86%。
代码仓库已开源(脱敏版本):github.com/yourname/rag-optimization-recipes,包含完整评估脚本和配置模板。有问题欢迎评论区交流。