RAG 是什么
用一个真实项目,把「检索增强生成」讲清楚。
一、为什么需要 RAG
大语言模型(LLM)有一个根本性的局限:它只知道训练截止日期之前的公开信息,对你的私有文档一无所知。
你的笔记、公司的内部文档、个人收藏的资料——这些东西 LLM 统统不知道。如果你把整份文档塞进对话窗口,很快就会超出上下文长度限制,而且成本也极高。
RAG(Retrieval-Augmented Generation,检索增强生成)就是为了解决这个问题而生的:
先检索,后生成。 不是把所有文档喂给 LLM,而是先找到最相关的片段,只把这几段内容作为上下文,让 LLM 在此基础上回答。
这个思路听起来简单,但要做好,里面有很多细节。下面我用自己写的一个真实项目——面向本地 Markdown 笔记的 RAG 问答系统——把每个环节都拆开讲清楚。
二、RAG 的两个阶段
RAG 系统的工作分为两个完全独立的阶段:
1 | 【离线阶段:索引】 【在线阶段:查询】 |
两个阶段共用同一套向量库,但时机不同:索引可以在后台静默运行、增量更新;查询则是实时响应用户提问。
三、离线阶段:把文档变成可检索的向量
1. 文件扫描与增量检测
每次索引不能都全量重跑,否则代价太大。这个系统用 SHA-256 + SQLite 实现增量检测:
1 | 文件路径 → SHA-256 哈希 → 与上次记录比较 |
SQLite 里维护了一张表,记录每个文件的路径、doc_id、sha256 和修改时间。这样文件一旦没有实质变化,哈希不变,就直接跳过,只处理真正有变化的文件。
2. 结构化分块(Chunking)
把整篇文档直接向量化是不行的,因为:
- 文档太长,向量会”平均化”,失去细节
- 检索时需要精确定位到具体段落,而不是整篇文章
分块策略采用两级切分:
第一级:按 Markdown 标题切分
1 | # 主标题 ← h1 |
每个标题下的内容作为一个 section,同时保留标题层级信息(headings)。这样每个 chunk 都知道自己属于哪个章节。
第二级:对超长 section 做递归字符切分
设定 chunk_size=700,chunk_overlap=100。超过 700 字的 section 会被进一步切分,相邻 chunk 保留 100 字的重叠,避免语义在边界断裂。
分隔符优先级:\n\n > \n > 中文句号 > 英文句号 > 空格。
每个最终的 chunk 携带这些元数据:
doc_id:文档的唯一 ID(由文件路径 sha256 派生,稳定不变)chunk_id:doc_id#序号path:文件相对路径title:文档标题headings:所在的标题层级路径,如["RAG系统", "检索", "重排"]tags:从 YAML Front Matter 读取的标签
3. 向量化(Embedding)
每个 chunk 的文本内容被送入 Embedding 模型,转换为一个高维向量(本项目用 Ollama 本地运行 nomic-embed-text,维度 768)。
向量的本质:把语义相近的文本映射到空间中相近的位置。 “苹果手机” 和 “iPhone” 在向量空间里距离很近,即使字面完全不同。
4. 存入向量库(Qdrant)
向量和对应的 payload(元数据)一起存入 Qdrant。Qdrant 使用 HNSW(Hierarchical Navigable Small World)索引结构,能在毫秒级完成近似最近邻搜索。
为了加速过滤,对 doc_id、tags、mtime 字段建立了 Payload 索引,支持按标签或时间范围预过滤后再做向量搜索。
四、在线阶段:从提问到带引用的回答
1. 问题向量化
用户的问题同样用相同的 Embedding 模型转换为向量。必须用同一个模型,否则问题向量和文档向量不在同一个语义空间里,检索会乱套。
2. 向量检索(Retrieve)
拿着问题向量去 Qdrant 做余弦相似度搜索,召回 top_k=50 个最相似的 chunk。
余弦相似度衡量的是方向的接近程度,而不是向量的长度。两个向量夹角越小,cosine 值越接近 1,语义越相近。
3. 重排(Rerank)
向量检索召回的 50 个结果里,有些虽然向量相似度高,但实际上并不精确匹配用户的问题。重排做二次筛选。
当前实现是一个轻量的关键词重排:统计问题中每个词在 chunk 内容里出现的次数,得分越高排越靠前,最终只保留 top_n=5 个。
这是一个 MVP 实现。生产系统通常会接入真实的 Cross-Encoder 重排模型(如 bge-reranker-v2-m3),效果显著更好,但速度也更慢、成本更高。
4. LLM 生成
把 5 个 chunk 的内容拼装成上下文,连同用户问题一起送给 LLM:
1 | 你是一个Markdown笔记问答助手。请严格依据提供的上下文回答。 |
LLM 被明确要求:只能依据提供的上下文回答,不能乱编;如果上下文不够,必须说不知道。 这个约束极为重要,是 RAG 减少幻觉的核心机制。
5. 返回带引用的回答
最终输出是结构化的 JSON:
1 | { |
每条引用精确到 文件路径#标题 级别,用户可以直接追溯原始笔记,验证回答是否准确。
五、完整链路回顾
1 | 【索引时】 |
六、RAG 的边界与局限
RAG 不是万能药,有几个天然的局限需要清醒认识:
1. 检索质量决定生成质量
“Garbage in, garbage out”。如果检索阶段没有找到相关内容,LLM 无论多聪明也无法给出好答案。Embedding 模型的质量、分块策略、召回数量都直接影响最终效果。
2. 跨文档推理很难
“综合我所有笔记,总结出一个结论” 这类需要跨多文档全局推理的问题,RAG 效果有限。它更擅长的是”找到相关段落,在此基础上回答”。
3. 实体关系理解弱
向量相似度捕捉的是语义相近,但对于”A 和 B 是什么关系”这类结构性问题,Graph RAG(知识图谱 + 向量混合)会更合适。
4. 分块粒度难以完美
块太大,向量被稀释,精度下降;块太小,单块缺少上下文,生成时 LLM 信息不足。需要针对具体场景调参,没有通用答案。
七、可以继续扩展的方向
这个项目是 MVP,后续有几个方向可以显著提升效果:
- 真实 Reranker:接入
bge-reranker-v2-m3等 Cross-Encoder 模型,比关键词匹配准确得多 - Multi-Query 检索:让 LLM 把原始问题改写为多个角度的子问题,分别检索后合并,提高召回率
- Hybrid Search:向量检索(dense)+ BM25 关键词检索(sparse)结合,互补彼此的盲区
- Parent Document Retriever:存储细粒度 chunk 用于检索,但把父级大块文本喂给 LLM,兼顾精度与上下文完整性
- 知识图谱(Graph RAG):提取实体和关系,支持跨文档的结构性推理
小结
RAG 的核心思想只有一句话:不要把所有东西塞给 LLM,而是先找到最相关的内容,只把这些内容作为上下文。
围绕这句话,展开了一套工程体系:增量索引、结构化分块、向量化、近似最近邻搜索、重排、Prompt 约束、引用溯源。每个环节都有工程取舍,每个环节的质量都影响最终体验。
RAG 本质上是一个信息检索问题 + 生成问题的结合体。做好它,需要同时理解这两个领域——这也是它有趣的地方。
本文基于作者实际开发的 rag_notes 项目,技术栈:LangChain + Qdrant + Ollama + MiniMax。