把 Milvus 接进 JDBC:用 SQL 完成向量检索与对象映射

2026-09-18 26 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

Java 应用接入 Milvus 时,常见做法是直接调用 Milvus SDK,组装检索参数并解析返回结果。jdbc-milvus 提供了另一种入口:让熟悉关系型数据库的开发者继续使用 SQL、JDBC 和结果集映射,同时把向量搜索、标量过滤与 Top-K 排序放进同一条查询中。

这并不意味着 Milvus 变成了关系型数据库。更准确地说,SQL 在这里是一层开发接口:它降低了 Java 项目的接入成本,也更容易嵌入现有的 Repository、MyBatis 或轻量 ORM 架构。

一条 SQL 同时表达过滤与相似度排序

以文章检索为例,集合 intro_articles 中可以包含以下字段:

  • id:文章标识;
  • title:标题;
  • category:普通标量字段;
  • embedding:文章向量;
  • score:查询返回的距离值。

查询意图可以写成:只搜索 Java 分类中的文章,再按照查询向量与 embedding 的距离排序。

下面的语句展示了一种常见写法。假设当前 jdbc-milvus 版本使用 <-> 表示向量距离运算;实际运行前,应按照所用版本的 SQL 方言调整向量运算符和向量参数格式。

SELECT id, title, score
FROM intro_articles
WHERE category = ?
ORDER BY embedding <-> ?
LIMIT ?;

这里有两个容易踩坑的地方。

其一,WHERE category = ? 是标量过滤条件。它让业务约束与向量检索出现在同一条 SQL 中,而不是先取回大量候选项,再在 Java 内存中筛选。

其二,如果 score 表示 L2 平方距离,数值越小代表越接近。不要沿用“分数越高越相关”的直觉。接口返回结果时,最好明确使用 distancel2Distance 这样的字段名,避免前端或排序逻辑把方向写反。

用 PreparedStatement 封装检索

下面是一个可直接改造成项目代码的 JDBC 示例。运行前需要完成三项修改:

  1. 在项目中加入与你的 Milvus 环境匹配的 jdbc-milvus 驱动;
  2. 设置 MILVUS_JDBC_URLMILVUS_USERMILVUS_PASSWORD
  3. 根据驱动版本修改 SQL 中的向量运算符,以及 toVectorLiteral 生成的参数格式。
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

public class MilvusArticleSearch {

    public record ArticleHit(long id, String title, double distance) {}

    public static List<ArticleHit> search(
            Connection connection,
            String category,
            float[] queryVector,
            int limit) throws Exception {

        if (limit < 1 || limit > 100) {
            throw new IllegalArgumentException("limit must be between 1 and 100");
        }

        String sql = """
                SELECT id, title, score
                FROM intro_articles
                WHERE category = ?
                ORDER BY embedding <-> ?
                LIMIT ?
                """;

        try (PreparedStatement statement = connection.prepareStatement(sql)) {
            statement.setString(1, category);
            statement.setString(2, toVectorLiteral(queryVector));
            statement.setInt(3, limit);

            List<ArticleHit> hits = new ArrayList<>();
            try (ResultSet resultSet = statement.executeQuery()) {
                while (resultSet.next()) {
                    hits.add(new ArticleHit(
                            resultSet.getLong("id"),
                            resultSet.getString("title"),
                            resultSet.getDouble("score")
                    ));
                }
            }
            return hits;
        }
    }

    private static String toVectorLiteral(float[] vector) {
        StringBuilder value = new StringBuilder("[");
        for (int i = 0; i < vector.length; i++) {
            if (i > 0) {
                value.append(',');
            }
            value.append(String.format(Locale.ROOT, "%s", vector[i]));
        }
        return value.append(']').toString();
    }

