2. 环境搭建与第一个 RAG
这是什么:最小 RAG = 不引任何框架,用十几行代码就能跑通「切分 → 检索 → 带资料生成」的小闭环。
为什么用:上一章讲清了 RAG 的原理,可原理图再漂亮也不等于会写。先手搓一个最笨但完整的版本,才能看清每一步到底在干什么。
如何用:本章用 NumPy 拼出切分、向量检索、带资料生成这三步,跑通后再让它敢说「资料中没有」。
2.1 环境准备
按 虚拟环境 venv 教程建环境(沿用 OCR 教程那套):
mkdir rag-lab && cd rag-lab
python -m venv .venv
.venv\Scripts\Activate.ps1 # (macOS/Linux: source .venv/bin/activate)
pip install openai numpy
Key 和模型名沿用 LLM 教程 的约定,从环境变量读,不写死在代码里:
$env:LLM_API_KEY = "你的key"
$env:LLM_BASE_URL = "https://api.openai.com/v1" # 可换成任意兼容服务
$env:EMBED_MODEL = "text-embedding-3-small" # 向量模型
$env:CHAT_MODEL = "gpt-4o-mini" # 对话模型
版本说明:NumPy 2.5.4 要求 Python ≥3.12,openai 3.35.1 要求 ≥3.10——本教程用 Python 3.12 及以上,站点基线 3.15 直接可用。 前置检查:兼容服务必须支持
/v1/embeddings接口(下一章会讲不支持时怎么办)。
2.2 最小 RAG:三步闭环
新建 first_rag.py,整段照抄可跑:
import os
import numpy as np
from openai import OpenAI
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1"),
)
EMBED_MODEL = os.environ.get("EMBED_MODEL", "text-embedding-3-small")
CHAT_MODEL = os.environ.get("CHAT_MODEL", "gpt-4o-mini")
# 「知识库」:先用几条手写资料代替真实文档
DOCS = [
"RAG 是检索增强生成:先检索资料,再让大模型基于资料回答。",
"向量嵌入把文字映射成高维数字,语义相近的文字距离更近。",
"Chroma 是一个开源的向量数据库,支持本地持久化。",
]
def embed(texts):
"""文字 → 向量。openai SDK 会把输入按批次发给模型"""
resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
# 按 index 排序,保证返回顺序和输入顺序一致
return [d.embedding for d in sorted(resp.data, key=lambda d: d.index)]
def cosine(a, b):
"""余弦相似度:1 = 完全同向,0 = 无关,-1 = 相反"""
a, b = np.asarray(a), np.asarray(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
# ① 建索引:把资料向量化一次
VECTORS = embed(DOCS)
# ② 检索:拿问题向量跟每条资料算相似度
def search(question, top_k=2):
qv = embed([question])[0]
scored = sorted(
((cosine(qv, v), text) for v, text in zip(VECTORS, DOCS)),
key=lambda x: x[0], reverse=True,
)
return scored[:top_k]
# ③ 生成:把检索到的资料和问题一起交给模型
def ask(question):
hits = search(question)
context = "\n".join(f"[{i + 1}] {text}" for i, (_, text) in enumerate(hits))
prompt = (
"只根据下面资料回答问题。资料里没有的信息,就回答「资料中未提及」。\n\n"
f"资料:\n{context}\n\n"
f"问题:{question}"
)
resp = client.chat.completions.create(
model=CHAT_MODEL,
messages=[{"role": "user", "content": prompt}],
)
return resp.choices[0].message.content, hits
answer, hits = ask("RAG 是什么?")
print(answer)
for score, text in hits:
print(f" {score:.3f} {text}")
运行:
python first_rag.py
你会看到(示例):
RAG 是检索增强生成,即先检索资料,再让大模型基于这些资料来回答问题。
0.812 RAG 是检索增强生成:先检索资料,再让大模型基于资料回答。
0.655 向量嵌入把文字映射成高维数字,语义相近的文字距离更近。
2.3 读懂这三步
| 代码 | 作用 |
|---|---|
client.embeddings.create(...) |
把文字换成向量,可一次传一批 |
sorted(resp.data, key=...) |
保证向量顺序和输入顺序对应 |
np.dot / np.linalg.norm |
点积与长度,余弦相似度的手写实现 |
zip(VECTORS, DOCS) |
索引与原文一一配对 |
context 里的 [1] [2] |
编号,后面能让模型标出处 |
2.4 让它敢说「资料中没有」
提示词里那句「资料里没有的信息,就回答『资料中未提及』」是 RAG 里最值钱的一句话——它把「不知道」变成了合法答案,模型就不容易瞎编。
再稳一点,可以加个兜底:
if not hits or hits[0][0] < 0.3: # 最高相似度太低,说明没找到相关资料
return "抱歉,知识库里没有找到相关资料。", hits
2.5 常见翻车现场
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
KeyError: 'LLM_API_KEY' |
没设环境变量 | $env:LLM_API_KEY = "你的key" |
404 / NotFoundError |
服务不支持 /v1/embeddings |
换支持嵌入的服务,或走 第 4 章 4.6 的本地嵌入 |
401 Unauthorized |
Key 错误或没余额 | 检查 Key |
| 答案全靠编 | 没给资料或提示词太松 | 确认 context 非空,提示词写死「只根据资料回答」 |
| 检索结果跑题 | 嵌入模型不合适 | 换嵌入模型,并重建索引(第 4 章 4.5) |
2.6 动手改
- 把
DOCS换成 10 条你自己的资料(比如你博客的简介),问三个问题,看检索排序合不合理 - 故意问一个资料里没有的问题,看看模型是老实说「未提及」还是开始编
- 把
top_k改成 1 和 3,对比答案质量的变化
2.7 小结
- 最小 RAG = 建索引 → 检索 → 生成,三步就够
- 嵌入用
client.embeddings.create,相似度用 NumPy 手写余弦 - 提示词里必须写死「只根据资料回答,没有就说未提及」
top_k是第一个要调的旋钮
下一章:文档切分,决定 RAG 效果上限的那一步 🐸