一、问题背景:上线两周,用户开始不信任这个"智能问答"
去年年底我接手了一个内部知识库问答系统,底层就是典型的RAG(检索增强生成)架构:文档切片 → 向量化入库 → 用户提问 → 向量检索 → 拼prompt → 丢给LLM生成。
上线第一周大家还图个新鲜,第二周开始就有人在群里吐槽"答非所问""明明文档里有却说不知道"。我拉了一批badcase做人工评估,数据很难看:
- 测试集:150条真实用户问题,覆盖产品手册、运维文档、FAQ三类
- Top-5召回率:0.62(即38%的问题,正确chunk压根没进前5)
- 答案准确率:54%(人工判定"完全正确"或"基本正确")
- P99延迟:2.3s
召回率0.62意味着LLM再强也救不回来——检索阶段就把正确答案丢了。所以优化重点很明确:先把召回做上去。
二、环境与版本
先交代下技术栈,避免版本差异导致结论不可复现:
Python 3.10.13
langchain 0.1.16
langchain-community 0.0.32
chromadb 0.4.24
sentence-transformers 2.7.0
FlagEmbedding 1.2.10
openai 1.23.2 # 仅用于对比基线
torch 2.2.1+cu121
transformers 4.39.3
硬件:单卡 A10 24G,文档规模约2000篇(产品手册+运维文档),切分后初始1.2万chunk。
三、方案设计:三阶段递进优化
我没有一上来就全换,而是分三步走,每步单独评估,方便定位收益来源:
| 阶段 | 改动 | 目的 |
|---|---|---|
| V1(基线) | 固定512字符切分 + text-embedding-ada-002 + 纯向量Top-5 | 现状 |
| V2 | 语义切分+128重叠 + bge-large-zh-v1.5 | 解决切分断裂和中文语义 |
| V3 | 在V2基础上加bge-reranker-large,召回Top-20重排取Top-5 | 解决"语义相近但无关"的干扰 |
评估指标固定为:Top-5召回率、答案准确率、P99延迟。
四、核心实现
4.1 V2:切分策略 + embedding切换
原来的固定512字符切分问题很致命。举个真实例子:一份运维文档里"重启服务前需先备份配置"这句话,被切在了"重启服务前需先"和"备份配置"两个chunk里,检索时两个chunk语义都不完整,全都没召回。
改成基于标点的语义切分,并保留128字符重叠:
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
import chromadb
# 1. 语义切分:优先按段落、换行、中文标点切
splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=128,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
# 2. 换用 bge-large-zh-v1.5
model_name = "BAAI/bge-large-zh-v1.5"
encode_kwargs = {"normalize_embeddings": True} # bge必须归一化
embedding = HuggingFaceBgeEmbeddings(
model_name=model_name,
model_kwargs={"device": "cuda"},
encode_kwargs=encode_kwargs,
query_instruction="为这个句子生成表示以用于检索相关文章:",
)
# 3. 重建向量库
client = chromadb.PersistentClient(path="./chroma_db_v2")
collection = client.create_collection(
name="kb_v2",
metadata={"hnsw:space": "cosine"},
)
docs = load_all_documents("./docs") # 2000篇
chunks = splitter.split_documents(docs)
collection.add(
ids=[f"c_{i}" for i in range(len(chunks))],
documents=[c.page_content for c in chunks],
embeddings=embedding.embed_documents([c.page_content for c in chunks]),
)
print(f"V2 入库完成,chunk 数:{len(chunks)}")
跑完这一步,chunk数从1.2万涨到1.9万(重叠带来的膨胀),Top-5召回率从0.62 → 0.78,准确率54% → 68%。提升明显,但离可用还有距离。
4.2 V3:引入reranker
V2之后我分析badcase,发现一个典型模式:用户问"如何重置管理员密码",向量检索召回了"如何重置普通用户密码""管理员权限申请流程"这类语义高度相近但答案不对的chunk。这是双塔embedding的天然缺陷——它只算整体语义相似度,分不清细粒度差异。
Reranker(cross-encoder)把query和doc拼在一起过一遍模型,能捕捉这种细粒度差异。方案是:向量召回Top-20 → rerank → 取Top-5。
from FlagEmbedding import FlagReranker
import chromadb
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
embedding = HuggingFaceBgeEmbeddings(
model_name="BAAI/bge-large-zh-v1.5",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True},
query_instruction="为这个句子生成表示以用于检索相关文章:",
)
client = chromadb.PersistentClient(path="./chroma_db_v2")
collection = client.get_collection("kb_v2")
def retrieve_with_rerank(query: str, top_k: int = 20, final_k: int = 5):
q_emb = embedding.embed_query(query)
res = collection.query(query_embeddings=[q_emb], n_results=top_k)
candidates = res["documents"][0]
# cross-encoder 打分
pairs = [[query, doc] for doc in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in ranked[:final_k]]
# 测试
q = "如何重置管理员密码"
for i, doc in enumerate(retrieve_with_rerank(q)):
print(f"--- Top{i+1} ---")
print(doc[:120])
引入rerank后,Top-5召回率0.78 → 0.89,准确率68% → 81%。这是三阶段里单步收益最大的一次。
五、踩坑与优化
坑1:bge必须加query instruction且归一化。 我第一版忘了normalize_embeddings=True,召回率比ada-002还低。bge系列训练时用了对比学习,向量必须L2归一化后算cosine才有意义。query侧还要加指令前缀,doc侧不加,这个非对称处理很容易漏。
坑2:rerank的top_k不能太小。 我一开始只召回Top-10去rerank,召回率只涨到0.83。改成Top-20后到0.89。原因是reranker只能重排已有候选,候选里没有正确答案它也无能为力。但也不能无限大——Top-50时P99延迟冲到4.2s,性价比崩了,最终定在20。
坑3:reranker吃显存。 bge-reranker-large在FP32下batch=32会OOM(A10 24G),开use_fp16=True后稳定,单次20对打分约120ms。
坑4:chunk_overlap别贪大。 我试过256重叠,chunk数暴涨到2.6万,检索变慢且冗余chunk挤占Top-5名额,召回反而降到0.85。128是这套文档的甜点值。
六、效果数据
150条测试集,最终对比:
| 版本 | 方案 | Top-5召回率 | 答案准确率 | P99延迟 |
|---|---|---|---|---|
| V1 | 512固定切分 + ada-002 | 0.62 | 54% | 2.3s |
| V2 | 语义切分+128重叠 + bge-large-zh | 0.78 | 68% | 2.6s |
| V3 | V2 + bge-reranker-large (20→5) | 0.89 | 81% | 3.1s |
延迟从2.3s涨到3.1s,主要来自reranker的~120ms和更大的候选集检索,但换来27个百分点的召回提升,完全值得。用户群里"答非所问"的投诉基本消失。
七、总结
这次优化最大的体会是:RAG的瓶颈八成在检索,不在LLM。 很多人一上来就换GPT-4、调prompt,但如果正确chunk压根没召回,后面全是白搭。
三阶段的收益排序也很清楚:embedding切换(+16%召回)> rerank(+11%)> 切分优化(占比小但必要,切分烂了embedding再好也白费)。如果时间有限,我建议的优先级是:先把切分和embedding做对,再上rerank。
后续还想试的方向:query改写(HyDE、多查询扩展)和混合检索(BM25+向量),理论上还能再抠几个点。等有结果再来写第二篇。