Ohhnews

分类导航

$ cd ..
DZone Java原文

在Neo4j中运行TypeSafe AI的Jev:实现置信度感知的情感判断

#neo4j#jev#typesafe ai#情感分析#置信度

这是关于在 Neo4j 内部运行情感分析系列文章的第三部分。

第 3 部分采用了一种不同的方法。我们不在插件 JAR 中打包评分模型,而是在 Java UDF 内部调用 TypeSafe AI 的 Jev。Jev 是一个 System One 模型——它不生成文本。它接收一个状态(待评估的文本)和一组带类型的问题,并返回带有显式概率与置信度信息的结构化决策。无需提示词工程,无需 JSON schema 强制转换,无需解析。

结果可以直接从 Cypher 调用:

完整源代码可在 GitHub 上获取。

Jev 能给你 VADER 给不了的东西

VADER 给你一个复合得分。对于 "The movie had some good moments but ultimately disappointed" 这句话,它返回大约 -0.3 的分数。那是一个数字,但它并没有告诉你该对它有多信任。

Jev 将决策与不确定性分离。对同一句话,它返回:

获胜的情感选项的置信度值为 1.0。但 score——即我们向 Jev 提出的那个独立的置信度问题的结果,按 0 到 2 的量表评分——为 1.55,介于"有点自信"和"非常自信"之间。这个独立的置信度分数为分类提供了额外的信号,而这种信息在某种程度上是可操作的,而原始情感得分则不是。

对于含义明确的文本,Jev 同样毫不含糊:

这种结构化的不确定性输出是第 3 部分的关键差异化因素。如果你需要对低置信度分类采取不同的行动——将它们路由到人工审核、标记以便后续跟进,或者仅仅在 UI 中呈现这种不确定性——VADER 的得分并不能提供由模型推导出的、关于分类不确定性的估计。Jev 在给出决策的同时返回一个显式的置信度评估。置信度信息不应与准确性相混淆:模型可以非常自信,但仍然是错的。

我们要构建的内容

一个 Neo4j Java UDF,通过 HTTPS 调用 Jev API,将输入文本作为评估状态传入,并提出两个带类型的问题:一个情感分类(正面、负面或中性)和一个置信度评分。对于非空白输入,UDF 返回一个 Map,包含六个键:choice、confidence、score、positive、negative 和 neutral。对于空白输入,它仅返回 choice 和 confidence 作为保护性响应,而不调用 API。没有 Wasm 运行时,没有打包的词典,没有原生库。该插件没有 Wasm 运行时、原生库或打包的情感词典。除 Neo4j 本身之外,其唯一的运行时依赖是 Jackson,它被重定位到一个私有包命名空间中,以避免与 Neo4j 自带的 Jackson 依赖发生类路径冲突。

前置条件

  • Java 21。OpenJDK 21 或更高版本。用 java -version 确认。
  • Maven 3.9.6。在 Apple Silicon 上请手动安装,而不要通过 Homebrew。用 mvn -version 确认。
  • Neo4j Desktop。下载并安装 Neo4j for Desktop。我们使用的是 2026.07.0 版本。请将 pom.xml 中的 neo4j.version 属性更新为与你的安装相匹配。
  • 一个 Jev API 密钥。在 TypeSafe AI 注册以获取访问权限。该密钥通过 neo4j.conf 中的 JVM 属性传递给 Neo4j——更多内容见部署部分。

获取代码

完整源代码在 GitHub 上。克隆仓库并导航到 neo4j-jev-udf 目录:

Maven 项目

pom.xml 中有两点值得注意。首先,org.neo4j:neo4j 被声明为 provided 作用域——Neo4j 在运行时已经存在于数据库 JVM 中。其次,我们使用 maven-shade-plugin 将 Jackson 重定位到一个私有包命名空间。Neo4j 内部自带它自己版本的 Jackson,如果不进行重定位,两者在运行时可能以产生静默失败的方式发生冲突。

$ xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>jev-neo4j-poc</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <neo4j.version>2026.07.0</neo4j.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.neo4j</groupId>
            <artifactId>neo4j</artifactId>
            <version>${neo4j.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.17.2</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>21</source>
                    <target>21</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.5.1</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <artifactSet>
                                <excludes>
                                    <exclude>org.neo4j:*</exclude>
                                </excludes>
                            </artifactSet>
                            <relocations>
                                <relocation>
                                    <pattern>com.fasterxml.jackson</pattern>
                                    <shadedPattern>com.example.jev.shaded.jackson</shadedPattern>
                                </relocation>
                            </relocations>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

将 neo4j.version 属性更新为与你自己的 Neo4j Desktop 安装相匹配。

Java UDF

UDF 使用 Java 内置的 HttpClient——不需要第三方 HTTP 库。Jev API 密钥首先从环境变量 TYPESAFE_API_KEY 读取,然后从同名的 JVM 系统属性读取作为回退。回退很重要,因为 Neo4j Desktop 作为独立进程运行,不会继承你 shell 的环境变量;我们将在部署部分于 neo4j.conf 中设置该 JVM 属性。

$ java
package com.example;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.neo4j.procedure.Description;
import org.neo4j.procedure.Name;
import org.neo4j.procedure.UserFunction;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.HashMap;
import java.util.Map;

public class JevUDF {

    private static final String API_URL = "https://api.typesafe.ai/v1/systemone";
    private static final ObjectMapper mapper = new ObjectMapper();
    private static final HttpClient client = HttpClient.newHttpClient();

    private static String getApiKey() {
        String key = System.getenv("TYPESAFE_API_KEY");
        if (key == null || key.isBlank()) {
            key = System.getProperty("TYPESAFE_API_KEY");
        }
        if (key == null || key.isBlank()) {
            throw new RuntimeException("TYPESAFE_API_KEY not set as environment variable or JVM property");
        }
        return key;
    }

    @UserFunction("com.example.jev.sentiment")
    @Description("Classifies text sentiment using Jev (TypeSafe AI). Returns choice, probabilities and confidence.")
    public Map<String, Object> sentiment(@Name("text") String text) throws Exception {

        if (text == null || text.isBlank()) {
            Map<String, Object> empty = new HashMap<>();
            empty.put("choice", "neutral");
            empty.put("confidence", 0.0);
            return empty;
        }

        String apiKey = getApiKey();

        ObjectNode body = mapper.createObjectNode();
        body.put("model", "jev-latest");
        body.put("state", text);

        ObjectNode questions = mapper.createObjectNode();

        ObjectNode sentiment = mapper.createObjectNode();
        sentiment.put("type", "choice");
        sentiment.put("instructions", "What is the overall sentiment of this text?");
        ObjectNode criteria = mapper.createObjectNode();
        criteria.put("positive", "Text expresses positive sentiment");
        criteria.put("negative", "Text expresses negative sentiment");
        criteria.put("neutral", "Text is neutral or factual");
        sentiment.set("criteria", criteria);
        questions.set("sentiment", sentiment);

        ObjectNode confidence = mapper.createObjectNode();
        confidence.put("type", "score");
        confidence.put("instructions", "How confident are you in the sentiment classification?");
        ArrayNode levels = mapper.createArrayNode();
        levels.add("Not confident");
        levels.add("Somewhat confident");
        levels.add("Very confident");
        confidence.set("criteria", levels);
        questions.set("confidence", confidence);

        body.set("questions", questions);

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(API_URL))
                .header("Content-Type", "application/json")
                .header("Authorization", "Bearer " + apiKey)
                .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
                .build();

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        if (response.statusCode() != 200) {
            throw new RuntimeException("Jev API error " + response.statusCode() + ": " + response.body());
        }

        JsonNode result = mapper.readTree(response.body());
        JsonNode answers = result.get("answers");
        JsonNode sentimentAnswer = answers.get("sentiment");
        JsonNode confidenceAnswer = answers.get("confidence");

        Map<String, Object> output = new HashMap<>();
        output.put("choice", sentimentAnswer.get("choice").asText());
        output.put("confidence", sentimentAnswer.get("confidence").asDouble());
        output.put("score", confidenceAnswer.get("score").asDouble());
        output.put("positive", sentimentAnswer.get("probabilities").get("positive").asDouble());
        output.put("negative", sentimentAnswer.get("probabilities").get("negative").asDouble());
        output.put("neutral", sentimentAnswer.get("probabilities").get("neutral").asDouble());

        return output;
    }
}

