1. 引言
今天不聊概念,直接上手。我带你从零搭一个 RAG(Retrieval-Augmented Generation,检索增强生成)系统,让大模型能基于你自己的文档回答问题。整个过程我会按真实操作的口吻来写,每一步都告诉你我做了什么、踩了什么坑、怎么排查。
先交代一下我的环境:Python 3.10,Windows 11,用 OpenAI 兼容接口(本地也可以用 Ollama 跑开源模型)。代码我尽量写得能直接复制运行。
本文的最终成果:一个能直接复制运行、可复用的 RAG 脚本——你只要把docs/里的资料换成自己的,就能让大模型基于你的知识库回答问题。适用读者:有 Python 基础、想快速搭建 RAG 的开发者。阅读完你将获得什么:从原理到踩坑的完整实战路径,以及一套开箱即用的代码,照着敲就能跑通。
2. 原理与大纲
先花两分钟把原理讲清楚,后面动手才不会懵。RAG 说白了就一句话:先检索,再生成。大模型本身记不住你的私有资料,那就先把资料切成块、转成向量存起来;用户提问时,先从向量库里捞出最相关的几块,拼进 prompt 一起喂给模型,让它「看着资料回答」。
整个流程拆开就是四步:
- 加载:把文档读进来
- 切块:把长文档切成小块
- 向量化存储:把块转成向量存进向量库
- 检索生成:提问时检索相关块,拼给模型生成答案
下面这张图把整个 RAG 流程串起来,后面每一步都对应图里的一个环节:
下面按这个顺序一步步来。先看大纲:
- 引言
- 原理与大纲
- 准备工作:装依赖
- 准备文档数据
- 切分文档
- 生成向量并存入向量库
- 构建检索链
- 完整跑通:一个可复用的脚本
- 实现方式对比
- 踩坑记录
- 总结
2. 准备工作:装依赖
打开终端,先建个虚拟环境,免得把系统 Python 搞乱:
python-mvenv rag_env rag_env\Scripts\activate# Windows# source rag_env/bin/activate # Linux/Mac然后装依赖。我用的核心库是 LangChain 0.3 系列、ChromaDB 做向量库、以及 OpenAI 的 SDK:
pipinstalllangchain langchain-community langchain-openai chromadb openai python-dotenv装完验证一下版本,防止版本不兼容:
python-c"import langchain; print(langchain.__version__)"我这边输出的是0.3.x。如果你装到 0.2 或更早,建议升级到 0.3,因为后面有些 API 写法不一样。
3. 准备文档数据
RAG 的第一步是「喂数据」。我准备了几篇 Markdown 格式的技术笔记,放在docs/目录下。你也可以用 PDF、TXT、网页,后面我会说怎么换加载器。
先写一个加载文档的脚本。这里我用 LangChain 的目录加载器,一次性把整个文件夹读进来:
fromlangchain_community.document_loadersimportDirectoryLoader,TextLoader loader=DirectoryLoader("docs",glob="**/*.md",loader_cls=TextLoader,loader_kwargs={"encoding":"utf-8"},)docs=loader.load()print(f"加载了{len(docs)}个文档")这里有个坑:Windows 下如果文档是 GBK 编码,TextLoader默认用 UTF-8 读会报UnicodeDecodeError。我一开始就踩了,后来在loader_kwargs里显式指定encoding="utf-8"才解决。如果你的文档是别的编码,改成对应的就行。
4. 切分文档
加载进来的文档可能很长,直接塞给向量库效果很差,所以要先切块。切块的大小和重叠度直接影响检索质量,我一般这样配:
fromlangchain_text_splittersimportRecursiveCharacterTextSplitter splitter=RecursiveCharacterTextSplitter(chunk_size=500,chunk_overlap=50,separators=["\n\n","\n","。","!","?"," ",""],)chunks=splitter.split_documents(docs)print(f"切分成{len(chunks)}个块")chunk_size=500表示每块约 500 字符,chunk_overlap=50让相邻块有 50 字符重叠,避免把一句话从中间切断导致语义丢失。separators里我加了中文标点,这样中文文档切得更自然。
5. 生成向量并存入向量库
接下来把每个块转成向量,存进 ChromaDB。这一步是 RAG 的核心——把文本变成机器能算相似度的数字。
我用 OpenAI 的 embedding 模型:
fromlangchain_openaiimportOpenAIEmbeddingsfromlangchain_community.vectorstoresimportChroma embeddings=OpenAIEmbeddings(model="text-embedding-3-small")vectorstore=Chroma.from_documents(documents=chunks,embedding=embeddings,persist_directory="./chroma_db",)print("向量库创建完成,已持久化到 ./chroma_db")跑完你会看到./chroma_db目录下生成了索引文件。persist_directory指定持久化路径,这样下次启动不用重新算向量。
如果你没有 OpenAI 的 key,也可以用本地模型。我试过用 Ollama 跑nomic-embed-text,效果也还行:
fromlangchain_community.embeddingsimportOllamaEmbeddings embeddings=OllamaEmbeddings(model="nomic-embed-text")6. 构建检索链
向量库建好了,现在把它接进大模型。这里我用 LangChain 的RetrievalQA链,它会把「检索 + 生成」串起来:
fromlangchain_openaiimportChatOpenAIfromlangchain.chainsimportRetrievalQA llm=ChatOpenAI(model="gpt-4o-mini",temperature=0)qa_chain=RetrievalQA.from_chain_type(llm=llm,chain_type="stuff",retriever=vectorstore.as_retriever(search_kwargs={"k":4}),)answer=qa_chain.invoke("什么是 RAG?")print(answer["result"])k=4表示每次检索取最相似的 4 个块喂给模型。chain_type="stuff"表示把所有检索结果直接拼进 prompt,适合块数不多的情况。
7. 完整跑通:一个可复用的脚本
上面几步拆开讲,实际用的时候我习惯合成一个脚本,方便反复跑。下面是我最终用的版本:
importosfromdotenvimportload_dotenvfromlangchain_community.document_loadersimportDirectoryLoader,TextLoaderfromlangchain_text_splittersimportRecursiveCharacterTextSplitterfromlangchain_openaiimportOpenAIEmbeddings,ChatOpenAIfromlangchain_community.vectorstoresimportChromafromlangchain.chainsimportRetrievalQA load_dotenv()# 1. 加载loader=DirectoryLoader("docs",glob="**/*.md",loader_cls=TextLoader,loader_kwargs={"encoding":"utf-8"})docs=loader.load()# 2. 切分splitter=RecursiveCharacterTextSplitter(chunk_size=500,chunk_overlap=50,separators=["\n\n","\n","。","!","?"," ",""])chunks=splitter.split_documents(docs)# 3. 向量化 + 存储embeddings=OpenAIEmbeddings(model="text-embedding-3-small")vectorstore=Chroma.from_documents(documents=chunks,embedding=embeddings,persist_directory="./chroma_db")# 4. 检索 + 生成llm=ChatOpenAI(model="gpt-4o-mini",temperature=0)qa_chain=RetrievalQA.from_chain_type(llm=llm,chain_type="stuff",retriever=vectorstore.as_retriever(search_kwargs={"k":4}),return_source_documents=True,)whileTrue:query=input("请输入问题(输入 exit 退出):")ifquery.lower()=="exit":breakresult=qa_chain.invoke(query)print("\n回答:",result["result"],"\n")# 打印来源文档,方便追溯答案出处print("--- 来源文档 ---")fori,docinenumerate(result["source_documents"],1):source=doc.metadata.get("source","未知来源")snippet=doc.page_content[:100]print(f"[{i}] 来源:{source}")print(f" 片段:{snippet}...")print().env文件里放你的 API key:
OPENAI_API_KEY=sk-xxxx9. 实现方式对比
上面用的是 LangChain + ChromaDB 这一套,也是目前最主流的组合。但 RAG 的实现路径不止一条,我把我试过的几种列出来,优缺点都摆一摆,你按自己的场景挑。
方式一:LangChain + ChromaDB(本文用的)
- 优点:生态成熟、文档多、上手快,切块、向量化、检索一条龙都封装好了
- 缺点:依赖较重,LangChain 版本升级 API 容易变;ChromaDB 单机够用,数据量大或要并发时吃力
方式二:LlamaIndex + FAISS
- 优点:对「索引」这件事做得更细,支持多种索引结构,检索精度高
- 缺点:概念比 LangChain 多,学习曲线陡一点;FAISS 是内存索引,重启要重新加载
方式三:纯手写(embedding + 向量检索 + prompt 拼接)
- 优点:最轻量,没有框架包袱,每一步都看得懂、可控
- 缺点:要自己处理切块、持久化、检索逻辑,代码量上去了,维护成本高
方式四:RAGFlow / Dify 这类平台
- 优点:可视化配置,拖拽就能搭,适合快速验证和给非技术同事用
- 缺点:定制性差,想改底层逻辑就受限;数据量大时性能不一定好
怎么选?我的建议是:想快速跑通、验证想法,用方式一;对检索精度要求高、愿意折腾,试方式二;想彻底搞懂原理,强烈建议手写一遍方式三;团队里非技术人多,再考虑方式四。
怎么选?我的建议是:想快速跑通、验证想法,用方式一;对检索精度要求高、愿意折腾,试方式二;想彻底搞懂原理,强烈建议手写一遍方式三;团队里非技术人多,再考虑方式四。
四种方式放在一起对比,优缺点和适用场景一目了然:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 方式一:LangChain + ChromaDB | 生态成熟、文档多、上手快,切块、向量化、检索一条龙都封装好了 | 依赖较重,LangChain 版本升级 API 容易变;ChromaDB 单机够用,数据量大或要并发时吃力 | 快速跑通、验证想法,个人或小团队起步 |
| 方式二:LlamaIndex + FAISS | 对「索引」这件事做得更细,支持多种索引结构,检索精度高 | 概念比 LangChain 多,学习曲线陡一点;FAISS 是内存索引,重启要重新加载 | 对检索精度要求高、愿意折腾的进阶场景 |
| 方式三:纯手写 | 最轻量,没有框架包袱,每一步都看得懂、可控 | 要自己处理切块、持久化、检索逻辑,代码量上去了,维护成本高 | 想彻底搞懂原理、学习 RAG 内部机制 |
| 方式四:RAGFlow / Dify 平台 | 可视化配置,拖拽就能搭,适合快速验证和给非技术同事用 | 定制性差,想改底层逻辑就受限;数据量大时性能不一定好 | 团队里非技术人多、需要快速交付原型 |
四种方式的定位差异,用一张图看得更清楚:
8. 踩坑记录
这一节把我实际遇到的问题列出来,你遇到类似情况可以直接对照:
坑 1:编码问题
- 现象:加载文档报
UnicodeDecodeError - 解决:
loader_kwargs={"encoding": "utf-8"},或改成文档实际编码
坑 2:检索结果答非所问
- 现象:模型回答跟问题对不上
- 解决:调大
k值,或减小chunk_size让块更聚焦;也可以检查文档切分是否把关键信息切断了
坑 3:向量库重复写入
- 现象:每次跑脚本都往同一个
./chroma_db追加,导致检索结果重复 - 解决:重建前先删掉旧目录,或者用
Chroma(persist_directory=..., embedding=...)加载已有库而不是重新from_documents
坑 4:LangChain 版本 API 变化
- 现象:
RetrievalQA或Chroma导入报错 - 解决:确认
langchain是 0.3.x,langchain-community和langchain-openai都装了
11. 总结
到这里,一个能用的 RAG 系统就跑通了。核心流程就四步:加载文档 → 切块 → 向量化存储 → 检索生成。你只要把docs/里的内容换成自己的资料,就能让大模型基于你的知识库回答问题。
最后用一张图回顾整个核心流程:
说到底,RAG 的本质就一件事:给大模型配一个「外挂记忆」。模型记不住你的私有资料,那就把资料变成可检索的向量,提问时先捞出来再让它回答。不管用 LangChain、LlamaIndex 还是手写,绕来绕去都是「检索 + 生成」这两个动作。理解了这一点,换什么框架都只是换工具,核心思路不变。
下一步你可以试试:换 PDF 加载器、接入本地模型、或者把检索结果和引用来源一起返回。有问题欢迎在评论区交流。