从零搭一个 RAG:把「检索」这件事做对

很多人第一次做 RAG 的路径是:把 PDF 丢进切分函数 → 灌进向量库 → 相似度检索 → 拼进提示词。跑通 demo 只要半小时,但上线之后会发现,回答经常「看起来很流畅,其实答非所问」。问题几乎从来不在模型,而在检索链路。这篇把我在工程上真正会调的几个环节拆开讲。
一、先承认:RAG 是个检索系统
大语言模型负责的是「给定上下文,组织和表达答案」,它不会替你找到正确的上下文。所以评价体系也应该分成两层:
- 检索层:正确的片段有没有进到 top-k 里?
- 生成层:有了正确片段,答案有没有忠实于它、有没有引用来源?
把这两层混在一起调提示词,是最常见的浪费时间方式。先用检索命中率把第一段打稳,再动生成。
二、切分决定上限
按固定字符数硬切最省事,也最容易把一句话、一张表、一个代码块从中间劈开。更稳的做法是沿文档结构切:先按标题分层,再在段落边界合并到目标长度,并保留一小段重叠。
def split_by_heading(text, max_chars=800, overlap=80):
"""按 Markdown 标题分块,段落内不硬切。"""
chunks, buf, heading = [], [], ''
for line in text.splitlines():
if line.startswith('#') and buf:
chunks.append((heading, '\n'.join(buf).strip()))
buf = []
if line.startswith('#'):
heading = line.lstrip('#').strip()
buf.append(line)
if sum(len(x) for x in buf) >= max_chars and not line.startswith('#'):
body = '\n'.join(buf).strip()
chunks.append((heading, body))
buf = [body[-overlap:]] if overlap else []
if buf:
chunks.append((heading, '\n'.join(buf).strip()))
return [(h, b) for h, b in chunks if b]
几个经验值可以当起点,但一定要用自己的文档验证:
| 策略 | 适合的文档 | 主要风险 |
|---|---|---|
| 固定长度 + 重叠 | 纯文本、聊天记录 | 切断语义单元 |
| 按标题/段落 | 技术文档、手册 | 章节长度差异大 |
| 语义切分 | 长篇叙述 | 计算成本、结果不稳定 |
| 父子块 | 问答型知识库 | 索引结构更复杂 |
父子块是我最常加的一层优化:用小片段去检索(匹配精准),命中后把所属的整个小节喂给模型(上下文完整)。实现上只是给每个 chunk 存一个 parent_id,成本很低,收益通常很明显。
三、别只信向量:混合检索
纯稠密向量擅长语义近似,但对「错误码 E4021」「某个具体函数名」这类精确字符串反而不敏感。BM25 这类关键词检索正好互补。两路结果用 RRF(倒数排名融合) 合并,比手动调权重省心,也不需要分数归一化:
def rrf(rank_lists, k=60):
"""rank_lists: [[doc_id, ...], [doc_id, ...]],按相关性从高到低。"""
scores = {}
for lst in rank_lists:
for rank, doc_id in enumerate(lst, 1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
top = rrf([vector_hits, bm25_hits])[:20]
融合之后还有一步值得做:重排。用一个 cross-encoder 或轻量重排模型,把 query 和候选片段成对打分,把 20 条压到 5 条。这一步只处理少量候选,延迟可控,对最终答案质量的提升往往比换更大的生成模型更划算。
四、提示词里要给「不知道」留出口
拼装上下文时,把每段带上来源编号,并要求模型只用给定片段回答、引用编号、无依据时明确说不知道。模板不必花哨:
请仅依据以下资料回答问题。每个结论后标注来源编号,如 [1]。
资料中没有依据的内容,直接回答「资料未提及」,不要推测。
[1] {chunk_1}
[2] {chunk_2}
...
问题:{question}
前端把编号渲染成可点击的原文链接,用户就能自己核验——这比任何「提高准确率的话术」都更能建立信任。
五、上线前检查清单
- 建了 30~50 条人工标注问题,能算 recall@k 和答案忠实度
- 切分后抽样看过 20 个 chunk,确认没有把表格/代码切断
- 关键词检索与向量检索都启用,做了融合
- 上下文超长时有截断与优先级策略,不是简单丢弃尾部
- 文档更新后索引能增量重建,且删除的文档不再被召回
- 加了「未检索到相关内容」的降级回复,而不是硬答
最后
RAG 的复杂度不在模型,而在数据:脏文档、过期版本、重复条目、权限隔离,这些才是长期要还的债。把检索命中率做成一条能每天跑的指标,后面每次改动才有依据,否则就只是在反复调参碰运气。