一、问题背景
我们内部有一个面向运维文档的RAG问答系统,知识库大概12万篇文档,涵盖部署手册、故障排查、API说明。上线三个月,用户反馈集中在两点:一是回答经常“张冠李戴”,问A故障却召回B故障的段落;二是答案不完整,明明文档里有步骤,模型只答了一半。
初期架构很朴素:LangChain 0.0.2xx + FAISS + text-embedding-ada-002,chunk用RecursiveCharacterTextSplitter固定512字符、overlap 50,top_k=4直接塞进prompt。这套东西demo阶段够用,真上量就露馅了。
我先做了一轮badcase归因,抽了200条负反馈:
- 召回阶段就没找到正确段落:78条(39%)
- 召回了但排在top_k之外:31条(15.5%)
- 召回正确但被噪声段落干扰:52条(26%)
- 生成阶段问题(模型忽略上下文):39条(19.5%)
也就是说,超过一半的问题出在检索侧。这决定了优化方向:先动chunk和embedding,再考虑rerank。
二、环境与版本
先把基线环境固定下来,方便复现:
Python 3.10.13
torch 2.1.2 + cu121
transformers 4.36.2
sentence-transformers 2.2.2
langchain 0.1.0
langchain-community 0.0.10
faiss-cpu 1.7.4(离线评估用),线上用faiss-gpu 1.7.4
FlagEmbedding 1.2.5(reranker)
评估集:从真实日志里抽500条query,人工标注golden chunk(文档ID+段落起止),指标用Recall@k、MRR、以及端到端“首答准确率”(人工判定答案是否覆盖关键点)。基线数据:
| 指标 | 基线值 |
|---|---|
| Recall@4 | 0.68 |
| MRR | 0.54 |
| 首答准确率 | 61% |
| P95检索延迟 | 120ms |
三、方案设计
整体分三步走,每步单独评估,避免多变量耦合说不清归因:
- Chunk策略调整:固定长度 → 结构感知+递归混合切分
- Embedding切换:ada-002 → bge-large-zh-v1.5(中文场景)
- 引入Rerank:bi-encoder召回top20 → cross-encoder精排取top4
顺序上先chunk再embedding,因为chunk决定语义单元边界,边界不对,再好的模型也救不回来。rerank放最后,它是“锦上添花”型优化,前提是召回池里得有正确结果。
四、核心实现
4.1 Chunk策略调整
原方案的问题:512字符硬切,把“故障现象-排查步骤-解决方案”这种结构化段落拦腰截断,且overlap 50不足以覆盖跨段指代。
新方案按文档结构走。我们的文档是Markdown,先按标题层级切,再对超长section做递归切分,同时保留标题路径作为metadata注入chunk头部:
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
def structure_aware_split(md_text: str, doc_id: str, max_len: int = 800):
"""
按 Markdown 标题层级切分,超长 section 再递归切。
chunk 头部拼接标题路径,提升 embedding 的语义完整性。
"""
# 按 ## / ### 标题切块,保留标题行
blocks = re.split(r'\n(?=#{2,3}\s)', md_text)
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_len,
chunk_overlap=100,
separators=["\n\n", "\n", "。", ";", " ", ""],
length_function=len,
)
docs = []
current_h1 = ""
for block in blocks:
block = block.strip()
if not block:
continue
# 记录一级标题作为路径前缀
m1 = re.match(r'#\s+(.+)', block)
if m1:
current_h1 = m1.group(1).strip()
continue
m2 = re.match(r'(#{2,3})\s+(.+)', block)
heading = m2.group(2).strip() if m2 else ""
path = f"{current_h1} > {heading}" if heading else current_h1
if len(block) 排查步骤”这样的路径进入embedding,解决孤立段落语义漂移的问题。
### 4.2 Embedding切换
中文场景下ada-002其实不差,但有两个硬伤:一是按token计费,12万文档全量重建索引成本不低;二是中文短query的区分度一般。我们对比了三个候选(都在评估集上跑):
| 模型 | 维度 | Recall@4 | 单条推理(CPU) | 备注 |
|---|---|---|---|---|
| text-embedding-ada-002 | 1536 | 0.71 | API调用 | 基线,chunk优化后 |
| bge-large-zh-v1.5 | 1024 | 0.78 | ~18ms | 中文最优档 |
| bge-m3 | 1024 | 0.79 | ~45ms | 多语言强,但慢 |
最终选bge-large-zh-v1.5,Recall提升明显且能本地部署,批量编码12万chunk在单张A10上约25分钟。注意bge系列检索时要加query instruction,这是官方推荐的用法,不加会掉2-3个点:
```python
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("BAAI/bge-large-zh-v1.5", device="cuda")
# 关键:query 侧加 instruction,doc 侧不加
QUERY_INSTR = "为这个句子生成表示以用于检索相关文章:"
def encode_queries(texts):
return model.encode(
[QUERY_INSTR + t for t in texts],
normalize_embeddings=True,
batch_size=64,
)
def encode_docs(texts):
return model.encode(
texts,
normalize_embeddings=True,
batch_size=128,
)
归一化后直接用内积当余弦相似度,FAISS用IndexFlatIP。12万条1024维向量,内存占用约470MB,单机完全扛得住。
4.3 Rerank引入
bi-encoder召回top20,再上bge-reranker-base做cross-encoder精排,取top4进prompt。reranker输入是(query, chunk)对,逐对打分,20对在A10上约35ms(batch=20)。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def rerank(query: str, candidates: list[dict], top_n: int = 4):
pairs = [[query, c["content"]] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
for c, s in zip(candidates, scores):
c["rerank_score"] = float(s)
return sorted(candidates, key=lambda x: x["rerank_score"],
reverse=True)[:top_n]
阈值上我们没有做硬截断,因为运维问答里“部分相关”的段落有时也有用,硬截断反而丢信息。只看排序,取top4。
五、踩坑与优化
坑1:chunk变长后top_k=4塞爆prompt。 800字符×4加上标题路径,单次上下文约3500 token,加上系统prompt逼近4k。解决:chunk上限压到800的同时,把进prompt的top_k从4降到3,rerank后取3,实测准确率没掉,延迟降了。
坑2:bge的instruction加错位置。 一开始doc侧也加了instruction,Recall反而掉到0.74。查了官方说明才确认instruction只加query侧。这个细节很多教程写错,值得单独提。
坑3:reranker的normalize参数。 compute_score默认不归一化,直接拿原始logits排序没问题,但如果要和阈值比较必须开normalize。我们线上只排序不比阈值,所以开了normalize方便监控分布。
坑4:FAISS索引重建期间的服务降级。 全量重建索引要25分钟,期间用旧索引+新chunk会错位。做法是双索引灰度:新索引写好后原子切换,切换前用10%流量对比两版召回结果,确认无异常再全量。
坑5:长文档的metadata膨胀。 标题路径注入后,部分深层文档path长达80字符,占了chunk十分之一。限制path最多两级,超出截断,embedding质量没有可感知下降。
六、效果数据
三轮迭代的对比(同一评估集500条,人工判定):
| 阶段 | Recall@4/5 | MRR | 首答准确率 | P95检索延迟 |
|---|---|---|---|---|
| 基线 | 0.68 | 0.54 | 61% | 120ms |
| +chunk优化 | 0.71 | 0.58 | 65% | 118ms |
| +embedding切换 | 0.78 | 0.66 | 73% | 135ms |
| +rerank(top20→3) | 0.89 | 0.81 | 84% | 315ms |
几点观察:
- chunk优化的增益比预期小(+4pt),但它是后面两步的地基,单独看低估了它的价值。
- embedding切换贡献最大的一步(+8pt),中文场景换中文模型收益确实直接。
- rerank把Recall@3从0.78拉到0.89,代价是P95延迟从135ms到315ms。考虑到LLM生成本身要1-2秒,这180ms用户无感,性价比极高。
- 最终首答准确率84%,剩余16%的badcase里,生成阶段问题占了大头(模型漏答、格式错乱),检索侧基本到位了。
线上灰度两周,用户“答非所问”类反馈下降约70%,平均会话轮次从2.8降到1.9,说明一次答对的概率确实上来了。
七、总结
这次优化的核心体会:RAG的瓶颈大概率在检索侧,而检索侧的顺序应该是chunk → embedding → rerank,不要一上来就堆rerank。 chunk边界错了,reranker也救不回来;embedding不匹配语种,召回池里根本没正确结果,精排无从谈起。
另外两个工程细节值得强调:一是评估集必须提前建好,500条人工标注query是这次所有决策的依据,没有它就是在盲调;二是每步单独评估,多变量一起上,效果涨了也不知道是谁的功劳,跌了更抓瞎。
后续计划:把生成侧的prompt做结构化约束(要求按“现象-原因-步骤”输出),再试试query改写(HyDE)看能不能把剩下那16%再啃下来。有进展再写一篇。