欢迎光临
我们一直在努力

手写一个最小RAG:把向量数据库讲明白

大模型的回答质量,上限取决于生成那一刻它手边有什么材料。模型只记得训练时见过的内容,训练之后的新闻、公司内部的文档、私人资料,它一概不知道。遇到没见过的知识,它不会承认自己不会,而是照着相似的文本自由发挥,这猜出来的流畅回答,就是幻觉。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 模型精排。这个坑我踩过,之前一个英文缩写的问题怎么召回都不对,加上关键词召回立刻就好了。混合检索展开又是一篇长文,先知道有这回事,遇到问题的时候能想到这条路就行。

总结

这篇文章的核心就五件事,列在下面。

  • RAG 给大模型外挂知识,检索环节决定回答质量
  • embedding 把文本映射成向量,语义相似变成空间距离
  • 向量数据库用 ANN(典型如 HNSW)把检索做到毫秒级,原理像图书馆先粗筛再精查
  • 最小 RAG 只需要 openai 兼容 SDK 加 Chroma,几十行代码跑通
  • 生产环境真正花时间的是分块、embedding 选型、混合检索这些细节
  • 写到这里,可能有人想问,这个方案是不是就不会胡说八道了。不是的,它该编还是会编,只不过现在手边有了一份真实的材料,编之前会先看一眼。你能做的,是让知识库覆盖得更完整、更新得更及时。

    外挂记忆这件事,说到底是把「记住」的负担从模型身上挪走,挪到我们随时能维护、随时能更新的地方。大模型负责流畅,知识库负责正确,各干各的活。

    赞(0)
    未经允许不得转载:171主机测评 » 手写一个最小RAG:把向量数据库讲明白
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址