架构说明 --- RAG from scratch(Ollama 版)
定位 :本文档是
...rag-from-scratch的完整架构说明,回答三个问题------系统由什么组成、数据怎么流动、每个决策为什么这么定。
1. 一图总览:双流水线
RAG 系统由两条独立的流水线组成。离线流水线 慢、重、跑一次管很久;在线流水线快、轻,每次提问都走一遍。两者只通过 FAISS 索引文件和数据文件连接------这是"先建库、后查询"的经典检索系统形态。
┌─────────────────────── 离线流水线(索引构建,跑一次管数月)───────────────────────┐
│ │
│ 维基百科 dump(.xml.bz2, ~2.5GB) │
│ │ download.py 断点续传下载 → data/raw/ │
│ ▼ │
│ 原始 XML │
│ │ parse.py wikiextractor 清洗 → data/cleaned/wiki_zh.jsonl │
│ ▼ │
│ 干净文章(一行一篇) │
│ │ chunk.py 500 字 / 50 字重叠切片 → data/chunks/wiki_chunks.jsonl │
│ ▼ │
│ 文本块(一行一块 + 元数据) │
│ │ embed.py → HTTP → Ollama(bge-m3, GPU)→ 1024 维向量, L2 归一化 │
│ ▼ │
│ 向量矩阵 (N × 1024, float32) │
│ │ index_build.py → index/wiki.faiss + index/chunks_meta.jsonl │
│ ▼ │
│ ═══════════════════ FAISS 索引 + 元数据(离线产物)═══════════════════ │
└────────────────────────────────────────────────────────────────────────────────┘
│ 只读
┌─────────────────────── 在线流水线(每次提问,秒级)──────────────────────────────┐
│ ▼ │
│ 用户问题 │
│ │ pipeline.py 编排 │
│ ├─① embed.py 问题 → 1024 维向量(Ollama, GPU, ~毫秒级) │
│ ├─② retrieve.py FAISS IndexFlatIP 检索 top-k → 回填元数据(标题/正文) │
│ ├─③ rerank.py (进阶)精排重排 → top-m(Ollama 不支持,见 §6-R4) │
│ └─④ qwen.py 组装 prompt(引用标注 [来源N])→ Qwen API → 答案 │
│ ▼ │
│ 带引用的最终答案 │
└────────────────────────────────────────────────────────────────────────────────┘
关键理解:①② 是纯本地计算(向量检索),④ 是远程 LLM 调用。检索质量由离线流水线(切片策略 + embedding 质量)决定;答案质量由在线流水线(召回 + prompt 组装)决定。
2. 设计原则(全部决策的上位约束)
| # | 原则 | 具体含义 |
|---|---|---|
| P1 | 无框架 | 不用 LangChain/LlamaIndex/FastGPT------每个环节自己写,向量怎么算、索引怎么建全部可见可改(学习目标是底层技术) |
| P2 | 向量本地,生成远程 | embedding 用本机 GPU(Ollama),生成用云端 Qwen API------4GB 显卡跑不动本地 LLM,但 embedding 模型绰绰有余;生成质量交给云端 |
| P3 | 数据与索引分离 | FAISS 只存向量(它只认数字);文本和元数据自管(JSONL)------逼自己理解"索引里到底有什么" |
| P4 | 一切可复现 | 依赖锁版本(requirements-freeze)、数据带元数据、索引可由脚本从头重建 |
| P5 | 防御性归一化 | 向量入库前显式 L2 归一化(实测 Ollama 已归一化,但保留此步保证对任何 embedding 后端都正确) |
3. 离线流水线:五个模块详解
3.1 download.py --- 数据获取
| 项 | 说明 |
|---|---|
| 输入 | 无(固定源) |
| 输出 | data/raw/zhwiki-latest-pages-articles-multistream.xml.bz2(约 2.5GB) |
| 数据源 | https://dumps.wikimedia.org/zhwiki/latest/(维基媒体官方,免费可商用) |
设计要点:
- 选 multistream 版本而非普通版:它把文章按包分块,配合
mwxml/wikiextractor 可并行解析,且支持随机访问 - 断点续传:HTTP Range 请求接着下,2.5GB 在国内网络必中断
- 完整性校验 :对照官方
sha1sums文件校验,防止下到半截文件污染下游 - 落盘到
data/raw/(该目录已被 .gitignore 排除------数据不进 git)
3.2 parse.py --- 清洗提取
| 项 | 说明 |
|---|---|
| 输入 | data/raw/*.xml.bz2 |
| 输出 | data/cleaned/wiki_zh.jsonl(一行一篇: {id, title, text}) |
| 工具 | wikiextractor 3.0.6(命令行,注意仅支持 Python ≤3.12) |
设计要点:
- wikiextractor 负责"从 XML 里剥出正文"------去模板、去引用标记、去 infobox 这些脏活它都干了,输出已经是纯文本 JSON
- parse.py 在此之上做二次清洗:过滤超短文章(<100 字,无检索价值)、去重、繁简统一(中文维基混有繁体)
- 为什么输出 JSONL 而不是 JSON 数组:JSONL 可逐行流式读写,8GB 语料不可能整个载入内存;一行一篇文章天然适配逐条处理
- 内存策略:流式逐行写盘,峰值内存 <1GB(32GB 内存下毫无压力)
3.3 chunk.py --- 切片(检索质量的命门)
| 项 | 说明 |
|---|---|
| 输入 | data/cleaned/wiki_zh.jsonl |
| 输出 | data/chunks/wiki_chunks.jsonl(一行一块,见 §5 数据规格) |
| 策略 | 500 字 / 50 字重叠 的滑动窗口 |
为什么是 500/50(三个约束共同决定):
- embedding 模型友好:bge-m3 支持 8192 token,但短文本 embedding 质量更聚焦;500 字 ≈ 250~350 token,落在语义最凝聚的区间
- LLM 上下文预算:检索 top-5 × 500 字 ≈ 2500 字进 prompt,加上问题和指令远在 qwen-plus 128K 限内,还为多轮对话留足空间
- 50 字重叠防切断:相邻块重叠 50 字,一句关键定义恰好在边界时,至少有一个块保住完整语义;代价只是语料膨胀 10%
切分顺序(由粗到细):先按段落切 → 段落超 500 字再按句子聚合滑窗 → 每块记录来源(文章 id、标题、块序号)。
学习点:切片是 RAG 里"最土但影响最大"的环节------检索不到十有八九是切坏了,不是模型差。
3.4 embed.py --- 向量化
| 项 | 说明 |
|---|---|
| 输入 | 文本块列表 |
| 输出 | (N, 1024) float32 numpy 矩阵,每行 L2 归一化 |
| 后端 | Ollama /api/embed(HTTP),模型 bge-m3,GPU 推理 |
设计要点:
- 服务化架构:embedding 模型由 Ollama 常驻服务托管(模型加载一次进显存,常驻),Python 只发 HTTP------换 embedding 模型 = 换 URL 参数,代码不动
- 批量请求:一次 POST 带一批文本(input 数组),摊薄 HTTP 开销;批大小 32~64 起,实测调优
- 归一化 :拿到向量后
vecs /= norm(vecs)(P5 防御性写法)。归一化的数学意义:让内积(IP) == 余弦相似度,这正是选 IndexFlatIP 的前提 - 超时:首次调用需把 1.2GB 模型加载进显存(几十秒),timeout 给 300s;之后毫秒级
- 维度即契约:1024 维写死在代码里是 bge-m3 的属性;换模型必须同步改,索引也必须重建
3.5 index_build.py --- 建索引
| 项 | 说明 |
|---|---|
| 输入 | 归一化向量矩阵 + 块元数据 |
| 输出 | index/wiki.faiss(向量索引)+ index/chunks_meta.jsonl(元数据) |
设计要点:
- 选 IndexFlatIP(暴力精确检索):学习阶段必须从最透明的索引进------它就是"拿查询向量和库内每个向量算内积,取 top-k",没有任何黑盒。N 百万块以内,8 线程 CPU 毫秒~几十毫秒,够用
- 为什么是 IP 不是 L2:向量已归一化时,内积 = 余弦相似度(直觉:两向量夹角越小内积越大),而余弦是文本语义相似的标准度量。IndexFlatL2 留作 Week 3 对照实验
- ID 对齐契约:FAISS 里第 i 行向量 ↔ chunks_meta.jsonl 第 i 行------这个约定是两文件唯一的关联,绝不能乱序;meta 里冗余存一份 chunk_id 防错位
- 内存预算公式 :
索引内存 ≈ N × 1024 × 4 字节 = N × 4KB。100 万块 ≈ 4GB,500 万块 ≈ 20GB------32GB RAM 下,百万级块量用 Flat 无压力;再大就该学 HNSW 了(这是设计好的进阶路径,见 §9)
4. 在线流水线:一次提问的完整生命周期
以问题 "京剧的起源是什么?" 为例:
| 步骤 | 模块 | 发生什么 | 耗时量级 |
|---|---|---|---|
| 0 | pipeline.py | 编排开始,加载索引(首次)与 .env | 首次 ~2s,之后 0 |
| 1 | embed.py | 问题文本 → POST Ollama → 1024 维归一化向量 | 模型常驻显存后 ~10ms |
| 2 | retrieve.py | 向量进 FAISS → 内积 top-50 → 回填标题/正文 | ~10-50ms(百万级 Flat) |
| 3 | rerank.py | (进阶)对 top-50 精排出 top-5 | 见 §6-R4 |
| 4 | qwen.py | 拼 prompt:系统指令 + 来源1...5 正文 + 问题;tiktoken 预算检查;调 qwen-plus | ~1-3s(网络) |
| 5 | pipeline.py | 输出答案 + 引用来源列表 | --- |
prompt 组装模板(qwen.py 核心):
你是一个基于参考资料回答问题的助手。仅根据参考资料回答,
引用时标注 [来源N];资料不足以回答时明确说明。
参考资料:
[来源1] {标题}
{正文,单来源截断 800 字}
[来源2] ...
问题:{用户问题}
回答:
为什么要求标注 来源N:逼模型把答案锚定在检索到的文本上,抑制幻觉;同时给你人工核查的入口------RAG 的可信度就来自"答案可溯源"。
token 预算策略(qwen.py):tiktoken(cl100k 估算中文)数一遍 prompt 总长;超限时的削减顺序:先减单来源截断长度 → 再减来源个数 → 绝不动问题本身。
5. 数据规格(全部文件的字段定义)
| 文件 | 格式 | 关键字段 | 说明 |
|---|---|---|---|
data/raw/*.xml.bz2 |
维基 XML | --- | 原始 dump,只读不改 |
data/cleaned/wiki_zh.jsonl |
JSONL | id, title, text |
一行一篇干净文章 |
data/chunks/wiki_chunks.jsonl |
JSONL | chunk_id, doc_id, title, chunk_index, text |
一行一块;chunk_id 全局唯一;chunk_index 是块在原文内的序号 |
index/wiki.faiss |
FAISS 二进制 | 1024 维向量 × N | 只存数字,顺序即 chunk 顺序 |
index/chunks_meta.jsonl |
JSONL | 与 wiki_chunks 相同 + faiss_row |
第 i 行 ↔ FAISS 第 i 行;faiss_row 冗余存行号,校验用 |
.env |
dotenv | DASHSCOPE_API_KEY |
密钥,不进 git |
贯穿全系统的主键 :chunk_id(格式如 wiki_12345_chunk_03)。从 chunk 生成到最终答案引用,靠它一路追踪。
6. 技术选型决策记录
每条 = 决策 + 理由 + 被放弃的备选(学习项目,理由比结论重要)。
R1. embedding 用 Ollama bge-m3,不用 sentence-transformers
- 理由:服务化(HTTP)与解耦;llama.cpp 原生支持 Pascal 老显卡,免去 torch CUDA 版本地狱;bge-m3 是 Ollama 库中中文最佳(1024 维、多语言、8K 上下文)
- 放弃:sentence-transformers + bge-small-zh-v1.5(旧方案,已废弃------需本地 torch,cu126 兼容坑)
- 代价:Reranker 无法走 Ollama(R4)
R2. 检索用 faiss-cpu IndexFlatIP
- 理由:完全透明(暴力检索无黑盒),学习价值最大;百万级块毫秒级返回,8 线程 CPU 足够
- 放弃:Qdrant/Chroma(生产更好但抽象掉索引细节);IndexHNSWFlat(更快但留作 Week 3+ 对照实验)
- 升级路径:块量 >500 万或要求 <5ms 时切 HNSW
R3. 切片 500 字/50 字重叠,不用语义切块
- 理由:固定滑窗简单可控,是所有高级切块的基线;三个约束推导见 §3.3
- 放弃:按语义/按标题层级切块(进阶再对比,先有基线才有比较)
R4. 暂不做重排;将来二选一
- 现状:Ollama 不支持 cross-encoder,重排是当前架构唯一缺失的精排环节
- 方案 A:torch + bge-reranker-base(CPU 跑,~1GB RAM)------精准但要引入 torch
- 方案 B:Qwen API 做 LLM-as-reranker(把 top-20 列表给 qwen-plus 排序)------零本地资源但花 API 钱、慢
- 建议:Week 5 先试 B(不动环境),不满意再上 A
R5. 生成用 Qwen API(qwen-plus),不本地部署 LLM
- 理由:4GB 显卡只能跑 7B 级量化小模型,答案质量远低于 qwen-plus;API 按量计费,学习场景成本可忽略;128K 上下文给 RAG 留足空间
- 放弃:本地 qwen2.5:7b(Ollama 里已有,可作 Week 5 对比实验------体验"本地 LLM 的 RAG"是什么水平)
R6. 元数据自管(JSONL),不塞进 FAISS
- 理由:FAISS 的抽象只到"向量集合",塞 meta 要用 IndexIDMap 且类型受限;自己存 JSONL 逼自己理解索引与元数据的对齐关系(P3)
7. 资源与硬件映射(1050Ti / i7-6700 / 32GB / E 盘)
| 资源 | 分配 | 余量核算 |
|---|---|---|
| GPU 4GB 显存 | bge-m3 常驻 ~1.2GB | 剩 ~2.8GB;模型常驻后 embed 毫秒级 |
| CPU 8 线程 | FAISS Flat 检索(MKL/OpenMP 并行)+ 文本处理 | 百万级块检索 <100ms |
| RAM 32GB | FAISS 索引(100 万块 ≈ 4GB)+ meta(~1GB)+ Python ~1GB | 大量余量;500 万块是 Flat 的舒适上限 |
| E 盘 | dump 2.5GB + 解压 8GB + chunks ~2GB + 索引 ~4GB + Ollama 模型 16GB | 共 ~33GB,1TB 盘无压力 |
| C 盘 | 0 新增(缓存已重定向,见 SETUP.md 阶段 1) | --- |
| 网络 | Qwen API(在线)+ 维基 dump(一次性 2.5GB) | embed/检索全程离线 |
吞吐预估(待实测校准):bge-m3 在 1050Ti 上批量 embedding 约 50~200 块/秒 → 100 万块约 2~6 小时,跑一次过夜即可。
8. 目录结构 ↔ 架构对照
rag-from-scratch/
├── SETUP.md # 环境配置手册(已冻结,7 项自检 PASS)
├── ARCHITECTURE.md # 本文
├── requirements.txt # 依赖清单(无 torch)
├── .env / .env.example # 百炼 Key
├── src/
│ ├── download.py # §3.1 下载 + 校验 + 断点续传
│ ├── parse.py # §3.2 wikiextractor + 二次清洗
│ ├── chunk.py # §3.3 500/50 滑窗切片
│ ├── embed.py # §3.4 Ollama 向量化 + 归一化
│ ├── index_build.py # §3.5 IndexFlatIP 建库 + meta 落盘
│ ├── retrieve.py # §4 查询向量化 + top-k + 元数据回填
│ ├── rerank.py # §4/R4 (进阶)精排
│ ├── qwen.py # §4 prompt 组装 + Qwen 调用 + token 预算
│ ├── pipeline.py # §4 端到端编排:ask("...") → 答案
│ ├── eval.py # §9 hit@k / MRR 评测
│ └── check_env.py # 环境自检(已 PASS)
├── data/{raw,cleaned,chunks}/ # 离线流水线三级产物
├── index/ # wiki.faiss + chunks_meta.jsonl
└── notebooks/ # 实验笔记(向量可视化、索引对比)
9. 质量评估(eval.py)
没有度量的检索系统无法改进。评测闭环:
- 构造评测集:手写 50~100 个"问题 + 应命中的文章标题"(从 cleaned 语料里挑覆盖面广的题目;让 LLM 生成候选问题再人工筛选是捷径)
- 指标 :
hit@k:top-k 结果里是否含目标文章(k=5/10)------回答"能不能检到"MRR:目标文章排名的倒数均值------回答"排得靠不靠前"端到端正确率:小样本人工评答案质量------回答"最终答得对不对"
- 调优实验顺序:改切片参数(300/1000 字对比)→ 换 k → 加 rerank → 换 embedding 模型,每次只动一个变量,跑 eval 对比
10. 扩展路线图
| 阶段 | 内容 | 对应架构变化 |
|---|---|---|
| 主线(当前) | Flat 索引 + 单路稠密检索 + Qwen 生成 | §3/§4 全部 |
| Week 3+ | IndexHNSWFlat 对照实验 | 仅 index_build/retrieve 内部换索引类型 |
| Week 5 | Rerank 上线(方案 B → A) | 在线流水线插入 ③;离线不变 |
| 进阶 | 混合检索(BM25 稀疏 + 稠密融合 RRF) | parse/chunk 顺带产 BM25 词表;retrieve 双路召回 |
| 进阶 | metadata 过滤(按标题/类目) | retrieve 加过滤层;meta 已含 title,无 schema 变更 |
| 生产化 | 增量更新(新文章只增量建索引) | index_build 支持 append;chunk_id 幂等 |
| 生产化 | API 服务化(FastAPI 包 pipeline) | pipeline 已是纯函数,套壳即可 |
11. 三句话记住这套架构
- 两条流水线,一份索引:离线"文本 → 向量 → FAISS"建库,在线"问题 → 向量 → 检索 → 生成",两线只在索引文件处相交。
- 归一化是契约:全库向量 + 查询向量都归一化,内积即余弦------IndexFlatIP 一切魔法的根基。
- 检索质量在离线:答案不好先查切片和 embedding,别急着怪 LLM------RAG 的天花板由检索决定,LLM 只负责把检到的东西讲成人话。