用 200 行 Python 搭一个全本地 RAG:一次笔记本工程师的踩坑实录
没有 API Key,没有云服务,一台 RTX 4050 的笔记本从零跑起来的 RAG demo。 代码地址:github.com/spark-prair...
起因
最近在学大模型相关的工程实践,RAG(Retrieval-Augmented Generation,检索增强生成)是绕不开的一块。
我不想从"调用 OpenAI API + LangChain"开始------那会让我学到的是"怎么用某个框架",而不是"RAG 到底是什么"。我想要的是:
- 完全本地运行,不依赖任何云服务(省钱,也是为了数据隐私)
- 核心代码集中在一个文件里,方便反复读、反复改
- 能跑得动,但不要追求 SOTA(笔记本上跑 70B 是不现实的)
于是花了一个周末,做了一个总共只有四个依赖包、核心代码 200 行的 RAG demo。这篇文章记录从选型到踩坑的完整过程。
一句话 RAG
在开始之前先把概念扯清楚。RAG 的本质可以用一句话概括:
让大模型在回答之前,先从你自己的文档里"查资料"。
流程只有六步:
加载文档 → 切分成 chunk → 向量化 → 入向量库 → 相似度检索 → 喂给 LLM 生成答案
第一步到第四步是"建索引",做一次就好;第五、六步是每次提问都要做的。
为什么要这么绕?因为大模型本身有三个硬伤:
- 知识截止:训完之后的事它不知道
- 幻觉:不确定的时候会编造合理的答案
- 私有数据:你公司的文档、你的个人笔记都不在公开语料里
RAG 通过"先查再答"把这三个问题都缓解了------知识在外挂的向量库里,想更新就重新入库,不用重新训模型。
技术选型
我的硬件是 RTX 4050 Mobile(6GB 显存)+ 32GB 内存。选型原则是**"小而够用"**:
| 组件 | 选型 | 理由 |
|---|---|---|
| LLM | Ollama + qwen2.5:3b | Ollama 把"本地跑大模型"做到一条命令;3B 模型量化后约 2GB 显存,RTX 4050 能跑 |
| Embedding | BAAI/bge-small-zh-v1.5 | 约 100MB,CPU 都能跑;中文效果在同量级里是第一梯队 |
| 向量库 | ChromaDB | 文件型数据库,pip install 完就能用 |
| Web UI | Gradio | 几行代码出一个像模像样的聊天页面 |
特意没用 LangChain/LlamaIndex。它们适合快速搭产品,但会把 RAG 的每一步藏在抽象层后面,学习阶段反而看不清到底发生了什么。等我把每一步都手搓一遍、理解透了,再用框架才有意义。
核心代码长什么样
整个 RAG pipeline 就是一个 RAGPipeline 类,六个方法对应六个步骤。挑关键点讲讲。
切分(split_text):起手最简单的做法是"每 500 字切一段,相邻段重叠 50 字"。重叠是为了避免一句话正好被切在两段中间,两边都捞不到关键信息。
为什么是 500 字?这是个经验折中------chunk 太大语义稀释(检索时匹配不到真正相关的东西),太小又缺上下文(模型看不到完整意思)。
向量化(index):每个 chunk 过一遍 BGE 模型,变成一个固定长度的向量,塞进 Chroma。这里有两个容易踩的细节:
python
embeddings = self.embedder.encode(
all_chunks,
normalize_embeddings=True, # 归一化到单位长度
).tolist()
self.collection = self.client.get_or_create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"}, # 必须指定用余弦距离
)
Chroma 默认用的是欧氏距离(L2),对文本相似度不合适,要手动指定成 cosine。我第一次跑的时候没注意这个,检索出来的结果莫名其妙,排查了半小时才发现。
检索(retrieve) :用户问题过同一个 embedding 模型转成向量,然后在 Chroma 里找 top-k 最相似的。同一个模型这点很关键------不然向量空间对不上,结果全是乱的。
生成(generate):RAG 的灵魂在 prompt 里这两句:
erlang
你是一个严谨的问答助手. 请只根据下面提供的资料片段回答用户问题.
如果资料里没有相关信息, 直接回答"资料中未提及", 不要编造.
这就是在压制幻觉。再加上要求它末尾标注引用的片段编号,就做到了"可追溯"------用户看到答案能知道是从哪几段推出来的。
真正花时间的不是代码,是环境
代码其实写得挺顺,难的是让它在我这台机器上跑起来。下面这几个坑依次踩过,每一个都让我对相关技术栈多了一层理解。
坑 1:PyTorch 装完认不出 GPU
用 conda 装完 PyTorch 后,跑验证脚本:
bash
python -c "import torch; print(torch.cuda.is_available())"
报错:
javascript
ImportError: /home/.../libtorch_cpu.so: undefined symbol: iJIT_NotifyEvent
这个 iJIT_NotifyEvent 是 Intel VTune 的 JIT 分析接口,属于 Intel MKL。查了一下才明白:conda-forge 和 pytorch 官方 channel 各自维护的 MKL 版本经常错位,新版 MKL 挪走了一些符号,PyTorch 链接不上就报这个错。
解法:干脆别用 conda 装 PyTorch,改成 pip:
bash
pip install torch --index-url https://download.pytorch.org/whl/cu121
pip 装的 wheel 是静态链接好的,不依赖 conda 环境里的 MKL,干净利落。
启发:PyTorch 官方从 2.5 开始已经不再维护 conda channel,现在统一推荐 pip。conda 擅长管 Python 解释器本身,但对带 C/CUDA 扩展的包,pip 反而更稳。
坑 2:Gradio 6.0 悄悄改了 API
按教程写的代码:
python
gr.ChatInterface(fn=chat, type="messages", ...)
在新版 Gradio 上直接报错:
css
TypeError: ChatInterface.__init__() got an unexpected keyword argument 'type'
翻 release notes 才知道:Gradio 5.x 里有两种消息格式------老的 tuples 格式 [(user, bot), ...] 和新的 messages 格式 [{"role": "user", ...}](和 OpenAI 对齐)。6.0 把后者变成了唯一默认值 ,参数就去掉了。同期被移动的还有 theme,从 Blocks() 挪到了 launch()。
启发 :开源项目升级大版本经常破坏 API。所以 requirements.txt 不能只写下界:
shell
gradio>=5.0,<7.0 # 锁上界, 避免意外升级到 7.0 又炸
另外建议 pip freeze > requirements-lock.txt 把你跑通时的精确版本存下来。这是开源项目的基本礼貌------别人 clone 你的仓库不应该再踩一遍同样的坑。
坑 3:Chroma 数据库向前不兼容
升级了 Chroma 版本后,重跑索引:
vbnet
KeyError: '_type'
原因是旧版本 Chroma 写进 SQLite 的 collection 配置 JSON 格式变了,新版本读不懂旧格式。
解法 :删掉 data/chroma/ 重建。反正向量数据本来就能从源文档重算出来,不算什么损失。
启发 :这是一个所有存数据的程序都要面对的问题------库升级了,之前存下来的数据怎么办?业界有几种做法:
- 版本号 + migration 脚本:PostgreSQL、SQLAlchemy 都这么干。稳,但工程量大
- 向前兼容读:新版代码能读旧格式,只是不按旧格式写
- 直接报错让你手动迁移:Chroma 目前就是这样
早期库走第三种是合理的------demo 项目里数据都能重建,代价不大。真的要做存重要数据的系统,就得严肃考虑前两种了。
一些让仓库"看起来专业"的小事
除了代码,我在仓库上做了这些事,都花不了几分钟但效果明显:
- 在 README 里放 demo 截图和徽章(shields.io 生成)
- 写清楚
requirements.txt和requirements-lock.txt两份文件的分工 .gitignore里忽略data/、__pycache__/、HF 缓存等运行时产物- 在 README 末尾加了一节"常见问题",把踩过的坑都用折叠块写出来
- GitHub 仓库设置里加了 topics(
rag、ollama、chromadb等) - 补了 LICENSE 文件------README 里写 MIT 不算数,要有文件才合法
这些事做完之后,仓库看起来像是真的被用过、被维护着,而不是一个 fork 下来就扔的 demo。这种"专业感"在技术圈里其实是一种隐性的信任。
我的一些真实感受
做完这个项目,对 RAG 最大的感受是:它并不神奇,难点在工程细节而非模型本身。
向量检索的原理(余弦相似度、HNSW 索引)很朴素。真正影响最终效果的是那些被教程一笔带过的决策:chunk 切多大、overlap 留多少、top-k 取几个、prompt 怎么写压得住幻觉、检索漏掉关键片段时怎么办......这些没有标准答案,只能靠具体场景反复试。
第二个感受是**"小工具" 比 "大框架" 更适合学习**。如果我一上来就用 LangChain,RetrievalQA.from_chain_type() 一行就跑通了,但我永远不会知道 metadata={"hnsw:space": "cosine"} 这个参数的存在。踩过了才是自己的。
下一步
这个 demo 还很简陋,有几个明显可以优化的点:
- 加 PDF 支持 :现在只吃
.md和.txt,真实场景 PDF 占大头 - 加 Reranker:向量检索召回 top-20,再用 cross-encoder 精排出 top-3,业界公认性价比最高的优化
- 加评估:没评估的 RAG 就是玄学,改个参数只能靠感觉说"好像好一点"
- 加对话历史:让它能回答"那刚才第二点展开讲讲"这种上下文依赖的问题
这些做完我会写第二篇,带上真实的对比数据。
仓库地址:github.com/spark-prair...
如果这篇文章对你有帮助,点个 star 就是对我最大的鼓励 :)