各位好,我是老周。这次分享一个真实的RAG优化项目,不是demo,是上线前被业务方打回三次的那种。
一、问题背景:为什么Naive RAG在医疗领域翻车
我们做的是一个医疗知识库问答系统,知识源是3000多篇医学指南、药品说明书和临床路径文档。初版RAG流程很简单:PDF解析 → 固定长度chunk(512字符) → bge-large-zh-embedding → FAISS检索Top10 → 拼接后直接丢给Qwen-14B。
上线前用200条真实医生提问做评测,结果很惨:答案准确率61%,具体错误类型拆解:
- 上下文断裂(34%):一个完整的“用药禁忌”段落被切成两半,检索回来的只有“禁忌”,丢失了前面的“适用人群”。
- 实体混淆(27%):医生问“阿莫西林过敏”,检索结果包含大量“青霉素过敏”内容,因为两者在embedding空间距离过近。
- 位置偏差(22%):正确答案在文档第3页,但chunk顺序打乱后,检索回来的片段没有完整逻辑链。
核心结论:固定长度切分不适合长文档,通用embedding对低频专业实体敏感度不够,单路召回无法解决“相似≠相关”的伪命中问题。
二、环境与版本:这套方案跑在什么上
- Python 3.10.12
- langchain 0.1.16(注意,不是langchain-community那套新API)
- sentence-transformers 2.5.1
- faiss-cpu 1.8.0(检索量不大,CPU够用)
- FlagEmbedding 1.2.8(用于reranker)
- Qwen-14B-Chat(部署在vLLM 0.4.2,max_tokens=512)
- 评测工具:自建RAGAS框架(基于prompt的LLM-as-Judge)
Embedding模型对比清单:
| 模型 | 维度 | 平均检索耗时(200条) |
|------|------|---------------------|
| bge-large-zh-v1.5 | 1024 | 1.8s |
| text2vec-large-chinese | 1024 | 1.9s |
| m3e-large | 768 | 1.5s |
三、方案设计:三步走,不是一步到位
优化分三个阶段执行,每阶段都有独立评测,避免“所有变量一起改,出了问题不知道怪谁”。
阶段一:chunk策略从“固定长度”改为“结构感知递归切分”
利用文档的标题、列表、表格结构作为分割点。具体用langchain的RecursiveCharacterTextSplitter,但自定义了separators列表,并且增加了is_separator_regex=True来匹配医疗文档中的“【】”“第X节”等模式。
阶段二:embedding从bge切换为text2vec-large-chinese(领域微调版)
这里不是直接换模型,而是在自有医疗语料上做了1000步的对比学习微调(contrastive learning)。微调数据用我们已有的QA对,正样本是“问题-标准答案段落”,负样本是“问题-相似但无关段落”。
阶段三:引入bge-reranker-base做两阶段召回
第一阶段FAISS召回Top30(放宽阈值),第二阶段reranker精排,取Top5。核心思路是:embedding负责“找得到”,reranker负责“找得准”。
四、核心实现:关键代码与参数细节
4.1 结构感知的chunk切分
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 核心:根据医疗文档结构定义分隔符优先级
separators = [
r"\n第[一二三四五六七八九十]+节\s", # 章节标题
r"\n【[^】]+】", # 药品说明书中的【适应症】【禁忌】等
r"\n\d+\.\s", # 有序列表
r"\n\n", # 空行分隔
r"。", # 句号
r";", # 分号
r",", # 逗号
]
text_splitter = RecursiveCharacterTextSplitter(
separators=separators,
is_separator_regex=True, # 关键!启用正则匹配
chunk_size=512,
chunk_overlap=80, # 重叠区保证边界上下文不丢失
length_function=len,
)
# 使用方式:每个文档单独切分,并保留metadata
docs = []
for doc_id, content in raw_docs.items():
chunks = text_splitter.split_text(content)
for i, chunk in enumerate(chunks):
docs.append({
"text": chunk,
"metadata": {"doc_id": doc_id, "chunk_index": i}
})
踩坑记录:
- is_separator_regex=True时必须保证separators内的字符串都是合法的正则,否则直接报错。
- chunk_overlap不能设太大,80字符在医疗文本中可能把两个不相关的段落拼接,我试过120,效果反而下降。
- 切分后需要做空文本过滤,有些标题行切出来只有“【适应症】”三个字,没有内容,需要丢弃。
4.2 embedding微调与切换
from sentence_transformers import SentenceTransformer, losses, InputExample
from torch.utils.data import DataLoader
# 1. 加载基础模型
model = SentenceTransformer("text2vec-large-chinese")
# 2. 构造对比学习训练数据(2000对)
train_examples = []
for q, pos, neg in train_triplets:
train_examples.append(InputExample(texts=[q, pos, neg]))
# 3. 使用TripletLoss进行微调
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
train_loss = losses.TripletLoss(model=model)
model.fit(
train_objectives=[(train_dataloader, train_loss)],
epochs=3,
warmup_steps=100,
output_path="./models/text2vec-medical",
save_best_model=True,
)
# 4. 推理阶段
from langchain.embeddings import HuggingFaceEmbeddings
embedding_model = HuggingFaceEmbeddings(
model_name="./models/text2vec-medical",
model_kwargs={"device": "cuda:0"},
encode_kwargs={"normalize_embeddings": True}, # 必须归一化,否则FAISS内积失效
)
关键参数:温度系数temperature=0.05,triplet margin设为0.3,这两个值调参后效果差异明显。
4.3 Reranker引入
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def rerank(query, candidates, top_k=5):
"""candidates: list[str], 返回重排后的索引"""
pairs = [(query, doc) for doc in candidates]
scores = reranker.compute_score(pairs, normalize=True) # 返回0-1分数
sorted_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
return sorted_indices[:top_k]
# 在检索链路中:
initial_top_30 = vector_store.similarity_search(query, k=30)
reranked_indices = rerank(query, [doc.page_content for doc in initial_top_30], top_k=5)
final_docs = [initial_top_30[i] for i in reranked_indices]
注意:bge-reranker-base的输入长度限制是512个token,所以candidates需要先截断到400字左右,否则会报错或静默截断导致分数不准。
五、踩坑与优化:不是每一步都顺利
坑1:FAISS索引重建导致embedding维度不匹配
切换embedding模型后,忘了删除旧的index文件,FAISS加载时直接dimension mismatch异常。解决:重建索引时强制检查d与模型维度一致,不一致就删除重建。
坑2:reranker的batch推理优化
200条评测集,单条query要rerank30个候选,总共6000次推理。用reranker.compute_score批量传list,比循环调用快5倍。
坑3:chunk重叠区带来的重复信息
结构感知切分后,重叠区可能让同一内容出现在两个chunk中,直接导致LLM回答时两次引用同一段话,输出冗余。解决:在prompt中明确“如果多个资料内容重复,只引用一次”。
坑4:微调embedding时负样本太难
如果用随机负样本,模型学不到区分度。后来改成hard negative mining:先用当前模型检索,把“相似但无关”的结果作为负样本,效果提升很明显。
六、效果数据:每一步涨了多少
评测集:200条医疗QA,答案由Qwen-14B生成,用RAGAS的answer_relevancy和factual_correctness打分,阈值0.7算通过。
| 版本 | Hit Rate@5 | 答案准确率 | 平均检索耗时 |
|---|---|---|---|
| V1:固定切分 + bge-large-zh | 78.3% | 61.0% | 1.8s |
| V2:结构感知切分 + bge-large-zh | 84.6% | 69.5% | 1.9s |
| V3:结构感知切分 + text2vec-微调 | 88.2% | 78.0% | 2.0s |
| V4:V3 + bge-reranker-base | 92.1% | 89.0% | 3.4s |
关键观察:
- chunk策略单独带来+8.5%准确率,这是最值得做的优化,成本最低。
- embedding微调带来+8.5%,但需要标注数据,成本高。
- reranker带来+11%,但耗时增加70%,需要权衡。
七、总结:这套方案能复用吗
说点大实话。这套方案在医疗领域有效,但核心不是某个模型,而是“结构感知切分 + 领域微调 + 重排”的组合拳。如果你做的是代码文档问答,chunk策略可能完全不同;如果你做的是新闻问答,embedding微调收益可能不大。建议按顺序尝试:
- 先用
RecursiveCharacterTextSplitter做结构切分,看效果。 - 如果Hit Rate已经>90%,不需要reranker。
- 如果专业术语混淆严重,再做embedding微调,别一上来就换模型。
另外,RAG系统的评测一定要做“bad case分析”,而不是只看准确率数字。我们V3到V4虽然准确率只涨了11%,但把“禁忌症”相关的问题从40%错误率降到了8%,这才是业务方真正关心的。有问题欢迎评论区交流。