Ohhnews

分类导航

$ cd ..
foojay原文

仅用JDK与Jakarta规范为Jakarta EE 11应用添加RAG功能

#rag#jakarta ee#java#向量检索#ollama

关于企业 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 辅助方法都省略了。完整代码在文末链接的仓库中。

@Entity
@Table(name = "document_chunks")
public class DocumentChunk {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 2000)
    private String content;

    @Column(length = 200)
    private String source;

    @Column(length = 200)
    private String title;

    @Lob
    @Column(columnDefinition = "bytea")
    private byte[] embedding;

    public float[] getEmbeddingVector() {
        return embedding != null ? EmbeddingConverter.toFloatArray(embedding) : null;
    }

    public void setEmbeddingVector(float[] vector) {
        this.embedding = EmbeddingConverter.toByteArray(vector);
    }
}

byte[] 与 float[] 之间的转换放在一个小工具类中:

public final class EmbeddingConverter {

    private EmbeddingConverter() {}

    public static byte[] toByteArray(float[] floats) {
        ByteBuffer buffer = ByteBuffer.allocate(floats.length * Float.BYTES);
        for (float f : floats) buffer.putFloat(f);
        return buffer.array();
    }

    public static float[] toFloatArray(byte[] bytes) {
        ByteBuffer buffer = ByteBuffer.wrap(bytes);
        float[] floats = new float[bytes.length / Float.BYTES];
        for (int i = 0; i < floats.length; i++) floats[i] = buffer.getFloat();
        return floats;
    }
}

实体不知道这些字节意味着什么。JPA 将 bytea 列视为任意其他 LOB 来编组。向量的解释是搜索层的事,而不是持久化层的事。

无需 DAO 的仓库

Jakarta Data 1.0 是 Jakarta EE 11 的新特性,用声明式仓库接口(很像 Spring Data JPA)取代手写的数据访问对象(DAO)类。以下是用于文档分块的仓库:

@Repository
public interface Chunks extends BasicRepository<DocumentChunk, Long> {

    @Find
    List<DocumentChunk> findBySource(String source);

    @Query("select count(this) where source = :source")
    long countBySource(@Param("source") String source);
}

有三点值得注意。第一,不需要实现类;Payara 在部署时生成一个。第二,BasicRepository 接口免费提供 save、delete、findById、findAll 等。第三,当需要自定义查询时,@Query 接收 Jakarta Data Query Language (JDQL),这是 JPQL 的一个更精简方言。

与之相比,等价的原生 JPA 代码需要一个有状态类、注入的 EntityManager、@Transactional 方法,以及每个查询手写的 JPQL 字符串。对这个应用来说,Jakarta Data 将仓库层减少了大约四分之三。

十五行代码实现相似度搜索

如果你把嵌入存储在一个标准列中,且分块数量有限,就不需要向量数据库。用 Java 计算余弦相似度的线性扫描就够了:

@ApplicationScoped
public class VectorSearch {

    @Inject Chunks chunks;
    @Inject OllamaEmbeddings embeddings;

    public List<DocumentChunk> search(String query, int maxResults) {
        float[] queryVec = embeddings.embed(query);

        return chunks.findAll().toList().stream()
            .filter(c -> c.getEmbedding() != null)
            .map(c -> new SimilarityResult(c,
                cosineSimilarity(queryVec, c.getEmbeddingVector())))
            .sorted(Comparator.comparingDouble(SimilarityResult::score).reversed())
            .limit(maxResults)
            .map(SimilarityResult::chunk)
            .toList();
    }

    static double cosineSimilarity(float[] a, float[] b) {
        double dot = 0, na = 0, nb = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            na += (double) a[i] * a[i];
            nb += (double) b[i] * b[i];
        }
        if (na == 0 || nb == 0) return 0.0;
        return dot / (Math.sqrt(na) * Math.sqrt(nb));
    }

    private record SimilarityResult(DocumentChunk chunk, double score) {}
}

每次查询是 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 解析响应。以下是嵌入客户端:

@ApplicationScoped
public class OllamaEmbeddings {

    @Inject @ConfigProperty(name = "ollama.base.url")
    String baseUrl;

    @Inject @ConfigProperty(name = "ollama.embedding.model")
    String model;

    private HttpClient http;

    @PostConstruct
    void init() {
        http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    }

