RAG 系统从零到一的实战经验与教训
分享构建 RAG 系统的成功和失败案例,帮助开发者规避常见坑点并理解关键设计决策。
分享构建 RAG 系统的成功和失败案例,帮助开发者规避常见坑点并理解关键设计决策。
几个月前,我接到一个任务:为公司的工程师开发一款内部工具——一个使用本地 LLM 的 Chat。到这里还没什么特别的。随后需求来了:响应速度必须快,我再强调一次……要快!而且……它还必须能够回答公司成立以来做过的所有项目相关问题(时间跨度接近十年)。他们不想要传统搜索引擎,而是希望有一个可以用自然语言提问,并获得附带原始文档引用的答案的工具。尤其需要重点提供 OrcaFlex 文件中的信息(OrcaFlex 是一款用于浮体动力学、缆索等仿真的软件,在海洋工程行业中应用广泛)。这听上去已经很复杂了,而当我获得 1 TB 项目资料的访问权限后,这一点得到了进一步证实:里面混杂着技术文档、报告、分析材料、法规、CSV 等各种内容。情绪过山车就此启动。
先把话说在前面:整个过程既不快,也不轻松,正因如此,我才想把它分享出来。从最初的尝试和犯过的错误,到最终投入生产的架构,我都会讲到。另外,我此前从未做过任何类似的项目,甚至连 RAG 的工作原理都不知道。
下面我们逐个分析遇到的问题,以及我为每个问题采用的解决方案。
第一步是确定技术栈。
出于保密原因,我需要一个本地语言模型,不能依赖外部 API。Ollama 是当时在本地运行 LLaMA 模型最成熟、最易用的选择。我尝试了多种 embeddings,最终发现 nomic-embed-text 在处理技术文档时兼具不错的性能和质量。
接下来需要一个 RAG 引擎,用来编排文档索引、embedding 生成、向量数据库存储和查询等流程。没有它,无论语言模型有多快,我们都无法从文档中检索出相关信息。你可以把它想象成一本书的索引:没有索引,你必须读完整本书才能找到所需的信息;而有了良好的索引,就可以直接翻到正确的页面。为了方便,后文我会把这个过程简称为「索引」,尽管严格来说,它实际上是一个向量化和索引过程。
经过一番研究,我找到了一个成熟的开源框架:LlamaIndex。
我决定使用 Python。理由可以列出很多,但最重要的一点是,我用它写代码既顺手又高效。此外,Ollama 和 LlamaIndex 都提供了非常出色的 Python SDK。
至此,我已经准备好开始构建软件。我编写了第一批脚本,对 RAG 系统进行向量测试,并做了一些查询实验。只用了很少的代码,效果就相当不错。我以为这会是一个几周就能完成的项目。事实证明,我错得不能再离谱。
下一步是处理真实文档。抓稳了,接下来会一路颠簸!
我的文件来源是 Azure 上的一个文件夹,里面堆放着海量技术文档:数百 GB、数千个文件、格式五花八门,除了文件夹层级之外,没有任何组织方式或结构。简直是每一位数据工程师的梦想——注意,这里是反话。
我活动了一下手指,把 RAG 的输出设置为保存到磁盘,然后启动了第一个脚本。结果几分钟之内,LlamaIndex 就耗尽了笔记本的 RAM,把操作系统拖到无法喘息,最后一切彻底卡死。我尝试了许多配置、缓存系统和其他策略,但无论怎样,机器总会在某个时刻崩溃。
经过调试,我发现系统正在处理一些体积巨大、却毫无价值的文件:视频、仿真文件、备份文件……这些文档对 RAG 系统没有任何帮助,但 LlamaIndex 却试图像处理文本一样处理它们。如果某个文件有几 GB,系统就会尝试将它完整加载到内存中进行处理,这无异于自杀。
于是,我在 pipeline 中加入了一套过滤系统,根据文件扩展名和文件名模式排除文件,例如仿真文件、数值计算结果等。
我还移除了那些处理成本高、同样无法带来价值的文件,例如 CSV、JSON 等。另一方面,我将 PDF、DOCX、XLSX、PPTX 等文件转换为纯文本,让 LlamaIndex 能够顺利处理它们。
最终,需要建立索引的文件数量减少了 54%。当然,我的 RAM 也终于不再爆炸。
现在,我总算可以放心地开始索引了。
RAG 需要创建一个包含文档 embeddings 的向量索引文件。向量是文档的数值化表示,可以用来衡量文档之间的相似度。LlamaIndex 提供了一套简单的系统,只需几行代码即可完成配置。你只要让它指向目标目录,它就会负责将其中的所有信息以 JSON 格式存储起来。这确实非常方便,效果也不错——除非你面对的是数百 GB 的文档。
此时,整个系统变得难以管理:每次服务重启,都必须从头重新处理所有文档,可能需要耗费数天。此外,默认格式 JSON 也不适合大规模搜索。
我添加了一套 checkpoint 系统,用来保存索引进度。这样每当出现问题时,我就不必丢掉全部进度,而是可以从上一个处理完成的文件继续。不过,数据会发生损坏,整个方案容易出错,而且速度很慢。我遇到了一个无法突破的瓶颈。
在经历大量反复试错,并进一步阅读相关资料后,我决定转向专用的向量数据库:ChromaDB。它是一个用于存储和查询向量的开源数据库,采用 Apache-2.0 许可证。注意不要把它和 Chrome/Chromium 浏览器混淆。ChromaDB 是一个构建在传统数据库之上的抽象层;我将底层数据库配置为 SQLite,而 ChromaDB 则提供了相似度搜索、聚类等专用功能。
这次改动立刻带来了彻底的变化。索引过程不再是一个把所有内容都加载到内存中的单体流程,而是变成了一条 batch pipeline:每次处理 150 个文件,生成它们的 embeddings,然后直接存入 ChromaDB。
这让我能够分多个 session 完成 451 GB 文档的索引,并通过 checkpoint 保留进度。即使任务中断,也不会丢失进度或造成数据损坏。此外,在发生故障时,备份和恢复索引也非常简单——只需复制 SQLite 文件即可。
系统已经准备就绪。但在进行了一次简单的 benchmark 后,我发现如果使用自己的笔记本,要花几个月才能完成所有内容的索引。现在,瓶颈既不是 RAM,也不是索引系统或文件,而是 GPU。
我的笔记本只有一块集成显卡。通过 CPU 处理 500 MB 文档需要 4~5 个小时,这个数据实在不理想。我迫切需要一块性能强劲的 GPU。在后续会议中,大家决定为我租用一台配备 NVIDIA RTX 4000 SFF Ada 的虚拟机,它拥有 20 GB VRAM。这类租用服务可一点都不便宜。此时,我承受的压力更大了。
我修改了 containers,并优化系统,使其能够充分利用 GPU。随后,我启动了脚本。经过 2~3 周,索引过程终于在没有发生故障的情况下完成:总计 738,470 个向量、ChromaDB 中 54 GB 的索引,以及一个已经可以回答问题的 RAG 系统。
我将 ChromaDB 数据库——也就是一个 SQLite 文件——复制到本地机器上,事情就完成了。我们的 Sysadmin 和 Project Manager 终于松了一口气,因为总算可以关闭那台虚拟机。Hetzner 的账单是 184 欧元,并不便宜。
接下来,该构建 backend 和 frontend 了。
我使用 Flask 构建了一个简单的 API,用来访问 LlamaIndex,而 LlamaIndex 又会查询 ChromaDB 和 Ollama。
我非常喜欢用 Streamlit 构建各种内部项目,因此决定用它来开发 frontend——这样还能继续使用 Python。Streamlit 还原生提供了一个问答 widget,形式类似当前各种用于和 AI 交互的 Chat。
只用了几个小时,我就完成了整个可视化部分。剩下的都是一些需要打磨的细节:显示公司 Logo、在处理查询时显示 spinner、保存 session 等。
系统中不同组件之间的通信方式如下:
flowchart TD
U["👤 User"]:::user --> E["Streamlit (Web UI)"]:::web
E <-->|HTTP| D["Flask API"]:::api
D --> F["Python Backend"]:::backend
F <--> C["Ollama (LLM + Embeddings)"]:::llm
C <--> B["RAG (LlamaIndex)"]:::rag
B <--> G["ChromaDB"]:::chroma
classDef user fill:#37474F,stroke:#263238,stroke-width:2px,color:#fff
classDef web fill:#8E24AA,stroke:#6A1B9A,stroke-width:2px,color:#fff
classDef api fill:#D32F2F,stroke:#B71C1C,stroke-width:2px,color:#fff
classDef backend fill:#00897B,stroke:#00695C,stroke-width:2px,color:#fff
classDef rag fill:#7CB342,stroke:#558B2F,stroke-width:2px,color:#fff
classDef chroma fill:#4CAF50,stroke:#388E3C,stroke-width:2px,color:#fff
classDef llm fill:#FF6F00,stroke:#E65100,stroke-width:2px,color:#fff
我调整了 LLM 回答的格式模板。对于每一条回答,系统都必须显示信息来源,也就是用于生成答案的文档。
问题:公司做过哪些风电场项目?
回答:公司实施过多个与风电场有关的项目,包括:
但问题也随之而来:我需要将所有原始数据与向量数据库、LLM 和 backend 一同存储到磁盘上,而我没有足够的空间。我的生产环境是一台资源非常有限的虚拟机,磁盘空间尤其紧张,只有 100 GB。我不可能在服务器上存放半 TB 的文档。
必须牢记:拥有向量数据库,并不意味着我们就可以舍弃原始文档。不过,我可以将包含文档 embeddings 的向量索引放在一处,把原始文档放在另一处——无论是在同一台服务器的物理磁盘上,还是放在云端。
我的解决方案是直接通过 Azure Blob Storage 提供原始文档,当然,换成其他系统也可以。对于回答中引用的每一份文档,系统都会生成一个带有 SAS token 的下载链接,让用户可以直接从云端下载。
%%{init: {'theme':'default'}}%%
flowchart LR
U["👤 User"]:::user -->|Question| S["Server (VM)"]:::server
S -->|Response + links| U
U -->|Direct download with SAS token| A["Azure Blob Storage(451 GB of documents)"]:::azure
classDef user fill:#37474F,stroke:#263238,stroke-width:2px,color:#fff
classDef server fill:#00897B,stroke:#00695C,stroke-width:2px,color:#fff
classDef azure fill:#0078D4,stroke:#005A9E,stroke-width:2px,color:#fff
真正必须占用磁盘空间的是 ChromaDB 向量索引,它的大小为 54 GB,放在本地磁盘上完全可以接受;LLM 大约需要 10 GB;backend 只有几 MB;frontend 也只有几 MB。其余文档全部保留在 Azure 中,需要时再访问。
最终的系统架构如下:
flowchart LR
A["Azure Blob Storage"]:::azure -- Documents --> B["RAG (LlamaIndex)"]:::rag
B <--> G["ChromaDB"]:::chroma
B <--> C["Ollama (LLM + Embeddings)"]:::llm
D["Flask API"]:::api <-- HTTP --> E["Streamlit (Web UI)"]:::web
C <--> F["Python Backend"]:::backend
D -- Call --> F
classDef azure fill:#0078D4,stroke:#005A9E,stroke-width:2px,color:#fff
classDef rag fill:#7CB342,stroke:#558B2F,stroke-width:2px,color:#fff
classDef chroma fill:#4CAF50,stroke:#388E3C,stroke-width:2px,color:#fff
classDef llm fill:#FF6F00,stroke:#E65100,stroke-width:2px,color:#fff
classDef api fill:#D32F2F,stroke:#B71C1C,stroke-width:2px,color:#fff
classDef web fill:#8E24AA,stroke:#6A1B9A,stroke-width:2px,color:#fff
classDef backend fill:#00897B,stroke:#00695C,stroke-width:2px,color:#fff
内存管理: 一次性将所有文档加载到内存并进行处理非常危险。应采用 batch processing;我每轮处理 150 个文件,并在 batch 之间显式调用 garbage collector。每个 batch 都会依次完成处理、生成 embeddings、存入 ChromaDB,然后释放内存,再继续处理下一批。
问题文件: 即使经过多层过滤,仍然会有一些文件漏网,并在解析时失败,例如损坏的 PDF、包含异常宏的 Word 文档、格式不符合预期的电子表格等。我的策略是提高错误容忍度:如果某个文件处理失败,就记录日志并继续处理下一个文件。单个问题文件绝不能导致整个 batch 停止。之后,我可以手动检查、下载它,或者把它分配到其他 batch 中处理。
Checkpoints: 每个 batch 可能要运行数小时,不能因为停电或重启就从零开始。应实现一套 checkpoint 系统,保存最后完成的 batch、已经处理的节点总数,以及时间戳。系统重启后,索引可以从上次停止的位置准确继续。
监控: 为不同状态添加监控和信息脚本,例如 index-progress、index-watch、index-speed、index-checkpoint、index-failed……当 RAG 连续运行数小时后,你必须知道它正在做什么。
这并不是一个完美的系统,但已经足够用了。如果能启动一个 OrcaFlex 实例,让 LLM 按需运行项目或自行执行仿真,那当然会非常棒。但这需要的时间和资源超出了项目所能提供的范围。尽管如此,我对最终结果非常满意。这个系统速度快、可靠,最重要的是,它对我的同事们确实有用。
如果你也在考虑构建类似的系统,我有一条不成熟的建议:多花些时间,尽可能构建出高质量的数据。如果数据源的相关性不够高,LLM 就无法生成好的答案。
问题 1:选择合适的技术
问题 2:混乱的文档
问题 3:如何在不把自己搞垮的情况下索引 451 GB 文档
问题 4:我的显卡不是火箭
问题 5:用户体验
问题 6:在不塞满磁盘的情况下提供文档
本作品采用署名—非商业性使用—禁止演绎 4.0 国际许可证。
愿意请我喝杯咖啡吗?
正是通过这种方式,我才能在没有广告和付费墙的情况下继续写作。