从零开始学习RAG——02 项目架构

架构说明 --- 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(三个约束共同决定):

  1. embedding 模型友好:bge-m3 支持 8192 token,但短文本 embedding 质量更聚焦;500 字 ≈ 250~350 token,落在语义最凝聚的区间
  2. LLM 上下文预算:检索 top-5 × 500 字 ≈ 2500 字进 prompt,加上问题和指令远在 qwen-plus 128K 限内,还为多轮对话留足空间
  3. 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)

没有度量的检索系统无法改进。评测闭环:

  1. 构造评测集:手写 50~100 个"问题 + 应命中的文章标题"(从 cleaned 语料里挑覆盖面广的题目;让 LLM 生成候选问题再人工筛选是捷径)
  2. 指标 :
    • hit@k:top-k 结果里是否含目标文章(k=5/10)------回答"能不能检到"
    • MRR:目标文章排名的倒数均值------回答"排得靠不靠前"
    • 端到端正确率:小样本人工评答案质量------回答"最终答得对不对"
  3. 调优实验顺序:改切片参数(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. 三句话记住这套架构

  1. 两条流水线,一份索引:离线"文本 → 向量 → FAISS"建库,在线"问题 → 向量 → 检索 → 生成",两线只在索引文件处相交。
  2. 归一化是契约:全库向量 + 查询向量都归一化,内积即余弦------IndexFlatIP 一切魔法的根基。
  3. 检索质量在离线:答案不好先查切片和 embedding,别急着怪 LLM------RAG 的天花板由检索决定,LLM 只负责把检到的东西讲成人话。
相关推荐
书源丶1 天前
Qwen3-Embedding-0.6B 纯 CPU
网络·语言模型·embedding·llama
牛油果子哥q6 天前
多模态大模型工程入门:图文Embedding、图文RAG、图片解析、C++多模态接口封装实战
开发语言·c++·embedding
好菇娘の当自强7 天前
安装 Embedding 模型所需环境(完整指南)
embedding
ZYJCSZKJ7 天前
基于向量检索重排序与查询扩展的GEO优化:从Embedding空间视角提升区域实体引用率
embedding
richard_first8 天前
Transformer与大语言模型:第18章 向量数据库
数据库·人工智能·自然语言处理·transformer·embedding
今天AI了吗8 天前
去中心化 AI 反馈系统:数据不上链,凭证与激励分开管
人工智能·windows·python·数据分析·去中心化·区块链·embedding
CIO_Alliance8 天前
AI微调系列(1)| 全量微调、LoRA、QLoRA 三种方案怎么选
前端·人工智能·神经网络·机器学习·embedding·企业ai转型
ShallWeL12 天前
RAG Embedding 模型替换与索引回归
人工智能·embedding·知识库·工作流·rag
Harry Lei13 天前
长期记忆系统的设计与实现
spring boot·postgresql·embedding