    public float[] embed(String text) {
        JsonObject body = Json.createObjectBuilder()
            .add("model", model)
            .add("input", text)
            .build();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + "/api/embed"))
            .timeout(Duration.ofSeconds(120))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body.toString()))
            .build();

        try {
            HttpResponse<String> response = http.send(
                request, HttpResponse.BodyHandlers.ofString());
            JsonObject result = Json.createReader(
                new StringReader(response.body())).readObject();
            JsonArray vector = result.getJsonArray("embeddings").getJsonArray(0);
            float[] floats = new float[vector.size()];
            for (int i = 0; i < vector.size(); i++) {
                floats[i] = (float) vector.getJsonNumber(i).doubleValue();
            }
            return floats;
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) Thread.currentThread().interrupt();
            throw new IllegalStateException("Embedding request failed", e);
        }
    }
}

那个类中的每个 import 都来自 java.*、jakarta.* 或 org.eclipse.microprofile.*。类路径上除了 JDK、Jakarta EE API 和 MicroProfile Config API 之外没有别的东西。

聊天客户端遵循相同的形态,但有一个重要的架构选择:模型名放在 volatile 字段中,因此可以在运行时切换而无需重新部署。

@ApplicationScoped
public class OllamaChat {

    @Inject @ConfigProperty(name = "ollama.base.url")
    String baseUrl;

    @Inject @ConfigProperty(name = "ollama.chat.model")
    String defaultModel;

    private HttpClient http;
    private volatile String currentModel;

    @PostConstruct
    void init() {
        this.currentModel = defaultModel;
        this.http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();
    }

    public void switchModel(String modelName) { this.currentModel = modelName; }

    public String getCurrentModel() { return currentModel; }

    public String chat(String userMessage) {
        JsonObject body = Json.createObjectBuilder()
            .add("model", currentModel)
            .add("stream", false)
            .add("messages", Json.createArrayBuilder()
                .add(Json.createObjectBuilder()
                    .add("role", "user")
                    .add("content", userMessage)))
            .build();

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(baseUrl + "/api/chat"))
            .timeout(Duration.ofSeconds(300))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body.toString()))
            .build();

        try {
            HttpResponse<String> response = http.send(
                request, HttpResponse.BodyHandlers.ofString());
            JsonObject result = Json.createReader(
                new StringReader(response.body())).readObject();
            return result.getJsonObject("message").getString("content");
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) Thread.currentThread().interrupt();
            throw new IllegalStateException("Chat request failed", e);
        }
    }
}

currentModel 上的 volatile 关键字正在做真正的工作。当一个请求调用 switchModel("mistral") 时,Java 中的引用赋值是原子的,而 volatile 保证任何线程上的下一个聊天请求都能看到新值。从 gemma4 切换到 mistral 只是一次字段写入,由发往 /api/models 的一次 HTTP POST 触发,无需重新部署或重启。

正是这个单一特性,是声明式 AI 框架无法匹敌的。像 @RegisterAIService 这样通过注解装配的服务,在部署时就绑定了模型。如果你想要 A/B 测试、租户特定路由,或在负载下从大模型优雅降级到小模型,你就需要一个可变的模型引用,这意味着你需要自己写上面那几行代码。用额外的二十行编排代码换取运行时灵活性,在它真正重要的那一刻,这笔交易就值得做。

RAG 管道本身

有了嵌入和聊天客户端,完整的检索增强生成管道可以放进一个方法中:

@ApplicationScoped
public class AiService {

    @Inject VectorSearch vectorSearch;
    @Inject OllamaChat ollamaChat;

    public String ask(String question) {
        List<DocumentChunk> relevant = vectorSearch.search(question, 10);

        if (relevant.isEmpty()) {
            return "No relevant information found for that question.";
        }

        String context = relevant.stream()
            .map(c -> "Source: " + c.getSource() + "\n" + c.getContent())
            .collect(Collectors.joining("\n\n"));

        String prompt = """
            Context:
            %s

            Question: %s
            """.formatted(context, question);

        return ollamaChat.chat(prompt);
    }
}

嵌入查询,找到十个最相似的分块,把它们组装成提示词,调用模型,返回答案。这就是整个 RAG 原语。每一步都是可见、可记录、可修改的:添加相关性阈值就是一个 if 语句,运行时更改提示词格式就是一次字符串编辑。

用虚拟线程在启动时并行生成嵌入

应用启动时,需要为种子语料库生成嵌入。对于 50 个分块,这意味着 50 次到 Ollama 的 HTTP 往返。串行执行很慢;用传统线程池会变成调优线程池大小的练习;用虚拟线程只需一个注解。

@ApplicationScoped
@ManagedExecutorDefinition(
    name = "java:module/concurrent/VirtualThreadExecutor",
    virtual = true,
    qualifiers = VirtualThreadExecutor.class
)
public class ConcurrencyConfig {}

