**
一、问题背景:检索不准,后续全白搭
事情是这样的。上个月接了个需求,要给某律所的合同库做智能问答。文档量不大,就2000多份PDF,但每份都是几十页的复杂排版。第一版系统上线后,用户反馈很直接:“问它‘违约金上限怎么约定’,它给我返回一份关于‘合同解除’的条款,这玩意儿能用?”
我拉了一下日志,发现检索模块的Recall@5只有78.3%。这意味着五分之一的问题,正确答案压根没进候选集。更麻烦的是,很多返回的chunk是半句话,上下文被拦腰截断,导致生成阶段胡编乱造。
问题定位在三个层面:
- chunk策略太粗暴:直接按512字符硬切,很多法律条款被切断,语义完整性荡然无存。
- embedding模型太弱:当时用的
BAAI/bge-small-zh-v1.5,对于法律这种专业术语密集的文本,表征能力明显不足。 - 缺少精排环节:向量检索返回Top-20后直接送入LLM,相关段落和无关段落混在一起,干扰了生成。
二、环境与版本:先把底交代清楚
优化前的基础环境如下:
- Python 3.10.13
- langchain 0.1.12
- sentence-transformers 2.3.1
- ChromaDB 0.4.24
- 部署GPU:单张A10(24GB显存)
- Embedding模型:BAAI/bge-small-zh-v1.5(512维)
- LLM:qwen1.5-14b-chat(vLLM部署)
注意,这里用的是ChromaDB纯本地部署,没上ES或Milvus,主要图省事。但后续发现,纯向量检索在这种场景下确实不够精准,必须加rerank。
三、方案设计:三刀并行,缺一不可
我的优化策略分三步走,每一步都有独立的验证指标:
- chunk策略重设计:放弃固定长度切分,改为基于段落边界和语义完整性的递归切块。核心思路是:先按标题和换行符分割成大块,再对超过
chunk_size的大块利用分隔符优先级递归切分。 - embedding模型升级:从bge-small换到bge-large-zh-v1.5(1024维)。虽然向量维度翻倍,存储和计算开销增加,但A10显存够用,检索精度提升明显。
- 引入rerank精排:在向量召回Top-50的基础上,用bge-reranker-base对候选chunk与query做交叉编码打分,取Top-5送入LLM。
整个链路变成:文档 → 智能切块 → 向量化 → 向量召回Top-50 → 重排取Top-5 → LLM生成。
四、核心实现:三段关键代码
4.1 基于语义边界的递归切块
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 每个块的目标token数(中文约400字)
chunk_overlap=80, # 块间重叠,保证跨块语义连续
separators=["\n\n", "\n", "。", ";", ",", " ", ""], # 优先级从高到低
is_separator_regex=False
)
# 假设text是从PDF中抽取的全文
chunks = text_splitter.split_text(text)
# 自定义后处理:剔除长度小于50字符的碎片块
valid_chunks = [c for c in chunks if len(c) > 50]
关键点在于separators列表的优先级。先尝试按段落(\n\n)切,不行再按句子(。)切,最后才按字符硬切。这样能最大程度保证每个chunk是语义完整的单元。
4.2 Embedding模型切换
from sentence_transformers import SentenceTransformer
from chromadb.utils import embedding_functions
# 旧模型(512维)
# model_name = "BAAI/bge-small-zh-v1.5"
# 新模型(1024维)
model_name = "BAAI/bge-large-zh-v1.5"
model = SentenceTransformer(model_name, device="cuda")
# 自定义embedding函数,适配ChromaDB
class CustomEmbeddingFunction(embedding_functions.EmbeddingFunction):
def __call__(self, input):
# 注意:bge模型需要加查询指令前缀(用于检索场景)
if isinstance(input, str):
input = ["为这个句子生成表示以用于检索相关文章:" + input]
else:
input = ["为这个句子生成表示以用于检索相关文章:" + t for t in input]
embeddings = model.encode(input, normalize_embeddings=True)
return embeddings.tolist()
embed_fn = CustomEmbeddingFunction()
这里有个大坑:bge系列模型在检索场景下,query必须加前缀指令,否则效果退化严重。我一开始没加,直接用裸query去检索,结果Recall@5反而掉了2个点。
4.3 Rerank引入
from FlagEmbedding import FlagReranker
# 加载重排模型
reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True)
def rerank_documents(query, documents, top_k=5):
"""
documents: list[str] 向量召回后的候选chunk列表
"""
pairs = [[query, doc] for doc in documents]
scores = reranker.compute_score(pairs, normalize=True)
# 按分数降序排列,取top_k
sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
reranked_docs = [documents[i] for i in sorted_idx[:top_k]]
return reranked_docs
# 使用示例
# query = "违约金的上限如何约定?"
# candidates = vector_store.similarity_search(query, k=50) # 先召回50个
# final_docs = rerank_documents(query, [c.page_content for c in candidates])
bge-reranker-base是一个交叉编码器,对query和doc的拼接做二分类打分,虽然比双塔慢,但精度高。在A10上,50个候选重排一次约需300ms,完全可接受。
五、踩坑与优化:不是所有的升级都是正向的
坑1:chunk_size翻倍后,召回反而变差
我一开始把chunk_size从400调到800,想着上下文更完整。结果Recall@5跌了4个百分点。分析后发现,chunk变大导致向量表征被稀释——一个chunk里包含太多不相关主题,embedding被平均掉了。后来用400字作为基准,配合80字重叠,效果最稳。
坑2:原embedding模型没删干净
切换模型时,ChromaDB里的旧向量还留着。由于新旧模型维度不同,直接查会报错。必须重建collection:
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
try:
client.delete_collection("legal_docs")
except:
pass
collection = client.create_collection(
name="legal_docs",
embedding_function=embed_fn,
metadata={"hnsw:space": "cosine"}
)
坑3:rerank引入后,延迟暴增
第一次接入rerank时,我傻乎乎地对Top-50全部重排,单次查询总耗时从800ms涨到1.6s。后来优化成两步:先用向量召回Top-20,再对Top-20做重排取Top-5。延迟只增加200ms,精度几乎无损失。
六、效果数据:用数字说话
整个优化过程历时三天,每步的验证结果如下:
| 配置 | Recall@5 | Recall@10 | 平均检索延迟 | 生成答案可接受率* |
|---|---|---|---|---|
| 初始方案(512字符+small+无rerank) | 78.3% | 85.1% | 620ms | 71% |
| 切块优化后(400字+语义边界) | 84.2% | 89.7% | 640ms | 76% |
| + embedding切换(large-zh) | 91.5% | 94.8% | 720ms | 83% |
| + rerank(Top-20重排取5) | 94.6% | 96.2% | 920ms | 89% |
*可接受率:由两名标注员对100个问答对独立打分,取一致结果。
最终线上效果:
- 用户主要问题的首答准确率明显提升,投诉明显减少。
- 幻觉现象减少,因为送入LLM的上下文更聚焦。
- 单次查询成本几乎没变,因为虽然embedding算力翻倍,但rerank过滤后,LLM的输入token数平均减少了30%。
七、总结:RAG优化没有银弹
这次优化的核心体会是:RAG的瓶颈往往不在生成,而在检索。而检索质量取决于三件事:怎么切块、用什么向量、要不要精排。
- 切块要遵循语义完整性,但也不要太大,400字左右对中文法律文本是个不错的平衡点。
- Embedding模型能上大就上大,bge-large比small在专业领域强了不止一个档次。
- Rerank是性价比最高的精排手段,尤其适合候选集较大、噪声较多的场景。
当然,这套方案不是万能的。如果你的场景是实时聊天,延迟敏感,可能需要考虑用更轻量的rerank模型或减少候选集。但总体来说,这套组合拳在知识库问答场景下有很强的普适性。
代码已开源在项目的rag_optimization分支,有兴趣的可以拉下来跑一跑。有问题欢迎评论区交流。