一、问题背景:一个“能跑但不好用”的RAG
去年底我接手了公司内部知识库问答系统。数据源是Confluence导出的Markdown和少量PDF,总量约2000篇文档,平均每篇1800字。架构很朴素:LangChain 0.1.0 + Chroma 0.4.24 + OpenAI text-embedding-ada-002 + GPT-3.5-turbo,检索Top-5直接拼进prompt。
上线两周后,运营同学反馈“答非所问”占比很高。我搭了一个100条问题的评测集(人工标注标准答案段落),跑出来几个关键指标:
- Top-5检索命中率(标准段落是否在前5):0.63
- 答案可用率(人工判定可用):0.58
- 平均检索耗时:120ms
- 端到端P95延迟:1.9s
问题很明显:召回不够,模型再强也白搭。于是我开始按“chunk → embedding → rerank”的顺序做分层优化。
二、环境与版本
先把环境固定下来,避免后面归因混乱:
- Python 3.10.13
- LangChain 0.1.0
- Chroma 0.4.24(持久化模式)
- sentence-transformers 2.5.1
- FlagEmbedding 1.2.10
- torch 2.1.2 + cu121
- GPU:单卡 RTX 4090 24GB
- LLM:GPT-3.5-turbo(temperature=0.2)
评测脚本固定为同一份100题集,每次只改一个变量。
三、方案设计:三步走
我的优化顺序是刻意的:先解决“切得对不对”,再解决“表示得好不好”,最后才上rerank。因为如果chunk本身把语义切碎了,换再好的embedding也救不回来;而rerank是锦上添花,前两步没做好,rerank的候选池本身就是脏的。
- Chunk策略:从固定512字符,改为“按标题层级递归切分 + 语义相似度合并”,目标块长300-500 token,重叠80 token。
- Embedding模型:从ada-002切到bge-large-zh-v1.5,中文语义匹配明显更强,且可本地部署,省成本。
- Rerank:召回Top-20,用bge-reranker-large重排取Top-5。
四、核心实现
4.1 Chunk策略调整
原来的切分是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),问题是它按字符数硬切,经常把一个小节切成两半。我改成先按Markdown标题切,再在段内做递归切分,最后用相邻块embedding相似度决定是否合并。
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from sentence_transformers import SentenceTransformer
import numpy as np
def split_by_headers(md_text: str):
# 按二级、三级标题切分,保留标题作为上下文
pattern = r'(?=^#{2,3}\s)'
parts = re.split(pattern, md_text, flags=re.MULTILINE)
return [p.strip() for p in parts if p.strip()]
def semantic_merge(chunks, model, threshold=0.82, max_len=500):
merged, buf = [], chunks[0]
for nxt in chunks[1:]:
emb = model.encode([buf, nxt], normalize_embeddings=True)
sim = float(np.dot(emb[0], emb[1]))
if sim > threshold and len(buf) + len(nxt) < max_len:
buf += "\n" + nxt
else:
merged.append(buf)
buf = nxt
merged.append(buf)
return merged
embed_model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, chunk_overlap=80,
separators=["\n\n", "\n", "。", ";", " "]
)
def build_chunks(md_text):
header_blocks = split_by_headers(md_text)
fine = []
for b in header_blocks:
fine.extend(splitter.split_text(b))
return semantic_merge(fine, embed_model)
参数上我试过threshold 0.75/0.82/0.88,0.82时块数从原来的4200降到2600,平均块长从380字符升到460字符,效果最好。
4.2 Embedding模型切换
Chroma的collection需要重建,因为维度从1536变成1024。我用FlagEmbedding加载bge-large-zh-v1.5,注意query要加指令前缀。
from FlagEmbedding import FlagModel
import chromadb
model = FlagModel(
"BAAI/bge-large-zh-v1.5",
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True
)
client = chromadb.PersistentClient(path="./chroma_bge")
col = client.get_or_create_collection(
name="kb_v2", metadata={"hnsw:space": "cosine"}
)
def index_chunks(chunks):
embs = model.encode(chunks, batch_size=64, max_length=512)
col.add(
ids=[f"c{i}" for i in range(len(chunks))],
documents=chunks,
embeddings=embs.tolist()
)
def retrieve(query, top_k=20):
q_emb = model.encode_queries([query])[0]
res = col.query(query_embeddings=[q_emb.tolist()], n_results=top_k)
return res["documents"][0]
这里有个坑:encode和encode_queries不能混用,query不加指令前缀会掉3-5个点。
4.3 Rerank引入
召回Top-20后用bge-reranker-large重排。它比cross-encoder更划算,4090上20条重排约45ms。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
def rerank(query, candidates, top_n=5):
pairs = [[query, c] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return [c for c, _ in ranked[:top_n]]
五、踩坑与优化
- Chroma维度不匹配:切换embedding后没重建collection,直接报维度错误。一定要新建collection名。
- rerank拖慢首字延迟:Top-20重排虽然只要45ms,但串行在检索后会让P95从1.9s升到2.6s。后来把rerank和LLM的prompt组装并行,压回2.3s。
- 语义合并过度:threshold设0.75时,把不同小节的块合并了,反而丢信息。0.82是甜点。
- bge模型显存:large模型fp16占约1.3GB,reranker约2.2GB,4090完全够,但如果你用T4要留意。
六、效果数据
同一份100题评测集,逐项对比:
| 阶段 | Top-5命中率 | 答案可用率 | 检索耗时 | P95延迟 |
|---|---|---|---|---|
| 基线(ada-002+512) | 0.63 | 0.58 | 120ms | 1.9s |
| +chunk优化 | 0.71 | 0.66 | 135ms | 2.0s |
| +bge-embedding | 0.83 | 0.78 | 110ms | 2.0s |
| +rerank | 0.89 | 0.85 | 165ms | 2.6s |
检索命中率从0.63到0.89,提升26个百分点;答案可用率从0.58到0.85。代价是P95延迟多了0.7s,但用户主观感受“答得准”比“答得快”重要得多。
七、总结
这次优化最大的体会是:RAG的瓶颈90%在检索,检索的瓶颈80%在chunk。换embedding和加rerank都有效,但如果chunk把语义切碎,后面全是徒劳。另外,评测集一定要提前建好,否则你根本不知道每次改动是涨是跌。下一步我打算试试query改写和多路召回(BM25+向量),目标把命中率推到0.92以上。