1 前言
在之前的文章中(参见文章:家庭数据中心系列 从零理解 RAG(一):原理与完整流程解析),我介绍了 RAG 理论上的五步流程:“切分 → 向量化 → 向量存储 → 检索 → 生成答案”;随后,我基于 Ollama 自建的嵌入模型 nomic-embed-text,在 Chatbox 上实现了一个知识库,这可以算是最简单的 RAG 实践(参见文章:家庭数据中心系列 使用Ollama自建嵌入模型 + Chatbox 知识库实战)。
不过,Chatbox 知识库这种实现方式,其实是把“切分、向量存储、检索”这三步都交给 Chatbox 完成,而生成答案的部分,则由用户指定的大语言模型(文章中我是用的 gpt-5-mini)来执行。对于日常个人使用,这种方式倒也够用,但如果想做一个 完全免费的博客聊天机器人,这种方式就不行了。
要真正实现 完全本地、免费的博客聊天机器人,就必须自己搭建一套完整的本地 RAG 流程。理论上,这可以借助现成的框架,例如 LangChain 或 LlamaIndex 来实现。不过,为了先熟悉整个 RAG 流程,我决定先做一套 基于 Python 的最小化 Demo,使用 transformers 调用 Hugging Face 的嵌入模型和LLM模型:不追求复杂功能或性能优化,只为跑通从“文档 → embedding → 检索 → 调用大模型生成答案”的完整闭环。与 Ollama 或其他推理平台相比,这种方式更轻量、可控且完全本地化,非常适合初学者理解 RAG 流程的每一步。
通过这个 Demo,我希望能真正 理解每一步的内部逻辑和数据流,为之后在 PVE LXC 里搭建正式的博客聊天机器人打好基础。换句话说,这篇文章就是一个手把手演示最小本地 RAG 流程的学习案例,可以先熟悉RAG流程,再考虑用框架或部署到生产环境。
注:简版 Demo 的目标是“轻量化、可控、完全本地”,适合学习 RAG 流程;而 Ollama 或其他推理平台则面向正式部署,支持更大模型、更高性能和在线服务。在学习阶段,完全可以先用这个最小 Demo 理解流程,再逐步升级到正式环境。
2 准备工作
2.1 准备硬件环境
要跑通一个最小的本地 RAG 流程,硬件门槛其实并不高。我这次的实践环境是在 M4 Pro Mac mini(24GB 内存) 上完成的,这样的配置足以流畅运行 Hugging Face 的小型模型(例如 sentence-transformers/all-MiniLM-L6-v2 作为嵌入模型,以及 Jackrong/llama-3.2-3B-Chinese-Elite-v2 作为大模型)。
当然,你不一定需要和我一样的 Mac mini,只要电脑满足以下条件,基本就能跑通:
- CPU 支持 AVX2 指令集(大多数近几年的 Intel/AMD 处理器都没问题;苹果芯片在 macOS 下也原生支持)。
- 内存至少 16GB(运行 3B 规模模型更稳妥,如果要跑 8B 模型,建议 24GB+)。
- 磁盘空间 15GB 以上(主要用于存放 Hugging Face 模型文件和向量库数据)。
- 可联网(首次下载模型需要网络,如果下载中断,也可以手动下载模型文件再放到本地缓存目录;后续推理则完全本地运行)。
换句话说,你只要能在设备上装好 Hugging Face 的依赖库并运行一个合适大小的模型,就能完成本文展示的最小 RAG Demo。
2.2 准备软件环境
2.2.1 python环境
要运行最小 RAG Demo,首先需要准备合适的 Python 环境。
macOS 系统自带的 Python 通常是 3.9:

这个版本过老,不兼容我们后续要用到的依赖,因此需要单独安装一个 3.11 或以上版本的 Python。在 macOS 上,主要有两种方式:
方式一:通过 Homebrew 安装(推荐)
这是 macOS 用户最常见也最推荐的方式,因为 Homebrew 可以很好地和系统隔离,不会影响自带的 Python,也方便日后升级或卸载。
如果你只想追求稳定,建议直接安装 Python 3.11:
brew install [email protected]
成功安装后显示结果类似下面:

也可以用以下命令确认:
python3.11 --version

