大模型的回答质量,上限取决于生成那一刻它手边有什么材料。模型只记得训练时见过的内容,训练之后的新闻、公司内部的文档、私人资料,它一概不知道。遇到没见过的知识,它不会承认自己不会,而是照着相似的文本自由发挥,这猜出来的流畅回答,就是幻觉。RAG 就是补这个缺口的方案。
原理本身不复杂,难的是把链路跑通。这篇按实际搭建的顺序走,切块、向量化、检索、生成,每一步配一段能直接运行的 Python 代码。四段跑完,你手里就是一个能用的知识问答系统。
大模型为什么需要外挂记忆
模型的全部知识,冻结在训练完成的那一刻。
训练时喂给它的是某个时间点之前的公开语料,之后发生的新闻、你公司的内部制度、你同事刚写的方案,它一概不知。遇到没见过的内容,它不会承认不知道,而是基于见过的相似文本自由发挥,这就是幻觉的根源。幻觉的根源是知识缺口,不是模型故障。
对抗知识缺口有两条路,微调和 RAG。
微调像是给模型上培训班,把领域知识揉进参数。这条路能改变模型的行为习惯,比如学会特定的回答格式、语气和风格,这是 RAG 做不到的。但代价也实打实,要准备高质量标注数据,要花算力训练,知识一变还得重训。
RAG 完全反过来,不动模型一根参数,知识全部放在模型外面,用的时候临时检索、临时喂进去。改知识库就是改答案,今天制度改了,明天换文档就行,零训练成本。
坦率的讲,这两个不是二选一,成熟的生产系统经常同时用,微调管「说话方式」,RAG 管「知识来源」。但如果你只想解决「回答不对」这个问题,RAG 是投入产出比最高的起点。不需要 GPU,不需要训练,知识库更新即时生效。
RAG 是什么
RAG 全称 Retrieval-Augmented Generation,检索增强生成。名字已经把工作说完了,先检索,再生成,把检索到的资料当作生成时的参考。
整个流程分三个阶段,索引、检索、生成。索引阶段离线把文档切成小块,每块转成向量,存进向量数据库。检索阶段把用户的问题也转成向量,去库里找出最相似的几块。生成阶段把这几块拼进 prompt,让模型基于它们回答。
#mermaid-svg-qFJBG18uZvGQV7pj{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qFJBG18uZvGQV7pj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qFJBG18uZvGQV7pj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qFJBG18uZvGQV7pj .error-icon{fill:#552222;}#mermaid-svg-qFJBG18uZvGQV7pj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qFJBG18uZvGQV7pj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qFJBG18uZvGQV7pj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qFJBG18uZvGQV7pj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qFJBG18uZvGQV7pj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qFJBG18uZvGQV7pj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qFJBG18uZvGQV7pj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qFJBG18uZvGQV7pj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qFJBG18uZvGQV7pj .marker.cross{stroke:#333333;}#mermaid-svg-qFJBG18uZvGQV7pj svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qFJBG18uZvGQV7pj p{margin:0;}#mermaid-svg-qFJBG18uZvGQV7pj .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-qFJBG18uZvGQV7pj .cluster-label text{fill:#333;}#mermaid-svg-qFJBG18uZvGQV7pj .cluster-label span{color:#333;}#mermaid-svg-qFJBG18uZvGQV7pj .cluster-label span p{background-color:transparent;}#mermaid-svg-qFJBG18uZvGQV7pj .label text,#mermaid-svg-qFJBG18uZvGQV7pj span{fill:#333;color:#333;}#mermaid-svg-qFJBG18uZvGQV7pj .node rect,#mermaid-svg-qFJBG18uZvGQV7pj .node circle,#mermaid-svg-qFJBG18uZvGQV7pj .node ellipse,#mermaid-svg-qFJBG18uZvGQV7pj .node polygon,#mermaid-svg-qFJBG18uZvGQV7pj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qFJBG18uZvGQV7pj .rough-node .label text,#mermaid-svg-qFJBG18uZvGQV7pj .node .label text,#mermaid-svg-qFJBG18uZvGQV7pj .image-shape .label,#mermaid-svg-qFJBG18uZvGQV7pj .icon-shape .label{text-anchor:middle;}#mermaid-svg-qFJBG18uZvGQV7pj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qFJBG18uZvGQV7pj .rough-node .label,#mermaid-svg-qFJBG18uZvGQV7pj .node .label,#mermaid-svg-qFJBG18uZvGQV7pj .image-shape .label,#mermaid-svg-qFJBG18uZvGQV7pj .icon-shape .label{text-align:center;}#mermaid-svg-qFJBG18uZvGQV7pj .node.clickable{cursor:pointer;}#mermaid-svg-qFJBG18uZvGQV7pj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qFJBG18uZvGQV7pj .arrowheadPath{fill:#333333;}#mermaid-svg-qFJBG18uZvGQV7pj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qFJBG18uZvGQV7pj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qFJBG18uZvGQV7pj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qFJBG18uZvGQV7pj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qFJBG18uZvGQV7pj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qFJBG18uZvGQV7pj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qFJBG18uZvGQV7pj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qFJBG18uZvGQV7pj .cluster text{fill:#333;}#mermaid-svg-qFJBG18uZvGQV7pj .cluster span{color:#333;}#mermaid-svg-qFJBG18uZvGQV7pj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qFJBG18uZvGQV7pj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qFJBG18uZvGQV7pj rect.text{fill:none;stroke-width:0;}#mermaid-svg-qFJBG18uZvGQV7pj .icon-shape,#mermaid-svg-qFJBG18uZvGQV7pj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qFJBG18uZvGQV7pj .icon-shape p,#mermaid-svg-qFJBG18uZvGQV7pj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qFJBG18uZvGQV7pj .icon-shape .label rect,#mermaid-svg-qFJBG18uZvGQV7pj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qFJBG18uZvGQV7pj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qFJBG18uZvGQV7pj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qFJBG18uZvGQV7pj :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
原始文档
分块
向量化
向量数据库
用户提问
向量化
相似度检索
拼接上下文
大模型生成
最终回答
三个阶段里,最核心也最容易被忽视的是检索。生成是模型的老本行,向量化是调一次 API,只有检索这一步决定了模型能不能拿到对的材料。材料不对,模型再聪明也是巧妇难为无米之炊。
把文本变成向量
现在到最关键的环节,怎么让计算机比较两段文字谁跟谁更相关。
直接比较字符串不靠谱,「报销几天内提交」和「报销时限」一个字都对不上,人一看就知道是同一个意思。于是有了 embedding 模型,它把一段文本映射成一个固定长度的数字数组,几百到几千维,OpenAI 的 text-embedding-3、智谱的 embedding-3 都是这类模型。
这个数组有个了不起的性质,语义相近的文本,向量在空间里离得近。「苹果很好吃」和「水果口感不错」两个向量挨得很近,「苹果很好吃」和「数据库挂了」就隔得很远。模型不知道这句话的字面含义,它学到的是语义的相对位置。
有了向量,相似度就变成了几何问题,最常用的是余弦相似度,值越接近 1 表示方向越一致。
import numpy as np
def cosine(a: list[float], b: list[float]) –> float:
"""余弦相似度,1 表示方向一致,-1 表示完全相反"""
va, vb = np.array(a), np.array(b)
return float(va @ vb / (np.linalg.norm(va) * np.linalg.norm(vb)))
向量检索不止是暴力算相似度
如果文档只有几百条,暴力检索就够了,把每条都和问题算一遍相似度,取最高。但十万条文档呢,每次提问算十万次高维点积,再快的机器也扛不住,延迟直接秒级。
于是有了专门的向量数据库,用近似最近邻(ANN)算法把这件事压到毫秒级。这里有个类比我觉得特别贴切,图书馆。
书架上的书不是按「跟我的问题像不像」排的,而是按杜威十进分类法,先大类后小类。你想找一本养蜂的书,不会从第一本开始翻,而是先走到 638 号书架,再逐本扫。HNSW 算法干的事跟图书馆一模一样,把向量组织成多层图,上层稀疏,一步跨一个大区域做粗筛,下层密集,精确定位收尾。先粗筛再精查,速度就是这么上来的。Chroma、Milvus 这些库的默认索引基本都是它。
回到 RAG 的主线,你现在已经知道它全部的原理了,embedding 存知识、ANN 捞知识、模型生成答案。剩下的问题只有一个,这套东西代码怎么写。往下看。
实战:Python 跑通最小 RAG
理论聊完了,接下来把它亲手跑通。代码全部能直接运行,只需要两个依赖和一个 API key。
环境准备
pip install openai chromadb
代码统一走 OpenAI 兼容接口,换模型服务商只需要改 base_url。我用智谱的 API 做演示,用 OpenAI 官方的话删掉 base_url 那行就行,DeepSeek、混元这类兼容服务同理。
from openai import OpenAI
openai_client = OpenAI(
api_key="sk-你的key", # 换成你自己的 key
base_url="https://open.bigmodel.cn/api/paas/v4", # 智谱示例,官方 API 可删此行
)
构建向量库
先准备素材。为演示方便我用一小段模拟的公司制度,真实场景里换成解析好的 PDF 文本就行,后面的处理一模一样。
company_manual = """
差旅报销制度(2026 年修订)
员工因公出差产生的交通、住宿费用,需在出差结束后 5 个工作日内提交报销申请。
住宿标准为一线城市每晚不超过 600 元,二线城市不超过 400 元。
报销单据需附发票与行程单,缺票不予受理。
"""
def chunk_text(text: str, size: int = 200, overlap: int = 50) –> list[str]:
"""按字符切块,重叠 50 字符,避免关键信息被拦腰截断"""
chunks = []
start = 0
while start < len(text):
chunks.append(text[start:start + size])
start += size – overlap
return chunks
def get_embedding(text: str) –> list[float]:
"""调用 embedding API,把文本变成向量"""
resp = openai_client.embeddings.create(model="embedding-3", input=text)
return resp.data[0].embedding
分块这个动作很重要,原因后面讲陷阱时细说,先记住一句话,小块才能精准召回。接下来建库写入。
import chromadb
chroma_client = chromadb.PersistentClient(path="./rag_demo")
collection = chroma_client.get_or_create_collection("knowledge")
chunks = chunk_text(company_manual)
collection.add(
ids=[f"chunk-{i}" for i in range(len(chunks))],
documents=chunks,
embeddings=[get_embedding(c) for c in chunks],
)
到这里知识已经进库了,可以开始问问题。
检索
question = "报销申请需要在几天内提交?"
results = collection.query(
query_embeddings=[get_embedding(question)],
n_results=2,
)
for i, doc in enumerate(results["documents"][0]):
print(f"命中 {i + 1}:{doc.strip()}")
注意这里跟关键词搜索的区别,你把问题改成「报销多久办」,同样能捞到这块文本,因为语义相近。这是 embedding 检索的核心价值,不需要关键词对上,只需要意思对上。
生成
生成这步要做的也很简单,把捞回来的材料拼进 prompt,交给模型。
context = "\\n\\n".join(results["documents"][0])
resp = openai_client.chat.completions.create(
model="glm-4-plus",
messages=[
{"role": "system", "content": "只能依据用户提供的资料回答,资料里没有的信息,直接说明资料中未提及。"},
{"role": "user", "content": f"参考资料:\\n{context}\\n\\n问题:{question}"},
],
)
print(resp.choices[0].message.content)
跑一遍,输出是这样的。
报销申请需在出差结束后 5 个工作日内提交。
跟制度原文一致。如果不用 RAG,直接让模型裸答这个问题,它只能凭训练数据里的印象发挥,答案对不对全靠运气。有了检索这一环,回答依据就变成了这份资料本身。
到这一步,最小 RAG 就完整了。模型没有变聪明,它还是那个爱编的模型,我们只是把正确答案提前放到了它眼前。
RAG 的全部魔法,就是这一下。
向量数据库怎么选
代码跑通了,但生产环境的选型还是得聊聊。市面上的选项大致分四类,对比如下。
| Chroma | 嵌入式库 | 百万级向量以内 | 极低 | 原型、小规模应用、个人项目 |
| FAISS | 算法库 | 千万级 | 中 | 纯检索场景、和现有服务集成 |
| Milvus | 独立服务 | 亿级 | 高 | 大规模生产、多租户、高并发 |
| pgvector | PostgreSQL 扩展 | 千万级 | 低 | 已有 PG 库、想少引入组件 |
选型我给一句掏心窝的话,原型和中小项目直接 Chroma,量上来了再考虑 Milvus,已经在用 PostgreSQL 的加个 pgvector 最省事。
不要一上来就上分布式大库,我见过不少团队死在过度设计上。说实话我也不确定他们的性能瓶颈是真的还是想象中的,但架构复杂度带来的成本是实打实的。先跑通再说,向量库之间迁移比想象中简单,接口长得都差不多。
常见陷阱
代码跑通只是起点,生产环境的坑都在细节里。列三个我踩过或者看别人踩过的。
先说 chunk 大小。 切太小,语义碎片化,检索出来的片段前言不搭后语。切太大,一块里混多个主题,相似度被稀释,还浪费 token。没有银弹,一般从 200 到 800 字符起调,overlap 留 10% 到 20%,拿真实问答数据反复测。
再一个是 embedding 模型的一致性。 索引和查询必须用同一个 embedding 模型,这个错我自己没犯过,但见过有人建好库之后换了模型没重建索引,检索结果从此完全随机,排查了半天。另外 embedding 模型和生成模型是两回事,不是 LLM 越强 embedding 越好,术业有专攻。
还有召回质量。 纯向量检索对短问题友好,但对专业术语多、缩写多的情况经常漏。生产环境的主流做法是混合检索,向量 + BM25 关键词召回,再叠一个 rerank 模型精排。这个坑我踩过,之前一个英文缩写的问题怎么召回都不对,加上关键词召回立刻就好了。混合检索展开又是一篇长文,先知道有这回事,遇到问题的时候能想到这条路就行。
总结
这篇文章的核心就五件事,列在下面。
写到这里,可能有人想问,这个方案是不是就不会胡说八道了。不是的,它该编还是会编,只不过现在手边有了一份真实的材料,编之前会先看一眼。你能做的,是让知识库覆盖得更完整、更新得更及时。
外挂记忆这件事,说到底是把「记住」的负担从模型身上挪走,挪到我们随时能维护、随时能更新的地方。大模型负责流畅,知识库负责正确,各干各的活。





