仅用JDK与Jakarta规范为Jakarta EE 11应用添加RAG功能
关于企业 AI 的标准叙事大概是这样的。你的应用需要 AI,于是你启动一个 Python 服务。你加入像 Pinecone 或 Weaviate 这样的向量数据库。你选一个编排框架。现在你有了两条部署流水线、两种语言和两个团队。原本处理业务逻辑毫无问题的 Java 单体应用,变成了分布式系统的一半,而它存在的主要目的就是连接另一半。
我在 Eclipse OCX 2026 上做了一个演讲,论证 Java 团队被过度推销了这种叙事。在 Java 21 上运行 Jakarta EE 11——你的企业已经在运行的这个乏味平台——是检索增强生成的生产级基础,不需要向量数据库、不需要 Python,也不需要 AI 编排框架。
本文会讲解整体架构,并作为演讲的延伸,展示如何连最后一个仍然存在的第三方依赖——到 Ollama 的 HTTP 适配器——也去掉,用 JDK 自带的 HTTP 客户端取而代之。最终结果是一个完全由 Jakarta EE 规范、MicroProfile 和 Java 标准库构建的 RAG 微服务,除此之外类路径上只有一个 JDBC 驱动。
承担主要工作的六个规范
整个东西建立在你已经拥有的规范之上:
- Jakarta Persistence 3.2 用于实体,包括将嵌入作为
byte[]列 - Jakarta Data 1.0 用于仓库,无需 DAO 类,无需为 CRUD 手写 JPQL
- Jakarta Concurrency 3.1 用于由虚拟线程支持的可管理执行器
- Jakarta CDI 4.1 用于依赖注入和 Bean 生命周期
- Jakarta REST 用于入站 HTTP API(如下所示,到 Ollama 的出站调用使用 JDK 内置的
java.net.http.HttpClient) - MicroProfile Config 用于模型名、温度和基础 URL
在此之上,持久化使用 PostgreSQL,模型推理使用 Ollama。PostgreSQL 很可能就是你已经在用的同一种数据库,而 Ollama 是一个暴露普通 HTTP API 的本地模型服务器。
注意列表上没有的东西:没有响应式框架,没有 JPA 之外的 ORM 包装,没有 CDI 可移植扩展,没有注解处理器,没有代码生成器。整个管道就是你可以用调试器逐步执行的字节码,在你的代码与数据库或模型服务器之间没有任何框架中介。
将嵌入存储为字节数组
嵌入是一个定长浮点数数组,表示一段文本的语义内容。对于 nomic-embed-text,每个嵌入是 768 个浮点数,即 3,072 字节。你不需要向量数据库列类型来存储它;JPA 实体上的 byte[] 字段就完全够用。
下面的 DocumentChunk 是一个 JPA 实体,建模更大文档的一个已处理分块。本文中的片段只保留核心内容:普通 getter(如 getEmbedding()、getSource() 和 getContent())、import、@VirtualThreadExecutor 限定符以及 renderAnswer 辅助方法都省略了。完整代码在文末链接的仓库中。
byte[] 与 float[] 之间的转换放在一个小工具类中:
实体不知道这些字节意味着什么。JPA 将 bytea 列视为任意其他 LOB 来编组。向量的解释是搜索层的事,而不是持久化层的事。
无需 DAO 的仓库
Jakarta Data 1.0 是 Jakarta EE 11 的新特性,用声明式仓库接口(很像 Spring Data JPA)取代手写的数据访问对象(DAO)类。以下是用于文档分块的仓库:
有三点值得注意。第一,不需要实现类;Payara 在部署时生成一个。第二,BasicRepository 接口免费提供 save、delete、findById、findAll 等。第三,当需要自定义查询时,@Query 接收 Jakarta Data Query Language (JDQL),这是 JPQL 的一个更精简方言。
与之相比,等价的原生 JPA 代码需要一个有状态类、注入的 EntityManager、@Transactional 方法,以及每个查询手写的 JPQL 字符串。对这个应用来说,Jakarta Data 将仓库层减少了大约四分之三。
十五行代码实现相似度搜索
如果你把嵌入存储在一个标准列中,且分块数量有限,就不需要向量数据库。用 Java 计算余弦相似度的线性扫描就够了:
每次查询是 O(n),并且把所有分块加载进内存。对于几千个分块,它实际上瞬间完成,内存占用可以忽略不计。对于数百万向量,你会想要带索引的向量数据库,但对于内部工具、文档助手、会议问答系统等场景,这已经足够,并避免了一整套基础设施依赖。
这个权衡值得提一下。向量数据库使用近似最近邻索引,通常是 HNSW,以极小的召回率损失换取亚线性查询时间。在五十个分块上,索引是无意义的开销;在五千万个分块上,索引则至关重要。拐点取决于你的查询延迟预算、嵌入维度和索引的磁盘预算。大多数团队在真正需要之前就伸手去拿向量数据库,因为默认叙事告诉他们必须这么做。一个十五行的 Java 方法能为你争取几个月的缓冲时间,让你验证这个功能到底重不重要。
通过 JDK HTTP 客户端直接调用 Ollama
大多数 Java AI 指南会指引你使用像 LangChain4j 这样的库来向模型服务器发起 HTTP 调用。Eclipse OCX 演讲中的演示代码也是这么做的,使用 LangChain4j 的 OllamaEmbeddingModel 和 OllamaChatModel 类作为 Ollama HTTP API 之上的薄封装。
在这篇博客中,我想展示真正无依赖的版本。Ollama 的 API 小巧且稳定。你可以直接用 java.net.http.HttpClient 调用它,它自 Java 11 起就随 JDK 提供,再加上 jakarta.json 解析响应。以下是嵌入客户端:
那个类中的每个 import 都来自 java.*、jakarta.* 或 org.eclipse.microprofile.*。类路径上除了 JDK、Jakarta EE API 和 MicroProfile Config API 之外没有别的东西。
聊天客户端遵循相同的形态,但有一个重要的架构选择:模型名放在 volatile 字段中,因此可以在运行时切换而无需重新部署。
currentModel 上的 volatile 关键字正在做真正的工作。当一个请求调用 switchModel("mistral") 时,Java 中的引用赋值是原子的,而 volatile 保证任何线程上的下一个聊天请求都能看到新值。从 gemma4 切换到 mistral 只是一次字段写入,由发往 /api/models 的一次 HTTP POST 触发,无需重新部署或重启。
正是这个单一特性,是声明式 AI 框架无法匹敌的。像 @RegisterAIService 这样通过注解装配的服务,在部署时就绑定了模型。如果你想要 A/B 测试、租户特定路由,或在负载下从大模型优雅降级到小模型,你就需要一个可变的模型引用,这意味着你需要自己写上面那几行代码。用额外的二十行编排代码换取运行时灵活性,在它真正重要的那一刻,这笔交易就值得做。
RAG 管道本身
有了嵌入和聊天客户端,完整的检索增强生成管道可以放进一个方法中:
嵌入查询,找到十个最相似的分块,把它们组装成提示词,调用模型,返回答案。这就是整个 RAG 原语。每一步都是可见、可记录、可修改的:添加相关性阈值就是一个 if 语句,运行时更改提示词格式就是一次字符串编辑。
用虚拟线程在启动时并行生成嵌入
应用启动时,需要为种子语料库生成嵌入。对于 50 个分块,这意味着 50 次到 Ollama 的 HTTP 往返。串行执行很慢;用传统线程池会变成调优线程池大小的练习;用虚拟线程只需一个注解。
Jakarta Concurrency 3.1 为 @ManagedExecutorDefinition 添加了 virtual = true。这要求容器提供一个由虚拟线程支持的 ManagedExecutorService(如果运行时无法创建虚拟线程或其配置限制它们,可能会回退到平台线程)。容器在 JNDI 中注册执行器,自动将 Jakarta EE 上下文传播到其线程,并通过限定符注解将其暴露给 CDI 注入。
摄取侧看起来像这样:
五十个分块变成五十个虚拟线程,全部并发运行。当虚拟线程在等待 Ollama 的 HTTP 响应而阻塞时,JVM 将它从载体线程卸载,并调度另一个线程。当响应到达时,虚拟线程重新挂载并继续。这是 JVM 在做响应式框架否则会强迫你手写的工作。
实际收益是你不再调优池大小。内存节省(每个栈 1 KB 对 1 MB)是真实的,但在实践中很少重要;工作负载产生多少任务就提交多少,JVM 会为你调度它们。
REST 端点
公开 HTTP API 是一个平平无奇的 JAX-RS 组件:
标准的 JAX-RS,没有 AI 框架注解或特殊配置,只有 CDI 注入和 HTTP。模型名来自表单字段,因此在它替换当前模型之前会先验证,并在回显之前进行 HTML 转义。这同样适用于 renderAnswer 渲染的答案:任何源自用户输入或模型的内容,在进入 HTML 响应之前都应该转义。## 这替代了什么
把这些组件与标准 AI 架构并列摆出来:
这套技术栈里没有魔法。每一行代码都在做你能阅读、修改和调试的事情。AI 流水线会像你们运维团队已经在运行的其他每个服务一样,作为同一个标准 WAR 部署。
这种方法在哪里会失效
一篇声称某个模式优于替代方案的文章,有义务指出它在哪些地方不再适用。四个诚实的限制:
数百万向量。 虽然使用 Java 的 O(n) 线性余弦相似度可以管理几千个文本块,但到了数百万个文本块时,性能会显著下降。在这个规模下,最直接的改进是迁移到带 HNSW 索引的 pgvector。这个升级幅度很小:你保留同一个数据库、JPA 实体和 Jakarta Data 仓库,只需安装扩展,并用一个执行索引查询的类替换 VectorSearch。
仅 CPU 推理。 在笔记本电脑 CPU 上运行一个 20 亿到 40 亿参数的模型,每个请求需要数秒。将 ollama.base.url 指向 GPU 主机,可以把延迟降低一个数量级甚至更多,而且无需修改代码。对于笔记本电脑上的演示来说,这很慢但还能用;对于面向用户的生产产品,你会希望在 Ollama 背后使用硬件加速,或者使用托管的模型端点。
模型切换的水平扩展。 OllamaChat 中的 volatile 字段是按 JVM 隔离的。如果你在负载均衡器后面运行五个实例,在其中一个实例上切换模型不会影响其他实例。生产环境中的答案是:通过一个 MicroProfile Config 源推送当前模型名称,让所有实例都观察并从中刷新。
长时间运行的工具使用与多轮对话。 这条流水线是单次执行的。如果你需要函数调用、多轮上下文或结构化输出,LangChain4j 或 Spring AI 更高级的 API 就值得引入。本文展示的是下限;它们提供的是上限。对于范围广得惊人的内部应用来说,下限已经足够。
要点
默认叙事说你需要 Python、向量数据库和框架。对于企业 Java 应用中很大一部分真实世界的 AI 功能来说,这些都不是真的。Jakarta EE 11 覆盖实体、仓库、并发、配置和 HTTP;Java 21 加入虚拟线程;Ollama 提供一个带有普通 HTTP API 的本地模型服务器。这就是一个完整的组合。
如果你的团队已经在运行 Jakarta EE,那么加入 AI 就是一个正确使用该生态、而不是另建一套生态的问题。无聊的技术栈并不令人兴奋,而这正是它能在生产环境中奏效的原因:它就是你们运维团队已经知道如何部署、监控和扩展的同一套技术栈。
原始演讲的代码,包括基于 LangChain4j 的版本,位于 GitHub 上的 pedanticdev/eclipse-ocx-2026。本文描述的版本通过 JDK HTTP 客户端而不是 LangChain4j 调用 Ollama,是自然的下一步。两者都是标准 WAR,可在任何兼容 Jakarta EE 11 的运行时上运行,并像你们已经发布的其他所有东西一样部署。
企业级 Java 今天已为 AI 做好准备
这一论证落在一个务实的观点上:你不需要为了采用 AI 而重写应用。上面的纯 Jakarta 流水线运行在一个标准 WAR 中,部署在你们团队已经在使用的同一个 Jakarta EE 11 运行时上。Payara Micro 7 把它打包成一个可执行 JAR。底层的 JDK 可以是任何 OpenJDK 构建,包括 Azul 免费的 Zulu 发行版,或面向有严格延迟预算的生产工作负载的商业 Azul Platform Prime。Azul 现在拥有 Payara,因此运行时、JDK 和平台工程都来自同一个 Java 供应商。
三个具体的下一步:
- 在本地运行。 克隆 pedanticdev/eclipse-ocx-2026,检出
blog/pure-jakarta,运行./run.sh deploy。整套流水线大约一分钟就能在你的笔记本电脑上跑起来,具体取决于你的网络连接速度。 - 在你自己的代码库上做原型验证。 在现有 Jakarta EE 应用中实现第一个 RAG 端点大约是一天的项目。CDI 负责装配,PostgreSQL 保存嵌入向量,你们现有的 CI/CD 负责部署。
- 就生产环境与 Azul 交流。 联系 Azul,为你的 Java 和 Jakarta EE 运行时获得完整的生产支持。
企业级 Java 今天已为 AI 做好准备。该平台已经过二十年的生产验证,而上面的模式展示了如何在不改变其他任何东西的情况下为它加入 AI。
老狗不需要新把戏。它已经全都会了。
作者
Luqman Saeed
企业级 Java(Jakarta EE)开发者、培训师和技术作者,热衷于解决问题与教学。