从零搭建你的第一个 RAG 应用:百行 Python 就够了

本文是 RAG 系列的第 2 篇。上一篇《一文搞懂 RAG》讲清了原理,这一篇不聊概念,直接动手。

上一篇我们说,RAG 就六个字:先查资料,再回答 。系统分三步走------建库、检索、生成

今天,我们把这三步变成能跑的代码。读完并跟着敲一遍,你会得到一个基于你自己文档的问答机器人:它只回答资料里有的内容,会标注出处,遇到不知道的问题会老实承认,而不是胡编。

全部代码百行左右,不依赖任何 RAG 框架。

一、开工前:技术选型

市面上大多数教程一上来就是 LangChain、LlamaIndex。框架当然好用,但对初学者有两个问题:一是封装太深,跑通了也不知道里面干了啥;二是框架 API 更新极快,教程隔三个月就过期。

所以这篇我们纯手写,让你看清每一个环节。等你理解了原理,再回头上框架,会快得多。

三个组件的选型如下:

向量模型(Embedding)Qwen/Qwen3-Embedding-0.6B。通义千问开源的轻量向量模型,中文效果好,通过硅基流动的 API 调用它,登录有免费试用额度。

向量数据库Chroma。pip 装完就能用,不用部署服务,数据直接存在本地文件夹里,对新手比较友好。

大模型DeepSeek-V4-Pro,同样通过硅基流动调用。接口与 OpenAI 完全兼容------以后想换成 Qwen、智谱、Kimi 或本地 Ollama,只改两行配置就行(最后会给出改法)。

为什么选硅基流动(SiliconFlow)?两个理由:一是向量模型和大模型共用同一个平台、同一个 API Key、同一个 base_url,环境配置直接减半;二是它的模型广场有近百个模型可选,从免费小模型到 DeepSeek 旗舰版,改一行模型名就能切换。

对应上一篇的三步走:

  • 建库 = 读取文档 + 切分 + 向量化存入 Chroma
  • 检索 = 把问题向量化,去 Chroma 里找最相近的段落
  • 生成 = 把"问题 + 段落"打包发给大模型

二、环境准备(3 分钟)

第 1 步:安装依赖(需要 Python 3.9 以上)

bash 复制代码
pip install chromadb openai

国内网络下载慢的话,加个清华镜像源:

bash 复制代码
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple chromadb openai

第 2 步:申请 API Key

到硅基流动官网(cloud.siliconflow.cn)注册,在控制台的「API 密钥」页面创建一个 Key,然后把它设置成环境变量------注意,永远不要把 Key 直接写进代码里,一旦代码外传或提交到git,Key 就泄露了。

macOS / Linux:

bash 复制代码
export SILICONFLOW_API_KEY="sk-你的key"

Windows PowerShell:

powershell 复制代码
$env:SILICONFLOW_API_KEY="sk-你的key"

三、准备一份"私有资料"

新建一个项目文件夹,在里面再建一个 docs 文件夹,用来放你的知识库文档(支持 .txt.md)。

还记得上一篇开头的吐槽吗------问 AI"我们公司的报销流程",它给你现编一套。今天我们就用一份报销制度文档,治好它这个毛病。

docs 里新建 差旅报销制度.md,贴入下面的示例内容(你也可以换成自己的任何文档):

markdown 复制代码
# 差旅报销制度(示例)

## 出差申请
员工出差需提前 3 个工作日在 OA 系统提交申请,写明目的地、
事由和预计天数,经直属主管审批通过后方可预订行程。

## 交通标准
市内交通实报实销;城际出行默认高铁二等座或飞机经济舱,
总监及以上级别可乘坐高铁一等座。

## 住宿标准
一线城市(北京、上海、广州、深圳)每晚不超过 500 元,
其他城市每晚不超过 350 元。

## 餐费补贴
出差期间按每天 100 元发放餐补,无需发票;客户招待餐费
另行走招待费流程,需事前审批。

## 报销流程与时限
出差结束后 30 天内,在 OA 系统提交报销单并粘贴发票原件,
经主管与财务审核后,款项在 10 个工作日内打入工资卡。
逾期提交需部门负责人特批。

## 发票要求
所有报销票据须为增值税发票,抬头为公司全称,
个人抬头或抬头错误的发票不予报销。

四、代码分步讲解

