一、问题背景:上线两周,用户开始不信任这个"智能问答"

去年年底我接手了一个内部知识库问答系统,底层就是典型的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+向量),理论上还能再抠几个点。等有结果再来写第二篇。