RAG 做出来效果不好,第一反应通常是换向量库、换 embedding 模型。这两件事往往是最后才该动的。按影响大小排,真正决定效果的是四层:怎么切、检索几路、要不要重排、怎么评估。任何一层做得糙,上面换多贵的模型都救不回来。
分块:切断了语义,后面全白搭
固定长度切块是最常见的做法,也是最常见的错误来源。按 500 字一刀切,会把一个表格切成两半、把"以下是配置项"和配置项本身切开、把代码和它的说明分开。
判断一个分块方案好不好,有个很土的标准:把块单独拿出来给人看,人能不能看懂它在说什么。看不懂,模型也看不懂。
按结构切比按长度切好。Markdown 有标题层级,就按标题切;PDF 有版面,就按段落和表格边界切。切完再对超长的块做二次切分,切的时候带上父级标题作为上下文前缀:
def split_by_heading(md: str, max_chars: int = 800) -> list[dict]:
chunks, cur_title, buf = [], "", []
for line in md.splitlines():
if line.startswith("#"):
if buf:
chunks.append({"title": cur_title, "text": "\n".join(buf)})
buf = []
cur_title = line.lstrip("#").strip()
else:
buf.append(line)
if buf:
chunks.append({"title": cur_title, "text": "\n".join(buf)})
out = []
for c in chunks:
prefix = f"[{c['title']}]\n" if c["title"] else ""
text = c["text"].strip()
for i in range(0, len(text), max_chars):
out.append({"text": prefix + text[i:i + max_chars]})
return out
这个 prefix 的作用常被低估。检索时命中的是某个小段,但模型看到的是"这段属于哪个标题之下",缺了它,回答里就经常出现"根据文档"这种没有指向的表述。
重叠(overlap)也有讲究。按固定长度切的时候需要 10% 到 20% 的重叠,防止一句话被切两半;按结构切的时候通常不需要,因为结构边界本身就是语义边界。两者混用会让重复内容变多,检索时同一段被召回两次,白占上下文。
检索:只做向量等于放弃精确匹配
向量检索擅长语义相近,不擅长精确匹配。用户问"ERR_CONN_RESET 怎么处理",向量检索可能召回一堆讲连接问题的段落,但真正含这个错误码的那一段排不到前面,因为错误码这种 token 在 embedding 空间里没有稳定语义。
BM25 正好相反。它靠词频和逆文档频率打分,对错误码、函数名、配置项名这类精确 token 极其敏感:
import math
from collections import Counter
def bm25_scores(query_terms, docs, k1=1.5, b=0.75):
N = len(docs)
avgdl = sum(len(d) for d in docs) / max(N, 1)
df = {t: sum(1 for d in docs if t in d) for t in query_terms}
scores = []
for d in docs:
tf = Counter(d)
s = 0.0
for t in query_terms:
if tf[t] == 0:
continue
idf = math.log(1 + (N - df[t] + 0.5) / (df[t] + 0.5))
denom = tf[t] + k1 * (1 - b + b * len(d) / avgdl)
s += idf * tf[t] * (k1 + 1) / denom
scores.append(s)
return scores
所以生产上一般是两路一起召回,再融合。融合有两种常见做法:一是用倒数排名融合(RRF),只吃排名不吃分数,对两路分数不可比的问题免疫;二是各自归一化后加权,需要调权重,但能保留"这一路非常确信"的信息。
RRF 的公式简单到有点反直觉:每个文档的得分是 1 / (k + rank) 之和,k 通常取 60。它不需要知道两路分数怎么来的,这也是它在混合检索里被用得多、调得少的原因。
重排:不是锦上添花,是必要环节
召回阶段追求的是"别漏",重排阶段追求的是"排对"。向量检索和 BM25 都是双塔式的:query 和文档分别编码再算相似度,两者在编码时看不到对方。这是它能快的原因,也是它精度上不去的原因。
cross-encoder 重排把 query 和文档拼在一起送进模型,能捕捉到"这段话到底有没有回答问题"。代价是慢,只能对前几十条做。
实践里的配置是:召回 50 到 100 条,重排取前 5 到 8 条进上下文。重排模型选小的就够,几十兆到几百兆的模型在中文技术文档上效果已经明显好于纯向量排序。
一个常被忽略的点:重排的输入要保留结构信息。把标题前缀一起送进去,比只送正文段落准确得多,因为很多 query 问的是"这个功能怎么配",判断依据就在标题里。
评估:没有黄金集就是在凭感觉调
RAG 调参最大的陷阱是"看着好像好了一点"。换分块、换模型、加重排,每次效果都有变化,但没人知道总体是进步还是退步,因为每次只试了几个问题。
最小可用的评估集是二十到五十个问题,每个问题标出正确答案所在的文档片段。然后盯两个指标:命中率(正确片段有没有进前 K)和答案正确率(模型拿到正确片段后有没有答对)。两个指标分开看,才能定位问题在检索还是在生成。
检索侧还有一个更细的指标:正确片段的排名分布。如果大量正确片段排在 8 到 20 名之间,说明召回没问题、排序有问题,该加重排;如果压根不在前 50,那是分块或召回策略的问题,加多少重排都没用。
几个具体建议
分块优先按结构,超长再切,切完补标题前缀。检索至少两路,向量加 BM25,用 RRF 融合省去调权重。重排别省,前 50 进前 8 出。评估集从第一天就建,二十个问题也比没有强。
还有个顺序问题:先修分块,再修检索,最后调重排和提示词。反过来做的结果通常是反复调提示词去补检索的窟窿,越调越复杂。