这篇是把若干公开实践分享中有关 RAG 检索环节的说法做交叉整理,属于实践总结,不是任何官方口径。文中出现的提升幅度、候选池大小、分块长度都按量级理解,不同语料上差别很大,别当成可以直接搬用的常数。
最小可用版
最小可用版只有四步:把文档切成块、每块算一个向量、按向量相似度取最近的若干块、拼进提示词让模型回答。文档结构规整、问题多是原文复述时,这个版本通常就能给出能用的答案,所以值得先跑起来。
下面这段是自写的骨架,用伪代码表达依赖顺序,不绑定具体库:
def minimal_rag(docs, question, k=4):
chunks = []
for doc in docs:
# 固定长度分块,最小可用阶段够用,后文会换掉
for i in range(0, len(doc.text), 1000):
chunks.append(Chunk(text=doc.text[i:i + 1000], source=doc.name))
index = VectorIndex()
for c in chunks:
index.add(c.id, embed(c.text))
hits = index.search(embed(question), top_k=k) # 只按向量相似度排序
context = "\n\n".join(f"[{i + 1}] {h.text}" for i, h in enumerate(hits))
return llm(f"只依据下方片段回答,缺少依据时直接说明。\n{context}\n问题:{question}")
这段代码解决的是"先把链路连通":确认切分、嵌入、检索、生成四段都能跑通,再用真实问题去观察答案错在哪。它刻意保持简单,因为固定长度分块和纯向量检索的缺陷要等真实问题进来才暴露得清楚。
什么时候开始失败
跑通之后的故障往往成批出现,位置也很集中。
一类是查不到。产品手册里问某个具体型号的额定功率,向量检索会把语义相近的型号一起拉回来,目标型号反而排在后面,因为向量空间里这几个型号几乎重叠。命名实体、部件号、法规条文引用、缩写,都是纯向量检索的弱项。
一类是切坏。按字符数切,表格会从中间断开,表头留在上一块、数据行落在下一块,两块单独看都没有信息。代码块和多层级列表是同样的情况。
一类是候选太窄。多个实践来源提到同一个观察:一些失败案例里正确答案其实进了候选列表,只排在第 7 位,而系统只把前 4 块交给模型。
还有一类是文档本身脏,同一份政策文档存在两个版本,检索命中的是过期那份。这类问题发生在向量计算之前,改检索算法没有意义。
有来源给过一个值得记住的顺序判断:多数失败是检索 miss,不是模型能力问题。先修检索,再怀疑模型。
语义分块与元数据
固定长度分块换成按文档结构切分:以标题层级、段落、列表项为单位,表格连同表头整体成块,长块再递归细分并保留一小段重叠。分块长度方面,多个来源给出的量级集中在 500 到 1500 token,滑窗重叠常见 200 token 上下;也有来源倾向于用 embedding 相似度检测主题切换的位置来切分,而不是只看长度。分块策略对最终效果的影响,有来源估到三成以上。
每块要带元数据:文档标题、所属小节路径、更新时间、来源系统。元数据后面要用于过滤和引用校验,缺了这两件事就做不了。
下面这段表达的是按结构切分并携带元数据的逻辑:
def chunk_document(doc):
"""按标题层级切分,表格整体成块,超长块递归细分并保留重叠。"""
out = []
for section in doc.sections(): # H2 / H3 构成的层级单元
path = "/".join(section.heading_path)
for unit in section.units(): # 段落、列表项、整张表格
text = unit.text
if len(tokenize(text)) > 1200:
pieces = split_recursive(text, size=1200, overlap=200)
else:
pieces = [text]
for p in pieces:
out.append(Chunk(
text=p,
doc_title=doc.title,
section_path=path,
updated_at=doc.updated_at,
source=doc.source_system,
))
return out
这段解决的是"块本身要能独立成立":切分单元跟着文档结构走,表格不再被拦腰截断,重叠保证跨块边界的句子还能被完整检索到。
混合检索与融合
生产配置里通常是两路召回并行:一路 dense 向量,一路 sparse 的 BM25 关键词。向量擅长语义相近,弱于精确标识;BM25 相反。两路各自取回一批候选,再合并。
合并方式上来源之间有分歧。加权平均需要先把两路分数归一化到同一量纲,静态权重在语料变化时经常伤召回;RRF(倒数排名融合)只看名次不看原始分数,省掉归一化这一步,因此被更多来源推荐。
RRF 的做法是每条结果在自己所在列表里的名次 r,贡献 1/(k + r),跨列表累加:
RRF_K = 60
def rrf_merge(rank_lists, weights=None):
"""rank_lists: [[chunk_id, ...], ...],每个列表已按分数从高到低排好。"""
scores = {}
for li, lst in enumerate(rank_lists):
w = 1.0 if weights is None else weights[li]
for rank, cid in enumerate(lst, start=1):
scores[cid] = scores.get(cid, 0.0) + w / (RRF_K + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
这段解决的是"两路分数不可比"的问题:不碰原始分数,只用名次求和,产品名、型号这类靠关键词命中的结果和语义相近的结果就能排进同一个序列。
候选池大小是另一个分歧点。有来源认为取 top-10 就够,代价是交集过滤后召回明显受损;也有来源给出实测:两路各取 top-20 再做交集过滤,召回率约 68%,改成取并集、扩大候选池到 200 条再走 RRF,召回率升到 91%——检索方法本身没变,只换了融合策略。主流意见倾向后者,代价是后续重排要多算一些。
重排层
合并出的序列交给 cross-encoder 重排。重排器把查询和候选块拼在一起算相关性,比向量召回准,但延迟明显,多个来源给出的量级是每次调用增加 50 到 200 毫秒,所以它只对合并后的前几十条生效。
常见配置是:两路各取 50 到 100 条候选,RRF 合并,重排,取前 6 到 10 条进提示词。有来源建议重排前保留 30 条宽候选,也有建议到 50 条,差别取决于重排模型速度与延迟预算。
生成侧要强制引用:要求模型在答案中标注所依据的块 ID,并在返回前程序化校验这些 ID 是否真的出现在交给它的上下文里。校验不通过就直接返回"我没有这个信息",而不是让模型编一段。
评估集与指标
评估要落在能复现的题集上。多个来源给出的规模量级是 50 到 100 道题,构造方式是人工标注或用强模型辅助生成,然后按周跑。指标用 Recall@K、MRR、NDCG、Precision@K,只看相似度分数或只看点击率都不足以反映检索质量。
下面这段是评估记录的落库结构,用于按周对比:
@dataclass
class EvalRecord:
question: str
expected_chunk_ids: list[str]
retrieved_chunk_ids: list[str] # 重排后进上下文的那批
answer: str
cited_ids: list[str]
unsupported: bool # 引用的 ID 不在 retrieved 集合内
def run_eval(golden_set):
rows = []
for case in golden_set:
hits, answer, cited = pipeline(case.question)
recalled = len(set(case.expected_chunk_ids) & set(hits))
rows.append(EvalRecord(
question=case.question,
expected_chunk_ids=case.expected_chunk_ids,
retrieved_chunk_ids=[h.id for h in hits],
answer=answer,
cited_ids=cited,
unsupported=any(c not in [h.id for h in hits] for c in cited),
))
return {
"recall@k": mean(len(set(r.expected_chunk_ids) & set(r.retrieved_chunk_ids)) / len(r.expected_chunk_ids) for r in rows),
"mrr": mean(first_hit_rank(r) for r in rows),
"unsupported_rate": mean(r.unsupported for r in rows),
}
这段解决的是"改动有没有变好":每次换分块策略、换候选池、换重排模型,都跑一遍同一批题,用 recall 和 MRR 对比,而不是凭几条样例下结论。引文校验单独留了字段,因为答案引用了上下文里不存在的 ID 是一种可以直接量化的幻觉。
常见误区
一是数据不清洗就上 RAG。重复文档、冲突版本、过期页面会直接进索引,检索再准也答错。
二是默认 token 分块。它会切断表格与表头,也切断论证链条。
三是迷信纯语义检索。没有关键词一路,产品型号、法规条款这类查询基本查不准。
四是没有重排。召回层的排序只对语义相近负责,不等于对问题相关性负责。
五是生成不强制引用。没有引用校验,答案里混进去的内容无从发现。
六是没有评估集。改动靠几条样例判断,回归无从谈起。
七是索引不更新。文档更新了而向量没重算,检索到的是旧内容。
适用边界
语料规模小于几十篇文档时,直接把内容塞进上下文更简单,不值得建检索链路。对延迟要求极严、每次生成只能接受几十毫秒额外开销的场景,重排层可能放不下。
RAG 和微调的分工也值得说清:补知识用 RAG,改动在即时生效;改风格或固化行为用微调。两者可以组合,不是一个替代另一个。
另外,这套改造的收益高度依赖语料本身。如果文档脏、结构混乱,先做数据治理,召回提升的幅度主要来自那里。