在项目文件夹里新建 rag_demo.py,我们一段一段往里写。

第 1 段:配置

python 复制代码
import os

import chromadb
from openai import OpenAI

API_KEY = os.getenv("SILICONFLOW_API_KEY")
if not API_KEY:
    raise SystemExit("未检测到环境变量 SILICONFLOW_API_KEY,请先配置。")

# 硅基流动的接口与 OpenAI 兼容:向量模型和大模型共用一个客户端、一个 Key
client = OpenAI(api_key=API_KEY, base_url="https://api.siliconflow.cn/v1")
LLM_MODEL = "deepseek-ai/DeepSeek-V4-Pro"   # 大模型(按量付费,效果稳)
EMBED_MODEL = "Qwen/Qwen3-Embedding-0.6B"   # 向量模型

# 向量数据库:数据会持久化到本地 chroma_db 文件夹
chroma = chromadb.PersistentClient(path="./chroma_db")
collection = chroma.get_or_create_collection(
    name="my_knowledge_base",
    metadata={"hnsw:space": "cosine"},  # 用余弦相似度衡量语义远近
)

这里就能体现了"OpenAI 兼容接口"的好处:调用硅基流动用的就是 openai 这个库,只是把 base_url 指向了它的服务器;而且向量模型和大模型共用同一个 client。以后想换模型,改 LLM_MODELEMBED_MODEL 两个字符串即可。

另外,创建集合时多传了一个 metadata,告诉 Chroma 用余弦相似度来比较向量------这是文本语义检索最常用的度量方式,照抄即可。

第 2 段:读取文档

python 复制代码
def load_documents(folder: str = "docs") -> list[dict]:
    if not os.path.isdir(folder):
        raise SystemExit(f"未找到 {folder}/ 文件夹,请先创建。")
    docs = []
    for name in os.listdir(folder):
        if name.endswith((".txt", ".md")):
            with open(os.path.join(folder, name), encoding="utf-8") as f:
                docs.append({"name": name, "text": f.read()})
    if not docs:
        raise SystemExit(f"{folder}/ 文件夹里还没有任何 .txt 或 .md 文件。")
    print(f"读取到 {len(docs)} 份文档")
    return docs

没什么玄机:遍历 docs 文件夹,把每份文档的文件名和全文读进来。文件名后面会作为"出处"展示。

第 3 段:切分文本(Chunking)

python 复制代码
def split_text(text: str, chunk_size: int = 300, overlap: int = 50) -> list[str]:
    chunks, start = [], 0
    while start < len(text):
        chunk = text[start : start + chunk_size]
        if chunk.strip():
            chunks.append(chunk)
        start += chunk_size - overlap
    return chunks

为什么要切?两个原因:一是向量模型能处理的文本长度有限;二是检索的粒度越合适,找到的内容越精准------拿整本手册去匹配一个具体问题,反而找不准。

两个参数值得注意:

  • chunk_size=300:每块 300 个字符;
  • overlap=50:相邻两块重叠 50 个字符,避免一句话正好被"拦腰斩断"后语义丢失。

这是最简单粗暴的"固定长度切分"。它够用,但远谈不上好------比如可能把一条完整的制度切成两半。怎么切才科学,是 RAG 效果好坏的关键之一,下一篇专门讲。

第 4 段:向量化,建立索引

python 复制代码
def embed(texts: list[str]) -> list[list[float]]:
    """调用硅基流动的向量接口,把一批文本变成向量(按 32 条分批,兼容各模型的单次上限)"""
    vectors = []
    for i in range(0, len(texts), 32):
        resp = client.embeddings.create(model=EMBED_MODEL, input=texts[i : i + 32])
        vectors.extend(item.embedding for item in resp.data)
    return vectors


def build_index(docs: list[dict]) -> None:
    all_chunks, ids, metas = [], [], []
    for doc in docs:
        for i, chunk in enumerate(split_text(doc["text"])):
            all_chunks.append(chunk)
            ids.append(f"{doc['name']}-{i}")          # 每个块一个唯一 ID
            metas.append({"source": doc["name"]})     # 记录出处
    collection.add(
        ids=ids,
        documents=all_chunks,
        embeddings=embed(all_chunks),
        metadatas=metas,
    )
    print(f"索引完成,共写入 {collection.count()} 个文本块")

