**
一、问题背景:为什么我的RAG系统像个“人工智障”
三个月前,我接手了一个内部知识库问答项目。数据源是约1.2万篇技术文档(Markdown、PDF、Confluence导出混杂),用户提问后系统返回答案。初版架构很朴素:LangChain + OpenAI text-embedding-ada-002 + FAISS + GPT-3.5-turbo。
上线第一周,业务方反馈:“答非所问,经常把不相关的段落拼在一起。”我跑了一个2000条真实问题的评测集,指标惨不忍睹:
- 检索Top-5召回准确率:62%(人工判断是否包含正确答案)
- 端到端回答可用率:58%(人工评分≥3/5)
- 平均响应延迟:2.3s(含LLM生成)
问题集中在三处:chunk切得太粗导致语义稀释、embedding对中文技术术语不敏感、检索结果未做精排。接下来我按顺序做了三组实验。
二、环境与版本
先交代基线环境,避免“版本不一致导致效果不可复现”的坑:
# requirements.txt 关键部分
python==3.10.12
langchain==0.1.16
langchain-community==0.0.34
faiss-cpu==1.8.0
openai==1.30.1
sentence-transformers==2.7.0
FlagEmbedding==1.2.10
torch==2.2.2+cu121
transformers==4.40.0
硬件:单卡 RTX 4090(24GB),CPU 32核,内存128GB。LLM固定用GPT-3.5-turbo-0125(temperature=0),这样对比时只变检索侧,生成侧不变。
三、方案设计:三阶段递进优化
我采用控制变量法,每轮只改一个因素,记录指标:
| 阶段 | Chunk策略 | Embedding模型 | Rerank | Top-5准确率 |
|---|---|---|---|---|
| Baseline | 1024字符,无重叠 | text-embedding-ada-002 | 无 | 62% |
| V1 | 256字符,重叠64 | text-embedding-ada-002 | 无 | 71% |
| V2 | 256字符,重叠64 | bge-large-zh-v1.5 | 无 | 82% |
| V3 | 256字符,重叠64 | bge-large-zh-v1.5 | bge-reranker-large | 89% |
端到端回答可用率对应为:58% → 66% → 78% → 87%。延迟从2.3s增至2.9s(rerank增加约300ms),可接受。
四、核心实现
4.1 Chunk策略调整(含代码)
原方案用RecursiveCharacterTextSplitter,chunk_size=1024,overlap=0。问题:技术文档中一个段落常包含多个概念,1024字符把“安装步骤”和“配置参数”混在一起,embedding向量被平均化。
改为chunk_size=256,overlap=64,并针对Markdown按标题层级切分:
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
def split_docs(docs):
# 先按Markdown标题粗切
headers_to_split_on = [
("#", "H1"), ("##", "H2"), ("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_splits = md_splitter.split_text(docs)
# 再按字符细切,保证语义单元完整
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=64,
separators=["\n\n", "\n", "。", ";", ",", " ", ""],
length_function=len,
)
return text_splitter.split_documents(md_splits)
实测:chunk_size从1024降到256后,单块平均token数从380降到95,检索时噪声段落减少。但太小会丢上下文,256+64是本次数据集的甜点。
4.2 Embedding模型切换(含代码)
text-embedding-ada-002对中文技术术语(如“K8s Ingress Controller”、“OAuth2.0授权码模式”)的向量区分度不足。换用BAAI/bge-large-zh-v1.5(中文榜单MTEB领先,维度1024,最大长度512)。
关键点:BGE模型检索时,query需要加指令前缀,passage不加。这是官方推荐做法,能提升1-3个百分点。
from FlagEmbedding import FlagModel
model = FlagModel(
'BAAI/bge-large-zh-v1.5',
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True # 4090上开启fp16,推理速度提升约40%
)
def embed_query(text):
return model.encode_queries(text)
def embed_passages(texts):
return model.encode(texts, batch_size=64, max_length=512)
注意:不要用sentence-transformers直接加载BGE而不加instruction,否则效果会掉。我实测不加instruction时Top-5准确率只有79%,加了之后82%。
4.3 Rerank引入与对比
检索阶段用FAISS做ANN,取Top-20候选,再用bge-reranker-large交叉编码器精排,取Top-5送给LLM。Reranker直接对(query, passage)打分,比向量内积更准,但计算量大,所以只对少量候选做。
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)
def rerank(query, candidates, top_k=5):
pairs = [[query, c.page_content] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [c for c, s in ranked[:top_k]]
对比数据(同一2000条评测集):
| 配置 | Top-5准确率 | MRR@10 | 延迟(检索+rerank) |
|---|---|---|---|
| 无rerank,向量Top-5 | 82% | 0.71 | 45ms |
| 向量Top-20 + rerank Top-5 | 89% | 0.84 | 340ms |
Rerank把“向量相似但语义无关”的段落过滤掉了。典型case:query“如何重置管理员密码”,向量检索会把“管理员权限说明”排前面,reranker则把“重置密码步骤”提到第一。
五、踩坑与优化
坑1:FAISS索引未归一化。 BGE输出向量需L2归一化后再用内积,否则余弦相似度计算错误。我一开始忘了,召回率掉8%。修正:faiss.normalize_L2(embeddings)。
坑2:Reranker batch_size过大导致OOM。 4090 24GB上,bge-reranker-large batch_size=32时显存飙到22GB,容易崩。改成16,稳定在14GB。
坑3:Chunk重叠导致重复检索。 overlap=64时,相邻块有重复内容,Top-5里可能出现同一段落的两个切片。加了一个简单的去重:按内容前50字符哈希去重。
坑4:PDF解析乱码。 部分PDF用PyPDF2提取后中文乱码,换成pdfplumber并设置laparams,问题解决。
六、效果数据与总结
最终线上灰度两周,日均请求1.2万次:
- Top-5检索准确率:89%(+27pt)
- 端到端回答可用率:87%(+29pt)
- P99延迟:3.1s(+0.8s,用户可接受)
- 人工badcase率:从12%降到4%
回看整个优化,性价比最高的是embedding模型切换(+11pt,成本几乎为零),其次是rerank(+7pt,延迟+300ms),chunk调整(+9pt,但需重新索引)。如果资源有限,优先换中文embedding,再加rerank。
RAG没有银弹,每个数据集的最优chunk_size和模型都不同。建议搭建一个200-500条的评测集,每改一个变量就跑一次,用数据说话。下一步我准备试试bge-m3多向量检索和query改写,有结果再写一篇。