一、问题背景:用户说“你根本没听懂我在问什么”
我们的项目是一个医疗问诊辅助系统,知识库里有3000多篇药品说明书和临床指南,总文本量约800MB。上线初期,用户满意度只有68%。翻看日志,大量反馈集中在“答非所问”——比如用户问“阿莫西林和头孢能一起吃吗”,系统答非所问地推荐了阿莫西林胶囊的用法用量。
最开始我怀疑是LLM生成的问题,但把召回的上下文打印出来一看,发现召回片段里压根没有“相互作用”相关的句子,全是药品成分和适应症的堆砌。问题出在召回,不是生成。
根因分析:
1. chunk太大(512字符):一个chunk里混入了多段语义,向量化后语义被“平均化”,相似度区分度下降。
2. Embedding模型不匹配:BGE-large-zh-v1.5在通用语料上表现不错,但医疗术语(如“药物相互作用”“药代动力学”)的向量空间区分度不够。
3. 无重排机制:Top5结果直接拼进Prompt,没有做精细的相关性排序,导致不相关片段混入。
二、环境与版本
- Python 3.10.12
- langchain 0.2.11
- langchain-openai 0.1.14
- chromadb 0.5.3(本地向量库)
- sentence-transformers 2.7.0
- 文本切分:langchain_text_splitters 0.2.2
- 向量模型:BGE-large-zh-v1.5 → text2vec-large-chinese(切换)
- 重排模型:bge-reranker-base(FlagEmbedding 1.2.10)
- 测试集:人工标注的200条真实问诊问题,每条有唯一正确答案的文档片段ID
三、方案设计:三步走,每步单独验证
我不喜欢一口气改所有东西,那样出了问题没法定位。所以分三步:
- 第一步:调整chunk策略。固定BGE-large-zh-v1.5,对比512字符 vs 256字符 vs 128字符。
- 第二步:切换Embedding模型。在最优chunk策略下,对比BGE-large-zh-v1.5 vs text2vec-large-chinese。
- 第三步:引入reranker。在最优chunk+最优Embedding基础上,加bge-reranker-base重排Top20。
评估指标:
- Recall@5:正确答案是否在Top5内
- Precision@1:第一名是否是正确答案
- 检索耗时:单次查询的p95耗时
四、核心实现:代码与参数
4.1 chunk策略调整
最初用的LangChain的RecursiveCharacterTextSplitter,参数如下:
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 初始配置:512字符,重叠64
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=64,
separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", ",", ","]
)
# 调优后配置:256字符,重叠16
splitter_256 = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=16, # 重叠率从12.5%降到6.25%
separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", ",", ","]
)
关键调整点:
- chunk_size从512降到256。128也试了,但Recall@5反而下降(后面踩坑部分细说)。
- 重叠率从64降到16。试过128重叠,发现召回结果大量重复,Top5里有3个是同一段内容的变体,浪费了名额。
4.2 Embedding模型切换
BGE-large-zh-v1.5的向量维度是1024,text2vec-large-chinese是1024,所以向量库不用重建索引结构,只需重新embedding所有chunk。
from sentence_transformers import SentenceTransformer
import chromadb
from tqdm import tqdm
# 加载text2vec模型
model = SentenceTransformer("GanymedeNil/text2vec-large-chinese")
# 重新embedding并写入chroma
client = chromadb.PersistentClient(path="./db_medical_v2")
collection = client.get_or_create_collection(
name="medical_docs",
metadata={"hnsw:space": "cosine"}
)
# 假设chunks是上面splitter切分后的文本列表
for i, text in enumerate(tqdm(chunks, desc="Embedding")):
vector = model.encode(text, normalize_embeddings=True).tolist()
collection.add(
ids=[f"chunk_{i}"],
embeddings=[vector],
documents=[text],
metadatas=[{"source": f"doc_{i // 10}"}]
)
注意点: normalize_embeddings=True必须开,否则cosine相似度计算会有偏差。另外,text2vec-large-chinese在GPU上编码速度大约是每千条8秒(batch_size=32, RTX 3090),3000个文档切出的1.2万个chunk,全量编码大概2分钟。
4.3 引入Reranker
重排我用的是FlagEmbedding库的bge-reranker-base。先召回Top20,再重排取Top5。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def retrieve_with_rerank(query, collection, top_k=20, rerank_top_k=5):
# 1. 向量检索Top20
results = collection.query(
query_embeddings=[model.encode(query, normalize_embeddings=True).tolist()],
n_results=top_k,
include=["documents", "metadatas", "distances"]
)
# 2. 构造pair进行rerank
pairs = [[query, doc] for doc in results["documents"][0]]
scores = reranker.compute_score(pairs)
# 3. 按重排分数取Top5
sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:rerank_top_k]
final_docs = [results["documents"][0][i] for i in sorted_idx]
return final_docs
耗时注意: bge-reranker-base在CPU上跑20个pair大概耗时50ms,GPU上约10ms。如果并发高,建议单独部署一个rerank服务,我用的是FastAPI包了一层。
五、踩坑与优化
5.1 坑1:chunk_size=128反而更差
我一开始以为chunk越小越好,试了128。结果Recall@5从64.3%降到58.7%。分析发现:
- 128字符的chunk太碎,一个完整的“药物相互作用”段落被切成两三块,每块都缺上下文。
- 向量化时,短文本的语义表达能力弱,容易产生语义偏移。
结论: chunk_size=256是当前语料的最优值,既保留了完整语义单元,又不会混入太多无关内容。
5.2 坑2:重叠率设太高导致召回重复
我试过chunk_overlap=128(重叠率25%),结果Top5里经常出现3个重复片段——因为同一个长句被两个chunk各包含一半,向量相似度都很高,挤占了其他有效结果的位置。
优化: 重叠率降到6%左右(16字符),只保留跨句边界的关键连接词。
5.3 坑3:text2vec在混合语料上需要归一化
text2vec-large-chinese在纯中文语料上效果很好,但我们的文档里有大量英文药物名(如Acetaminophen)。直接编码时,中英文混合文本的向量分布不均匀。后来在编码前加了一个简单的预处理:text.replace("\n", " ").strip(),并确保所有文本统一用小写。这步提升了大约2个百分点的Recall@5。
六、效果数据:三步优化后的对比
| 配置 | Recall@5 | Precision@1 | P95检索耗时 |
|---|---|---|---|
| 初始(512chunk + BGE + 无rerank) | 64.3% | 38.7% | 120ms |
| + chunk=256 | 71.5% | 44.2% | 115ms |
| + text2vec-large-chinese | 83.4% | 55.8% | 118ms |
| + bge-reranker-base | 91.2% | 67.4% | 165ms(含rerank) |
额外观察:
- 召回率提升后,LLM生成阶段的效果也有明显改善。用户满意度从68%升到82%(回访200名用户)。
- 检索耗时从120ms升到165ms,但还在可接受范围内。如果未来量大,可以上GPU推理rerank,预计能压回130ms以内。
最终方案上线配置:
- chunk_size=256, chunk_overlap=16
- embedding模型:text2vec-large-chinese(1024维,cosine距离)
- 召回Top20,rerank取Top5
- rerank模型:bge-reranker-base(FP16)
七、总结与建议
这次优化花了三个工作日,收益是实打实的:召回率提升27个百分点,首答准确率翻倍。几点经验供参考:
- 先别急着换模型,先调chunk。 chunk粒度对召回的影响往往被低估,而且调chunk成本最低——只需要重新切分和embedding,不用换模型。
- Embedding模型选择要结合语料领域。 通用模型在垂直场景下大概率不是最优解。如果预算允许,可以用领域语料做微调,但这次我们只是换模型就提升了12个百分点。
- Rerank是性价比最高的“短平快”优化。 只要你的向量检索能召回足够多的候选(比如Top20),加一个reranker就能显著提升Top5质量。bge-reranker-base只有约400MB,部署成本低。
- 评估集要人工标注。 别用正则或者LLM自动生成标准答案,你无法保证自动生成的就是“正确”的召回目标。
下一步计划:将chunk策略改为按语义段落切分(比如用LLM识别边界),以及尝试用ColBERT做后期交互的召回。等有结果了再写一篇。