这一段做的就是上一篇说的"给每段文字生成语义坐标":embed 函数调用硅基流动的 embeddings 接口,把每个文本块变成一串数字(一个 1024 维的向量),然后连同原文、出处一起存进 Chroma。

一个小细节:embeddings 接口对单次能接收的文本条数有上限 (不同模型的上限不一样),所以 embed 里按 32 条一批做了分批,换什么模型都稳妥。示例文档只有 2 个文本块、一批就够,但等你往 docs 里塞几十份文档时,这个循环就派上用场了。

第 5 段:检索

python 复制代码
def retrieve(query: str, top_k: int = 3):
    res = collection.query(query_embeddings=embed([query]), n_results=top_k)
    return res["documents"][0], res["metadatas"][0]

用户的问题也走同一个向量模型,变成同一空间里的"坐标",然后让 Chroma 找出坐标最接近的 3 个文本块(top_k=3,可自行调整)。坐标近,就是语义近。

第 6 段:生成------Prompt 是防幻觉的关键

python 复制代码
PROMPT_TEMPLATE = """你是一个严谨的知识库问答助手。请只根据下面提供的资料回答问题:
1. 如果资料里有答案,用简洁的中文回答,并在结尾注明出处文件名;
2. 如果资料里没有相关信息,直接回答"根据现有资料,我无法回答这个问题",禁止编造。

【资料】
{context}

【问题】
{question}"""


def answer(question: str) -> str:
    chunks, metas = retrieve(question)
    context = "\n\n".join(
        f"(出处:{m['source']})\n{c}" for c, m in zip(chunks, metas)
    )
    prompt = PROMPT_TEMPLATE.format(context=context, question=question)
    resp = client.chat.completions.create(
        model=LLM_MODEL,
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3,  # 温度调低,回答更稳定、少发挥
    )
    return resp.choices[0].message.content

这段 Prompt 有三个设计点,正是 RAG 对抗幻觉的核心手法:

  1. 限定信息来源:"只根据下面提供的资料回答",把模型从"凭记忆答题"锁死在"看资料答题";
  2. 要求注明出处:答案可追溯、可核对;
  3. 允许说"不知道":明确给模型一个"承认无知"的出口,它才不会硬编。

第 7 段:主程序

python 复制代码
if __name__ == "__main__":
    if collection.count() == 0:
        print("首次运行,正在建立索引......")
        build_index(load_documents("docs"))
    while True:
        q = input("\n请提问(输入 q 退出):").strip()
        if q.lower() in {"q", "quit", "exit"}:
            print("再见!")
            break
        if q:
            print("\n" + answer(q))

首次运行建索引,之后直接进入命令行问答循环。索引是持久化的,第二次运行不用重建;如果你更新了文档,删掉 chroma_db 文件夹再跑一次即可。

五、跑起来看效果

css 复制代码
$ python rag_demo.py
首次运行,正在建立索引......
读取到 1 份文档
索引完成,共写入 2 个文本块

请提问(输入 q 退出):出差住宿一晚最多能报销多少?

一线城市(北京、上海、广州、深圳)每晚不超过 500 元,
其他城市每晚不超过 350 元。
(出处:差旅报销制度.md)

请提问(输入 q 退出):公司年会一般在哪里举办?

根据现有资料,我无法回答这个问题。

(输出仅为示意,文本块数量取决于文档长度,回答措辞会因模型而略有差异。)

注意第二个问题------资料里没有年会的信息,它没有编,而是老实承认。这就是 RAG 与"裸问大模型"最直观的区别。

现在,把你自己的笔记、手册、周报丢进 docs 文件夹,删掉 chroma_db 重建索引,你就拥有了一个真正的私人知识库助手。

六、想换别的模型?改一行就行

想更省钱?改一行:硅基流动的模型广场上有近百个模型,DeepSeek、Qwen、GLM、Kimi 全都在。本文用的旗舰版效果最稳;想降低成本,换成更快更便宜的 Flash 版即可:

python 复制代码
LLM_MODEL = "deepseek-ai/DeepSeek-V4-Flash"

模型广场里还有一批免费档小模型,预算为零也能跑通全流程------不过免费档有限流、输出质量偶有波动,正式使用建议按量付费档。

向量模型同理,改 EMBED_MODEL 就行(注意:不同向量模型生成的向量互不通用,换完记得删掉 chroma_db 重建索引)。

