RAG 从零搭建实战:用向量检索增强大模型回答

RAG(Retrieval-Augmented Generation,检索增强生成)是给大模型接入外部知识的标准做法。大模型虽有海量参数,却存在知识截止、回答幻觉、无法访问你私有文档三个硬伤。本文用可运行代码,从零搭一个 RAG 私域知识问答系统,让你真正理解每一步在做什么、为什么这么做。

一、为什么需要 RAG:大模型的三个硬伤

直接用大模型问答,常常遇到三类问题。一是知识截止:模型训练数据有时间点,之后的新政策、新版本、内部资料它一概不知;二是幻觉:对不确定的事实,模型会”自信地编”,给出看似通顺却错误的答案;三是数据隔离:公司的产品文档、工单、Wiki 永远不会进公开训练集。RAG 的思路很简单——先把相关资料检索出来,再让模型”带着资料答题”,把生成建立在可引用的证据之上。

有人会问:直接微调模型不行吗?微调适合”改变模型能力风格”,而 RAG 适合”补充模型不知道的事实”。前者成本高、周期长、难更新;后者只改检索语料就能即时生效,且每次回答都能溯源到原文,对合规和可信度更友好。所以绝大多数”接私有知识”的场景,RAG 都是第一选择。

想深入理解向量检索的底层原理,可以参考我们之前写的 Embeddings 向量嵌入实战 与 向量数据库实战;想用平台快速搭,可看 Dify+Ollama 私有化部署。本文则聚焦”自己写一遍”的掌控感。

二、RAG 整体架构与组件选型

一个最小可用的 RAG 系统由五段流水线组成,离线建库与在线问答各占一半:

阶段职责常见选型
文档加载读取 PDF / Markdown / 网页LangChain Loaders、Unstructured
文本切块按语义切分为 chunk递归字符切分、按标题切分
向量化文本 → 向量OpenAI / bge / bge-m3 嵌入模型
向量存储存向量并支持检索FAISS、Chroma、pgvector
检索 + 生成取 top-k 拼 Prompt 调 LLMGPT / DeepSeek / Qwen

三、环境准备

我们用 OpenAI 兼容接口做嵌入与生成(可一键换成本地模型,参见 DeepSeek 本地部署),向量检索用轻量的 FAISS。

pip install openai faiss-cpu tiktoken

# 配置(本地部署时把 base_url 指向 Ollama / vLLM 即可)
export OPENAI_API_KEY="sk-xxxx"
export OPENAI_BASE_URL="https://api.openai.com/v1"

四、第一步:文档加载与清洗

真实文档常有噪声(页眉页脚、多余空行)。先做一次最小清洗,避免脏数据进入索引。

import re

def load_markdown(path: str) -> str:
    with open(path, "r", encoding="utf-8") as f:
        text = f.read()
    # 去掉代码块以外的多余空行,保留结构
    text = re.sub(r"
{3,}", "

", text)
    text = re.sub(r"[ 	]+", " ", text)
    return text.strip()

docs = [load_markdown(f) for f in ["product.md", "faq.md", "wiki.md"]]

五、第二步:文本切分(Chunking)

切块直接决定检索质量:太大则噪声多、命中不精准;太小则语义被切碎。经验值中文 300–500 字、重叠 10%–20% 比较稳。

切分策略也有讲究。最朴素的是”按固定字符数递归切”(遇到段落、句子边界再断),实现简单、通用;更进阶的是”按标题/章节结构切”,能保住文档的层级语义;若文档结构松散,还可以用小模型做”语义切分”,把意思完整的一句群归为一块。对中文来说,还要注意按标点而非按空格断句——英文的空格分词在中文里完全失效,所以本文示例直接按字符数切并保留重叠,是最稳的起步方案。

def split_text(text: str, size: int = 400, overlap: int = 60):
    step = size - overlap
    chunks = []
    for i in range(0, len(text), step):
        chunk = text[i:i + size]
        if len(chunk) >= 50:          # 丢弃过短碎片
            chunks.append(chunk)
    return chunks

all_chunks = []
for d in docs:
    all_chunks.extend(split_text(d))   # 全局合并成一个 chunk 池
print(f"共切出 {len(all_chunks)} 个 chunk")
参数含义建议值(中文)
size单块最大字符数300–500
overlap相邻块重叠字符size 的 10%–20%
最小长度丢弃过短碎片≥ 50

六、第三步:向量化(Embedding)

把每段文本映射成一个高维向量。同一个嵌入模型必须同时用于建库和查询,否则向量空间不一致,检索全错。

嵌入模型怎么选?英文场景 OpenAI text-embedding-3 系列性价比高;中文或需要本地化部署时,bge-large-zh、bge-m3 这类开源模型表现更好,且能跑在本地显卡上,数据不出内网。向量维度通常在 768–3072 之间,维度越高表达力越强但存储与检索开销也越大。无论选哪个,关键是”建库和查询用同一个模型、同一套预处理”,这是新手最常踩的坑。

from openai import OpenAI
client = OpenAI()

def embed(texts: list[str]) -> list[list[float]]:
    resp = client.embeddings.create(
        model="text-embedding-3-small",
        input=texts,
    )
    return [d.embedding for d in resp.data]

# 分批嵌入,避免单次请求过长
batch, vectors = 16, []
for i in range(0, len(all_chunks), batch):
    vectors.extend(embed(all_chunks[i:i + batch]))

七、第四步:向量存储与相似检索

FAISS 用内积 / 余弦检索最近邻。建库只需把向量矩阵喂进去,查询时把问题向量丢进去取 top-k。

import numpy as np, faiss

dim = len(vectors[0])
index = faiss.IndexFlatIP(dim)              # 内积 ≈ 余弦(已归一化时)
faiss.normalize_L2(np.array(vectors, dtype="float32"))
index.add(np.array(vectors, dtype="float32"))

def retrieve(query: str, k: int = 4):
    q = np.array(embed([query]), dtype="float32")
    faiss.normalize_L2(q)
    scores, idx = index.search(q, k)
    return [all_chunks[i] for i in idx[0]]

hits = retrieve("如何重置账户密码?")
print("检索到的相关片段:")
for h in hits:
    print("-", h[:80])

八、第五步:拼装 Prompt 并生成回答

把检索到的片段拼进系统提示,要求模型”只依据资料回答、无依据就如实说不知道”,并带上引用来源。

def ask(query: str) -> str:
    ctx = "

".join(f"[资料{i+1}] {c}" for i, c in enumerate(retrieve(query)))
    prompt = f'''你是企业知识助手。只依据下面的资料回答,
若资料中没有答案,请明确说「资料未覆盖」。不要编造。

{ctx}

问题:{query}'''
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.1,
    )
    return resp.choices[0].message.content

