一、问题背景:一个“看起来能用”的RAG系统
三个月前我接手了公司内部知识库问答系统。技术栈很标准:LangChain 0.1.0 + Chroma 0.4.24 + OpenAI GPT-3.5-turbo。文档量不大,约2000条产品FAQ和运维手册,每条平均300字。
上线第一周,运营同学反馈:“问‘退款流程要多久’,它回答的是‘注册流程’。”我手动测了50个问题,发现:
- Top-3召回率:62%
- 回答准确率(人工判断):约55%
- 平均检索延迟:120ms
- 端到端延迟:1.2s
问题很明显:检索环节太弱。用户问“退款”,返回的却是“注册”,说明embedding区分度不够,且chunk切分把上下文切碎了。我决定从三个方向优化:chunk策略、embedding模型、rerank。
二、环境与版本
先固定环境,避免“我本地能跑”的尴尬:
Python 3.10.13
langchain==0.1.0
chromadb==0.4.24
sentence-transformers==2.5.1
FlagEmbedding==1.2.10
torch==2.1.2+cu121
openai==1.6.1
硬件:单卡RTX 4090 24GB,CPU 32核,内存128GB。Embedding和Rerank模型都本地部署,不调OpenAI API,省成本且数据不出内网。
三、方案设计
优化分三步走,每步单独评估:
Step 1:Chunk策略调整
原方案:RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),按字符硬切。
新方案:先按Markdown标题和段落做语义分割,再对超长段落用递归切分,chunk_size=256,overlap=32。原因:FAQ文档本身有结构,硬切会切断“问题-答案”对。
Step 2:Embedding模型切换
原方案:text-embedding-ada-002(1536维,API调用)。
新方案:bge-large-zh-v1.5(1024维,本地推理)。中文场景下bge在C-MTEB榜单上明显优于ada-002,且本地推理无网络延迟。
Step 3:引入Rerank
检索阶段先用bge召回Top-20,再用bge-reranker-large做cross-encoder重排,取Top-3送入LLM。Rerank能捕捉query和doc的交互特征,对“退款”vs“注册”这种语义相近但意图不同的场景区分度极高。
四、核心实现
4.1 语义+递归混合Chunk
from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
from langchain.docstore.document import Document
def hybrid_chunk(text: str, source: str):
# 第一层:按Markdown标题切
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
md_docs = md_splitter.split_text(text)
# 第二层:对超长段落递归切分
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=32,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
final_docs = []
for doc in md_docs:
if len(doc.page_content) > 256:
sub_docs = recursive_splitter.split_documents([doc])
final_docs.extend(sub_docs)
else:
final_docs.append(doc)
# 补上source metadata
for d in final_docs:
d.metadata["source"] = source
return final_docs
关键点:separators里把中文标点放进去,避免在句子中间断开。实测chunk平均长度从512降到238,但每个chunk语义完整度大幅提升。
4.2 Embedding与Rerank集成
from FlagEmbedding import FlagModel, FlagReranker
import numpy as np
# 初始化
embed_model = FlagModel(
'BAAI/bge-large-zh-v1.5',
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True
)
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)
def retrieve_and_rerank(query: str, collection, top_k_recall=20, top_k_final=3):
# 1. 向量召回
q_emb = embed_model.encode_queries([query])[0]
results = collection.query(
query_embeddings=[q_emb.tolist()],
n_results=top_k_recall,
include=["documents", "metadatas", "distances"]
)
docs = results["documents"][0]
metas = results["metadatas"][0]
# 2. Rerank
pairs = [[query, doc] for doc in docs]
scores = reranker.compute_score(pairs, normalize=True)
# 3. 排序取Top-3
ranked = sorted(zip(docs, metas, scores), key=lambda x: x[2], reverse=True)
return ranked[:top_k_final]
注意:query_instruction_for_retrieval必须加,bge模型对query和passage的编码方式不同,不加指令会导致召回率下降约8%。
五、踩坑与优化
坑1:bge模型首次加载慢
首次加载bge-large-zh-v1.5耗时约12秒(从磁盘读+GPU warmup)。解决:服务启动时预加载,并跑一次dummy inference。
坑2:Rerank延迟高
bge-reranker-large对20个pair打分,单次约380ms(4090)。如果召回Top-50,延迟飙到900ms。折中:召回Top-20,延迟控制在400ms内。
坑3:Chroma的distance是L2,不是cosine
bge模型建议用cosine相似度。Chroma默认L2,需要创建collection时指定metadata={"hnsw:space": "cosine"}。改完后召回率提升约5%。
坑4:Overlap太大导致重复
chunk_overlap从50降到32后,重复内容减少,LLM回答不再“车轱辘话”。但overlap不能为0,否则跨chunk的答案会丢。
六、效果数据
在2000条FAQ上,用100个标注问题做评估:
| 指标 | 优化前 | Step1后 | Step2后 | Step3后 |
|---|---|---|---|---|
| Top-3召回率 | 62% | 71% | 80% | 89% |
| 回答准确率 | 55% | 63% | 74% | 84% |
| 检索延迟 | 120ms | 135ms | 180ms | 580ms |
| 端到端延迟 | 1.2s | 1.3s | 1.4s | 1.8s |
Rerank引入后延迟增加400ms,但准确率提升10个百分点,性价比极高。最终线上P95延迟2.1s,用户可接受。
另外,Embedding从API切到本地后,每月省下约$120的API费用,且数据不出内网,安全合规。
七、总结
RAG优化没有银弹,但优先级很明确:chunk > embedding > rerank。chunk是地基,切碎了后面怎么调都白搭;embedding决定召回上限;rerank是精度放大器,但会牺牲延迟。
如果重来一次,我会:
1. 先花一天做chunk策略实验,而不是直接上默认参数。
2. embedding选型时用C-MTEB榜单做参考,别迷信OpenAI。
3. rerank只在召回Top-20以上时用,Top-5没必要,收益太小。
最后,评估集一定要提前建好,否则你根本不知道优化有没有用。我用了100个标注问题,花了半天时间,但省下了后面两周的盲目调参。