一、问题背景:一个“看起来能用”的RAG系统
去年底我接手了一个内部技术文档问答系统。文档来源包括Confluence导出的Markdown、PDF规范书、以及部分API文档,总量约1.2万篇,平均每篇800字。技术栈是典型的RAG:LangChain 0.0.340 + Chroma 0.4.18 + OpenAI text-embedding-ada-002 + GPT-3.5-turbo。
上线第一周,产品经理拉了个表:随机抽100个真实提问,人工判定“答案是否可用”。结果可用率只有47%。更细的指标是:如果正确段落出现在Top-5召回结果里,GPT-3.5基本能答对;但正确段落的Top-5召回率只有42%。也就是说,一半以上的问题,检索阶段就错了。
我拉了几十条bad case,问题集中在三类:
1. 固定512 token切分把一段完整逻辑切碎,比如“配置步骤”被切成两半,检索到后半段却不知道在讲什么。
2. 中文语义匹配差,ada-002对“如何重置密码”和“密码重置流程”这类同义表达区分度不够。
3. 多个相似段落同时召回,LLM被干扰,比如“测试环境配置”和“生产环境配置”同时进上下文,模型混着答。
于是有了下面三阶段优化。
二、环境与版本
- Python 3.10.13
- LangChain 0.0.340
- Chroma 0.4.18(后期换为Milvus 2.3.4做对比)
- sentence-transformers 2.2.2
- FlagEmbedding 1.2.10(用于bge-reranker)
- OpenAI API:text-embedding-ada-002 / gpt-3.5-turbo
- 本地GPU:NVIDIA A10 24GB(用于bge-large-zh和reranker推理)
- 评估集:100条人工标注问答对,每条标注正确chunk的doc_id
三、方案设计:三阶段递进
整体思路是:先解决“切得对不对”,再解决“向量像不像”,最后解决“排序准不准”。
阶段一:chunk策略调整
- 原策略:RecursiveCharacterTextSplitter,chunk_size=512,overlap=50,按token近似。
- 新策略:先按Markdown标题层级(H1/H2/H3)做语义分段,再对超过800 token的段落按句子边界二次切分,chunk_size=800,overlap=120。对PDF类无标题文档,用正则识别“第X章”“1.1”等编号做分段。
- 关键点:overlap从50提到120,因为中文技术文档中,一个完整操作步骤经常跨段,重叠能保住上下文。
阶段二:embedding模型切换
- 从text-embedding-ada-002(1536维)切换到BAAI/bge-large-zh-v1.5(1024维)。
- 原因:ada-002在中文短查询上表现一般,且API调用有网络延迟和成本。bge-large-zh-v1.5在C-MTEB中文检索榜上长期靠前,本地推理可控。
- 配置:normalize_embeddings=True,查询侧加指令前缀"为这个句子生成表示以用于检索相关文章:"。
阶段三:引入rerank
- 检索阶段先用bge-large-zh召回Top-20,再用bge-reranker-large做cross-encoder重排,取Top-5送入LLM。
- reranker输入是(query, passage)对,输出相关性分数。相比向量内积,cross-encoder能捕捉细粒度交互。
- 代价:A10上单条query重排20个passage约耗时180ms,可接受。
四、核心实现
4.1 语义分段 + 重叠切分
```python
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
def semantic_chunk(text: str, max_tokens: int = 800, overlap: int = 120):
# 按Markdown标题切分
sections = re.split(r'(?=^#{1,3}\s)', text, flags=re.MULTILINE)
chunks = []
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_tokens,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", ";", ",", " ", ""],
length_function=lambda x: len(x) # 中文按字符近似token
)
for sec in sections:
if len(sec.strip()) 0.95的chunk做去重。
坑2:bge查询指令前缀不能省。
bge-large-zh-v1.5官方要求查询侧加指令,文档侧不加。我一开始两边都加,召回率反而降了6个点。改成只在encode_queries加后正常。
坑3:reranker的batch size。
A10 24GB上,compute_score默认batch_size=256会OOM。改成batch_size=32,耗时从150ms升到180ms,但稳定。
坑4:Milvus的nprobe调优。
nprobe从8提到16,召回率提升约3%,延迟增加12ms。最终定16。
六、效果数据
在100条评估集上,分阶段对比:
| 阶段 | Top-5召回率 | 答案可用率 | 平均检索延迟 |
|---|---|---|---|
| 基线(512/50 + ada-002) | 42% | 47% | 320ms |
| +语义分段(800/120) | 58% | 61% | 340ms |
| +bge-large-zh-v1.5 | 74% | 72% | 390ms |
| +bge-reranker-large | 89% | 86% | 570ms |
端到端响应时间(含GPT-3.5生成)从1.8s增至2.3s,主要增量在reranker的180ms和生成阶段上下文变长。但答案可用率从47%到86%,这个 trade-off 完全值得。
另外,embedding从API切到本地后,每月API成本从约$220降到$0(电费忽略),只保留GPT-3.5的生成成本。
七、总结
这次优化没有用花哨的HyDE、多路召回或GraphRAG,就是把chunk、embedding、rerank三个基础环节做扎实。几点体会:
- chunk策略对中文技术文档影响巨大,语义分段+适当overlap是性价比最高的一步。
- 中文场景下,bge-large-zh-v1.5比ada-002更合适,且本地部署省成本、可控。
- reranker是召回率从74%到89%的关键,但要注意延迟和显存。
- 评估集必须人工标注,不要只看loss或相似度分数。
下一步打算试试bge-m3做多向量检索,以及用LLM做query改写。如果效果明显,再写一篇记录。