手写一个 RAG:从零搭建可溯源的检索增强问答系统

不依赖 LangChain / LlamaIndex,用 numpy + OpenAI 兼容 API + 本地 BGE,把 RAG 的每个环节亲手搭一遍。

一、背景

RAG(Retrieval-Augmented Generation,检索增强生成)是让大模型回答私有/长尾知识的主流方案:先把文档切成块向量化,收到问题时检索相关片段,连同问题一起交给 LLM 生成带引用的回答。本项目的核心逻辑(切块、检索、引用溯源)全部从零手写,不依赖 LangChain / LlamaIndex,把 RAG 的每个环节亲手搭一遍。

具体的模型与工具(LLM、embedding 模型、向量检索实现)不做前置介绍,用到时再逐个讲。

语料是 10 篇原创的 iOS 开发知识笔记(Swift 基础、UI、并发、内存管理、网络等,见仓库 corpus/ 目录),RAG 的目标就是让模型能回答这些私有知识。

二、系统总览

flowchart LR subgraph 离线索引 A[语料 corpus] --> B[chunk 切块] B --> C[embed 向量化] C --> D[(VectorStore numpy 矩阵)] end subgraph 在线问答 Q[用户问题] --> E[embed 向量化] E --> F[retrieve 检索 top k] F --> G[answer 拼 prompt 调 LLM] G --> H[回答与来源溯源] end D -. 相似度计算 .-> F

整条系统分离线索引在线问答两条流水线,先逐个环节看它们在做什么、原理是什么:

离线索引(提前把文档准备好,只跑一次)

  • chunk 切块 :把整篇文档拆成一个个独立的小片段,每个片段单独向量化。这样检索时能精确命中 某一段内容。块的大小不宜过大,否则:
    1. 不精确:一个向量是整块内容的"平均语义",块太大时向量化后精确度下降,检索容易捞错;
    2. 超过 embedding 模型限制:embedding 模型有最大输入长度,文本超长会截断或报错。
  • embed 向量化:把文本变成数字。embedding 模型把每个 chunk 编码成一个向量(一长串浮点数),语义相近的文本向量在空间中靠得近、无关的远------这样"相似度"就有了可计算的数值。
  • 存进矩阵:所有向量拼成一个 numpy 矩阵(N 行 × 每行一个向量),N 是块数。这个矩阵就是"可检索的索引"。

在线问答(用户每次提问实时跑)

  • 问题向量化:用同一个 embedding 模型把用户的问题也编码成向量,保证和文档向量在同一个语义空间里,才能比距离。
  • retrieve 检索:算问题向量和矩阵每一行的相似度,找出最相关的 top-k 个块。这是整条链的核心------决定了 LLM 能不能"看到"正确的内容。
  • answer 生成 :把检索到的块编号塞进 prompt,连同问题交给 LLM,要求用 [n] 标注依据,生成带引用的回答。

三、环境配置与技术栈

开发环境:uv(包管理 + 虚拟环境二合一)+ Python 3.12。

技术栈与参数

环节 选型
LLM 生成 DeepSeek deepseek-v4-flash(OpenAI 兼容协议)
向量化 本地 BGE bge-base-zh-v1.5(CPU 可跑,免外部 API)
存储与检索 纯 numpy 矩阵

用到的库(环境配置):

  • openai:调 DeepSeek API(改 base_url 即可复用)
  • numpy:向量矩阵存储 + 余弦相似度计算
  • sentence-transformers:加载 BGE 模型做向量化
  • transformers:HuggingFace 模型加载库,被 sentence-transformers 依赖
  • torch:深度学习框架,BGE 前向推理的底层计算引擎
  • python-dotenv:从 .env 读 API key,避免硬编码

Intel Mac(x86_64)下 torch 最高只能用 2.2.2。

四、chunk:切块的艺术

切块是 RAG 第一个要设计的环节。目标:每块"信息完整、语义干净、带出身"。设计决策如下:

flowchart TD A[整篇文本] --> B[按换行切行 去空行] B --> C[短行贪心合并 上限 size 减 overlap] C --> D{单行超过上限?} D -->|是| E[标点处拆分 优先级 句末 逗号 非标点] D -->|否| F[保留] E --> G[从第2块起 前置前块尾部 overlap] F --> G G --> H[组装 Chunk text source index]

几个值得说的决策

  1. overlap 的意义 :纯顺序切块可能会把一句完整的话从中间劈开,前后两半各缺半截语义,检索时两边都"残"。所以让每块结尾重复带上前一块的尾巴(本项目 40 字),保证边界处的语义连贯。实现上两个细节:上限取 size - overlap (每块还要前置前一块的尾巴,留出余量才不超长);overlap 取自前一块的 base(不含其 overlap,避免逐块放大)。
  2. 先切分、后加 overlap:先按长度/标点切出互不重叠的 base 块,再给每块前置前一块的尾巴。这样切分逻辑只关心"边界切在哪",重叠是后加的修饰,两件事互不纠缠,边界在哪不因 overlap 变大而漂移。
  3. 标点优先级:句末(。!?;)→ 逗号(,)。中英标点都考虑。外加一个"最大回溯":无标点或标点离窗口末尾太远(欠填超 10%)就放弃干净边界,直接填满整块,避免切出小碎块。
