手写一个最小 RAG:130 行代码让 3B 模型答对 90% 的公司制度题
都说小模型不行。我用 130 行 Python(不用任何框架)搭了一个最小 RAG 系统,让 3B 参数的本地模型回答公司制度问题,20 道题对了 18 道。它做对的秘密不在模型,在于喂它的方式。
一、RAG 是什么:给模型"递资料"的系统
本地部署过大模型的人很快会撞上同一堵墙:问它通用问题头头是道,问它"我们公司的报销标准是什么"就一本正经地胡编。原因很简单——模型训练时没见过这家公司的任何文档。
解决办法不是重新训练模型(那是天文数字的成本),而是在每次提问时,把相关的资料塞进 prompt 里一起交给模型。这套"找资料 + 递资料"的系统就叫 RAG(Retrieval-Augmented Generation,检索增强生成)。
听起来朴素,但它是目前私有化部署里性价比最高的形态——模型不用动,知识库随时更新,效果立竿见影。
二、完整流程:五个环节
整个系统一共五环,每环一个函数,串起来就是这样:
语料(10 篇制度文档)
→ ① 切块 chunking
→ ② embedding(文字变向量)
→ ③ 检索(找语义最近的 top-3 块)
→ ④ 拼 prompt(资料 + 问题 + 防幻觉指令)
→ ⑤ 生成(本地模型读资料作答)
逐环说清"做什么、为什么需要它":
① 切块(chunking):把文档切成 300 字左右的小块。为什么不能整篇直接用?一篇 3000 字的制度文档讲五件事,如果压成一个向量,每件事的信号都被稀释——查询"报销标准"时,这篇文档和"会议室预订"的距离会被无关内容拉远。切小之后,每块主题单纯,检索才有辨识度。相邻块之间留了 30 字的重叠(overlap),防止恰好把一句话切断。
② Embedding:每块文字过一遍 embedding 模型(我用 bge-m3,本地 ollama 一条命令就能拉下来),变成一个 1024 维的向量。语义相近的文本,向量距离也近——这是后面"按语义找资料"的地基。
③ 检索:用户提问也做一次 embedding,然后和库里所有块算余弦相似度,排序取前三。十来块数据用裸 numpy 三行就够;百万级数据这一环会换成向量数据库(Chroma、Qdrant 这类),但"算相似度 + 排序"的本质不变。
④ 拼 prompt:这是"递资料的手法",也是最容易忽视的一环。我的模板是:
[系统提示] 你是公司制度的问答助手。只依据下面提供的资料回答问题;
资料中没有相关信息时,直接回答"根据现有资料无法回答",不要编造。
回答末尾注明依据的资料编号。
[用户消息] 参考资料:
【资料1】(出处:报销制度 · 差旅报销标准)……
【资料2】……【资料3】……
问题:出差住宿一线城市每晚最多能报多少钱?
两个设计点:那句"没有就说无法回答"是防幻觉指令——给模型一个体面的拒答出口;资料带编号和出处,既方便模型引用,也方便答案溯源。
⑤ 生成:把拼好的 prompt 发给本地模型(qwen2.5:3b),它做的是阅读理解题——答案就在资料里,抄对就行。
完整代码在这里:github.com/tw7613781/llm-experiments/tree/main/src/rag,包括 10 篇虚构的公司制度语料、五环实现、以及一套 20 题的评估脚本。环境只需 ollama + 两个本地模型,仓库 README 里有完整的运行命令。
三、运行结果:3B 答对了 90%,但秘密不在模型
我给这套系统配了 20 道评估题:12 道单点事实题("住宿报销上限是多少")、4 道口语转述题("电脑丢了怎么办",而文档里写的是"设备丢失需报备")、4 道语料里根本没有答案的题("食堂几点开门"——考它会不会编)。
用 qwen2.5:3b 跑出来的成绩:
| 指标 | 结果 |
|---|---|
| 检索命中率(正确文档进 top-3) | 16/16 = 100% |
| 拒答正确率(语料外题老实说不知道) | 4/4 = 100% |
| 答案准确率(关键数字抄对) | 18/20 = 90% |
| 平均耗时 | 1.3 秒/题 |
3B 的小模型,居然全须全尾地接住了。为什么?
关键在于把"检索"和"生成"拆开归因。 我又用 qwen2.5:7b 把同一套题重跑了一遍(检索链路完全相同,只换生成模型):
| 指标 | 3B | 7B |
|---|---|---|
| 检索命中率 | 100% | 100% |
| 拒答正确率 | 100% | 100% |
| 答案准确率 | 90% | 100% |
| 平均耗时 | 1.3s | 2.9s |
两列对比说明的事:检索侧完全相同(本来就用的是同一套检索),差距 100% 出在生成侧。用一句话总结就是——
检索质量决定这套系统的上限,模型的阅读能力决定下限。 检索把正确资料 100% 送达之后,剩下的就只是模型能不能把资料读对。
再看 3B 丢的那两道题,一真一假,都很有价值:
真错误(Q8):问"研发岗笔记本默认内存多大",检索第一名正确命中,原文写着"研发岗默认 32GB 内存配置,非研发岗 16GB"——3B 在两个并列的数字里抓错了对象,答成 16GB。资料就在嘴边还是抄错,这是纯粹的阅读理解失误,也是小模型在真实场景里最典型的翻车点:客户文档里到处是这种并列条件。
假错误(Q14):问客户来访怎么连 WiFi,模型答"访客 WiFi 无需密码,连接后跳转认证,与内网隔离"——语义完全正确,只是没写出 "Guest" 这个英文 SSID 原名,被我设定的关键词判分判了失败。这暴露的不是模型的问题,是评估集设计的问题:机器判分比模型输出更僵硬。做评估时,判分标准(rubric)的设计和模型能力同样重要。
最后给 7B 一个公平的注脚:它满分,但平均耗时慢了一倍多。如果业务是查报销制度这种低风险场景,3B + 修好判分后的约 95% 已经够用;如果答案关乎用药剂量,那必须 7B 起步。"哪个够用"取决于业务对错误的容忍度,而不是对参数量的崇拜。
四、Token 账:输入永远不要超过模型的上下文窗口
第三组值得看的数字藏在评测脚本的日志里:这套 RAG 每次请求的 prompt 平均只有 312 个 token。
先解释 token:它是模型处理文字的最小单位,介于"字"和"词"之间,中文大约 1 token ≈ 0.6–0.8 个字。模型的一切输入输出都以它计量。而每个模型都有一个上下文窗口(context window)——一次能"看见"的 token 总量上限,超出就会静默截断(模型没报错,只是看不见被裁掉的内容,表现成莫名其妙的答非所问)。我这台机器上 ollama 的默认窗口是 4096。
算一笔账:系统提示约 60 tok + 三个资料块约 230 tok + 问题 15 tok ≈ 312,距离 4096 的上限有十几倍余量,完全没有截断风险。
这笔账值得每个做 RAG 的人算,因为它恰好说明 RAG 是对小上下文最友好的应用形态:检索只送最相关的 top-K 块,prompt 的体积是你自己设计的(块大小 × K,一笔账算得清清楚楚)。作为对比,agent 类应用(让模型自主调用工具的那种)每次请求都要背上全套工具定义,动辄上万 token,小窗口模型根本吃不消。所以硬件差、窗口小 → 更应该做 RAG,而不是上 agent——top-K 就是你的上下文调节阀。
五、还有两个调优手段——但先测量,再调优
流程介绍完了,实践中最常被问到的两个调优参数也交代一下原理。但先说结论:它们在这次实验里都没用武之地,因为检索已经 100% 了。
Rerank(精排):第③环的向量检索有个天生局限——查询和文档各自压缩成一个向量再比距离(行话叫双塔),从没"见过面",压缩是有损的,所以它只适合回答"大概像不像",分不清细微差别。Rerank 换一种算法:把查询和文档拼在一起送进专门的精排模型逐词对读,精度高得多,但每一对都要过一次模型,很贵。所以生产的标准做法是两段式:向量检索先从百万块里粗筛出 top-20,rerank 只对这 20 块精算,重排取 top-5。就是搜索引擎"召回 + 精排"的老思路,也是数据库"索引粗扫 + 回表精查"的老思路。
Chunk 大小:第①环切多大是个两难。切太大,一块混多个主题,检索变钝,且 prompt 里有效信息密度低(白白吃 token 预算);切太小,语义被拦腰斩断,模型拿到的上下文碎片化。所以没有理论最优,只有针对你的文档实测最优——这正是"512 还是 1024"这类实验的意义所在。
为什么这次实验区分不出它们的效果?因为我的语料只有 38 块、且制度文本"一段一个事实"形态干净,检索已经满分,没有留下可提升的空间。此时上 rerank 是纯粹的浪费:每题多两次模型调用,换不来任何收益。要让这两个手段显出价值,得先让语料变大、问题变刁钻,让检索真正开始犯错。
这也是整套实验里我最想留下的纪律:
先测量,再调优。 手里有一套能量化的评估集,你才知道问题出在哪一环、哪个手段值得上。没有测量的调优,只是碰运气的堆料。
2026-11
声明:本文的实验、数据和结论均为本人实际操作所得;写作过程中 AI 参与了文字润色与结构整理,技术观点经本人核对确认。

浙公网安备 33010602011771号