在 Jev 请求体中有几点值得注意。choice 问题使用一个 criteria 对象——一个从选项名称到描述的映射。score 问题使用一个 criteria 数组——一个有序的级别描述列表。这两者是不同的形状,混淆它们会产生来自 API 的 422 错误。两个问题在单次 API 调用中都被回答;Jev 针对同一状态并行评估它们。

部署到 Neo4j

构建 JAR:

在 Desktop 中停止 Neo4j,然后将 JAR 复制到你的 plugins 文件夹:

在 Linux 上,plugins 文件夹通常位于 $NEO4J_HOME/plugins/ 下。在 Windows 上,plugins 文件夹通常位于 %NEO4J_HOME%\plugins\ 下。

编辑同一 dbms 目录的 conf/ 文件夹中的 neo4j.conf,并添加三行:

安全配置中显示了两行。allowlist 控制加载哪些扩展函数,并使该函数可从 Cypher 调用。unrestricted 为匹配的扩展授予完整的数据库访问权限,绕过 Neo4j 的安全限制,应仅在必要时使用。对于本示例,两个设置都针对 com.example.jev.** 命名空间进行配置;请使用你的 Neo4j 版本和部署所需的最窄配置。

server.jvm.additional 这一行将 API 密钥作为系统属性传递给 Neo4j 的 JVM。对于这个本地 Neo4j Desktop 示例,这是在不修改 UDF 代码的情况下使密钥可用的实用方法。Neo4j Desktop 进程不会自动接收你在终端会话中导出的环境变量,因此 JVM 属性是可靠的替代方案。不要将此值提交到源代码控制中。对于生产环境,请使用你的部署环境的密钥管理机制,将值注入 Neo4j 进程,而不要将密钥存储在受跟踪的配置文件中。

在 Desktop 中重启 Neo4j,然后从左侧导航窗格选择 Query,连接到你的实例,并确认该函数已注册:

预期结果:"com.example.jev.sentiment"

结果

正面句子

预期结果:

负面句子

预期结果:

混合情感

预期结果:

获胜的情感选项的置信度值为 1.0。但 score 降至 2 分制中的 1.55——介于"有点自信"和"非常自信"之间。这是结构化的不确定性输出,正是它使 Jev 区别于基于规则的评分器。

这里有两个不同的量。confidence 属于情感选择本身:它描述分类的概率集中度。score 来自一个单独的问题,明确询问 Jev 它对该分类有多自信。它们回答的是不同的问题,不应被解释为同一个置信度值的两个版本。

空白输入保护

预期结果:

空白输入保护会立即返回,而不进行 API 调用。在这里,neutral 是一个保护值,而不是 Jev 的分类:对于空白或仅含空白的输入,不执行任何情感分析。

可扩展性与局限

每次调用 com.example.jev.sentiment() 都会向 Jev API 发出一次出站 HTTPS 请求。这决定了此方法适用于何处以及不适用于何处。

延迟。Jev 每次调用响应时间为 70-500 毫秒。对于偶尔的交互式请求——客户提交评论、用户查找特定节点——这种延迟可能是可以接受的。对于批处理管道,如果以 200 毫秒的平均值顺序进行 1,000 次调用,那就是超过三分钟的 API 时间;并发执行会改变墙钟时间,但会引入其自身的速率限制和资源方面的考量。