python 复制代码
@dataclass
class Chunk:
    text: str
    source: str   # 来自哪个文件(兼作标题)
    index: int    # 文件内自增编号

五、embed:把语义变成数字

embedding 模型(又称向量模型 / 文本编码器)负责把"一段文本"变成"一串数字"(向量)。它在大规模语料上训练,目标是让语义相近的句子在向量空间里靠得近、语义无关的离得远------所以用向量距离就能度量"两段话有多相关",这是 RAG 检索的地基。

本项目用的是 BGE 的 bge-base-zh-v1.5(智源研究院开源,中文场景效果好,1.1 亿参数,模型权重约 400MB、CPU 可跑)。加载很简单,借助 sentence-transformers 库一行搞定------它会自动处理下载、分词、编码等琐碎环节,不用自己写模型前向推理代码。每个 chunk 会被编码成一个 768 维向量 (base 版模型的输出维度),即 768 个浮点数。项目里把它封装成 embed 函数,一次接收一批文本、批量返回向量:

python 复制代码
from functools import lru_cache
from sentence_transformers import SentenceTransformer

@lru_cache(maxsize=1)
def get_model() -> SentenceTransformer:
    return SentenceTransformer("BAAI/bge-base-zh-v1.5")

def embed(texts: list[str]) -> list[list[float]]:
    return get_model().encode(texts, normalize_embeddings=True).tolist()

get_model 上挂着 @lru_cache(本地缓存):模型加载很重(读几百 MB 权重),每次调用都从磁盘加载太慢,缓存后第一次加载、之后直接复用内存里的模型。

注意 normalize_embeddings=True:向量归一化后长度统一为 1,点积就等价于余弦相似度,检索时才能直接用矩阵乘。

arduino 复制代码
"怎么退钱" vs "退款流程"   → 相似度 0.732(近)
"怎么退钱" vs "今天天气真好" → 相似度 0.295(远)

为什么 embedding 不用 DeepSeek :DeepSeek 官方只提供 chat/reasoner 接口,没有 embedding API。所以 LLM 用 DeepSeek,向量化用本地 BGE------一个管生成,一个管检索,各司其职。

注意 embedding 支持批量embed(所有块的文本) 一次算完,比逐条调用快一个量级。

六、vector_store + retrieve:检索

初版不引入任何向量数据库,一个 numpy 矩阵搞定:

python 复制代码
class VectorStore:
    def add(self, chunk, vector):      # 矩阵加一行 (N, 768),块存列表
        ...
    def search(self, qv, top_k=3):     # 核心:矩阵乘算所有相似度
        scores = self.vectors @ qv      # 一次算出 N 个分数
        order = np.argsort(-scores)[:top_k]   # 按相似度从大到小排,取前 top_k 个
        return [(chunks[i], scores[i]) for i in order]

vectors @ qv 一次矩阵乘 = 算完所有块和问题的余弦相似度(embed 已归一化,点积即余弦)。排序取 top-k。33 块微秒级,一万块几十毫秒------暴力全查在小数据量下完全够用。

retrieve 在其上做业务层决策:相似度低于阈值(0.4)的块扔掉,防止拿无关内容硬答;结果为空就返回明确的"无结果",让 answer 不硬编。阈值 0.4 是初步判断,如果不准确可以根据实际情况调整。

python 复制代码
def retrieve(store, query, top_k=3, min_score=0.4) -> list[Chunk]:
    hits = store.search(embed([query])[0], top_k=top_k)
    return [chunk for chunk, score in hits if score >= min_score]

先取最相似的 top_k 个,再筛掉低于阈值的------两道过滤,避免拿无关内容硬答。

sequenceDiagram participant U as 用户 participant R as retrieve participant VS as VectorStore participant E as BGE U->>E: 问题文本 E-->>R: 问题向量 R->>VS: search VS-->>R: 相似度排序的前k个块 R->>R: 过滤低于阈值的块 R-->>U: 留下的块(可能为空)

七、answer:带引用的回答

检索到块后,把它们编号并塞进 prompt 生成最终的回答,并要求 LLM 用 [n] 标注依据。核心代码很简单,就是拼 prompt + 调 LLM:

python 复制代码
def build_prompt(query, chunks):
    context = "\n\n".join(f"[{i}] {c.text}" for i, c in enumerate(chunks, 1))
    return f"""只依据下面编号的资料回答问题,
用 [n] 标注依据,资料没有的说"资料中没有相关信息"。

资料:
{context}

问题:{query}"""

def answer(query, chunks):
    return AnswerResult(answer=chat(build_prompt(query, chunks)), chunks=chunks)

其中 chat 负责调用 LLM:DeepSeek 兼容 OpenAI 协议,用 OpenAI SDK 改一下 base_url 就能发请求,把 prompt 传给模型、取回回答文本。