换成其他厂商的 API (DeepSeek 官方、通义千问、智谱等大多兼容 OpenAI 接口),替换 base_url、Key 和模型名即可,例如 DeepSeek 官方:

python 复制代码
client = OpenAI(
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url="https://api.deepseek.com",
)
LLM_MODEL = "deepseek-chat"

不过注意:多数大模型厂商不提供向量接口,这样换过去之后,向量部分要么继续走硅基流动,要么改用下面的本地方案。

完全本地、零 API 依赖 :大模型用 Ollama(安装后 ollama pull qwen2.5:7b),向量模型用 sentence-transformers 在本地跑 BGE:

python 复制代码
client = OpenAI(api_key="ollama", base_url="http://localhost:11434/v1")
LLM_MODEL = "qwen2.5:7b"

# 需先 pip install sentence-transformers,然后把 embed 函数整体替换为:
from sentence_transformers import SentenceTransformer
st_model = SentenceTransformer("BAAI/bge-small-zh-v1.5")

def embed(texts: list[str]) -> list[list[float]]:
    return st_model.encode(texts, normalize_embeddings=True).tolist()

这样一改,整套 RAG 就 100% 离线了------涉密资料也能放心用。

七、常见坑排查

报 401 / API Key 错误 :环境变量没生效。设置完环境变量后要在同一个终端窗口里运行脚本,或重开终端重新设置。

偶发 429(Too Many Requests):免费档模型有每分钟请求次数限制,等几秒重试即可;频繁触发的话,换成按量付费的模型就没这个烦恼。

中文乱码:确保你的文档以 UTF-8 编码保存(Windows 记事本另存为时可以选择编码)。

改了文档或换了向量模型,但回答没变 / 报维度错误 :索引是持久化的,删掉 chroma_db 文件夹重新运行即可重建。

八、它还很粗糙------这正是后面几篇的主题

恭喜你跑通了第一个 RAG 应用。但必须坦白:它离"好用"还有距离。

  • 固定长度切分太粗暴,可能把完整语义切碎 → 下一篇《文本分块(Chunking)的艺术》专门解决;
  • 向量模型选了最轻量的 Qwen3-Embedding-0.6B,中文场景怎么选型、要不要上更大的向量模型 → 后续有专文对比;
  • 纯向量检索会漏掉一些"关键词明明对上了"的内容 → 混合检索与重排序,安排在进阶篇。

一句话:跑通只是起点,调优才是 RAG 的主战场。

写在最后

回顾一下,今天我们用百行代码走完了 RAG 全流程:

  1. 建库:读文档 → 固定长度切块 → Qwen3-Embedding 向量化 → 存入 Chroma;
  2. 检索:问题向量化 → 找语义最近的 3 个文本块;
  3. 生成:限定资料来源 + 要求注明出处 + 允许说"不知道"的 Prompt,交给大模型作答。

下期预告:《文本分块(Chunking)的艺术》------chunk 该切多大?重叠多少合适?按长度切还是按语义切?这个最容易被忽视的环节,恰恰是拉开 RAG 效果差距的第一道分水岭。

相关推荐
苏州邦恩精密1 小时前
蔡司3D扫描仪厂家如何应用于新能源行业检测
人工智能·机器学习·3d·自动化·制造
阿里技术2 小时前
Agent 评测:方法论与体系设计
大数据·人工智能·算法
deephub2 小时前
用 Scikit-LLM 和 Groq 搭建情感分析 pipeline
人工智能·大语言模型·sklearn
赛联区块链2 小时前
工厂使用AI降本增效实践与探索
人工智能·ai智能体·deepseek·ai赋能·工业ai·工厂ai
千里马学框架2 小时前
google官方Perfetto 中使用 AI相关skill
android·人工智能·ai·framework·perfetto·性能·skill
蓝速科技2 小时前
蓝速科技 3D 全息舱 AI 数字人校园智慧升级实效展示
人工智能·科技·3d
观远数据2 小时前
像装应用一样落地BI:云市场模板如何降低行业分析项目的实施风险
人工智能
道可云2 小时前
淄博电机模具领军企业如何用AI提质增效?道可云携手精密制造专精特新,探索工业大脑赋能新路径
人工智能·制造
cxr8282 小时前
大语言模型上下文缓存命中率测试全场景清单
人工智能·python·算法·缓存·语言模型·自然语言处理·llm