从零搭一个能用的 RAG:分块、向量库、检索重排一次讲透
你大概遇到过这种情况:问大模型一个公司内部的问题,它一本正经地胡说八道。或者问它 2026 年的新鲜事,它告诉你”我的知识截止到某某时间”。
这就是 RAG 要解决的事——让模型在回答之前,先去你的资料里查一遍。
RAG 不是什么新架构,它就是”检索 + 生成”。但很多人搭出来效果差,问题不在模型,在流水线没搭对。这篇顺着一条真实流水线,把每一步拆开讲。
RAG 解决什么问题:幻觉与知识截止
大模型有几个天生短板:
- 知识截止 + 私有数据:训练数据有截止日期,之后的事、以及你的私有数据(文档、数据库、内部 wiki),它压根没见过。
- 幻觉:没见过的事实,它倾向于编一个像样的答案。
RAG 的思路很朴素:既然模型不知道,那就先把相关资料找出来,塞进上下文,让它”开卷答题”。开卷比闭卷靠谱,人也是这样。
一条完整的 RAG 流水线长什么样
两条线,一个建库、一个查询,共用同一个向量库:

就这几步。难点不在”有没有”,在每一步的参数和取舍。下面挨个说。
先说个真实场景:公司知识库问答
假设你公司有个内部 wiki,攒了几年——HR 政策、报销流程、技术规范、产品 FAQ,上千篇。新员工天天问重复问题,你想做个问答机器人。
摆你面前三个选择:
- 把 wiki 全塞进 prompt? 上千篇塞不进上下文窗口,token 成本也爆炸。
- 微调一个模型? 文档天天在变,你总不能天天重新微调一遍。
- RAG? 把 wiki 灌进向量库,每次提问现查现答,文档更新了重新入库就行。
RAG 之所以对口,是因为这个场景有三个特征凑到了一起:数据是私有的、内容还在频繁更新、回答得能追溯到来源。客服机器人、法律文书检索、医疗指南问答、代码库问答,套路都差不多。下面每一步,都可以套回这个场景来理解。
分块(Chunking):最容易被低估的一环
新手最爱踩的坑:把整篇文档丢进去算一个向量。结果一个问题只命中文档的一个角落,却把整篇的向量拉过来,检索精度稀烂。
分块就是切香肠,把长文档切成一小段一小段,每段单独算向量。切法主要有三种:
- 固定长度切:每 N 个字切一段。最简单,但会把一句话拦腰切断。
- 按语义切:在段落、标题、句号边界切。上下文更完整。
- 重叠切:相邻块留一段重叠,避免边界信息丢失。
光说没用,拿一段真实文档当样本,看三种切法的区别。样本是一条团队代码规范:
代码规范:所有依赖用 pnpm 安装,禁止 npm 或 yarn,因为硬链接省磁盘。
每次提交前必须跑 npm test 全量通过,CI 红了禁止合并到 main。
API 密钥统一放 .env,绝不硬编码进源码,.env 要进 .gitignore。
① 固定长度切(每 30 字硬切)——最朴素的写法就是按字符切片,不看任何语义:
doc = ("代码规范:所有依赖用 pnpm 安装,禁止 npm 或 yarn,因为硬链接省磁盘。"
"每次提交前必须跑 npm test 全量通过,CI 红了禁止合并到 main。"
"API 密钥统一放 .env,绝不硬编码进源码,.env 要进 .gitignore。")
chunk_size = 30
chunks = [doc[i:i+chunk_size] for i in range(0, len(doc), chunk_size)]
上面这段代码实跑出来的结果:
块1: 代码规范:所有依赖用 pnpm 安装,禁止 npm 或 ya
块2: rn,因为硬链接省磁盘。每次提交前必须跑 npm test
块3: 全量通过,CI 红了禁止合并到 main。API 密钥统一放
块4: .env,绝不硬编码进源码,.env 要进 .gitign
块5: ore。
灾难现场:yarn 被切成 ya + rn(块1 末尾 + 块2 开头),gitignore 更惨,被切成 .gitign + ore。“禁止 npm 或 yarn”这条关键规则在块1 末尾断裂——用户问”能不能用 yarn”时,命中块1,但向量把半截 ya 和一堆无关内容混在一起,检索精度直接塌方。这就是固定长度的硬伤——它不认语义,只数字符。
② 按语义切(在句号 / 换行处切):
块1: 所有依赖用 pnpm 安装,禁止 npm 或 yarn,因为硬链接省磁盘。
块2: 每次提交前必须跑 npm test 全量通过,CI 红了禁止合并到 main。
块3: API 密钥统一放 .env,绝不硬编码进源码,.env 要进 .gitignore。
每块正好一条完整规则,干净。语义切的代价是要识别边界(句号、标题、段落),但换来的是每块”自成一个可被独立理解的小单元”,这对检索是决定性的。
③ 重叠切(在语义切基础上,相邻块尾部 / 头部重叠一段):
块1: [...所有依赖用 pnpm...] [禁止 npm 或 yarn。]
块2: [禁止 npm 或 yarn。] [每次提交前必须跑 npm test...]
为什么要重叠?因为切完的边界是”人为”的。比如一条规则恰好横跨块1 和块2 的接缝,切了之后两块都只剩半句,谁都不完整。留个重叠(通常 10%-20%),让边界附近的内容在两个块里都出现一次,检索时至少有一块能完整命中。
工业界实际怎么切:几乎没人手写切分逻辑,都用现成切分器。最主流的是 LangChain 的 RecursiveCharacterTextSplitter,它”智能”在哪?看它的分隔符优先级:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=200, # 每块目标 200 字符
chunk_overlap=40, # 相邻块重叠 40 字符
# 关键:按优先级递进地切,能保住大单元就不切碎小单元
separators=["\n\n", "\n", "。", ",", " ", ""],
)
chunks = splitter.split_text(doc)
separators 那行是灵魂。它的逻辑是逐级降级:先用段落(\n\n)切,只有某段仍超过 chunk_size 时,才对那一段退到行(\n)切;还超长,再退到句号(。)、逗号(,)、空格,最后才是任意字符。没超长的段原样保留,不会被无端切碎。能保住段落就不切行,能保住句子就不切逗号——这就是递归切分的精髓,等于把”固定长度”和”按语义”两套思路缝起来了。
经验值:一块 200-500 字、重叠 10%-20%(这里按字符数算,中文场景;英文场景一般按 token 谈,500-1000 token 起步,别把两套单位混了)。太大召回粗(一个块塞了好几件事,命中了却混在一起),太小上下文碎(每块信息量不够撑起回答)。没有银弹,得拿你自己的数据试——切完随机抽 10 块人眼看一遍,是不是每块都能独立讲清一件事,这是最快的自检。
向量库选型:Milvus / Qdrant / Weaviate 怎么选
向量库存的就是”这段话的向量 + 原文”,检索时按相似度(通常用余弦)找最近的几条。
具体点,库里每条记录长这样:
{
"id": "doc_42_chunk_3",
"embedding": [0.0231, -0.1142, 0.0877, ... ], // 几百到几千维浮点数
"document": "发版前必须跑 npm test,否则不让合并。",
"metadata": { "source": "团队规范.md", "chunk_idx": 3, "updated": "2026-06-01" }
}
向量就是那句话被嵌入模型压成的一串数字——可以理解成”语义的坐标”,意思接近的句子坐标也接近。检索时,你的问题也被压成同样维度的向量,然后算两串数字的余弦相似度(夹角越小越相关)。
metadata 别小看,它是”带过滤的检索”的关键:比如”只在今年更新的文档里查""只在某个产品线的文档里查”,全靠它。Qdrant 这类向量库的强项就在这。
| 向量库 | 适合 | 特点 |
|---|---|---|
| Chroma | 入门、原型 | 一行起,单机轻量,自带嵌入(默认英文) |
| Qdrant | 生产、强过滤 | Rust 高性能,过滤与检索深度融合 |
| Milvus | 大规模、企业级 | 分布式,扛亿级向量,标量过滤也强 |
| Weaviate | 模块化、多模态 | 内置向量化模块,GraphQL 查询灵活 |
我的建议:先 Chroma 跑通,量大或要上生产再换 Qdrant / Milvus。别一上来就 Milvus,运维成本够你喝一壶。
检索之后还有一步:重排(Rerank)
向量检索(用嵌入算相似度)召回率高,但精度一般——它擅长”找相关”,不擅长”排哪个最相关”。
所以检索完 top-20 之后,再加一个 reranker 精排成 top-5。重排模型(像 bge-reranker、Cohere Rerank)用的是 cross-encoder,比双塔的嵌入更准,但慢,所以只对候选集跑。
这一步经常被省,但加上之后效果肉眼可见地变好。top-K 检索召回,rerank 定序,这是工业界标配。
一个能跑的最小例子 + 常见坑
用 Chroma 跑通要装两个包(pip install chromadb sentence-transformers),第一次会下载一个中文嵌入模型:
import chromadb
from chromadb.utils import embedding_functions
# 中文必须换掉默认嵌入——Chroma 默认是英文模型(all-MiniLM-L6-v2),对中文基本是瞎子
ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="BAAI/bge-small-zh-v1.5")
client = chromadb.PersistentClient(path="./rag_db")
# 文本检索通常用余弦:Chroma 默认是 L2,要显式切到 cosine,且建库时定死、事后改不了
coll = client.get_or_create_collection(
"notes",
embedding_function=ef,
metadata={"hnsw:space": "cosine"},
)
# 建库:塞几段话进去
coll.add(
ids=["1", "2", "3"],
documents=[
"我们公司用 pnpm,不要用 npm 装依赖。",
"发版前必须跑 npm test,否则不让合并。",
"API 密钥统一放 .env,禁止硬编码进代码。",
],
)
# 查询:问题被嵌入后,按余弦相似度找最近的 2 条
res = coll.query(query_texts=["装依赖用什么?"], n_results=2)
print(res["documents"])
# → [['我们公司用 pnpm,不要用 npm 装依赖。', 'API 密钥统一放 .env,禁止硬编码进代码。']]
# res 里还带了相似度分数和 id,可拿来做阈值过滤
# res["distances"] → 余弦距离 [[0.35, 0.59]]:第1条 0.35 是确信命中,
# 第2条 0.59 明显跑偏了(命中的是无关的密钥规则)——
# 设个距离阈值把这种边角结果丢掉,别照单全收塞给 LLM
# res["ids"] → [['1', '3']]
最后把检索到的片段拼进 prompt,丢给 LLM:
context = "\n".join(res["documents"][0])
prompt = f"根据以下资料回答问题,找不到就说不知道。\n资料:{context}\n问题:装依赖用什么?"
# → 把 prompt 发给你的 LLM
能跑。但”能跑”和”能用”之间,隔着一个评估。
常见坑:
- 块切太大:一个块塞了三件事,检索命中了却混在一起。切小点。
- 没重排:top-1 经常不是最该要的那条。加 reranker。
- 嵌入模型选错:用英文嵌入模型处理中文,问题里恰好带答案关键词时(比如问”装依赖”命中”pnpm 装依赖”)还能靠词汇重叠蒙对;可一旦问题和答案是语义相关、字面没共同词(比如问”怎么回滚发版”,答案是”用 git revert 撤销提交”),英文模型就彻底召回不到。所以中文必须换
bge-large-zh这类中文嵌入。 - 不评估:感觉效果”还行”就上线。搭个简单的问答测试集,量化检索命中率。
- 资料本身烂:垃圾进垃圾出,RAG 救不了结构混乱的文档。
RAG vs 微调 vs 长上下文:什么时候用哪个
很多人第一反应是”我是不是该微调一个模型”。先别急,看清楚三个方案各管什么:
| 方案 | 适合 | 不适合 | 成本 |
|---|---|---|---|
| RAG | 私有数据、频繁更新、要引用来源 | 要改模型的说话风格或内在能力 | 中(搭向量库 + 检索) |
| 微调 | 固定领域知识、改输出风格/格式 | 数据天天变、要可追溯的来源 | 高(要训练 + 持续迭代) |
| 长上下文 | 文档就几篇、临时一次性分析 | 上千篇、成本敏感、要长期复用 | 单次低(不用建库),规模化高(每次重传全部 token) |
说人话:
- 想让模型知道它不知道的事(你的私有数据)→ RAG。
- 想让模型变得更像你(语气、格式、专业判断力)→ 微调。
- 文档就两三页、用完即弃 → 直接塞长上下文,别折腾 RAG。
这三者也不是非此即彼。生产系统经常几个一起上:长上下文兜底,RAG 负责精准召回,微调来调说话风格。先把 RAG 这条地基打牢,再谈组合。
RAG 不神秘,就六步:分块、嵌入、存进向量库、检索、重排、生成。先把最小例子跑通,再一步步调参、加重排、做评估。别被各种”高级 RAG”名词吓住,地基没打好,GraphRAG 也救不了你。
下一篇可以聊聊怎么给 RAG 做评估——那才是真正拉开差距的地方。