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_documentsRetrievalQA链路在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字段,检索时按时间降序优先返回最新内容。实现上就是给Chromafilter参数:

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对比