    public static void main(String[] args) throws Exception {
        String url = requiredEnv("MILVUS_JDBC_URL");
        String user = System.getenv().getOrDefault("MILVUS_USER", "");
        String password = System.getenv().getOrDefault("MILVUS_PASSWORD", "");

        // 示例维度仅用于演示;生产环境必须与 collection 的向量维度完全一致。
        float[] queryVector = {0.12f, -0.08f, 0.44f, 0.31f};

        try (Connection connection = DriverManager.getConnection(url, user, password)) {
            List<ArticleHit> hits = search(connection, "java", queryVector, 10);
            hits.forEach(hit -> System.out.printf(
                    "id=%d, distance=%.6f, title=%s%n",
                    hit.id(), hit.distance(), hit.title()
            ));
        }
    }

    private static String requiredEnv(String name) {
        String value = System.getenv(name);
        if (value == null || value.isBlank()) {
            throw new IllegalStateException("Missing environment variable: " + name);
        }
        return value;
    }
}

示例使用 PreparedStatement 绑定分类、查询向量和返回数量。除了减少字符串拼接,它还把 SQL 模板与运行时数据分开。需要注意的是,不同驱动版本可能要求使用 JDBC Array、自定义向量类型或特定文本格式;如果驱动支持原生向量参数,应优先使用原生绑定,而不是自行拼接向量字面量。

从 ResultSet 到 ORM,不必强行套用 JPA

SQL 接口最直接的收益,是能够复用 Java 团队熟悉的数据访问分层。上例中的 ArticleHit 已经是一个只读投影对象:它并不对应集合中的完整实体,只承载搜索结果所需的 idtitle 和距离。

在实际项目中,可以把它收进 Repository:

public interface ArticleSearchRepository {
    List<MilvusArticleSearch.ArticleHit> search(
            String category,
            float[] queryVector,
            int limit
    ) throws Exception;
}

如果项目使用 MyBatis,也可以把查询结果映射为 DTO。以下示例仍假设驱动接受字符串形式的向量参数:

<select id="searchArticles" resultType="com.example.ArticleHit">
    SELECT
        id,
        title,
        score AS distance
    FROM intro_articles
    WHERE category = #{category}
    ORDER BY embedding &lt;-&gt; #{queryVector}
    LIMIT #{limit}
</select>

这里更适合采用“查询对象映射”,而不是把 Milvus 集合伪装成标准 JPA 实体。向量数据库与关系型数据库在关联查询、约束、事务和更新语义上并不等价。除非驱动文档明确支持,否则不要假设 JOIN、跨表事务、外键或 JPA 自动脏检查可以照常工作。

一个稳妥的边界是:

  • 使用关系型数据库保存强事务业务数据;
  • 使用 Milvus 保存向量及检索所需的元数据;
  • 用稳定的业务 ID 关联两侧记录;
  • 将 jdbc-milvus 封装在独立 Repository 中,不让方言细节扩散到 Service 层。

生产接入时要检查什么

SQL 降低了接入门槛,但不会消除向量检索本身的约束。上线前至少检查以下事项:

  • 距离方向:L2 平方距离越小越近;其他度量方式的含义可能不同。
  • 向量维度:查询向量必须与集合字段定义一致。
  • 参数格式:确认驱动支持字符串、数组还是自定义向量类型。
  • 字段投影:只查询页面真正需要的字段,避免返回大字段或原始向量。
  • 连接管理:在服务中使用连接池,并验证驱动对连接复用、超时和并发的支持情况。
  • 分页策略:向量 Top-K 检索不应直接照搬深分页模式;优先限制合理的候选数量。
  • 错误隔离:将驱动异常转换为应用自己的数据访问异常,避免上层依赖具体方言。
  • 可观测性:记录过滤条件、Top-K、耗时和集合名,但不要把完整向量写入普通日志。

jdbc-milvus 的价值不只是“可以写 SQL”,而是让向量检索进入 Java 团队已有的工程体系:参数绑定、连接管理、Repository、DTO 映射和统一异常处理都能继续沿用。采用时应把它视为 Milvus 的 SQL/JDBC 适配层,而不是关系型数据库替代品。守住这个边界,代码会更熟悉,系统语义也不会被误解。


相关推荐