实际发出去的 prompt 长这样:

csharp 复制代码
资料:
[1] 循环引用是两个对象互相持有强引用,导致引用计数永远不为 0...
[2] 解决方式是使用 weak 弱引用,不增加引用计数...

问题:循环引用是什么?怎么解决?

规则:
- 只依据资料回答,资料没有的说"资料中没有相关信息"
- 用 [n] 标注依据,紧跟句末,如"内存无法释放 [1]";不要用括号
- 每个依据点单独一行

关键设计:[n] 表示参考资料的编号。这样不仅方便溯源,也避免了模型自己编造来源(幻觉)。

flowchart LR A[检索到的块] --> B[编号后拼 prompt 调 LLM] --> C[回答含编号] --> D[代码映射编号到文档]

实测效果(问"循环引用是什么?怎么解决?"):

ini 复制代码
循环引用是两个对象互相持有强引用,导致彼此的引用计数永远不为 0,内存无法释放 [2]。
解决方法一:使用 weak 弱引用,不增加引用计数,对象释放后自动变为 nil [1]。
解决方法二:使用 unowned 无主引用,也不增加计数,但需保证对象一直存在 [1]。

--- 引用来源 ---
[1] 04_arc内存管理.md #1
[2] 04_arc内存管理.md #0

引用和内容能对上:定义在 #0 块(标 2),解决方案在 #1 块(标 1)。

八、evaluate:用数字说话

肉眼看着准不够,评测才是硬道理。做法:手工造 10 个测试用例 (问题 → 期望文档)------从每篇语料挑一个它该答得上来的问题,期望它被检索到并作为引用来源。例如:

python 复制代码
TEST_CASES = [
    ("Swift 里的 Optional 是什么?怎么安全解包?", "01_swift基础.md"),
    ("什么是循环引用?怎么避免?", "04_arc内存管理.md"),
    ("App 的偏好设置该用什么存储?", "07_数据持久化.md"),
    ("TestFlight 怎么分发测试版给用户?", "10_app发布上架.md"),
]

每个用例测两个指标:

  • 检索 hit_rate:期望文档是否出现在 top-k 检索结果里
  • 引用有效:回答引用的来源里是否包含期望文档

评测逻辑:

python 复制代码
def evaluate(store) -> list[tuple]:
    results = []
    for query, expected in TEST_CASES:
        chunks = retrieve(store, query)
        hit = expected in {c.source for c in chunks}                 # 指标1:检索命中
        cited = {chunks[n-1].source for n in extract_citation_nums(
            answer(query, chunks).answer)}                           # 指标2:引用里的来源
        results.append((query, expected, hit, expected in cited))
    return results

extract_citation_nums 用正则把回答里的 [n] 提取出来,再映射回块,就能核对引用是否指向期望文档:

python 复制代码
import re

def extract_citation_nums(text: str) -> list[int]:
    return [int(n) for n in re.findall(r"\[(\d+)\]", text)]

评测的价值在于:以后调 size / overlap / min_score 参数时,不用再肉眼判断,跑一遍 evaluate 看数字

九、踩过的坑

  1. Intel Mac 兼容性:torch 最高只出到 2.2.2(x86_64),连带 transformers 锁 4.46.3、numpy 锁 1.26.4。
  2. Markdown 代码块被切断 :```````swift```` 代码块在 chunk 边界被截断。不识别 Markdown 结构是简化决策,代价就是这种边界瑕疵。
  3. 依据引用格式不稳定:同样规则下,依据文档编号的输出格式不稳定,需要在 prompt 里加上规范。
  4. 阈值太松放无关内容:0.3 会让完全无关的查询也返回块,调整到 0.4 正合适。

如果这篇文章对你有帮助,欢迎收藏转发。RAG 不难,但值得亲手搭一遍。

相关推荐
不吃辣4901 小时前
vibe coding | 如何做一个 AI 音乐生成工具?
java·人工智能·后端·ai·ai编程
文人sec1 小时前
从接口压测到全链路质量保障:AI智能客服系统的软件测试实践
人工智能
声讯电子1 小时前
实测A-59F音频模组:降噪稳、不啸叫、通话清晰
人工智能·语音识别·ai降噪·usb接口·回音消除
神奇霸王龙1 小时前
2026旗舰六阶段MCP流水线实战指南
人工智能·ai·ai作画·prompt·aigc·mcp
山河不见老1 小时前
【Cursor 、Qoder安装问题】Cursor 、Qoder安装卡在“正在准备安装”问题排查及解决
人工智能·windows·编辑器
新知图书1 小时前
2.3 开发工作流
人工智能·agent·ai agent·智能体
tuanxiang2 小时前
用免费 AI 改写工具处理批量文案的踩坑记录,附完整降噪脚本
人工智能
小邓的技术笔记2 小时前
DeepSeek 大模型落地应用与场景实战指南
大数据·人工智能
石榴2 小时前
从 Codex + Obsidian 到本地 RAG:我的私人知识库实践
人工智能