我最近给自己写了个叫 REA 的本地知识库问答工具。REA 全称是 Retrieve-Extract-Answer,也就是“先召回、再抽取、最后生成答案”。这东西不算复杂,但解决了我一个很真实的问题:电脑里几十个 Markdown 笔记、一堆 PDF 报告、从网页上摘下来的零碎观点,平时想找某个概念,脑子记得看过,文件名却想不起来,目录翻来覆去找不到。这篇文章就把 REA 从想法到落地,以及我在调通过程中踩过的坑,完整复盘一遍。
如果你正在折腾个人知识库、RAG(检索增强生成)应用,或者想给本地文档加一个“能对话的入口”,这篇文章应该能给你一些能直接上手的思路。
1. 这个名叫 REA 的小工具,到底是什么
1.1 为什么取名 REA
“REA”是我自己起的项目代号,没什么高深含义。它是我从需求倒推出来的三步流程:第一步 Retrieve,把用户问题转成向量,从本地文档库里召回最相关的内容片段;第二步 Extract,从召回的片段里把真正有信息量的句子和关键实体挑出来;第三步 Answer,把抽取结果交给大模型,让模型基于这些材料生成带引用的回答。
很多类似工具会把“Extract”这一层省掉,直接把召回的原文拼进 Prompt。但我实际用下来,原文里大量无意义段落会稀释模型的注意力,回答经常变得又长又空。所以我在中间加了一层抽取清洗,这也是 REA 和常见“关键词搜索 + LLM 缝合”版本最大的区别。
从工程角度看,REA 就是一条非常朴素的流水线:文档入库、切片、向量化、建索引,查询时走召回、重排、生成。技术栈没有炫技的地方,核心是每一步都踩稳。
1.2 它解决什么问题
先说说痛点。我手头的内容来源很杂:工作时的技术方案、日常阅读的读书笔记、微信公众号文章、PDF 教程、偶尔记在备忘录里的灵感。时间一长,它们散落在不同的文件夹和格式里。
用系统自带搜索,只能做文件名和正文关键词匹配。问题是,我经常只记得“某篇文章里讨论过语义相似度计算里 temperature 的影响”,却想不起原文有没有出现“temperature”这个词。换个场景,如果我只记得“有个公式讲了两个向量的夹角度量”,按“向量夹角”去搜索,大概率搜不到,因为原文可能写的是“cosine similarity”或者“余弦相似度”。
REA 把文本切成语义片段,再用向量表示每个片段。查询时不是做字符串匹配,而是做语义距离比较。这样就绕开了“用词不一致导致搜不到”的经典问题。它解决的不是“文件管理有序”的问题,而是“知识能被想起来”的问题。
1.3 适合谁用
我觉得 REA 适合三种人。
第一种是知识工作者,需要维护大量文档和笔记,但又不想把私有内容上传到云端服务。第二种是正在入门 RAG 的开发者,想搞明白一条完整的本地知识库问答链路是怎么串起来的。第三种是把 AI 应用当乐高来玩的人,希望有一个不依赖特定大模型厂商、可以随时替换内部组件的壳。
如果你只是想给手机装一个开箱即用的笔记问答助手,那 REA 不适合你,它连安装包都没有,需要自己跑起来。但如果你想理解“文档到答案”这个链路里的每一个环节,并且希望所有数据都留在自己手里,那 REA 的思路会很有参考价值。
2. 整体设计思路:不是换个皮肤的搜索引擎
2.1 从“关键词匹配”到“语义召回”
一开始我也想过,是不是直接拿 Elasticsearch 做全文检索就够了?后来发现不够。关键词检索和语义检索的差距,最直观的例子就是同义词。你说“怎么提高模型推理速度”,文章里写的是“如何降低推理延迟”,词面完全不重合,传统倒排索引很难召回。
语义召回的做法是把文本映射到一个高维向量空间。在这个空间里,“推理速度”和“推理延迟”的距离会比较近。问题变成一句话的向量,再和库里所有片段向量算相似度,取最接近的若干条。这个思路不是新鲜东西,但大模型普及后,开源的向量模型质量进步很快,普通开发者也能做出可用的效果。
我在 REA 里采用的方案是:用本地部署的开源中文向量模型把文本转成向量,所有计算都在自己机器上完成。模型不大,CPU 也能跑,只是慢一些。如果你的机器有支持加速的显卡,速度会好很多。
2.2 三层流水线:Ingest、Retrieve、Generate
REA 的整体架构分成三层。
Ingest 层负责把乱七八糟的文档统一转成干净的纯文本。我处理的文件类型包括 Markdown、TXT、PDF、HTML 网页保存版。PDF 解析最麻烦,后面单独说。这一层输出的是“清洗后的段落集合”。
Retrieve 层负责把段落切成适当大小的切片,对每个切片向量化,然后存入本地索引。查询的时候,把用户问题向量化,跟索引里的向量做相似度计算,取 Top-K。K 通常取 5 到 8 个切片,太多会让答案变啰嗦,太少则可能漏掉关键信息。
Generate 层负责把召回的切片做一次抽取压缩,只保留和问题高度相关的内容,再拼装 Prompt 送给大模型。大模型只根据这批材料组织答案,不允许凭空发挥。这样既控制了事实来源,也方便追溯回答依据。
三层各司其职,替换起来也很容易。今天用这个向量模型,明天可以换另一个,不需要动其他层的代码。
2.3 为什么坚持本地优先
数据本地化是我做 REA 的底线。笔记、报告、个人心得,这些东西包含太多隐私。如果全部上传到某个在线服务,等于把日记本交给别人保管,我不放心。
本地优先还有其他好处。一是离线可用,没有网络也能查历史笔记。二是可定制性强,想改切片大小、改召回数量、改 Prompt 模板,全部自己说了算。三是没有接口费用,向量化和问答模型只要本地能跑起来,就是零边际成本。
当然代价也很明显。本地部署需要自己维护环境,模型文件要下载,机器配置有一定要求。我的建议是:如果内容不敏感,想快速体验,可以用在线大模型 API;如果内容敏感,或者你想长期积累知识库,本地优先是更稳妥的方向。
2.4 设计取舍:轻量、可扩展、不绑定特定大模型
REA 没有采用重型的 RAG 框架,而是自己维护一条轻量流水线。原因很简单:框架给了一堆抽象概念,但我只需要二十来个函数。自己写的好处是出了问题能直接定位,不需要翻框架源码。
设计上我坚持三个原则。第一,轻量,依赖尽可能少,整个项目用一个 Python 虚拟环境就能跑。第二,可扩展,Ingest 层的解析器、Retrieve 层的向量模型、Generate 层的 Prompt 模板都做成可插拔形式。第三,不绑定特定大模型,问答接口只要求模型有 Chat 类能力,无论是本地部署的开源模型,还是云服务商的接口,都能接进来。
这样的取舍带来的坏处是,很多高级功能比如自动路由、多轮记忆、知识图谱都没有。但这些功能对第一阶段并不重要,先把核心链路跑通,后面再逐步加。
3. 核心环节拆解:文档进来之后发生了什么
3.1 文档解析与清洗:别让乱码毁掉检索
Ingest 层是整个 REA 里最不性感但最容易翻车的地方。原始文档里除了正文,还有页眉页脚、导航栏、代码块、表格、图片说明。如果不做清洗,后面生成的向量会被大量噪声污染。
Markdown 和 TXT 文件比较简单,直接读进来,去掉多余空行和标题符号即可。HTML 文件麻烦一些,需要剔除 script 和 style 标签,再提取可见文本。PDF 文件是重灾区:扫描版 PDF 没有文字层,需要 OCR;文字版 PDF 提取出来的文本经常把单词断成奇怪的顺序,段落顺序也乱。
我的处理顺序是先抽出纯文本,再做两层清洗。第一层去噪:删除空行、多余空格、无意义符号。第二层结构化:把文章按 Markdown 标题或视觉上的段落切块,尽量保留“标题-正文”的层级关系。清洗这一步千万别省,向量模型处理规范文本和处理乱码文本,效果差距非常大。
3.2 切片策略:如何让长文本不丢上下文
向量模型通常有输入长度限制,几百个 token 已经是极限。而一篇文档动辄几千上万字,必须切片。切片切得好不好,直接决定召回质量。
我踩过的坑是:按固定字符数硬切。比如每 256 个字符一刀切下去,结果一句话说到一半就被切开。被切开的碎片单独看语义不完整,向量表示也会飘。后来改成按段落优先切片:先按 Markdown 的二级标题把文档分成大块,再把超过上限的大块按句子边界二次切分。句子边界优先保证了每个切片内部语义相对完整。
同时我让相邻切片保留 15% 到 20% 的重叠。这样做的好处是,如果答案恰好落在切片边界附近,重叠区域能保证关键词不被切断。重叠切片会增加索引体积,但换来的是召回稳定性,值得。
3.3 向量化与索引存储
向量化就是把文本变成一个固定维度的数字列表。比如一个文本片段变成一个 768 维的向量,每一维都记录了它在某种语义空间里的位置。维度越高,表达能力越强,但存储和计算开销也越大。
在实际项目中,我对中文内容使用了一款开源的中文向量模型,量化后的文件体积大概一两百兆,普通 CPU 能跑。导入一万个文本切片,大概需要几分钟到十几分钟,取决于机器性能。向量化完成后,我使用本地文件型数据库落盘,每个切片记录三样东西:文本内容、来源文件、向量。这样重启服务后不需要重新向量化。
查询时的核心操作是余弦相似度计算。两个向量夹角越小,说明语义越接近。这个计算本身不复杂,但文档多的时候遍历所有向量会比较慢。我的库目前只有几千个切片,全遍历也能在几百毫秒内返回结果。如果你有几十万级切片,就需要引入近似最近邻索引,原理上可以理解为把高维空间分成很多区域,先快速排除明显不相似的区域,再做精确计算。
3.4 召回重排与答案生成
召回阶段拿到 Top-K 切片后,我不会直接全部塞进 Prompt。直接塞会导致上下文过长,而且很多切片离主题很远。REA 增加了一个轻量重排步骤:把召回结果按“关键词覆盖度”和“语义分数加权”重新排序。
具体做法是,对用户问题先做一次粗略的关键词抽取,计算关键词在每个切片里的命中密度。然后把关键词密度和向量相似度分数按 7:3 加权,得到最终排序。这样做的目的是修复一个问题:向量分数经常把一些“文风相似但内容跑偏”的片段排在前面,关键词密度能把这部分拉回来。
生成阶段,我把最终选中的切片按原文顺序拼装,加上固定的系统提示词。提示词里的关键指令是:只依据给定材料回答,如果材料不足,直接说不知道,不要编造。这个约束看似简单,却能显著减少大模型的幻觉。
3.5 最小检索代码示意
不用任何复杂框架,REA 的检索逻辑核心其实是一个距离函数和一个排序函数。下面是一段概念代码,你可以直接用 Python 跑。
import math def cos_sim(vec_a, vec_b): """计算两个向量的余弦相似度""" dot = sum(x * y for x, y in zip(vec_a, vec_b)) norm_a = math.sqrt(sum(x * x for x in vec_a)) norm_b = math.sqrt(sum(y * y for y in vec_b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) def search(query_vector, index, top_k=5): """遍历所有切片,返回相似度最高的 top_k 条""" scored = [] for chunk_id, chunk_vector in index.items(): score = cos_sim(query_vector, chunk_vector) scored.append((score, chunk_id)) scored.sort(key=lambda x: x[0], reverse=True) return scored[:top_k]向量模型输出的格式通常是一串浮点数列表,可以直接套进这个cos_sim。index可以是从本地文件加载的字典,键是切片 ID,值是向量。这个实现没有任何外部依赖,作为理解原理的起点非常合适。
4. 实操实录:从零搭一个 REA 最小闭环
4.1 准备环境与目录
动手之前先定目录结构。我建议按功能拆分,这样后续维护不迷路。
rea_project/ ├── data/ # 原始文档 ├── parsed/ # 清洗后的纯文本缓存 ├── index/ # 向量索引 ├── src/ │ ├── ingest.py # 文档解析与清洗 │ ├── chunk.py # 切片 │ ├── embed.py # 向量化 │ ├── retriever.py # 检索 │ └── generator.py # 生成回答 └── config.py # 各种参数环境方面,Python 3.10 或更高版本即可。建议创建一个虚拟环境,把依赖独立管理。向量化模型我采用本地部署,先下载模型文件,再通过本地推理加载。如果你的机器跑不动本地向量模型,退一步用云服务接口也可以,只是需要处理好网络依赖问题。
4.2 第一步:先跑通向量检索
不要一开始就想着做大而全的系统。我当时的顺序是:先把一个文档的数据灌进去,再验证能不能检索到正确片段。这一步跑通,等于地基打牢。
先在data目录放三到五篇你已经很熟悉的文章。然后运行ingest.py,把文档洗成纯文本,存到parsed。接着运行chunk.py,按段落边界切成 256 字左右的切片。最后运行embed.py,把所有切片向量化,写入index。
验证检索效果时,提一个你确定原文里有答案的问题,看返回的 Top-3 切片是否包含真正答案所在的段落。如果第一轮就能搜到,说明清洗、切片、向量化基本靠谱。
这一步常见的坑是:模型加载慢、向量维度不对、索引文件没有正确写入。逐个排查,不要跳步。
4.3 第二步:接入问答接口
检索闭环跑通后,再把生成环节接上。我把本地问答模型封装成一个统一接口,只要实现“输入消息列表、输出文本回复”的功能,就能替换成任何 Chat 类模型。
接入后第一件事是测试一个很简单的问题。如果答案质量不行,先别急着调 Prompt,而是看看召回结果是不是有问题。RAG 系统有个经验法则:检索质量决定回答上限。如果检索回来了完全不相关的切片,再好的 Prompt 也没用。
我在调通后经常用一句“依据是什么”来测试。好的回答应该能隐约透出来自哪个文档片段,比如“根据你笔记里关于向量检索的段落,余弦相似度……”
4.4 第三步:加上命令行交互
命令行交互是最快能用的界面。REA 的命令行很简单:启动后进入循环,输入问题,打印答案,再输入下一个。这个阶段不用花时间做界面,重点是体验完整闭环。
实现思路就是一个 while 循环:读取输入,调用检索,调用生成,打印结果。顺手把检索到的切片来源路径也打印出来,这样你看到答案时,能同时确认“证据链”是否正确。
如果还有余力,可以加一个最简单的本地 Web 页面。用 Web 框架起一个接口,前端只放一个输入框和一个答案区域。我试过,从命令行升级到 Web 页面大约需要半天,收益是查询体验友好很多,手机浏览器也能访问。
4.5 部署验收清单
我把 REA 跑通后的验收标准整理成了一份清单,每次改完代码都拿它过一遍:
- 新文档导入后,能否在 1 分钟内完成向量化并参与检索?
- 对一个明确的问题,Top-3 切片是否至少包含一个有效答案?
- 大模型是否只依据检索材料回答,而不是自由发挥?
- 连续问 10 个不同问题,是否有超过 2 个明显答非所问?
- 重启服务后,是否需要重新向量化所有文档?
如果这五条都通过,REA 就已经是一个可以日常使用的工具,而不是一个 demo。
5. 踩坑合集:十个问题,九个出在细节
5.1 中文切片把一句话从中间切断
这是我在 REA 开发里遇到的第一个大坑。按固定字符数切片,中文特别容易切在句子中间。比如“向量检索的核心是计算两个向量的相似度”这句话,如果从“相似”和“度”之间切开,两个切片各自语义都很残缺。向量模型会给残缺片段一个模糊的表示,检索时就可能召回到很多不相关的碎片。
解决办法是改变切片粒度。优先按段落标题和句号、问号、感叹号等强分隔符切分,尽量保证每个切片是一个完整的语义单元。同时保留重叠区域,给边界两边的信息一些缓冲。改完切片策略后,召回准确率提升非常明显。
5.2 召回结果里没有答案:相似度阈值要调
另一个常见问题是:召回结果排名靠前,但根本没有回答问题。我一开始也疑惑,为什么相似度分数很高,内容却驴唇不对马嘴。后来把分数打印出来才发现,本地向量模型的分数普遍偏高,0.75 以上的碎片一堆,但区分度不够。
问题出在相似度阈值的设置过松。向量相似度不能只看绝对数值,还要结合分布来看。我做了两件事:一是用“分数差”而不是“绝对分数”来截断,比如只保留比第三名高出 0.02 以上的片段;二是把关键词密度加进来做重排,相当于给向量分数增加一个交叉验证。
5.3 上下文太长,大模型答非所问
把 Top-8 切片全塞进 Prompt,看起来信息量充足,但模型经常会抓不住重点。切片一多,彼此之间还有重复内容,模型容易复述被反复提到的段落,而不是真正回答问题。
我在生成层加了抽取压缩。把召回切片里包含问题关键词的句子抽出来,过滤掉无实际含义的过渡句。这样传给模型的材料可能从 2000 字降到了 800 字,回答的命中率反而提高了。如果模型仍然跑偏,我还会在 Prompt 里显式给出“如果材料里没有你要找的信息,请回答‘知识库中暂无相关内容’”。
5.4 内存与延迟问题
本地向量模型和本地问答模型同时加载,内存会吃紧。我在一台 16G 内存的机器上跑,两个模型加起来占用超过 8G,再打开几个浏览器标签页,系统就开始卡。
后来我把问答模型临时加载改为按需加载:有查询时才加载,回答结束后释放。这样平时只常驻向量模型,内存占用降了一截。代价是第一次提问会多等几秒。如果你的机器性能更好,可以不这么折腾,但了解这个方向对低配机器很实用。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 检索结果总是跑偏 | 向量模型不适合当前语言领域 | 换用中文优化过的模型,调整清洗规则 |
| 答案经常说“不知道” | 切片太碎或召回数太少 | 增大切片长度,提高 Top-K 值 |
| 答案展开太长太啰嗦 | 没有压缩证据材料 | 在生成前增加抽取清洗步骤 |
| 引用的来源对不上 | 切片边界切断了信息 | 检查重叠区域设置,改为按段落切分 |
| 重启后检索失效 | 索引文件没有正确落盘 | 确认索引写入和加载路径一致 |
| 导入 PDF 后全是乱码 | 扫描版需要 OCR,文字版提取失败 | 先做文字层识别,再做清洗 |
这张表不是标准答案,但按照“数据流入问题、检索问题、生成问题”的顺序查,基本能覆盖大多数情况。
6. 一点个人经验与后续可以玩的方向
6.1 我的真实体会
把 REA 从零调通,我最深的感觉是:这类工具真正卡的环节往往不是大模型,而是前两公里的数据处理和切片。向量模型选型、切片策略、清洗规则,每一步看起来都不难,但每步差一点,最后效果就差很多。我反反复复改切片策略和重排规则,就是很好的例子。
另外一个体会是,不要把 RAG 系统想得太玄乎。它的本质就是给大模型配了一个可以翻阅的私人资料库。所有复杂模块,都可以拆成“文档进来、片段存好、问题进来、答案出去”这条直线。先跑通最简版本,再一步步优化,比一开始就上重型架构要靠谱得多。
6.2 后续还能怎么玩
REA 目前只是单轮问答,下一步我计划加自动摘要和多轮记忆。自动摘要可以帮用户快速了解一篇长文档的核心内容;多轮记忆则让追问里有隐含指代时,模型不会丢失上下文。
多模态也是值得探索的方向。图片里的截图、表格、流程图,只要配上文字说明或者 OCR 转写,就能进入现有链路。知识图谱方向更有意思,从切片里抽取实体和关系,把零散笔记连成一张网。到时候查“这个方案影响过哪些项目”,可能就不需要靠大模型猜,而是直接沿着图谱边跑查询。
如果你也想动手做类似的东西,我的建议是从一个特别小的场景开始,比如只整理一个文件夹里的笔记。先把闭环打通,再慢慢扩大库容量。REA 这个名字你可以直接拿去用,也可以用你自己的项目代号。关键是别纠结名字和框架,先去解决一个真实存在的小问题。