如果你愿意尝鲜,也可以直接安装 Homebrew 提供的最新release版本(目前是3.14):
brew install python
python3 --version
提示:虽然 Python 最新release版本已经到 3.14,但一些依赖可能还没有完全跟进。为了减少兼容性问题,这里更推荐安装 3.11 作为稳妥选择。
方式二:使用 Python 官网安装包(pkg)
Python 官网(https://www.python.org/downloads/)也提供了 macOS 的安装包(.pkg 格式),安装方式非常直观,下载并双击即可完成:

这种方式会将 Python 安装到系统路径下(/Library/Frameworks),相比 Homebrew 管理不够优雅,也可能与系统内置版本冲突。
因此,macOS 用户推荐 Homebrew;而 非 macOS 用户(如 Windows、Linux),则可以从 Python 官网下载对应平台的安装包。
2.2.2 pip 与依赖安装
2.2.2.1 确认安装python 3.11时附带的pip版本
当 Python 环境准备好之后,下一步就是确认 pip 是否可用,因为需要通过pip安装我们 Demo 所需的依赖。macOS 自带的旧 Python 3.9 通常会带一个 pip3,但版本较老,不推荐使用。而通过 Homebrew 或官网安装的新 Python后,会默认附带与之匹配的 pip。例如,如果你用 brew install [email protected],就会自动安装 pip3.11,这样既保证了依赖兼容性,也方便在虚拟环境中使用,可以运行以下命令检查:
python3.11 -m pip --version
如果能输出版本号(例如 pip 25.x from …),说明 pip 已经正常可用:

2.2.2.2 按需手动安装或者升级pip(可选)
如果发现 pip 缺失,可以手动安装:
curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11
建议把 pip 升级到最新版本,以避免后续依赖安装出错:
python3.11 -m pip install --upgrade pip
成功的话输入如下:

2.2.2.3 安装依赖
为了跑通最小 RAG Demo,我们需要一些基础包:
python3.11 -m pip install -U faiss-cpu numpy scipy torch torchvision sentence-transformers transformers tqdm safetensors
说明:
- faiss-cpu:高效的向量搜索库(CPU 版本,Mac mini 足够)。
- numpy:科学计算库,faiss 等库依赖它。
- scipy:常见的科学计算库,用于相似度计算(如余弦相似度)。
- sentence-transformers:常见的文本向量化工具包,用于将切分后的文本块转为向量。
- transformers:Hugging Face 的模型库,可调用 Embedding 模型或简单的 LLM。
- tqdm:进度条工具,在批量处理切分后的文本块时能实时显示进度,更直观。
安装完成后结果如下:

这样一来,Python 环境就具备了运行 RAG Demo 的最小依赖。
注:这里安装的依赖只是跑通最小 RAG Demo 所需的最基本包。在后续用 LangChain 或 LlamaIndex 来搭建正式的本地 RAG 系统,还会需要更多的依赖和配置。
3 附加知识:关于 transformers
3.1 什么是 transformers
在自然语言处理(NLP)领域,Transformers 已经成为最核心的模型架构之一。最初由 Vaswani 等人在 2017 年提出,核心思想是通过自注意力机制(Self-Attention),让模型能够理解文本中不同位置的关联,而不依赖传统的循环或卷积结构。这种机制让模型可以高效捕捉长文本中的上下文信息,适合处理各种语言理解和生成任务。
在 Hugging Face 生态里,transformers 不仅指这种模型架构,同时也指一个开源的 Python 库。这个库的作用是:
1. 统一接口加载模型:不管是 GPT、BERT、RoBERTa、T5 还是 Mistral,你都可以用同一个库去加载、推理、训练。
2. 方便调用各种预训练模型:Hugging Face Hub 上有数千种开源模型,涵盖文本生成、文本向量化(embedding)、分类、问答、翻译等任务。第一次调用时模型会自动下载并缓存到本地,之后就可以直接在 Python 脚本中运行。
3. 本地或云端灵活运行:可以直接在本地 CPU/GPU 上推理,也可以结合云服务或推理平台部署大模型。这意味着,即便没有 Ollama 或 OpenAI API,仍然可以用 transformers 完整跑通一个 RAG Demo。
简而言之,transformers 是 RAG Demo 的核心引擎:它负责把文本转成向量(Embedding),也可以用来生成回答(LLM),几乎覆盖了整个 NLP 流程中可能用到的功能。在本篇文章里,我主要用它来处理 Embedding,但它的能力远不止于此,这也为后续扩展到更复杂的本地 RAG 或 LangChain/LlamaIndex 做了铺垫。
3.2 Hugging Face可用模型类型
在 Hugging Face 的 transformers 生态里,你可以直接使用多种类型的模型。每种类型都有特定用途,下面整理了 RAG 或一般 NLP 场景中最常见的几类模型:
| 模型类型 | 典型用途 | 示例模型 | 说明 |
|---|---|---|---|
| Embedding(向量化) | 文本向量化,用于相似度检索、RAG 向量库 | sentence-transformers/all-MiniLM-L6-v2、all-mpnet-base-v2 | 主要负责把文本或句子转成固定维度向量,便于 FAISS 等库做向量检索 |
| LLM(大语言模型) | 文本生成、对话、摘要 | gpt2、mistralai/Mistral-7B-v0.1、LLaMA | 可以本地生成文本,用于 RAG 的“生成答案”步骤,也可单独做问答或创作 |
| 文本分类 | 情感分析、话题分类、垃圾邮件检测等 | distilbert-base-uncased-finetuned-sst-2-english | 输出类别概率,用于判断文本属性或标签 |
| 问答(Extractive QA) | 从段落中抽取具体答案 | deepset/roberta-base-squad2 | 给定问题和上下文,模型返回文本片段作为答案 |
| 翻译 | 语言间翻译 | Helsinki-NLP/opus-mt-en-zh | 支持多种语言对翻译,也可用于跨语言检索等场景 |
| 多模态 | 语音识别、图像+文本、音频分类 | facebook/wav2vec2-base-960h、部分 CLIP 模型 | 需要额外依赖(如 torchaudio、datasets),可处理非文本数据 |
💡 补充说明
- RAG Demo 的核心:在本文的最小化 RAG Demo 中,我们主要用 Embedding 做向量化,同时可以选择小型 LLM 做生成,其他类型模型可以作为后续扩展或其他 NLP 项目的基础。
- 不用 Ollama 也能跑:这个表格展示的模型都可以用 transformers 本地加载和推理,无需依赖 Ollama 或任何在线平台。
3.3 模型加载方式:Hub 与本地
在使用 transformers 或其他 Hugging Face 模型时,模型的加载方式主要有两种:直接从 Hugging Face Hub 下载,或者使用已经保存到 本地磁盘 的模型。需要注意的是,这两种方式可能加载的是同一个模型,只是来源和加载方式不同。
Hub 模型托管在 Hugging Face 官方服务器上,可以通过 from_pretrained() 直接下载。首次加载时需要联网,下载完成后模型会自动缓存到本地,后续调用通常不再依赖网络。Hub 模型的优势在于获取和更新非常方便,适合快速实验、学习或测试新模型,不需要手动管理文件;不足之处在于初次下载和更新时依赖网络,如果 Hugging Face 服务暂时不可用,可能会无法拉取或升级模型。
本地模型则是指已经保存在磁盘上的模型文件,通过指向本地路径即可加载。使用本地模型的好处是完全离线、可控性高,对于正式部署来说更稳定可靠。缺点在于需要手动管理模型文件,如果要升级,就必须手动替换或重新下载。
总的来说,Hub 模型与本地模型的区别主要在于 便利性与可控性:Hub 模型方便快捷,适合学习和快速迭代;本地模型稳定可控,适合正式部署或完全离线的场景。理解这两种加载方式的利弊,有助于在不同阶段选择最合适的策略,也为后续 RAG 流程的实现打下基础。
3.4 正式部署建议
在生产环境中,如果目标是稳定、高性能的博客聊天机器人,选择模型加载方式和推理平台就显得尤为关键。对于硬件足够的设备(比如 M4 Pro Mac mini),Ollama 方式通常是最优选择。Ollama 针对 Apple M 系列 GPU 进行了深度优化,能够充分利用 GPU 算力,支持低精度计算(fp16/bf16)和高效内存布局,因此即便是中大型模型也能在本地高效运行。同时,Ollama 对并发性能也有优化,更适合正式部署场景。
另一方面,使用 HF transformers 本地方式也完全可行。它依赖 PyTorch 的 Metal API 在 Mac GPU 上加速,对于小型模型可以显著提高推理速度,但大模型可能会受限于 GPU 内存,需要分批推理或混合 CPU-GPU 运行。HF 方式的优势在于完全可控和透明,适合教育、测试或低并发场景,让开发者能够深入理解 RAG 流程每一步的内部逻辑。
综合来看,对于 Demo 或学习阶段,直接使用 HF 本地模型即可,能够快速跑通“文档 → embedding → 检索 → 大模型生成答案”的完整流程,并便于理解每个环节的运作。而在 正式部署或追求高性能的场景下,如果硬件允许,Ollama 方式不仅省心,而且性能最优,是更推荐的方案;HF 本地方式仍可使用,但对大型模型的性能和并发支持有限。
4 RAG 核心模块解析与示例代码
4.1 RAG 最小闭环的五步与三大角色
在前言里我提到 RAG 的五步流程:“切分 → 向量化 → 向量存储 → 检索 → 生成答案”。这五步是具体的操作步骤,但从更高一层看,整个系统可以抽象为三大核心角色:文本块、向量库、大模型。
其中,“文本块”并不是指原始的整篇文章,而是指把原始文档经过切分和向量化处理后得到的最小语义单元——也就是知识在系统中真正被表示和检索的形态。换言之,原始文档通过“切分”和“向量化”变成文本块,文本块就是系统要存储和检索的对象。向量库负责保存这些文本块的向量并执行相似度检索;大模型则在检索到的上下文基础上负责生成最终答案。
因此,五步是“操作层面”的流水线,而三大角色是“结构层面”的参与者。理解了这个层次关系后,后续工作就很清晰:先把原始文档处理成合适粒度的文本块(这是第一个核心),把文本块向量化并存进向量库(第二个核心),然后用大模型结合检索结果来生成答案(第三个核心)。为了让读者更容易跟随,我会在后续小节标题后加上范畴标注(例如 4.2 准备输入文档(文本块范畴)、4.3 文档切分(文本块范畴) 等),这样每一节的内容和它对应的角色会一目了然。
理解了这三个核心角色后,下一步就要准备第一个核心:文本块(文本块范畴),也就是把你希望 RAG 系统“记住”的内容整理出来,并通过切分与向量化把它变成系统可以使用的单元。接下来的章节我会带你一步步把这个最小闭环搭起来。
4.2 文本块
4.2.1 准备原始文档
在 RAG 流程里,文本块是知识进入系统后的最小单元。但在切分之前,我们首先要准备一份原始文档,作为后续处理的起点。
为了演示方便,我直接选取一份已有的 .md 文件作为原始文档。选择 Markdown 文件的理由有两个:一方面,它是常见的知识管理格式,很多人写博客、记笔记都会用 .md;另一方面,它本质上还是纯文本文件,读取和处理非常简单,不涉及复杂的格式解析。
需要说明的是,原始文档的格式并没有硬性要求,.md 文件只是一个例子,也可以是 .txt,甚至是从网页爬取下来的纯文本。RAG 的本质是处理文本,只要能把内容转成字符串,就能进入后续流程。
在当前这个最小 demo 里,我们只使用最简单的纯文本文件(如 .md、.txt),因为 Python 可以直接读取,不依赖额外的库。如果要处理 .docx、.pdf 等复杂格式,则需要借助额外的解析工具(如 python-docx、pymupdf 等),这些超出了本章节的范围。
假设我们在当前目录下有一个 sample.md 文件,内容可能是一篇文章或一份笔记。接下来的章节里,我们会先把这份文档切分成文本块,再将它们向量化并存入向量库,逐步搭建起一个最小闭环的 RAG 系统。
4.2.2 文档切分
有了原始文档之后,第一步要做的就是 切分。原因很简单:大多数文档往往很长,甚至包含成千上万字,如果直接丢给向量化模型,不仅计算效率低,还会导致语义表示过于模糊,检索时很难命中精准的片段。
所谓切分,就是把一份长文档拆成若干较小的 文本块。每个块既要尽量保持语义完整,又不能太大,否则后续做 embedding 时依然可能超出模型的处理范围。通常一个块的长度在 几百到上千字符之间比较合适。
在 RAG 的世界里,切分是个非常关键的环节:切得太碎,语义可能被割裂;切得太长,又不利于检索和匹配。这里我们不追求复杂的切分算法,只做一个最小化 Demo。最简单的办法是按照段落来拆分,比如遇到一个换行符就切一次。这样一来,逻辑结构基本能保留下来,同时又能把文档切成小块。
在后续的代码实现中,我们会写一个简短的函数来完成这个任务:读取 sample.md,按照段落进行切分,并返回一个由文本块组成的列表。这个列表就是后续向量化的输入。
换句话说,切分是“入口的入口”,它决定了知识以怎样的粒度进入 RAG 系统。切分合理,检索和生成答案的效果才可能让人满意。
4.2.3 向量化
在“文本块”这个核心角色里,切分只是第一步,它让长文档被拆解成更小的片段。但光有文本块还不够,机器并不能直接理解文字。为了让它具备“语义感知”,我们需要把这些文本块进一步转化为 向量表示。
所谓向量化,就是把自然语言编码成一串数字——这些数字对应着模型在高维空间里的语义坐标,可以用来衡量不同文本块之间的相似性。
举个简单的例子:人类看到“苹果”和“香蕉”,会知道它们都属于水果;而“苹果”和“笔记本电脑”虽然字面上完全不同,但我们依然能理解到“苹果”在某些语境下指的是 Apple 公司。向量化的作用,就是让机器在数字世界里也能感受到这种“语义上的距离”。两个意思接近的文本块,它们的向量也会很接近;反之,距离就会拉远。(关于向量的详细介绍,可参见文章:……)
在实践中,我们可以借助 Hugging Face 提供的现成嵌入模型,比如 sentence-transformers/all-MiniLM-L6-v2。这个模型的好处是:小巧、速度快、精度也够用,非常适合做 demo。通过 transformers 库,我们可以轻松地把文本块转成向量。
到这里,”文本块”这一角色的工作就告一段落了。我们手里已经有了一组向量化的“语义数字”,但它们还只是散落在内存里的数据。接下来,就要引入第二个核心角色——向量库,把这些向量组织起来,方便检索和生成答案。
4.3 向量库
4.3.1 向量库的作用
在上一小节,我们已经把文本块转成了向量,乍一看,这些向量就是一堆高维的数字数组,单独存在时其实没什么用。真正的价值在于——我们能把它们放进一个 专门的存储与检索工具,也就是向量库。
为什么要有向量库?设想一下:如果我们有几百个文本块,直接用 Python 遍历数组做”余弦相似度”计算还凑合;但如果数据量变成几十万甚至几百万块,逐个比对就会变得非常慢,几乎不可用。而向量库的作用,就是提供 高效的相似度搜索,让我们在庞大的向量集合中,快速找到与查询语句最接近的几个块。
上面这段话中提到了”余弦相似度”这个名词,这个名字具体含义是什么呢?
简单来说,每个文本块经过向量化后,就变成了一个高维空间里的点。如果我们把向量看作从原点伸出去的一根箭头,那么“余弦相似度”就是比较两根箭头之间的夹角:夹角越小(方向越接近),说明两个文本块语义越相似;夹角越大(方向差很多),说明它们语义差别越大。
因为余弦相似度只关心方向,不在乎向量的长度,所以特别适合用来衡量语义相关性。换句话说,向量库就像是“语义索引”。它不像传统数据库那样用关键词做检索,而是通过“语义距离”来找到相关内容。比如用户问:“苹果新品什么时候发布?”——向量库会去找“Apple 发布会”之类的语义相关块,而不是死板地匹配“苹果”两个字。
在实际应用中,有很多“向量库”可供选择,不过需要注意的是,业界常说的“向量库”通常包含两类:向量搜索库(vector search library) 和 向量数据库(vector database)。前者更偏重于高效的相似度检索,后者则在此基础上增加了数据管理与扩展能力:
- 向量搜索库——这是底层的检索算法与索引实现,典型代表有 FAISS、Annoy、hnswlib。它们提供高效的近邻搜索(K-NN)、多种索引结构和压缩策略,适合嵌入到本地应用或作为向量检索的核心组件。优点是轻量、性能好、零运维成本(单机),缺点是通常不包含完整的数据库功能(比如复杂的元数据过滤、权限管理、分布式扩缩容等)。如果你用 FAISS 做检索,但需要保存每个向量对应的文档 ID、原文或其他元数据,通常需要在外部用一个小数据库(例如 SQLite、LevelDB,或简单的 JSON/CSV)来配合保存这些元信息。
- 向量数据库——这是在搜索库之上的产品化封装,既包含高性能检索能力,也提供数据库级的功能:元数据索引/过滤、持久化、分片/复制、在线索引管理、查询接口、云托管服务等。典型代表有 Milvus、Qdrant、Weaviate、Pinecone。优点是功能齐全、便于生产级部署和扩展;缺点是重量级、运维或成本更高(尤其是云服务)。其中 Milvus、Qdrant 更常作为自托管/开源部署;Pinecone、Weaviate(托管版) 则是云服务,省心但需付费。
举例对比(何时选哪个):
- 比如你在自己的环境做入门实验或小规模本地 RAG,想“零运维、快速验证”,FAISS(向量搜索库) 是首选,因为并不需要数据管理和扩展能力。
- 如果你要做线上服务、需要元数据过滤(例如按日期、作者、分类过滤候选片段)、多节点扩展或有稳定 SLA,建议考虑 向量数据库(Milvus、Qdrant、Pinecone 等)。
在我的最小化 RAG Demo 中,我会选择 FAISS(即之前安装的 faiss-cpu):它依赖少、配置简单、能直观展现语义检索效果,非常适合在 Mac mini 上做本地实验。如果将来需要把 Demo 升级为生产级系统,再考虑把 FAISS 替换或对接到向量数据库以获得更多管理与扩展能力。
4.3.2 构建向量索引
文本块被转成了向量表示之后,我需要把它们组织成一个可快速检索的数据结构,这就是 向量索引 的作用,向量索引可以理解为向量库里的核心组件——它负责高效存储向量并支持相似度查询。
在构建向量索引时,我们通常可以选择内存索引、磁盘索引,或者两者结合的方式,以下是向量索引的常见类型与存储方式:
| 使用场景 | 存储类型 | 常见索引 | 特点与适用场景 |
|---|---|---|---|
| 小规模实验 / 调试 | 内存索引 | IndexFlatIP / IndexFlatL2 | 精确搜索,结构简单,查询快;但内存消耗随数据量快速上升,适合 Demo 和验证 |
| 百万级规模检索 | 内存+近似索引 | IVFFlat / IVFPQ | 借助聚类和量化做近似搜索,牺牲部分精度换取速度和存储,适合大规模检索 |
| 在线高性能服务 | 内存图索引 | HNSW | 基于图的近似搜索,低延迟,高召回率,适合对实时性要求高的应用 |
| 海量数据持久化 | 磁盘索引或混合索引 | DiskANN / Faiss on-disk IVF | 支持持久化,重启后依然可用,适合 TB 级向量存储与离线/近线查询 |
对于本地 RAG Demo 来说,如果最终目标是把向量统一写入向量库,那么在索引构建和调试时直接使用内存索引完全没有问题。
接下来,我用 Python 脚本展示一个最小化 Demo,使用 FAISS(Facebook 开源的向量搜索库,性能高且易于本地运行)来实现向量索引,演示如何把向量加入索引并进行相似度查询。为了便于理解,我把脚本的关键步骤先分开成第1、2、3步来说明;实际使用时,可以直接使用第4步中的完整脚本来执行。
1. 初始化向量索引
import faiss
import numpy as np
dimension = 384 # 与文本向量维度一致
index = faiss.IndexFlatIP(dimension) # 使用内积,可用于余弦相似度
这里使用 IndexFlatIP,因为余弦相似度可以通过向量归一化后转换为内积计算,这样每个文本块的向量都可以直接加入索引。
2. 将向量加入索引
# 假设已有向量列表 vec1, vec2, vec3
vectors = np.array([vec1, vec2, vec3], dtype='float32')
index.add(vectors) # 将向量加入索引
print(f"向量索引中共有 {index.ntotal} 个向量")
加入之后,向量索引就搭建完成,可以立即用于相似度检索。
3. 持久化(可选)
# 保存索引到磁盘
faiss.write_index(index, "vector_index.faiss")
# 下次加载
index = faiss.read_index("vector_index.faiss")
这样即便程序结束,下次启动也能直接使用已有的向量索引,实现持久化。
到这里,我们就完成了最小向量库的搭建工作,它为后续的文本检索和答案生成提供了可靠的存储基础。下一节,我们可以讲如何对向量库进行增量管理、保存和加载,以便应对实际使用中不断增长的文本块。
4.3.3 向量库管理
向量库搭建完成后,并不是“放着就完事”。在实际使用中,向量库需要动态管理,以保证检索效率和数据完整性。管理主要包括 增量添加、更新、保存与加载 四个方面:
- 增量添加
新文本块不断生成新的向量,向量库应支持随时加入。FAISS 支持直接调用 index.add() 增量添加向量:
new_vectors = np.array([vec_new1, vec_new2], dtype='float32')
index.add(new_vectors)
print(f"向量库总数更新为 {index.ntotal}")
这种方式无需重建索引,非常适合 RAG 中动态扩充知识的场景。
- 向量更新
如果某个文本块被修改,需要先删除对应向量再重新加入。FAISS 原生索引(如 IndexFlatIP)不支持单条删除,可用 IndexIDMap 结合自定义 ID 实现:
# 创建带 ID 的索引
index_id = faiss.IndexIDMap(index)
index_id.add_with_ids(vectors, ids)
# 更新向量时,先 remove 再 add
- 保存与加载
为防止程序退出导致数据丢失,向量库应定期保存:
faiss.write_index(index_id, "vector_index.faiss")
# 下次加载
index_id = faiss.read_index("vector_index.faiss")
对于大型索引,可以结合分段保存或增量保存策略,提高效率。
- 索引优化(可选)
对于大规模向量库,可以使用 FAISS 的 IVF、PQ 等压缩或分簇索引,提高检索速度,同时节省内存:
nlist = 100 # 聚类数
quantizer = faiss.IndexFlatL2(dimension)
index_ivf = faiss.IndexIVFFlat(quantizer, dimension, nlist, faiss.METRIC_L2)
index_ivf.train(vectors)
index_ivf.add(vectors)
虽然复杂,但在向量数目增长到数十万、百万级时非常必要。通过这些管理手段,向量库能够 持续稳定地服务 RAG 检索,即便文本块不断增加,也不会影响检索效率和准确性。
到这里,向量库的雏形就已经搭建完成了。我们不仅能把文本块的向量有序地存放起来,还能在需要的时候快速检索,并通过持久化的方式让它们在程序重启后依然可用。有了这样一个稳定的“语义仓库”,RAG 的基础设施就逐渐齐备起来了。接下来,就进入第四章的最后一个环节——如何利用向量库来检索信息,并最终生成我们想要的答案。
4.4 大模型
4.4.1 大模型在 RAG 中的职责
在前两节中,我们已经完成了“文本块”的向量化处理,并将这些向量组织进“向量库”。这样一来,用户问题经过检索,就能找到一组与之语义相关的文本片段。接下来,就轮到 大模型(LLM) 上场了。
在 RAG 架构中,大模型的主要职责可以概括为三点:
- 理解用户问题
大模型首先要对用户输入的问题进行语义理解,识别其背后的信息需求。
- 结合检索结果
单靠大模型本身,它的知识可能过时或有限。因此我们需要把从向量库中检索到的“相关文本块”作为补充信息,提供给大模型参考。这样,模型的回答就能立足于最新、特定领域的数据,而不仅仅依赖训练语料。
- 生成最终答案
在获得问题和补充材料后,大模型负责将两者结合,生成自然语言的回答。这一步既要保证事实正确,也要保证语言流畅,体现了 RAG 的价值所在。
可以把大模型在 RAG 中的角色比作“最终解释员”:向量库提供“参考资料”,而大模型则决定如何组织这些资料,最终给出对用户最有用的答案。
为了让大模型顺利完成这些职责,我们必须设计一个合理的 输入结构(prompt 结构),即如何把“用户问题 + 检索到的文本块”组合在一起输入给大模型。
4.4.2 输入结构
前一小节我们明确了大模型在 RAG 架构中的三项职责:理解问题、结合检索结果、生成答案。要让大模型有效完成这些任务,关键就在于 输入结构(prompt 结构) 的设计。
换句话说,大模型能输出什么样的答案,很大程度上取决于我们 如何把用户问题和检索到的文本块拼接在一起。如果输入结构混乱,模型可能会忽略检索结果;如果输入过于冗长,模型又可能丢失重点。
常见的输入结构一般包含三部分:
- 指令部分(Instruction)
明确告诉大模型它的任务是什么,比如:“请根据以下资料回答用户问题,回答时不要编造。”,这部分相当于定调,防止模型跑题或幻觉。
- 上下文部分(Context)
这里放置从向量库中检索到的若干相关文本块。它们是大模型生成答案的“事实依据”,相当于临时外挂知识。
- 问题部分(Question)
最后是用户的原始问题。把问题放在靠后的位置,能帮助模型在理解上下文后,再聚焦于问题本身。
一个典型的输入结构可以像这样:
你是一名智能助手,请严格根据“上下文”回答用户的问题。如果答案无法在上下文中找到,请明确回答“我不知道”,不要编造。
【上下文】
{text_chunks}
【问题】
{user_question}
这种结构的好处是:有清晰的任务指令,避免模型随意发挥;上下文与问题分区明确,模型容易解析;既能保持生成内容的相关性,又能降低幻觉风险。
当然,实际项目中,输入结构可以根据需求不断优化,例如增加“回答格式要求”,或控制“回答长度”。但无论如何,核心思路都是一致的:让大模型在明确的任务框架下,把检索到的资料和用户问题结合起来生成答案。
4.4.3 检索增强的调用流程
理解了大模型的职责,也知道了输入结构的基本形态,现在就可以把它们和向量库串联起来,形成完整的 检索增强调用流程。这个流程的核心是:用户提问 → 检索相关文档 → 构造输入 → 大模型生成答案。
整个过程可以分为四步:
1. 用户提问
用户输入自然语言问题,例如:“向量数据库和向量搜索库有什么区别?”
2. 向量检索
系统先将问题转化为向量,再到向量库中检索出最相似的若干文本块。这些文本块就是“候选知识”,将作为上下文提供给大模型。
3. 构造输入结构
按照上一节的规则,把检索到的文本块(Context)和用户问题(Question)拼接到一个统一的输入中,并加上任务指令(Instruction)。
4. 大模型生成答案
最终,将拼好的输入交给大模型,让它基于上下文生成答案。如果上下文中没有覆盖问题所需的信息,模型则会按照指令回答“我不知道”。
一个简化的伪代码流程大概是这样的:
# 1. 用户输入
question = "向量数据库和向量搜索库有什么区别?"
# 2. 向量检索
q_vector = embed(question) # 将问题向量化
D, I = index.search(q_vector, k=3) # 在向量库中检索 top-3
retrieved_chunks = [chunks[i] for i in I[0]]
# 3. 构造输入
prompt = f"""
你是一名智能助手,请根据以下“上下文”回答问题。
如果答案无法在上下文中找到,请回答“我不知道”。
【上下文】
{retrieved_chunks}
【问题】
{question}
"""
# 4. 大模型生成答案
answer = llm.generate(prompt)
print(answer)
这样,一个完整的 RAG 调用闭环就建立起来了:
- 向量库提供外部知识;
- 输入结构保证知识被正确传递;
- 大模型负责理解并生成自然语言答案。
这也是为什么我们说,RAG 不是“让大模型知道一切”,而是“让大模型学会借助外部资料”。
4.4.4 附加知识:大模型的选择
在前面的小节中,我们已经明确了大模型在 RAG 中的职责、输入结构以及检索增强的调用流程。下一步,就要考虑 具体使用哪个大模型(本次 Demo 为了简洁起见,采用了 Hugging Face 提供的 LLM 模型进行演示;如果追求性能或希望本地部署,也可以通过 Ollama 平台运行 LLaMA 等模型),这一选择会直接影响模型的生成质量、速度,以及对硬件资源的要求。
1、模型选择原则
选择大模型时,一般需要考虑以下几个因素:
生成能力与准确性
模型越大,通常理解问题和生成高质量答案的能力越强,但也更耗资源。
硬件资源限制
包括 GPU/CPU 内存、线程数、是否支持量化等。
延迟与响应速度
实时交互场景对生成速度有一定要求,模型过大可能导致响应延迟。
可用性与兼容性
是否有可量化版本(GGUF、GGML、bitsandbytes 等)、是否易于本地部署或通过 Hugging Face 加载。
2、常见可选模型对比
| 模型 | 参数量 | 优势 | 硬件建议 |
|---|---|---|---|
| GPT-2 / GPT-Neo 125M–1.3B | 0.1–1.3B | 小巧,推理快,容易部署 | Mac/PC CPU 或小型 GPU 均可运行 |
| Mistral 7B | 7B | 高性能、推理速度快 | 16GB+ GPU 或 24GB Mac 内存 |
| LLaMA 3 8B(量化 GGUF) | 8B | 兼顾性能与占用,适合本地部署 | 24GB RAM 的 Mac M 系列可运行 |
| LLaMA 3 13B | 13B | 生成能力更强,适合复杂问题 | 40GB+ GPU 或大型服务器 |
| LLaMA 3 70B | 70B | 极高生成能力,但资源消耗大 | 多 GPU 或大型云服务器,本地 Mac 不适用 |
注:模型越大,所需的显存和内存越多。在本地部署时,选择量化版本(如 Q4_K_M GGUF)可以大幅降低资源占用,同时保持合理生成效果。
3、选择策略
- 个人笔记本或本地 Mac
推荐 LLaMA 3 8B(量化版 GGUF),兼顾效果与速度。
- 中型 GPU 服务器或云环境
可以选择 Mistral 7B 或 LLaMA 3 13B,获得更强生成能力。
- 高端多 GPU 云环境
可考虑 LLaMA 3 70B 或其他超大模型,追求极致生成效果。
4、实践建议
- 根据你的硬件资源量身选择模型,避免因内存不足导致运行失败。
- 对于 RAG Demo 这种交互式演示,模型不必过大,保持响应速度和可运行性即可。
- 如果未来需要处理更大规模知识或复杂问题,再考虑升级到更大模型。
5 脚本结构与数据流
5.1 脚本结构说明
在前面的章节里,我们分别从文档块、向量库和大模型三个角色的角度,逐步拆解了最小 RAG Demo 的关键环节。为了把这些分散的步骤真正串联起来,我们需要把它们整理成几个独立的 Python 脚本。这样做的好处是:逻辑清晰、职责明确,也方便日后扩展或替换某一部分。
在本次 Demo 中,我们会使用三个脚本:
1. document_process.py
- 负责:文档切分与向量化(对应第 4.2 章的“文本块”)。
- 输入:原始文档(txt/markdown 等)。
- 输出:向量化后的文本块列表,存放在内存或中间文件中。
2. vector_store.py
- 负责:构建向量索引与管理(对应第 4.3 章的“向量库”)。
- 输入:document_process.py 生成的向量。
- 输出:完成索引构建的向量库(支持检索,并可选择持久化到本地文件)。
3. rag_pipeline.py
- 负责:组织大模型调用,结合检索结果生成答案(对应第 4.4 章的“大模型”)。
- 输入:用户的查询问题。
- 输出:RAG 的最终生成结果。
这三个脚本既能独立运行(方便测试),又能通过数据传递形成一个完整的流水线。换句话说:它们和“三大核心角色”是完全对应的,只是实现上稍微拆分了任务。
5.2 document_process.py
"""
document_process.py
功能:完成“文档块”的处理工作(优化版)
包括:
1. 文档切分(对应第 4.2 文档块 - 文档切分)
2. 文本向量化(对应第 4.2 文档块 - 向量化)
3. 输出过程文件:processed_docs.json + embeddings.npy
"""
import os
import json
import numpy as np
from sentence_transformers import SentenceTransformer
# ======================
# 1. 文档切分
# ======================
def load_and_split_md(file_path):
"""加载 Markdown 文件,并按段落切分"""
blocks = []
with open(file_path, "r", encoding="utf-8") as f:
paragraph = []
for line in f:
line = line.strip()
if line == "":
if paragraph:
blocks.append(" ".join(paragraph))
paragraph = []
else:
paragraph.append(line)
if paragraph:
blocks.append(" ".join(paragraph))
return blocks
def load_and_split_dir(dir_path):
"""遍历目录及子目录下所有 .md 文件,并按段落切分"""
all_blocks = []
for root, dirs, files in os.walk(dir_path):
for file in files:
if file.lower().endswith(".md"):
file_path = os.path.join(root, file)
blocks = load_and_split_md(file_path)
all_blocks.extend(blocks)
return all_blocks
# ======================
# 2. 文本向量化
# ======================
def embed_texts(texts, model_name="sentence-transformers/all-MiniLM-L6-v2"):
"""使用预训练模型将文本块转为向量"""
model = SentenceTransformer(model_name)
embeddings = model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
return embeddings
# ======================
# 主流程
# ======================
if __name__ == "__main__":
dir_path = "document"
print(f"正在处理目录: {dir_path}")
# Step 1: 遍历目录,生成文本块
text_blocks = load_and_split_dir(dir_path)
print(f"共生成 {len(text_blocks)} 个文本块。")
# 保存文本块 JSON
with open("processed_docs.json", "w", encoding="utf-8") as f:
json.dump(text_blocks, f, ensure_ascii=False, indent=2)
print("文本块已保存至 processed_docs.json")
# Step 2: 文本向量化
vectors = embed_texts(text_blocks)
np.save("embeddings.npy", vectors)
print("向量已保存至 embeddings.npy")
print("向量化完成,向量矩阵维度:", vectors.shape)
document_process.py 脚本说明:
这个脚本做了两件事:
1. 文档切分(对应 4.2.1 文档块 – 文档切分):遍历 document/ 目录下的 Markdown 文件,将每个文档按段落拆成小块,并生成 processed_docs.json 文件,记录切分后的所有文本块。
2. 文本向量化(对应 4.2.2 文档块 – 向量化):调用 sentence-transformers 模型,把文本块转成向量,并将向量以 NumPy 数组形式保存在 embeddings.npy 文件中,供下一步构建向量索引使用。
生成的中间文件:
- processed_docs.json:切分后的文档片段列表,用于构建向量索引。
- embeddings.npy:文本块对应的向量矩阵,用于构建向量索引。
运行方式:
- 保存为 document_process.py
- 在终端运行:
python3.11 document_process.py
5.3 vector_index.py
"""
vector_index.py
功能:构建向量索引并管理
包括:
1. 加载文本块和向量文件
2. 构建 FAISS 向量索引
3. 保存索引及映射关系
"""
import faiss
import numpy as np
import pickle
import json
# ======================
# 参数设置
# ======================
dimension = 384
index_file = "vector_index.faiss"
mapping_file = "vector_index.pkl"
text_blocks_file = "processed_docs.json"
vectors_file = "embeddings.npy"
top_k_demo = 3 # 演示检索前 k 个结果
# ======================
# Step 1: 加载文本块与向量
# ======================
with open(text_blocks_file, "r", encoding="utf-8") as f:
text_blocks = json.load(f)
vectors = np.load(vectors_file)
print(f"加载 {len(text_blocks)} 个文本块和向量矩阵,维度 {vectors.shape}")
# ======================
# Step 2: 构建 FAISS 索引
# ======================
index = faiss.IndexFlatIP(dimension) # 内积作为余弦相似度
index.add(vectors)
print(f"向量索引构建完成,总向量数: {index.ntotal}")
# ======================
# Step 3: 保存索引和映射关系
# ======================
faiss.write_index(index, index_file)
print(f"索引已保存至 {index_file}")
with open(mapping_file, "wb") as f:
pickle.dump(text_blocks, f)
print(f"向量映射关系已保存至 {mapping_file}")
# ======================
# Step 4: 简单检索演示
# ======================
query_vec = vectors[0].reshape(1, -1)
distances, indices = index.search(query_vec, top_k_demo)
print("\n检索演示:")
print(f"查询文本块: {text_blocks[0]}\n")
for rank, idx in enumerate(indices[0], 1):
print(f"Rank {rank}: 相似度 {distances[0][rank-1]:.4f}")
print(f"文本块内容: {text_blocks[idx]}\n")
vector_index.py 脚本说明:
这个脚本做了两件事:
1. 构建向量索引(对应 4.3 向量库 – 构建索引):
- 从 processed_docs.json 中加载文本块
- 从 embeddings.npy 中加载文本块向量
- 使用 FAISS 将向量加入向量库
- 建立向量与文本块的映射关系
2. 向量库持久化(对应 4.3 向量库 – 持久化管理):
- 将构建好的向量索引写入磁盘,生成 vector_index.faiss
- 将向量与文本块的映射关系保存为 vector_index.pkl,方便后续检索时对应文本块
生成的中间文件:
- vector_index.faiss:向量索引文件,用于快速检索
- vector_index.pkl:向量与文本块映射关系,用于将检索结果还原为原始文本块
运行方式:
- 保存为 vector_index.py
- 确保 document_process.py 已生成 processed_docs.json 和 embeddings.npy
- 在终端运行:
python3.11 vector_index.py
5.4 rag_query.py
"""
rag_query.py
功能:完成“大模型”在 RAG 中的调用
包括:
1. 加载向量索引与映射关系
2. 接收用户查询
3. 基于相似度检索相关文本块
4. 调用大模型生成答案,并展示参考片段(控制台美化输出)
"""
import faiss
import pickle
from sentence_transformers import SentenceTransformer
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
# 尝试导入 colorama,若不可用则回退为空字符串(兼容无 colorama 环境)
try:
from colorama import Fore, Style, init
init(autoreset=True)
except Exception:
class _Dummy:
def __getattr__(self, name):
return ""
Fore = _Dummy()
Style = _Dummy()
# ======================
# 参数设置
# ======================
index_file = "vector_index.faiss"
mapping_file = "vector_index.pkl"
embedding_model_name = "sentence-transformers/all-MiniLM-L6-v2"
# 中文优化的公开模型
lm_model_name = "Jackrong/llama-3.2-3B-Chinese-Elite-v2"
top_k = 3
# ======================
# Step 1: 加载索引与映射
# ======================
index = faiss.read_index(index_file)
with open(mapping_file, "rb") as f:
text_blocks = pickle.load(f)
print(f"{Fore.CYAN}向量索引加载完成,总向量数: {index.ntotal}{Style.RESET_ALL}")
print(f"{Fore.CYAN}映射文本块加载完成,总数: {len(text_blocks)}{Style.RESET_ALL}")
# ======================
# Step 2: 初始化查询向量模型
# ======================
embed_model = SentenceTransformer(embedding_model_name)
# ======================
# Step 3: 初始化 LLM(适配 macOS / CPU)
# ======================
tokenizer = AutoTokenizer.from_pretrained(lm_model_name)
lm_model = AutoModelForCausalLM.from_pretrained(
lm_model_name,
device_map="auto",
torch_dtype=torch.float16
)
# ======================
# Step 4: 查询与检索
# ======================
def retrieve_relevant_blocks(query, top_k=3):
query_vec = embed_model.encode([query], convert_to_numpy=True, normalize_embeddings=True)
distances, indices = index.search(query_vec, top_k)
results = [(text_blocks[i], float(distances[0][rank])) for rank, i in enumerate(indices[0])]
return results
# ======================
# Step 5: 生成答案(优化 prompt)
# ======================
def generate_answer(query):
retrieved = retrieve_relevant_blocks(query, top_k)
context = "\n".join([block for block, _ in retrieved])
prompt = f"""
你是一名智能助手。你的任务是根据给定的“上下文”回答用户问题。
请注意:
- 严格引用上下文中的关键句回答问题;
- 将多条信息合并成一句或两句,尽量精简,避免重复;
- 不要复述上下文或问题;
- 如果上下文中找不到答案,请输出“我不知道”;
- 答案以 <END> 结束。
上下文:
{context}
问题:
{query}
答案:<START>"""
inputs = tokenizer(prompt, return_tensors="pt")
inputs = {k: v.to(lm_model.device) for k, v in inputs.items()}
output_ids = lm_model.generate(
**inputs,
max_new_tokens=200,
do_sample=False,
repetition_penalty=1.2,
eos_token_id=tokenizer.eos_token_id
)
full_output = tokenizer.decode(output_ids[0], skip_special_tokens=True)
answer = full_output.split("<START>")[-1].split("<END>")[0].strip()
return answer, retrieved
# ======================
# Step 6: 交互式演示
# ======================
if __name__ == "__main__":
while True:
user_query = input(Fore.YELLOW + "请输入你的问题(回车退出):" + Style.RESET_ALL)
if not user_query.strip():
break
answer, refs = generate_answer(user_query)
print(f"\n{Fore.GREEN}RAG 生成答案:{Style.RESET_ALL}\n{Fore.GREEN}{answer}{Style.RESET_ALL}")
print(f"\n{Fore.MAGENTA}参考片段:{Style.RESET_ALL}")
for rank, (block, score) in enumerate(refs, 1):
print(f"{Fore.YELLOW}[Rank {rank}] 相似度: {score:.4f}{Style.RESET_ALL}")
print(f"{Fore.LIGHTBLACK_EX}{block}{Style.RESET_ALL}")
print(f"{Fore.CYAN}{'-' * 40}{Style.RESET_ALL}")
print(f"{Fore.CYAN}{'=' * 80}{Style.RESET_ALL}")
这个 rag_query.py 脚本 实现了一个简易的 RAG流程,主要功能包括:
1、加载向量索引与映射关系
- 从 vector_index.faiss 加载已构建的向量索引
- 从 vector_index.pkl 加载文本块映射关系
- 恢复已处理的文档向量库与文本块对应关系
2、查询文本块
- 接收用户输入问题
- 使用 SentenceTransformer 将问题向量化
- 在向量索引中检索与问题最相似的文本块(默认返回 top_k=3)
3、调用大模型生成答案
- 将检索到的文本块拼接成上下文
- 构造 Prompt:只允许基于上下文回答问题
- 调用大模型生成最终答案,并返回生成答案时参考的片段
- 若上下文中无法找到答案,则返回 “我不知道”
4、交互式查询演示(带控制台美化)
- 支持循环输入问题
- 输出结果时进行了颜色区分(如果安装了colorama依赖):绿色 → 生成的答案;紫色/黄色 → 参考片段标题与相似度分数;灰色 → 参考文本块正文;青色 → 分隔符。
- 让查询结果更加直观
5、Prompt 设计原则
- 回答必须严格基于“上下文”文本块
- 如果上下文中没有答案,返回 “我不知道”
- 避免模型凭空生成或扩展内容
依赖的中间文件
- vector_index.faiss:向量索引文件(由 vector_index.py 生成)
- vector_index.pkl:向量映射关系(由 vector_index.py 生成)
运行方式:
python3.11 rag_query.py
注意事项与常见问题说明:
- 模型量化问题
- 早期脚本中使用
load_in_8bit=True或load_in_4bit=True进行量化加载, 在 Mac CPU / M 系列上可能会报错,因为需要依赖bitsandbytes或accelerate。 - 最新 Transformers 版本推荐使用
quantization_config配置对象来代替旧参数。 - 在本脚本中,已经去掉了 bitsandbytes 依赖,直接使用 CPU 加载模型,避免量化报错。
- 早期脚本中使用
- 运行报错问题
- 如果遇到
ImportError: CUDA not available或bitsandbytes相关错误, 多半是因为旧量化方式需要 CUDA 支持。 - 在本脚本中无需 GPU,直接用 CPU 执行,已适配 Mac M 系列 CPU。
- 部分 transformers 版本可能提示某些参数无效(如 temperature、top_p),不影响正常运行,可忽略。
- 如果遇到
- 模型下载中断或下载速度慢
- 大模型(尤其是 3B+ 模型)在 Hugging Face 下载时可能出现中断或网络问题, 会提示重试或下载失败。
- 遇到此类问题,可以手动在浏览器或使用
git lfs下载模型文件到本地,并在from_pretrained()中指定本地路径。示例:lm_model_name = “/path/to/local/model/directory”
- 建议
- 对于 Mac CPU / M 系列用户,建议使用 1B 或 3B 的中文优化公开模型,避免 7B 或以上模型导致内存压力过大。
- 若仅测试 RAG 流程,使用较小模型即可满足功能验证。
5.5 总结
根据前面几个小节的内容,最终项目目录结构如下:
project_root/
├── document/ # 原始文档目录(放 .md 文件),也可以嵌套子目录
│ ├── doc1.md
│ ├── doc2.md
│ └── …
├── processed_docs.json # 文本块 JSON(由 document_process.py 生成)
├── embeddings.npy # 文本向量矩阵(由 document_process.py 生成)
├── vector_index.faiss # FAISS 向量索引文件(由 vector_index.py 生成)
├── vector_index.pkl # 向量映射关系(由 vector_index.py 生成)
├── document_process.py # 文档切分与向量化脚本
├── vector_index.py # 构建向量索引脚本
└── rag_query.py # 交互式问答脚本
实际数据流过程如下:
1. **文档切分与向量化(document_process.py)**
- 输入:`document/` 下的所有 `.md` 文件
- 输出:
- `processed_docs.json`(切分后的文本块)
- `embeddings.npy`(对应文本块的向量矩阵)
2. **构建向量索引(vector_index.py)**
- 输入:`processed_docs.json` + `embeddings.npy`
- 输出:
- `vector_index.faiss`(FAISS 向量索引)
- `vector_index.pkl`(文本块与向量映射关系)
3. **交互式问答(rag_query.py)**
- 输入:用户问题 + 向量索引文件
- 过程:检索相关文本块 → 结合 LLM 生成答案
- 输出:在命令行交互式显示最终答案
6 实操演练——让 RAG 跑起来
在前一章我们已经准备好了三个核心脚本(document_process.py、vector_index.py、rag_query.py),以及用于测试的 Markdown 文档。下面的步骤将演示如何实际运行整个 RAG Demo:
- 新建项目目录及文档目录
mkdir ~/Projects
mkdir ~/Projects/document
- 将准备好的3个核心脚本复制到项目目录中(也可直接在目录中新建)
cp /xx/document_process.py ~/Projects
cp /xx/vector_index.py ~/Projects
cp /xx/rag_query.py ~/Projects
- 在文档目录中放置测试文档
将若干 .md 文件放入 ~/Projects/document/ 文档目录中(可以包含子目录),这些文件就是后续问答的知识来源,本次实操我放入了3篇文章对应.md文件:

4. 切分与向量化
在终端依次运行:
python3.11 document_process.py
完成后终端输出如下:

再运行:
python3.11 vector_index.py
完成后终端输出如下:

2个脚本都运行完成后会,在项目根目录生成 processed_docs.json和embeddings.npy(由document_process.py脚本生成)、vector_index.faiss 和 vector_index.pkl(由vector_index.py脚本生成)。
- 交互式问答
执行:
python3.11 rag_query.py

进入交互界面后输入问题,即可得到基于本地文档检索的答案,以下是我针对不同文章内容准备的一些测试问题及相应的输出:
针对文章 1(博客与 AI 时代价值)
- 为什么在 AI 时代写个人博客仍然有价值?
RAG的响应如下:

- 什么是“可信知识锚点”?它的意义是什么?
RAG的响应如下:

针对文章 2(Cloudflare Tunnel)
- 使用 Cloudflare Tunnel 建站可能存在哪些 SEO 隐患?
RAG响应如下:

- 为什么非标准端口会影响搜索引擎收录?
RAG响应如下:

针对文章 3(WordPress 多活架构)
- 个人博客为什么要考虑 WordPress 多活架构?
RAG响应如下:

- ** WordPress 多活架构的关键技术点有哪些?**
RAG响应如下:

从测试结果来看,RAG demo的效果是让我满意的。
RAG 的强项是在“针对单个问题,结合相关片段”来给出回答,也就是做“点对点”的知识检索和问答。
如果尝试让它一次性跨越多篇文章、甚至跨主题去做聚合总结,效果往往不稳定——因为模型需要在多个上下文间做“逻辑缝合”,这超出了 RAG 的舒适区。
所以在实际应用中,建议把问题聚焦到某个知识点或某篇文章上,逐步获得答案,再由你自己来做整合。这样才能最大化 RAG 的价值,也避免期望和实际结果之间的落差。
7 总结
终于把这个最小化的本地 RAG demo 跑通了,没想到从最初梳理流程、整理技术细节,到最后真正完成实操,居然花了我两个多星期。过程中踩了不少坑,比如:下载 HF 模型时因为网速太快导致 safetensors 文件卡住,显存/内存或底层库不兼容导致的 Segmentation Fault,以及 macOS 上没有 CUDA 导致 bitsandbytes 8bit 量化报错……这些问题叠加起来,硬是让我把 rag_query.py 改了几十次~好在,最终还是一个个解决掉了。
不过,凡事有弊也有利,折腾的过程虽然痛苦,但也让我对很多平时不太在意的底层细节有了更直观的理解:比如模型下载的机制、Python 库和硬件加速的耦合关系、内存与显存的边界等等。这些原本“隐形”的东西,在一次次报错和调试中都被迫拆开来看,反而让我收获了意想不到的理解。
通过这次 demo,我对 RAG 的整体流程已经有了更扎实的把握:从文档切片、向量化存储,到检索和重组,再到最后的生成回答,环环相扣,缺一不可。光是搭建出一个能跑通的最小原型,就足够让我体会到 RAG 在“检索 + 生成”这个框架下的力量与局限。它并不是万能的,却是一个非常高效、实用的范式。
下一步,我就可以考虑用 LangChain 或 LlamaIndex(感觉还是需要专门用一篇文章来进行这两者的比较才能梳理清楚)来把这些流程模块化,真正向“生产级”应用靠拢了,目标很明确——和我的博客结合起来,打造一个能为读者提供即时答疑和深度交互的聊天机器人。到那时,遇到的挑战可能会更多:比如如何优化检索效率、如何让答案更贴近上下文、如何平衡准确性和生成的流畅度……但也正是这些挑战,让整个过程变得更有意思。
回过头看,这个 demo 其实就像是一个“试炼场”:它逼着我在有限的资源下想办法解决问题,也让我看到技术的边界与潜力。可以预见,未来在我的博客生态里,RAG 不会是孤立存在的工具,而会成为一块拼图,与知识图谱、智能推荐、自动摘要等能力结合在一起,形成一个更立体的知识系统。
注1:写到这里,我觉得才算是真正完成了“RAG 入门”的第一步,不知道之后还有多少步,也不知道我能走到哪里,只能一步一步来了。
注2:这篇文章实际上去年9月底就写了,不过由于一直给”声音的觉醒”系列文章让路,一让就让了将近半年。
注3:关于文章的排版,一直是让我很蛋痛的事,因为我现在都是直接把obsidian里的markdown格式的文章直接粘贴到文章里,而我用的markdown插件”WP Editor.md”很久没更新了,并且我还用了首段自动缩进的CSS,加上argon主题的一些渲染,所以当文章格式有问题的时候,我都不知道该从哪里开始找问题,毕竟这方面我不擅长,也没啥兴趣研究。也因此导致我最怕复杂的文章——一旦内容种类多了排版看起来就会乱七八糟,就像这篇文章一样~。