Jakarta Concurrency 3.1 为 @ManagedExecutorDefinition 添加了 virtual = true。这要求容器提供一个由虚拟线程支持的 ManagedExecutorService(如果运行时无法创建虚拟线程或其配置限制它们,可能会回退到平台线程)。容器在 JNDI 中注册执行器,自动将 Jakarta EE 上下文传播到其线程,并通过限定符注解将其暴露给 CDI 注入。

摄取侧看起来像这样:

@Inject @VirtualThreadExecutor
ManagedExecutorService executor;

private void generateEmbeddingsConcurrently(List<DocumentChunk> chunks) {
    List<Future<Void>> futures = chunks.stream()
        .map(chunk -> executor.submit(() -> {
            chunk.setEmbeddingVector(ollamaEmbeddings.embed(chunk.getContent()));
            return (Void) null;
        }))
        .toList();

    for (Future<Void> f : futures) f.get();
}

五十个分块变成五十个虚拟线程,全部并发运行。当虚拟线程在等待 Ollama 的 HTTP 响应而阻塞时,JVM 将它从载体线程卸载,并调度另一个线程。当响应到达时,虚拟线程重新挂载并继续。这是 JVM 在做响应式框架否则会强迫你手写的工作。

实际收益是你不再调优池大小。内存节省(每个栈 1 KB 对 1 MB)是真实的,但在实践中很少重要;工作负载产生多少任务就提交多少,JVM 会为你调度它们。

REST 端点

公开 HTTP API 是一个平平无奇的 JAX-RS 组件:

@Path("/chat")
public class ChatResource {

    @Inject AiService aiService;

    @POST
    @Consumes(APPLICATION_FORM_URLENCODED)
    @Produces(TEXT_HTML)
    public String ask(@FormParam("question") String question) {
        return renderAnswer(question, aiService.ask(question));
    }
}

@Path("/models")
public class ModelResource {

    @Inject OllamaChat ollamaChat;

    @POST
    @Consumes(APPLICATION_FORM_URLENCODED)
    @Produces(TEXT_HTML)
    public Response switchModel(@FormParam("model") String modelName) {
        if (modelName == null || modelName.isBlank()) {
            return Response.status(Response.Status.BAD_REQUEST)
                .entity("A model name is required").build();
        }
        ollamaChat.switchModel(modelName);
        return Response.ok("<strong>" + escapeHtml(modelName) + "</strong>").build();
    }

    private static String escapeHtml(String s) {
        return s.replace("&", "&amp;").replace("<", "&lt;")
                .replace(">", "&gt;").replace("\"", "&quot;");
    }
}

标准的 JAX-RS,没有 AI 框架注解或特殊配置,只有 CDI 注入和 HTTP。模型名来自表单字段,因此在它替换当前模型之前会先验证,并在回显之前进行 HTML 转义。这同样适用于 renderAnswer 渲染的答案:任何源自用户输入或模型的内容,在进入 HTML 响应之前都应该转义。## 这替代了什么

把这些组件与标准 AI 架构并列摆出来:

标准 AI 架构Jakarta EE 对应方案
Pinecone 或 WeaviateJPA 实体中的 byte[] + Java 中的余弦相似度
LangChain 或 HaystackAiService 类,25 行
@RegisterAIService带 volatile 字段的 OllamaChat
EmbeddingStore 抽象VectorSearch,15 行
响应式框架@ManagedExecutorDefinition(virtual = true)
ConfigProvider.getConfig()@Inject @ConfigProperty
FastAPIJAX-RS
Python 服务,独立团队同一团队,同一 WAR,同一套监控

这套技术栈里没有魔法。每一行代码都在做你能阅读、修改和调试的事情。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 供应商。

三个具体的下一步:

  1. 在本地运行。 克隆 pedanticdev/eclipse-ocx-2026,检出 blog/pure-jakarta,运行 ./run.sh deploy。整套流水线大约一分钟就能在你的笔记本电脑上跑起来,具体取决于你的网络连接速度。
  2. 在你自己的代码库上做原型验证。 在现有 Jakarta EE 应用中实现第一个 RAG 端点大约是一天的项目。CDI 负责装配,PostgreSQL 保存嵌入向量,你们现有的 CI/CD 负责部署。
  3. 就生产环境与 Azul 交流。 联系 Azul,为你的 Java 和 Jakarta EE 运行时获得完整的生产支持。

企业级 Java 今天已为 AI 做好准备。该平台已经过二十年的生产验证,而上面的模式展示了如何在不改变其他任何东西的情况下为它加入 AI。

老狗不需要新把戏。它已经全都会了。


作者

Luqman Saeed

企业级 Java(Jakarta EE)开发者、培训师和技术作者,热衷于解决问题与教学。