print(ask("忘记密码后多久能找回账户?"))

九、有无 RAG 的效果对比

维度无 RAG(裸模型)有 RAG
私有数据问答答不出 / 瞎编基于真实文档作答
时效性停留在训练截止随文档更新而更新
可溯源性无引用可标注资料来源
幻觉率高显著降低

十、进阶优化:混合检索、重排与溯源

朴素的向量检索对”精确关键词”(如报错码、型号)不敏感,可叠加 BM25 做混合检索;检索后接一个轻量重排模型(rerank)把真正相关的片段顶到前面;生成时让模型输出引用的资料编号,实现溯源。关于如何量化检索质量,可看 RAG 评估实战(RAGAS),它能量化”检索准不准、回答贴不贴”。

十一、常见坑与排障

现象原因解法
检索到的内容不相关chunk 太大 / 切块无重叠调小 size、加大 overlap
问答”答非所问”建库与查询用了不同嵌入模型统一 model 与维度
回答仍幻觉Prompt 没限制”无依据就说不知道”收紧系统提示 + 降低 temperature
长文档越界top-k 拼接超上下文限制 token 预算 / 先重排再截断

十二、知识库持久化与增量更新

上面示例在内存里建索引,进程一关就没了。生产环境要把索引落盘,并支持”加新文档不重建”。FAISS 可以直接序列化,新文档只需嵌入后 index.add 增量追加;同时要另存一份 chunk 文本列表,保证索引下标与原文一一对应。如果规模再大,建议换成 Chroma、pgvector(见 pgvector 实战)这类带元数据的向量库,按文档 ID 做增删更省心。

import faiss
faiss.write_index(index, "kb.index")          # 落盘
loaded = faiss.read_index("kb.index")          # 重载
# 新文档:嵌入后追加,并更新 all_chunks 列表
new_vecs = np.array(embed(new_chunks), dtype="float32")
faiss.normalize_L2(new_vecs)
loaded.add(new_vecs)
all_chunks.extend(new_chunks)

十三、RAG 与 Agent 的边界

RAG 解决”模型不知道的事实”,Agent 解决”模型不会主动执行的动作”。两者并不冲突:更成熟的形态是让 Agent 拥有一个”知识检索”工具,遇到需要内部资料的问题时自动调用检索、再基于结果作答。想体验这种组合,可以从 从零手写 AI Agent 入手,把本文的 retrieve 函数包装成一个工具函数即可。也别忘了用 RAGAS 评估 持续量化检索与回答质量,避免”看起来能用、实际在瞎答”。

十四、落地前检查清单

上线前对照四件事:① 嵌入模型建库与查询是否一致;② chunk 大小与重叠是否经过小样本验证;③ Prompt 是否强制”无依据就说不知道”以防幻觉;④ 是否保留了原文索引以支持溯源。把这几条守住,一个朴素 RAG 的可用性就已经超过大多数”裸模型 + 复制粘贴文档”的临时方案。

十五、小结

RAG 并不神秘:加载 → 切块 → 向量化 → 存储检索 → 拼 Prompt 生成,每一步都可独立替换与调优。把它跑通一次,你对”大模型如何用好私有知识”的理解会扎实很多。想进一步做成自主问答的智能体,可延伸阅读 从零手写 AI Agent,把检索能力嵌进 Agent 的工具箱里。

上一篇 Redis 读写分离实战:主从架构与高可用扩展
下一篇 Spring WebFlux 实战:响应式高并发接口开发
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]