1. 问题背景:业务方说“答非所问”
三个月前接手了一个金融研报问答系统,用户问“2024年Q3宁德时代海外营收占比”,系统返回的是“公司主营业务介绍”这类泛泛内容。当时用LangChain 0.1.0 + ChromaDB + text2vec-large-chinese,固定512字符切chunk,无重叠。问题定位很明确:检索阶段召回的内容和用户意图不匹配,生成阶段再强也白搭。
这里先给个基线数据:人工标注的200条测试集上,Top-5文档命中准确率61.3%,其中有27%的case是因为chunk把关键数字和上下文截断了,还有12%是embedding模型对金融术语的语义区分度不够。
2. 环境与版本:锁定依赖,避免玄学
先交代环境,避免版本差异导致复现失败。核心依赖如下:
langchain==0.1.11
langchain-community==0.0.24
chromadb==0.4.24
sentence-transformers==2.5.1
FlagEmbedding==1.2.8
torch==2.2.1+cu118
硬件是单张A10(24GB显存),embedding模型推理batch_size设32,检索库约2.4万条chunk。注意不要用最新版LangChain,0.2.x之后API变动较大,0.1.x的Chroma.from_documents和RetrievalQA链路在0.2里要改不少。
3. 方案设计:三步走的优化路径
整体拆成三个阶段,每阶段有独立的评估指标:
| 阶段 | 改动点 | 评估指标 |
|---|---|---|
| 1 | chunk策略:固定512 → 滑动窗口256+128重叠 | 上下文完整率、召回率 |
| 2 | embedding:text2vec → bge-large-zh-v1.5 | Top-5命中率、检索延迟 |
| 3 | 引入rerank:bge-reranker-base | 重排后Top-3准确率、最终答案BLEU |
第一阶段的动机很直接:金融研报里“2024年Q3营收为123.4亿元,同比增长15.2%,环比下降3.1%”这种连续数字,固定512字符切分很容易把“同比增长15.2%”切到下一个chunk,导致检索时上下文断裂。滑动窗口让相邻chunk保持128字符重叠,至少保证关键指标不跨块丢失。
4. 核心实现:三段代码与关键参数
4.1 chunk重构:用递归字符切分器
之前的实现是CharacterTextSplitter(chunk_size=512, chunk_overlap=0),问题在于纯按字符数硬切,不管句子边界和语义完整性。改成LangChain的RecursiveCharacterTextSplitter,按优先级从["\n\n", "\n", "。", "!", "?"]切分,保证每个chunk尽量以完整句子结尾。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=256, # 调小,避免长chunk稀释语义
chunk_overlap=128, # 50%重叠,保留上下文边界
separators=["\n\n", "\n", "。", "!", "?", ";"],
keep_separator=True, # 保留下一个chunk的开头是完整句子
length_function=len,
)
# 用tiktoken估算更准,但中文场景len够用
# docs = splitter.split_documents(raw_documents)
这里有个细节:chunk_size从512降到256,一开始担心信息量不够,实际效果反而更好。因为bge-large-zh-v1.5的最大序列长度是512 token,256字符(约等于170-200 token)能让向量表示更聚焦主题,不会被长文本里的大段废话稀释。重叠128字符保证了跨chunk的上下文连续性。
4.2 embedding模型切换:从text2vec到bge
text2vec-large-chinese(1024维)在金融术语上的区分度不够,比如“股票回购”和“股份回购”的余弦相似度高达0.93,导致检索结果冗余。换成bge-large-zh-v1.5(1024维)后,相似度降到0.84,但这0.09的差值在向量检索里就是“相关”和“不相关”的界限。
from sentence_transformers import SentenceTransformer
from langchain.embeddings import HuggingFaceBgeEmbeddings
model_name = "BAAI/bge-large-zh-v1.5"
encode_kwargs = {'normalize_embeddings': True} # 必须归一化,否则余弦相似度不稳定
embedding_model = HuggingFaceBgeEmbeddings(
model_name=model_name,
model_kwargs={'device': 'cuda'},
encode_kwargs=encode_kwargs,
query_instruction="为这个句子生成表示以用于检索相关文章:"
# bge对检索任务,query侧需要加指令前缀,文档侧不加
)
# 重新构建向量库
# vectordb = Chroma.from_documents(docs, embedding=embedding_model, persist_directory="./chroma_bge")
4.3 rerank引入:二阶段精排
向量检索本质是“粗筛”,Top-20里可能只有5条是真正相关的。加入bge-reranker-base做精排,它的交叉编码器结构能同时看到query和文档的完整token,对语义匹配的判断远超双塔结构的向量相似度。
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True)
def rerank_documents(query, candidate_docs, top_k=3):
"""对向量检索Top-20结果做重排"""
pairs = [[query, doc.page_content] for doc in candidate_docs]
scores = reranker.compute_score(pairs, normalize=True) # 归一化分数
# 按分数降序,取top_k
sorted_pairs = sorted(zip(scores, candidate_docs), key=lambda x: x[0], reverse=True)
return [doc for score, doc in sorted_pairs[:top_k]]
5. 踩坑与优化:细节决定成败
5.1 ChromaDB检索参数n_results的选择
一开始直接similarity_search(query, k=3),效果很差。后来发现向量检索的召回率太低时,硬取Top-3等于赌博。改成分两阶段:先用k=20粗召回,再用rerank从20里挑Top-3。检索延迟从80ms涨到120ms(向量检索)+ 40ms(rerank),总共160ms左右,可接受。但注意不要用k=50,ChromaDB在十万级向量库上会明显变慢,且rerank对50条pair的推理也要额外100ms。
5.2 bge指令前缀的必要性
bge-large-zh-v1.5在训练时对query和document用了不同的表示方式,query侧必须加“为这个句子生成表示以用于检索相关文章:”前缀,否则检索效果甚至不如text2vec。实测不加前缀,Top-5命中率反而比text2vec低4个百分点,加了前缀后高8个百分点。
5.3 rerank阈值不要一刀切
金融场景里有些query是用户口误或表述模糊,比如“宁德时代市占率”其实是“宁德时代市场份额”。rerank分数在0.3-0.5之间时,答案质量不可靠。我设了动态阈值:最高分低于0.35时,直接返回“无法确认相关信息”,避免生成阶段强行编造。这个设计减少了35%的幻觉输出。
5.4 另一个容易忽略的点:chunk时间戳关联
金融研报有很强的时效性,比如“2024年Q3”和“2023年Q3”的营收数据不能混淆。我在chunk的metadata里加了publish_date字段,检索时按时间降序优先返回最新内容。实现上就是给Chroma传filter参数:
vectordb.similarity_search(query, k=20, filter={"publish_date": {"$gte": "2024-01-01"}})
6. 效果数据:三阶段全部量化对比
最终在200条人工标注测试集上,各阶段效果如下:
| 指标 | 基线(固定512+text2vec) | +滑动窗口chunk | +bge-large-zh-v1.5 | +bge-reranker-base |
|---|---|---|---|---|
| Top-5召回率 | 61.3% | 68.9% | 82.4% | 89.7% |
| Top-3准确率 | 44.2% | 51.8% | 67.3% | 81.5% |
| 检索延迟(ms) | 65 | 72 | 85 | 160 |
| 最终答案BLEU-4 | 0.21 | 0.26 | 0.33 | 0.41 |
关键结论:
- chunk重叠只带来7.6%的提升,但embedding切换带来13.5%——embedding模型对语义理解的重要性远大于chunk策略。
- rerank让Top-3准确率提升了14.2%,但延迟翻倍——如果业务对实时性要求高,可以用batch_size=16减少rerank推理时间(实测40ms→28ms)。
- BLEU-4从0.21到0.41,说明检索质量直接决定了生成质量,生成模型本身没做任何改动。
7. 总结与可复用经验
这套优化链路的核心逻辑是:先保证“相关上下文不被切碎”,再用更懂业务的embedding缩小语义距离,最后用交叉编码器做精排。每一步都是独立的,可以按需取舍。
如果你只有精力做一件事,我建议先换embedding模型。bge-large-zh-v1.5在中文场景的普适性确实比text2vec强不少,且HuggingFace上有大量针对金融、法律等垂直领域的微调版本,替换成本极低——只需要重新build一次向量库。
但需要注意的是,embedding模型切换后必须重新构建向量库,不能混用。另外,如果业务文档量超过百万级,建议用FAISS替换ChromaDB,向量检索延迟能从80ms降到10ms以内,给rerank留出更多预算。
最后说一句:RAG系统优化没有银弹,chunk_size、重叠率、rerank模型这些都要基于你自己的数据分布去调。我的参数只适用于金融研报这种长文本、强数字、多术语的场景,换成客服对话或代码文档,效果可能完全不同。但方法论是一致的:拿数据说话,每一步改动都要有A/B对比。