一、问题背景:为什么我的RAG像个“复读机”?
上个月,我们内部的知识库问答系统上线了。老板很开心,因为终于不用每天回答“报销流程是什么”这种问题了。但用了两周后,吐槽声不断:
- 答非所问:问“2024年Q3的营收数据”,系统返回的是“2023年Q3”的内容,因为切块时把年份信息切碎了。
- 引用错乱:回答里引用了文档ID [23],但内容实际来自文档ID [87]。原因是Top-5检索结果里混入了大量语义相似但实体不同的段落。
- 长文档吃不下:一份50页的PDF,系统只检索到了前10页的内容,因为固定窗口切块导致后面的内容被“淹没”在噪音中。
我最初用的是 LangChain 0.1.0 + Chroma 0.4.22 + text2vec-large-chinese(512维)。这个组合在测试集上看起来还行,但一到生产环境就原形毕露。问题的根源在于:切块策略太粗暴,embedding模型太老,并且没有重排序机制。
二、环境与版本:先把底仓打牢
在动手优化前,我先把依赖版本固定了,避免玄学问题:
Python 3.10.13
langchain==0.2.11
langchain-community==0.2.10
chromadb==0.4.24
sentence-transformers==3.0.1
FlagEmbedding==1.2.8
torch==2.3.1+cu118
硬件:单张RTX 4090 24G,用于本地embedding和rerank推理(其实batch_size调小点,16G显存也能跑)。
关键决策:不使用OpenAI的text-embedding-3-large,原因有二:一是数据合规要求不能出内网;二是我们测试发现中文场景下,bge-large-zh-v1.5的效果更稳。
三、方案设计:三步走,步步为营
3.1 第一步:重构Chunk策略(从固定切块到语义切块)
原始策略是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50)。这种切法的问题在于:
- 语义断裂:一个完整的技术方案被切成两半,导致检索时只命中一半。
- 边界噪音:overlap区域会产生大量重复向量,干扰检索精准度。
我改用了基于句号/分号的递归切块 + 段落聚合策略。核心思路:先按\n\n切分为大段落,再对超长段落按句子边界切分,最后合并短段落至300-500字。
3.2 第二步:切换Embedding模型(从text2vec到bge)
text2vec-large-chinese(512维)是2022年的模型了,对长文档和实体关联的表征能力不足。我换成了 BAAI/bge-large-zh-v1.5(1024维),并启用了query_instruction_for_retrieval前缀(这是BGE系列的官方建议)。
3.3 第三步:引入Rerank(从“一次检索”到“两段式检索”)
Chroma召回Top-50后,再用bge-reranker-v2-m3对结果重排,取Top-5送入LLM。这一步把“相关性”从向量相似度换成了交叉编码器的精确匹配,效果提升最明显。
四、核心实现:代码与踩坑记录
4.1 语义切块代码(LangChain自定义Splitter)
```python
from langchain.text_splitter import RecursiveCharacterTextSplitter
class SemanticChunkSplitter:
"""基于段落和句子边界的语义切块器"""
def init(self, max_chunk_size=400, min_chunk_size=150):
self.max_chunk_size = max_chunk_size
self.min_chunk_size = min_chunk_size
self.splitter = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", "!", "?", ";"],
chunk_size=max_chunk_size,
chunk_overlap=30, # 保留少量重叠避免断句
length_function=len,
)
def split_text(self, text: str):
# 第一步:按段落粗粒度切分
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
chunks = []
for para in paragraphs:
# 第二步:对超长段落递归切分
if len(para) > self.max_chunk_size:
sub_chunks = self.splitter.split_text(para)
chunks.extend(sub_chunks)
elif len(para) >= self.min_chunk_size:
chunks.append(para)
else:
# 短段落合并到前一个chunk(避免碎片化)
if chunks:
if len(chunks[-1] + para) 100页)引入“章节摘要”作为一级索引,减少噪音。
如果你也在调RAG,欢迎评论区交流具体的踩坑经历。毕竟,调参的苦,只有调过的人懂。