各位好,我是老周。这次分享一个真实的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_relevancyfactual_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微调收益可能不大。建议按顺序尝试:

  1. 先用RecursiveCharacterTextSplitter做结构切分,看效果。
  2. 如果Hit Rate已经>90%,不需要reranker。
  3. 如果专业术语混淆严重,再做embedding微调,别一上来就换模型。

另外,RAG系统的评测一定要做“bad case分析”,而不是只看准确率数字。我们V3到V4虽然准确率只涨了11%,但把“禁忌症”相关的问题从40%错误率降到了8%,这才是业务方真正关心的。有问题欢迎评论区交流。