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,包含完整评估脚本和配置模板。有问题欢迎评论区交流。