我读过的每一个 RAG 教程都做了同样的两个假设:你有 GPU,而且可以调用云端 API。而我所构建的环境,这两个假设都不成立。
我在公共卫生信息系统领域工作。技术栈必须运行在机构内部网络——数据不能离开网络——而我得到的硬件是采购周期两年前生产的。实际就是 Windows Server、仅 CPU,以及在本地运行的开放权重模型。
于是我构建了一个完全本地运行的 RAG 栈,没有 GPU、没有云、没有 Docker。它已在 github.com/psychohub/rag-onpremise 开源:ASP.NET Core 9 用于编排,Ollama 用于本地推理,Qdrant 用于向量存储,Python 用于摄入管道,Mistral 7B 作为 LLM,nomic-embed-text 用于嵌入。
把它投入生产花费的时间比设计更长,因为有五个教程从未警告过的问题出现了。下面是现场报告。
环境,以及它为何重要
在进入教训之前,值得先明确约束条件,因为它会改变“优秀”的定义。
技术栈必须运行在 Windows Server 上,而不是 Linux 工作站。很多目标机器上都无法使用 Docker——要么因为未获批准,要么因为 GPO 策略限制,要么因为运维团队已将所有服务都作为 Windows 服务运行,增加容器运行时会带来新的运维面,没人想接手。GPU 只是奢望。目前你只能用 CPU 推理,并且必须让它工作。
这并不罕见。这是许多公共部门、医疗保健和传统企业环境的默认现实。这也是互联网上大多数 RAG 内容悄悄假定不存在的现实。
系统整体结构如下:
Documents (PDF / Word / Excel)
│
▼
[ Python ingest ]
├─ Text extraction (pdfplumber, python-docx, openpyxl)
├─ Chunking (500 tokens, 50 overlap)
├─ Embeddings (nomic-embed-text via Ollama)
└─ Store (Qdrant, cosine similarity)
│
User query │
│ │
▼ │
[ ASP.NET Core 9 API ] ──────────────────────────── ┘
├─ 1. Embed the question
├─ 2. Retrieve top-K chunks from Qdrant
├─ 3. Assemble prompt with context
├─ 4. Call Mistral 7B via Ollama
└─ 5. Return answer + cited sources
Enter fullscreen mode Exit fullscreen mode
教训 1:Qdrant .NET SDK 使用 gRPC。直接使用 REST。
我首先尝试的是官方 Qdrant .NET SDK。API 干净,文档完善,看起来是正确的选择。但它以一种花了我一天才诊断出的方式失败了,因为失败并不明显:连接已建立,然后被丢弃,错误信息指向了除实际原因之外的所有问题。
原因:SDK 通过 gRPC 与 Qdrant 通信,而 .NET 应用程序与 Qdrant 实例之间的网络路径仅支持 HTTP/1.1。gRPC 需要 HTTP/2。某个中间代理或负载均衡器降级了连接,而 SDK 没有优雅降级——它只是失败了。
解决方法是完全跳过 SDK,直接使用 HttpClient 调用 Qdrant 的 REST API:
// Not this — the SDK uses gRPC under the hood
// var client = new QdrantClient(new Uri(url));
// This — plain REST works everywhere
var response = await _httpClient.PostAsync(
$"{qdrantUrl}/collections/{collection}/points/search",
content);
Enter fullscreen mode Exit fullscreen mode
Qdrant 的 REST API 对于 RAG 工作负载已经足够完善。你会失去类型安全和一些人体工程学,但获得了无需在每个网络跳点争论 HTTP/2 支持就能部署的能力。在企业或公共部门网络中,这是一个不错的权衡。
教训 2:HttpClient 默认超时会杀死你的响应。
.NET 中的 HttpClient 默认超时为 100 秒。这对大多数 HTTP 工作来说没问题。但当另一端是运行在 CPU 上的 Mistral 7B 时,就不行了。
在一台普通服务器(4 vCPU、16 GB RAM)上,Mistral 7B 生成完整响应需要 60 到 120 秒。第一次端到端查询运行成功,第二次也成功。第三次模型恰好生成了更长的答案,客户端在流式传输中超时了,让用户盯着通用错误,而服务器仍在生成没人能看到的答案。
修复只需两行代码:
// Not this — 100s default, will cut you off
var client = new HttpClient();
// This — set the ceiling explicitly, above your worst-case
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
Enter fullscreen mode Exit fullscreen mode
数字本身不如纪律重要。如果你通过 CPU 调用本地 LLM,请在实际硬件上测量最坏情况,并将超时时长设置得比它更宽裕。如果你在此基础上构建 UI,请添加进度指示器。90 秒的沉默看起来像是系统故障,即使它正在完全按设计工作。
教训 3:Ollama 默认只监听 localhost。
当我从笔记本电脑开发转向服务器部署时发现了这个问题,另一台机器上的 .NET 应用程序无法连接到 Ollama。
Ollama 开箱即用绑定到 127.0.0.1:11434。这对本地开发没问题。但对于 LLM 主机与应用主机分离的任何部署,或者应用在与交互用户不共享回环上下文的服务账户下运行的场景,就毫无用处。
解决方法是一个环境变量:
$env:OLLAMA_HOST = "0.0.0.0:11434"
ollama serve
Enter fullscreen mode Exit fullscreen mode
一旦你知道,这很简单。陷阱在于 Ollama 不可达时的错误信息是通用的连接错误,而不是“嘿,我只监听回环”。我在检查绑定之前花了一下午阅读防火墙规则。
如果你将 Ollama 部署为 Windows 服务——你很可能应该这样做——该环境变量需要在服务级别设置,而不是用户级别。在 PowerShell 提示符中设置不会影响服务。小细节,真正的时间成本。
教训 4:Python MSI 安装程序在企业 GPO 下会失败。使用可嵌入包。
摄入管道使用 Python。在受企业组策略对象控制的可安装程序的锁定 Windows Server 上,标准 Python MSI 无法安装。它以各种方式失败,从静默的“操作完成”但磁盘上什么都没有,到响亮的提升权限错误,连真正的管理员账户也无法解决。
第一次看起来并不明显的解决方法:使用 Python 可嵌入包。它是一个 ZIP 文件,而不是安装程序,因此绕过了大多数 GPO 限制。
设置比安装程序更手动一些:
- 从 python.org 下载
python-3.x.x-amd64-embed.zip。 - 解压到文件夹——
C:\Python311\,随便哪里。 - 在该文件夹中,打开
python3xx._pth并取消注释import site行。没有这一步,pip无法工作。 - 下载
get-pip.py并从该文件夹运行python get-pip.py。 - 之后,
pip install -r requirements.txt即可正常工作。
这里没什么难的。只是没有被记录为默认路径,所以如果你不知道它的存在,你会花两天时间与一个永远不会成功的安装程序战斗。
教训 5:提示词是质量的主要来源。
我花了几周时间调整分块、嵌入参数和检索 top-K,每次只得到个位数的百分比改进。然后我重写了提示词模板,获得了响应质量的阶跃式提升,使得所有检索调整看起来像舍入误差。
我一直在两种失败模式之间摇摆:
过于严格。 “仅根据上下文回答。如果上下文不包含答案,就说你不知道。”模型对上下文过敏。它会拒绝回答部分覆盖的问题,拒绝做出合理推断,并用“我不知道”来回复任何阅读相同文档的人类都能回答的问题。
过于宽松。 “使用上下文来帮助你回答问题。”模型开始自信地幻觉,用听起来合理的发明填充检索块中的空白。在受监管的环境中,这不是质量问题,而是责任问题。
最终有效的方法,大致是:
Answer BASED on the provided context.
If the information is partially relevant, use it and be explicit
about what the context does and does not say.
Only if there is absolutely nothing related to the question,
say so clearly.
Do NOT invent data that is not in the context.
Enter fullscreen mode Exit fullscreen mode
关键的关键词是“部分相关”(允许从不完整上下文中推理)和“明确说明上下文说了什么和没说什么”(迫使模型区分它读到的和它推断的)。两者都不是魔法咒语。但它们共同将平衡从“拒绝回答”和“胡编乱造”移到了“能回答时回答,不能回答时推迟,并告诉你哪种情况”。
CPU 推理实际是什么样子
教程跳过的另一件事:数字。以上所有内容都假设你可以接受的延迟。以下是我在实际硬件上测量的结果:
| Hardware | Model | Response time |
|---|---|---|
| 4 vCPU / 16 GB RAM | Mistral 7B | 60–120 seconds |
| 16 vCPU / 32 GB RAM | Mistral 7B | 20–45 seconds |
| 4 vCPU / 8 GB RAM | phi3:mini | 15–30 seconds |
| GPU 8 GB+ | Mistral 7B | 3–8 seconds |
CPU 行是在我实际部署的服务器上进行的持续测量。GPU 行是借用硬件上的单次测试,不是持续的生产测量——请将其作为参考点,而不是承诺。
值得指出的两件事。首先,phi3:mini 在普通硬件上的表现与 Mistral 7B 在更好硬件上的延迟相当。如果你的质量标准允许,降级模型比升级硬件更划算。其次,从 CPU 到 GPU 的提升大约是 10 倍。如果能在你的环境中获得一块 8 GB GPU,就去做——它会改变可能的交互方式。
因为 CPU 延迟就是这样,仓库在 LLM 前面包含了一个语义缓存:传入查询与缓存查询之间的余弦相似度,阈值为 0.92。当用户询问与之前查询语义相似的内容时,他们会在一秒内获得缓存答案。当他们询问新内容时,他们等待模型。在中等繁忙的内部系统中,缓存命中率足够高,平均用户体验感觉合理,尽管最坏情况仍然是 90 秒。
我通过破坏它学到的一个警告:更改 LLM 时清除缓存。缓存答案与生成它们的模型绑定。当你将 Mistral 替换为新模型时,缓存现在返回来自你不再运行的模型的答案,用户会在你之前注意到个性变化。
我对今天开始的人会说什么
如果你在受限硬件上构建本地 RAG,压缩版本是:
- 通过 REST 而非 gRPC 与 Qdrant 通信。 在企业网络上减少意外。
- 显式设置 HTTP 超时。 默认值是为 Web 流量设计的,而不是 CPU 上的本地 LLM。
- 为你的部署而非笔记本电脑配置 Ollama 的绑定。 如果作为服务运行,请在服务级别设置环境变量。
- 在锁定的 Windows 上使用 Python 可嵌入包。 MSI 在 GPO 下不是你的朋友。
- 在检索之前调整提示词。 分块和 top-K 很重要,但提示词才是质量标准真正所在的地方。
- 当 LLM 缓慢时积极缓存,并在更改模型时记得使其失效。
-
在升级硬件之前降级模型。
phi3:mini在 8 GB RAM 上的表现优于在你买不起的机器上的 Mistral 7B。
这些都不罕见。这是 RAG 中被跳过的部分,因为教程假设 GPU、云和 Linux 开发盒。当你没有这些时,这就是你必须面对的现实。
我路线图上的下一件事是在西班牙语临床文本上进行适当的嵌入评估——因为“它有效”和“它在你的语言和语料库上有效”不是一回事,而我还没有测量差距。那是下一篇文章。
本文描述了我个人开源项目 rag-onpremise 的设计和实现。测量结果来自我自己的测试硬件和我自己的项目,而不是任何特定的机构部署。此处表达的观点是我个人的。
Hubert García Gordon 在哥斯达黎加公共部门从事卫生信息系统工作,并在 UNED Costa Rica 任教。他维护 rag-onpremise,并撰写关于受限环境中应用 AI 的文章。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.