让每种语言都能用上嵌入式向量数据库 -- milvus-lite 的 Go 和 Node.js 封装
前言
最近在做一些 AI Agent 相关的开发,需要一个本地的向量数据库做语义搜索。Milvus 是一个不错的选择,而且它有一个轻量版本 milvus-lite,不需要部署 etcd、MinIO 这些依赖,单文件就能跑。
问题是,milvus-lite 只有 Python 封装。如果你用 Go 或者 Node.js 开发 Agent,就没法用了。官方的态度也很明确:Node.js SDK 的维护者直接关掉了 feature request,理由是"C++ core 无法移植到 TypeScript";Go 版本则一直停留在 issue 讨论阶段。
但仔细看 milvus-lite 的架构就会发现,这个说法并不准确。
milvus-lite 到底是什么
milvus-lite 本质上是一个独立的 C++ gRPC 服务进程,Python 那层只做了两件事:
- 用
subprocess.Popen启动这个二进制 - 通过标准的 Milvus gRPC 协议通信
也就是说,Python 只是一个启动器。真正干活的是那个 C++ 二进制,它对外暴露的是和 Milvus Standalone 完全一样的 gRPC 接口。
既然如此,任何语言只要能启动一个子进程、连上 gRPC,就能用 milvus-lite。不需要 CGo,不需要 FFI,不需要把 C++ 移植到别的语言。
所以我做了两个封装库:
- Go: github.com/lyyyuna/milvus-lite-go
- Node.js/TypeScript: @lyyyuna/milvus-lite(npm 包)
安装
Go
go get github.com/lyyyuna/milvus-lite-go/v2
二进制通过 go:embed 内嵌在平台子模块里。go get 时 Go module proxy 只会下载你当前平台对应的那个子模块(大约 25-55MB),不会把所有平台的都拉下来。
Node.js
npm install @lyyyuna/milvus-lite @zilliz/milvus2-sdk-node
采用和 esbuild、swc 一样的分发方式:主包是纯 JS,二进制按平台拆成独立的 npm 包放在 optionalDependencies 里。npm 会自动只安装匹配当前系统的那个。
两个版本都不需要运行时下载,安装完就能直接用。
使用
Go
package main
import (
"context"
"log"
milvuslite "github.com/lyyyuna/milvus-lite-go/v2"
"github.com/milvus-io/milvus-sdk-go/v2/client"
"github.com/milvus-io/milvus-sdk-go/v2/entity"
)
func main() {
// 启动 milvus-lite,数据存在本地文件
server, err := milvuslite.Start("./milvus.db")
if err != nil {
log.Fatal(err)
}
defer server.Stop()
// 用官方 SDK 连接,一行不用改
c, _ := client.NewClient(context.Background(), client.Config{
Address: server.Addr(),
})
defer c.Close()
// 后面就是正常的 Milvus 操作了
schema := entity.NewSchema().WithName("demo").
WithField(entity.NewField().WithName("id").WithDataType(entity.FieldTypeInt64).WithIsPrimaryKey(true).WithIsAutoID(true)).
WithField(entity.NewField().WithName("vector").WithDataType(entity.FieldTypeFloatVector).WithDim(128))
c.CreateCollection(context.Background(), schema, entity.DefaultShardNumber)
}
Node.js
import { start } from "@lyyyuna/milvus-lite";
import { MilvusClient, DataType } from "@zilliz/milvus2-sdk-node";
const server = await start("./milvus.db");
const client = new MilvusClient({ address: server.addr });
await client.createCollection({
collection_name: "demo",
fields: [
{ name: "id", data_type: DataType.Int64, is_primary_key: true, autoID: true },
{ name: "vector", data_type: DataType.FloatVector, dim: 128 },
],
});
await server.stop();
两个版本的 API 都很简单:Start/start 启动,拿到地址,用各自语言的官方 Milvus SDK 连接。数据操作的代码和连接远程 Milvus 集群完全一样,想迁移到生产环境只需要改一下连接地址。
支持的平台
| OS | Arch | Go | npm |
|---|---|---|---|
| macOS | arm64 (Apple Silicon) | ✅ | ✅ |
| macOS | amd64 (Intel) | ✅ | ✅ |
| Linux | amd64 | ✅ | ✅ |
| Linux | arm64 | ✅ | ✅ |
原理简介
整个方案的核心思路就一句话:不碰 C++ core,只做进程管理和分发。
二进制从哪来
milvus-lite 的 Python 包(PyPI 上的 milvus-lite)是一个 wheel 文件,本质上是个 zip 包。里面除了 Python 代码,还打包了编译好的 milvus 二进制和一堆动态库(libknowhere、libglog、libtbb 等)。
我写了一个脚本,从 PyPI 下载各平台的 wheel,解压出 milvus_lite/lib/ 目录,塞到对应的平台包里。
分发方式
Go 用的是子目录多模块方案。一个 repo 里有多个 go.mod,每个平台一个子模块:
platform/
├── darwin-arm64/go.mod # 独立模块,go:embed 内嵌二进制
├── darwin-amd64/go.mod
├── linux-amd64/go.mod
└── linux-arm64/go.mod
Go module proxy 在分发时会排除有独立 go.mod 的子目录,所以用户 go get 时只下载主模块(几 KB)+ 自己平台的子模块(几十 MB)。运行时通过 build tags 选择对应平台的嵌入数据,解压到临时目录后启动。
npm 用的是 optionalDependencies 方案,和 esbuild 的做法一样。主包声明四个平台包为可选依赖,npm 安装时自动跳过不匹配的平台。
启动流程
- 解压(Go)或定位(npm)二进制和动态库到一个目录
- 设置
LD_LIBRARY_PATH(Linux)或DYLD_LIBRARY_PATH(macOS) - 用
exec.Command(Go)或child_process.spawn(Node.js)启动 milvus 进程 - 等待 gRPC 端口就绪
- 返回地址,用户用官方 SDK 连接
一个已知的坑
milvus-lite 的 ShowCollections gRPC 响应只填了 collection_names,没填 collection_ids。Python SDK 用的是 collection_names 遍历所以没问题,但 Go SDK 用的是 collection_ids 遍历,导致 client.ListCollections() 返回空列表。Node.js SDK 恰好用的也是 collection_names,所以没这个问题。
Go 版本提供了一个 workaround 函数:
names, _ := milvuslite.ListCollections(ctx, server.Addr())
写在最后
这个项目的代码量并不大,Go 版本几百行,Node.js 版本也差不多。真正花时间的是理解 milvus-lite 的架构、搞清楚各平台的分发机制、处理 Go module 的版本号规则这些事情。
如果你正在用 Go 或 Node.js 开发 AI Agent,需要一个本地嵌入式向量数据库来做语义搜索、RAG 或者知识库,可以试试。