RAG 召回差?先改切片和重排
我第一次给内部文档搭 RAG,召回回来的块总差一口气。问怎么降大模型调用成本,它回了一段讲向量库存储贵的。排查下来,症结在切片和重排这两道工序,模型本身没问题。这篇把 RAG 检索的原理拆开讲,代码能直接跑。
一、切片定召回上限
1、按照字符长度硬切(按照模型限制且)
2、按照语句段落切(保留完整句子)
切片直接决定召回的上限。固定长度切块图省事,却常把一句完整的话从中间劈开,模型拿到的块结尾是断的,语义被切断,召回自然掉。
我把同一段 226 字文档喂给两种切法。固定长度切出 2 块,其中 1 块在句子中间断开。递归字符切块同样 2 块,没有一处断句。差距就在句子完不完整。
递归切块先按段落、再按句子、最后按长度,能保住大多数句子完整,是工程上的默认选择。块大小我一般控制在两百到五百字,再留百分之十到二十的重叠给边界句,让相邻块共享上下文。
python
def recursive_chunk(text, size=200, overlap=40):
"""先按段落、再按句子、最后按长度切。
为什么这么写:保证句子不被切断,召回的块语义才完整。"""
chunks = []
for para in re.split(r"\n+", text):
para = para.strip()
if not para:
continue
# 按句切,单句超过 size 才硬切,正常句子整段保留
sents = re.split(r"(?<=[。!?])", para)
cur = ""
for s in sents:
if len(cur) + len(s) <= size:
cur += s
else:
if cur:
chunks.append(cur)
while len(s) > size:
chunks.append(s[:size])
s = s[size - overlap:]
cur = s
if cur:
chunks.append(cur)
return chunks
二、混合检索补语义

词法检索(BM25)擅长精确词匹配,可查询词和文档用词不一致时就抓不到。我问每月开销怎么缩减,语料里没有开销、缩减这两个词,纯词法前 3 名里根本没有讲成本的块。
语义通道补上这一块。生产环境用 bge-m3、gte 这类嵌入模型把文本编码成向量再比余弦,查询写缩减开销也能命中讲降低成本的块。我这个 demo 用同义词扩展离线近似,机制一致,两路打分后按排名融合。
融合用 Reciprocal Rank Fusion,不依赖两路分数量纲一致,实现成本低。上面那个口语查询,语义通道把讲成本的块从第 3 名外拉进前 3,重排再抬到第 2。
python
import math, re
def expand_query(query):
"""把口语词换回语料里的标准词,模拟语义检索跨词形匹配的能力。"""
q = re.sub(r"\s+", "", query)
for std, cols in {"成本": ["开销", "花费", "账单"], "切块": ["切分", "分块"]}.items():
for c in cols:
if c in q:
q = q.replace(c, std)
return q
def rrf(ranks, k=60):
"""Reciprocal Rank Fusion:按排名融合,不挑两路分数量纲。"""
fused = {}
for rank in ranks:
for pos, (idx, _) in enumerate(rank):
fused[idx] = fused.get(idx, 0) + 1 / (k + pos + 1)
return [i for i, _ in sorted(fused.items(), key=lambda x: -x[1])]
真实跑出来的前几名,口语查询每月开销怎么缩减的结果如下。
text
纯词法前 3:c_embed、c_trap、c_distract(无讲成本的块)
语义通道前 3:c_embed、c_trap、c_cost(c_cost 被拉进前 3)
重排后前 5:c_embed、c_cost、c_trap、c_distract、c_hybrid
三、重排定答案准度

召回只要不漏,重排才决定答案准不准。融合阶段只看排名,谁词频高谁靠前,偏题块容易混进来。
我放了个陷阱块,满篇都是成本和大模型调用成本,词法分很高,排在融合第 1。但它只讲涨价和担心账单,没讲怎么降。重排阶段我加了融合没有的两个信号,块里有没有查询的意图动作词(省钱、压),以及有没有精确短语。带省钱、压的块被抬上去,陷阱块被压到第 2,讲怎么降成本的块回到第 1。
重排只挑召回前 N 条做,因为交叉编码器逐对打分很慢,全量做不现实。
python
def rerank(query, idxs, bm25, topk=5):
"""对融合后的候选做特征重排。
加的两个信号融合阶段没有:精确短语、意图动作词。"""
q = re.sub(r"\s+", "", query)
scored = []
for idx in idxs:
doc = CORPUS[list(CORPUS)[idx]]
bm = bm25.score(query, idx)
phrase = 1.0 if any(q[i:i+4] in doc for i in range(len(q)-3)) else 0.0
intent = 1.0 if any(t in doc for t in ["省钱", "压", "降低"]) else 0.0
length_penalty = 1.0 if len(doc) <= 200 else 0.6
scored.append((idx, 0.3*bm + 0.8*phrase + 1.2*intent + length_penalty))
scored.sort(key=lambda x: -x[1])
return [i for i, _ in scored[:topk]]
四、常见坑
坑 1 用字符数估算成本。 表现是用 len(text) 估 token,中文场景账单比预期高两三倍。改用分词器的 encode 实测字符对应的 token 数。
坑 2 切块太大或太小。 块超过五百字噪声多,小于两百字上下文不够。按二百到五百字切,留百分之十到二十的重叠。
坑 3 只跑词法不跑语义。 查询和文档用词不一致时召回漏掉正确答案。加一路嵌入检索再融合。
坑 4 把召回当排序。 召回阶段只管不漏,排序交给重排。别让词频高的偏题块直接进答案。
五、小结
- 切片定召回上限。递归字符切块保句子完整,块控制二百到五百字。
- 词法加语义才稳。BM25 保精确,嵌入保语义,RRF 融合不挑量纲。
- 重排定答案准度。加意图词和精确短语两个信号,把偏题块压下去。
- 召回和重排分工。召回管不漏,重排管准,别混为一谈。