成本。在撰写本文时可用的定价下,输入 token 每百万约 $0.042,输出 token 免费。本文中的测试请求平均每次调用约 381 个输入 token,得出每次调用成本约为 $0.000016(381 ÷ 1,000,000 × $0.042)。定价可能会变化;在估算生产成本之前,请查看 TypeSafe AI 当前的定价。在这种工作负载规模下,成本不太可能是主要制约因素;延迟、吞吐量、网络依赖和数据驻留是更重要的考量。按此费率,1,000 次调用大约花费 $0.016,而一百万次调用大约花费 $16,假设平均输入大小相同。

网络依赖。本系列文章的第 1 部分和第 2 部分完全本地——没有网络、没有外部依赖、没有数据离开数据库。第 3 部分引入了这三点。UDF 本身在 Neo4j 内部运行,但推理不是:文本跨越网络边界传输到 TypeSafe AI,只有结构化结果返回 Neo4j。如果 Jev API 不可达,UDF 会抛出异常。在生产部署中,这是一个值得规划的运营风险:本示例中的 HttpClient 没有配置显式超时;在生产中,请设置连接超时和每请求超时,以防止缓慢或无响应的 API 无限期阻塞 Neo4j 工作线程。回退返回值和调用路径上的监控也值得考虑。另请注意,该 UDF 在数据库查询执行内部进行同步阻塞 HTTP 调用;并发的 Cypher 调用会增加 Neo4j 工作线程、API 速率限制和网络的压力。

数据驻留。传递给 com.example.jev.sentiment() 的文本会离开 Neo4j 并被发送到 TypeSafe AI 的 API。如果你的数据驻留要求禁止这样做,此方法就不适用。在受监管环境中部署之前,请查看 TypeSafe AI 的数据处理政策。

缓存作为缓解措施。对于同一文本被反复评分的工作负载,可以在首次调用时将结果存储为节点属性,并在后续查询时直接读取——完全跳过 API。这是 Neo4j 中的一种自然模式,并且对于稳定的数据集,在很大程度上消除了延迟和成本方面的担忧。

合适的用例。 此方法最适合低频、交互式的工作负载,在这种场景中,显式的不确定性信息比吞吐量更重要,并且数据可以离开数据库。对于大批量批处理摄取,第 1 部分(Java VADER UDF)仍然是强有力的选择——本地、快速、无网络依赖。第 3 部分以本地吞吐量和离线运行换取了更丰富的决策输出和显式的不确定性信息。

三种方法的比较

第 1 部分:Java VADER第 2 部分:Wasm/VADER第 3 部分:Jev
输出极性得分(浮点数)极性得分(浮点数)带类型的决策 + 显式不确定性信息
位置完全本地完全本地网络(TypeSafe AI API)
延迟< 1ms50-200ms(初始化)70-500ms
成本免费免费约 $0.000016/次调用
离线是是否
执行边界Java UDFWasm 运行时外部 API
不确定性输出否否是
典型适用场景大批量批处理沙箱化本地推理交互式、感知不确定性的决策

总结

我们为该系列添加了第三个选项:在 Neo4j Java UDF 内部调用 TypeSafe AI 的 Jev,直接从 Cypher 返回带类型的情感决策及显式不确定性信息。该实现是三者中推理占用最小的——没有 Wasm 运行时,没有打包的词典,没有原生库。JAR 仅包含 UDF 类、重定位后的 Jackson 以及 Java 内置的 HttpClient。

主要的配置考量是通过 neo4j.conf 中的 server.jvm.additional 将 API 密钥传递给 Neo4j 的 JVM,因为 Neo4j Desktop 进程不会自动接收你在终端会话中导出的环境变量。

第 3 部分的关键贡献不是机制——而是显式的不确定性输出。对于明确的句子,Jev 毫不含糊。对于真正混合的情感,它同时返回决策和结构化的置信度度量,Cypher 可以直接据此采取行动。这是 VADER 和 Wasm 实现都无法提供的。

关于将 Jev 放入 Neo4j 的更深入讨论,可在免费在线书籍 将 Jev 放入 Neo4j 的经验教训 中找到。

完整源代码可在 GitHub 上获取。

DZone 贡献者表达的观点仅代表他们自己。