这是我的“大模型应用开发学习笔记”系列第四篇。前面三篇分别记了API怎么调、Prompt怎么写、Embedding是什么,但说实话,学到第三篇的时候我一直觉得手里有零件,拼不成产品。直到我把这几块零件焊在一起,做出一个能针对自己资料回答问题的小系统——也就是现在大家常说的RAG——才真正感觉“通了”。这篇笔记的主题就是RAG知识库问答里的四个关键环节:文档切片、向量化、向量检索和上下文生成,全部基于我自己踩过的坑和实测过的最小实现来写。
如果你也处于“会调接口、会写Prompt,但不知道怎么做成一个完整应用”的阶段,这篇笔记应该能帮你省掉不少摸索时间。我会尽量把每个环节为什么这么做讲清楚,而不只是甩一段能跑的代码。
1. 为什么学到第四篇,我决定动手写一个RAG
1.1 前三篇笔记留下的问题
学前三篇的时候,我其实是有点挫败的。调大模型接口很容易,一个chat/completions请求就能得到一段不错的回答;写Prompt也不难,无非是把背景、约束、示例说清楚;用Embedding把文本变成向量也很快,几行代码就能算出两句中文之间的相似度。
但问题在于:这些东西单独摆在那里,哪个都不能直接回答“我的私有资料里有没有提到……”“把这份文档和那篇文章里矛盾的地方列出来”这类实际需求。模型没看过我的资料,Prompt写得再好,它也只能拿它自己的知识硬答。Embedding能把文本向量化,但向量化之后呢?谁来帮你把用户的问题变成检索条件?谁来决定哪些片段该被塞进Prompt?这些在初学者教程里往往被一笔带过了,我决定自己把它们串起来。
1.2 RAG是个什么东西
RAG,全称是Retrieval-Augmented Generation,检索增强生成。我的理解特别简单:这就是一场开卷考试。模型不靠背诵来答题,而是在答题前先给他翻一摞参考资料,让他从里面找到依据,再结合自己的语言能力组织成答案。
开卷考试的参考书怎么来,就是这个系统的核心逻辑。整个链路可以拆成两条线:
- 离线构建:把一批文档读进来,切成片段,每一段转成向量,存进向量库。
- 在线问答:拿到用户问题,把问题转成向量,到库里检索最相关的一批片段,然后把“问题+片段”一起交给LLM生成答案。
这两条线里,最容易出问题的地方根本不是模型调用,而是文档切得好不好、检索准不准、上下文组装得合不合理。后面几节我会按这个顺序逐个讲。
1.3 这一篇的目标定义
动手之前我先给自己定了一个明确目标:做一个最小可用的本地问答系统,能够对一批中文Markdown和PDF文档进行问答,回答必须基于资料内容,并且能给出对应的原文位置。不追求做成产品,也不追求跑多大数据量,核心是把链路跑通,并且每一步都能看到中间结果。
这个目标定义很关键。因为定了“能看到中间结果”,我就逼着自己把每个环节都拆开而不是直接套框架。事实证明这个决定帮我躲开了很多黑盒问题——后面排查故障时,如果不是每一环都有日志,估计得抓狂一整天。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最小项目骨架:先搞清楚每个文件在干嘛
2.1 技术选型:为什么不用LangChain,为什么用OpenAI兼容接口
动手之前最纠结的一件事,就是用不用LangChain。当时我的感受是:LangChain的抽象很多,Chain、VectorStore、DocumentLoader一层套一层,好处是入门快,坏处是你很难知道中间到底发生了什么。尤其是我这种想搞清楚RAG原理的人,用它反而容易被各种包装劝退。
所以我决定这一版不依赖任何RAG框架,核心链路自己写。用到的东西只有:
- Python 3.10作为主语言,做文本处理、HTTP调用和简单的数值计算。
- sentence-transformers加载本地Embedding模型,负责把文本转向量。
- numpy做向量相似度计算,Top-K检索先用暴力算一遍,数据量大了再换专用索引。
- pypdf处理PDF文档的文本提取。
- requests直接调用OpenAI兼容格式的LLM接口,不额外引SDK。
简单做了一个对比,帮助自己理清思路:
| 方案 | 优点 | 缺点 | 我的结论 |
|---|---|---|---|
| LangChain全流程 | 封装度高,能快速跑通 | 黑盒多,中间环节难排查 | 不合适,学不到东西 |
| 自己写加载/切片/检索/组装 | 每步可控,能看中间结果 | 代码量略大,需要自己处理边界 | 选定这个方案 |
| 直接用商业RAG产品 | 零开发,开箱即用 | 无法学习原理,难以定制 | 不符合我的学习目标 |
LLM接口选择OpenAI兼容格式,是因为这种方式同时兼容云服务和本地部署服务。我在config.py里把base_url和api_key做成了配置项,本地部署时指向本机端口,用云服务时替换成自己的密钥就可以,不用改业务代码。这一点后面联调时省了不少事。
2.2 项目文件结构
整个项目我拆成了七个文件,每个文件只干一件事:
code复制rag_demo/
├── config.py # 所有可调参数
├── loader.py # 读取txt/md/pdf,输出原始文本
├── splitter.py # 文本清洗 + 切片
├── embedder.py # 加载embedding模型,封装向量化
├── vector_store.py # numpy向量库 + 余弦相似度检索
├── llm_client.py # 调用OpenAI兼容格式的LLM接口
└── rag.py # 主流程串联,带日志输出
这个拆分方式是我刻意保持的。每个文件对应链路里的一个环节,出了问题只需要看对应模块。比如检索不准,就单独测vector_store和embedder;回答不像话,就去看rag.py里组装出来的Prompt长什么样。项目规模小,做成一层目录就够
