向量数据库实战:LangChain4j 双后端向量检索(InMemory 持久化 + ChromaDB)

向量数据库实战:LangChain4j 双后端向量检索(InMemory 持久化 + ChromaDB)

前几篇文章,把对话、AI Service、RAG、Chat Memory、Agent、流式 SSE、多模态图文都跑通了,RAG 篇里我们用了 InMemoryEmbeddingStore------内存里的向量检索,重启就没了。这一篇把向量存储这一层真正做实:本地 InMemory 持久化 + Chroma 向量数据库双后端

环境与版本:LangChain4j 1.17.2 / langchain4j-community 1.17.2-beta27 / langchain4j-chroma 1.17.2-beta27 / Spring Boot 3.5.0 / JDK 17 / chromadb 1.5.9(Python 3.13,Windows)。所有 API 签名均经 javap 反编译核实。

一、为什么 RAG Demo 跑通了还不够

RAG 篇的检索链路是这样的:

scss 复制代码
用户提问 → embeddingModel.embed(query) → InMemoryEmbeddingStore.search() → 拼进 Prompt

InMemoryEmbeddingStore 有三个硬伤:

问题 后果
数据在 JVM 堆里 进程重启索引全丢,每次启动要重新嵌入全量文档(耗时 + 花钱)
无法多实例共享 水平扩容后每个 Pod 各一份索引,写入无法互通
全量暴力扫描 O(n) 余弦计算,10 万条以上向量响应明显变慢

所以生产 RAG 一定要落到真正的向量存储。但这一步的坑比想象中多得多

二、先看 1.17.2 的核心抽象:EmbeddingStore

LangChain4j 把所有向量库抽象成一个接口(位于 langchain4j-core,以下为 javap 反编译的真实签名):

java 复制代码
public interface EmbeddingStore<Embedded> {
    String add(Embedding);
    void add(String id, Embedding);
    String add(Embedding, Embedded);                          // Embedded 通常是 TextSegment
    List<String> addAll(List<Embedding>, List<Embedded>);     // 批量写入
    void removeAll(Collection<String> ids);
    void removeAll(Filter filter);                            // metadata 过滤删除
    void removeAll();
    EmbeddingSearchResult<Embedded> search(EmbeddingSearchRequest);
}

EmbeddingSearchRequest 是 builder 风格,四个参数:

java 复制代码
EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
        .queryEmbedding(queryEmbedding)   // 查询向量
        .maxResults(5)                    // 召回条数
        .minScore(0.5)                    // 相似度阈值(cosine,0.0~1.0)
        .filter(metadataKey("category").isEqualTo("faq"))  // metadata 过滤!
        .build();

接口的意义InMemoryEmbeddingStoreChromaEmbeddingStore、PGVector、Milvus、Qdrant......全部实现这一个接口。你的 RAG 业务代码一行不改,换后端只换装配。这就是本篇「双后端」架构成立的基础。

另外两个本篇用到的真实 API(都在 1.17.2 里核实过):

  • InMemoryEmbeddingStore.serializeToFile(Path) / fromFile(String) ------ 内存索引 JSON 落盘与恢复,size() 返回向量数;
  • MetadataFilterBuilder.metadataKey("k").isEqualTo(v) ------ 构造 Filter,对 Chroma 会翻译成服务端 where 条件下推执行(jar 里有专门的 ChromaMetadataFilterMapper)。

三、选型:为什么是 Chroma

先说结论,再看对比:

方案 部署形态 过滤 适用场景
InMemoryEmbeddingStore 无(进程内) 内存过滤 Demo / 单测 / <5 万条
Chroma pip install chromadb 一条命令 where 下推 开发/中小规模,本篇实战
PGVector 复用现有 PostgreSQL SQL 级 已有 PG 栈的团队
Milvus Docker/集群 标量字段 亿级向量、大规模检索
Qdrant 单二进制 payload 过滤 高性能场景
Elasticsearch 复用 ES 集群 DSL 过滤 已有 ES 栈、需要混合检索

本文选 Chroma 有个非常实际的原因:它是唯一一个不用 Docker、不用额外数据库、一条 pip 命令就能在 Windows 上起服务的向量库 (本机没装 Docker 和 PG)。生产上选型请结合自己栈来------反正 EmbeddingStore 接口都一样,切换成本趋近于零。

启动 Chroma:pip install chromadb && chroma run --path ./chroma-data --port 8000

四、工程实现

4.1 依赖(pom.xml)

xml 复制代码
<!-- Chroma 向量数据库客户端(版本由 langchain4j-bom 管理,与核心 1.17.2-beta27 对齐) -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-chroma</artifactId>
</dependency>

langchain4j-chroma 传递依赖 langchain4j-http-client-jdk(JDK 自带 HttpClient 实现,零额外 HTTP 客户端)。

4.2 本地嵌入模型:384 维,完全离线

关键决策:本篇不用 DashScope text-embedding-v3,改用工程里已有的进程内模型 AllMiniLmL6V2EmbeddingModellangchain4j-embeddings-all-minilm-l6-v2,ONNX 推理,384 维)。

两个理由:

  1. 离线可验证------不依赖 API Key,向量检索全链路在本机真实跑通(分数是真实算出来的,不是 mock);
  2. 避免 bean 冲突 ------这是前面多模态的文章中踩过的坑的翻版:工程里已有 8 处按类型注入 EmbeddingModel(全部装配 DashScope 的 qwenEmbeddingModel),再注册一个 EmbeddingModel bean 会全部注入失败。所以本地模型用 holder 模式,不进 Spring 容器:
java 复制代码
public class LocalEmbeddingProvider {

    private volatile AllMiniLmL6V2EmbeddingModel model;

    public EmbeddingModel get() {
        AllMiniLmL6V2EmbeddingModel result = model;
        if (result == null) {
            synchronized (this) {
                if (model == null) {
                    model = new AllMiniLmL6V2EmbeddingModel();  // 首次调用加载 ONNX,1~2 秒
                }
                result = model;
            }
        }
        return result;
    }

    public Embedding embed(String text) { return get().embed(text).content(); }

    public List<Embedding> embedAll(List<TextSegment> segments) {
        return get().embedAll(segments).content();   // 批量推理,比逐条快一个量级
    }
}

真实细节:in-process 模型的 modelName() 返回 "unknown"(实测 stats 接口输出),别拿它当监控指标用,维度 dimension() 返回 384 是准的。

4.3 Chroma 客户端装配(真实 Builder 签名)

java 复制代码
ChromaEmbeddingStore store = ChromaEmbeddingStore.builder()
        .baseUrl("http://localhost:8000")
        .apiVersion(ChromaApiVersion.V2)        // V2 = /api/v2/tenants/{t}/databases/{d}/collections/...
        .collectionName("langchain4j_demo")
        .tenantName("default_tenant")
        .databaseName("default_database")
        .timeout(Duration.ofSeconds(10))
        .build();

反编译 ChromaApiV2Impl 确认 V2 客户端打的端点是:

bash 复制代码
POST /api/v2/tenants/{tenant}/databases/{database}/collections        # 创建 collection
POST /api/v2/tenants/{tenant}/databases/{database}/collections/{id}/add
POST /api/v2/tenants/{tenant}/databases/{db}/collections/{id}/query   # 检索
POST /api/v2/tenants/{tenant}/databases/{db}/collections/{id}/delete

和 chromadb 1.5.9 服务端的路由完全对得上(服务端日志验证过)。

4.4 双后端服务:懒连接 + 失败降级

设计要点:Chroma 是「可选后端」,不是「必需依赖」。服务没起、中途挂了,应用照常跑,InMemory 兜底:

java 复制代码
private ChromaEmbeddingStore getChromaStore() {
    if (!properties.getChroma().isEnabled()) return null;
    ChromaEmbeddingStore store = this.chromaStore;
    if (store != null) return store;
    synchronized (this) {
        if (chromaStore != null) return chromaStore;
        try {
            chromaStore = ChromaEmbeddingStore.builder()/* ... */.build();
            chromaStatus = "已连接 " + baseUrl;
        } catch (Exception e) {
            chromaStatus = "连接失败: " + e.getMessage() + "(已降级 InMemory,可 /reconnect 重试)";
            // 不抛异常,降级
        }
        return chromaStore;
    }
}

写入走双后端:

java 复制代码
List<Embedding> embeddings = embeddingProvider.embedAll(segments);  // 一次嵌入

localStore.addAll(embeddings, segments);      // InMemory 后端
localStore.serializeToFile(path);             // 落盘,重启恢复

ChromaEmbeddingStore chroma = getChromaStore();
if (chroma != null) {
    chroma.addAll(embeddings, segments);      // Chroma 后端(HTTP)
}

检索按 backend 参数路由(inmemory / chroma / both),compare 接口同一个 query 同时打两个后端------这就是后面的 A/B 验证。

4.5 InMemory 持久化

java 复制代码
// 启动时恢复
InMemoryEmbeddingStore<TextSegment> restored = InMemoryEmbeddingStore.fromFile("data/vector-store.json");

// 每次 ingest 后落盘
localStore.serializeToFile(Path.of("data/vector-store.json"));

配合 metadataKey("category").isEqualTo("faq") 过滤,InMemory 也具备了一个「嵌入式向量库」的完整能力。

五、实测记录(全部真实输出)

5.1 降级链路:Chroma 没启动,应用照常

先不启动 Chroma,直接起 Java 应用:

json 复制代码
GET /api/vector/stats
{"embeddingModel":"unknown","embeddingDimension":384,"inMemoryCount":0,
 "chromaEnabled":true,"chromaStatus":"未初始化","chromaCollection":"langchain4j_demo"}

注意 chromaStatus: "未初始化"------懒连接生效,启动阶段零 Chroma 依赖。此时 ingest:

json 复制代码
POST /api/vector/ingest
{"ingestedSegments":6,"localStore":"ok(6 条,已落盘)",
 "chromaStore":"skipped(未连接)","embeddingDimension":384}

InMemory 检索立即可用(这就是 DashScope key 401 时依然能演示完整 RAG 的底气):

json 复制代码
POST /api/vector/search  {"query":"积分多久会过期","backend":"inmemory"}
{"query":"积分多久会过期","embeddingCostMs":6,"inmemory":[
  {"score":"0.7799","preview":"积分自获得之日起2年内有效,过期自动清零。..."},
  {"score":"0.7560","preview":"商城商品均以积分计价,不支持现金购买。..."},
  {"score":"0.7374","preview":"道路救援年卡、代驾服务券、车辆年检预约..."}]}

首条 0.7799,语义命中正确,嵌入耗时 6ms。

5.2 双后端写入 + 服务端交叉验证

启动 Chroma(chroma run),调 /api/vector/reconnect,再 reset + ingest:

json 复制代码
POST /api/vector/reconnect
{"connected":true,"status":"已连接 http://localhost:8000(collection=langchain4j_demo,耗时 44ms)"}

POST /api/vector/ingest
{"ingestedSegments":6,"localStore":"ok(6 条,已落盘)","chromaStore":"ok(6 条)","embeddingDimension":384}

服务端交叉验证(绕开 Java,直接 curl Chroma):

bash 复制代码
GET /api/v2/.../collections/{id}/count  →  6

服务端 collection 元数据还能看到 dimension: 384hnsw:space: cosine、metadata 键 category/source 已建索引------collection 维度在第一次写入时固定 ,之后写入维度不符会直接 400(实测:塞 4 维向量返回 "Collection expecting embedding with dimension of 384, got 4")。

5.3 双后端分数逐位一致(重点)

json 复制代码
POST /api/vector/compare  {"query":"保养套餐包含什么","maxResults":2}
{"query":"保养套餐包含什么",
 "inmemory":[{"score":"0.9330","preview":"保养套餐:包含机油+机滤+工时的一站式保养套餐..."},
             {"score":"0.8560","preview":"道路救援年卡、代驾服务券、车辆年检预约..."}],
 "chroma":  [{"score":"0.9330","preview":"保养套餐:包含机油+机滤+工时的一站式保养套餐..."},
             {"score":"0.8560","preview":"道路救援年卡、代驾服务券、车辆年检预约..."}]}

两个后端分数到小数点后四位完全一致 。这不是巧合------两边都是同一嵌入模型出的向量 + 同样的 cosine 空间,Chroma 客户端把 cosine 距离换算回了 relevance score(1 - distance)。这个 A/B 接口可以直接当「向量库迁移验收工具」用:换后端前后跑一遍 compare,分数对得上就说明迁移无损。

5.4 metadata 过滤下推

json 复制代码
POST /api/vector/search-filtered
{"query":"积分","filterKey":"category","filterValue":"faq","backend":"chroma"}
{"filter":"category == faq","chroma":[
  {"score":"0.7472","preview":"商城商品均以积分计价...","metadata":{"category":"faq","source":"mall-faq.txt"}},
  {"score":"0.7447","preview":"积分自获得之日起2年内有效...","metadata":{"category":"faq"}},
  {"score":"0.7246","preview":"平安保险:投保金额的5%折算为积分...","metadata":{"category":"faq"}}]}

只有 category=faq 的三条回来了。注意这个 Filter 是被 ChromaMetadataFilterMapper 翻译成 Chroma 服务端 where 条件下推执行的------先过滤再算相似度,不是拉回内存再筛。数据量越大,这个差别越重要。

5.5 重启持久化验证

kill 应用 → 重新 java -jar

json 复制代码
GET /api/vector/stats
{"inMemoryCount":6,"persistFile":"data/vector-store.json(存在)",...}

POST /api/vector/search  {"query":"公司注册资本多少","backend":"inmemory"}
{"inmemory":[{"score":"0.8781","preview":"XX汽车服务有限公司成立于2017年,注册资本1000万..."}]}

重启后索引从 JSON 文件恢复(6 条),无需重新嵌入,直接检索且语义命中正确。

六、踩坑记录

坑 1:chromadb 1.x 在 Windows 上的 VC++ 运行库地狱(本篇最大的坑)

pip install chromadb 装的是 1.5.9,内核是 Rust(chromadb_rust_bindings.pyd)。启动直接炸:

lua 复制代码
ImportError: DLL load failed while importing chromadb_rust_bindings: 找不到指定的模块

排查三步:

  1. 解析 PE 导入表定位缺失 DLL (机器上没有 dumpbin,用 20 行 Python 解析 pyd 的 import table):发现它依赖 MSVCP140.dllVCRUNTIME140.dllVCRUNTIME140_1.dll
  2. 本机 System32 里只有 .NET 用的 _clr0400 变体,标准版 VC++ 运行库压根没装过
  3. 从 TortoiseGit 里借了一份 msvcp140.dll 放到 pyd 同目录------能 import 了,但更大的坑在后面

真正恶心的是:借的 DLL 是旧版,加载成功但运行期段错误(Segmentation fault) 。Chroma 服务端表现为:请求进去了(服务日志能看到 collection_add 路由命中),处理到一半整个进程静默死掉,Java 侧报 HTTP/1.1 header parser received no bytes。用 python -X faulthandler 定位:

arduino 复制代码
Windows fatal exception: access violation
  File "...chromadb\api\rust.py", line 541 in _upsert

根治 :安装微软官方 VC++ 2015-2022 运行库,删掉借用的 DLL。装完之后进程内 upsert/query、HTTP 服务、Java 全链路一次通过。

教训:Windows 上 Rust/MSVC 编译的 pyd 依赖运行库,「能加载」≠「能运行」,ABI 不匹配会以段错误的形式在最深处爆炸。

坑 2:想退回 chromadb 0.6.x?Python 3.13 编译失败

1.x 有坑,第一反应是降级。结果:

ini 复制代码
pip install chromadb==0.6.3
× Failed to build installable wheels for some pyproject.toml based projects
╰─> chroma-hnswlib

0.6.x 依赖 chroma-hnswlib,没有 Python 3.13 的预编译 wheel,本地又没有 C++ 工具链。Python 3.13 + Windows 这条路上 0.6.x 走不通,只能修 1.x 的运行库。

坑 3:JDK HttpClient 的 keep-alive 竞态

Chroma 首次写入时 Java 侧报过一次:

yaml 复制代码
java.io.IOException: HTTP/1.1 header parser received no bytes

诡异的是服务端实际已写入成功 (重启后 collection 里数据还在、维度已固定)。原因:JDK HttpClient 复用的连接恰好被服务端关闭,POST 请求默认不自动重试。langchain4j-chroma 的 ChromaEmbeddingStore.Builder 留了 httpClientBuilder(...) 口子可以定制 HttpClient 行为;业务侧的兜底是把 Chroma 写入包在可重试的 try/catch 里(本篇 ingest 的写入就是 fail-soft 的:失败记状态,不影响 InMemory 主链路)。

坑 4:进程托管方式决定服务寿命

chroma ... & 挂在一条临时 shell 里启动,shell 会话结束进程跟着死,表现为「刚才还通,现在连不上」。长驻服务要用真正持久的后台任务方式起 ,并且 Java 侧保留 /api/vector/reconnect 手动重连口子(服务端后启动也救得回来)。

坑 5:collection 维度一旦固定就是终身的

第一笔写入决定 dimension。中途换嵌入模型(384 → 1536)继续往同一个 collection 写,服务端直接 400。换模型必须换 collection(或先清空),这个约束在多模型共存的工程里特别容易踩。

坑 6:minScore 的语义随模型而变

all-minilm-l6-v2 的 cosine 分数,语义相关的中文段落大概 0.70~0.93;不相关的也在 0.60+(中文小模型区分度一般)。沿用 RAG 篇 0.5 的阈值基本等于没过滤。建议按 compare 接口实测分数分布再定阈值,或者干脆 InMemory/本地模型阶段用 0.0 观察全量、线上用 API 模型时再收紧。

七、生产化清单

  • 双写一致性:本篇 ingest 是「尽力双写」,生产上要决定主从(谁是 source of truth)+ 失败补偿(Chroma 挂了期间的增量,重连后补写);
  • 重建索引入口 :保留 /api/vector/reset + 全量重导接口,换嵌入模型 = 换 collection = 全量重建,这个操作一定会发生;
  • 指标:stats 接口暴露两后端向量数,生产上加「双后端数量差」告警,差值持续扩大 = 双写失衡;
  • 连接管理:Chroma 超时设短(本篇 10s),重连入口必须有,懒连接 + 降级是应用不被向量库拖死的关键;
  • 成本:本地模型零调用成本但 384 维区分度有限;生产检索质量要求高就上 API 嵌入模型(1536+ 维), 此时 vectorDB 换 PGVector/Milvus 的收益也会显现。

八、API 速查表(1.17.2 实测)

API 位置 说明
EmbeddingStore.addAll(List<Embedding>, List<Embedded>) langchain4j-core 批量写入
EmbeddingStore.search(EmbeddingSearchRequest) langchain4j-core 统一检索入口
EmbeddingStore.removeAll(Filter) langchain4j-core 按条件删除
EmbeddingSearchRequest.builder().queryEmbedding().maxResults().minScore().filter() langchain4j-core 四参数请求
InMemoryEmbeddingStore.serializeToFile(Path) / fromFile(String) langchain4j 主 jar JSON 持久化/恢复
ChromaEmbeddingStore.builder().baseUrl().apiVersion(V2).collectionName().tenantName().databaseName().timeout() langchain4j-chroma Chroma 装配
MetadataFilterBuilder.metadataKey(k).isEqualTo(v) langchain4j-core Filter 构造(支持下推)
AllMiniLmL6V2EmbeddingModel embeddings jar 384 维本地 ONNX 模型

九、测试接口清单

端点 说明
POST /api/vector/ingest 双后端写入(带 metadata)
POST /api/vector/search 检索(backend: inmemory/chroma/both)
POST /api/vector/search-filtered metadata 过滤检索(下推)
POST /api/vector/compare 双后端 A/B 分数对比
GET /api/vector/stats 嵌入模型/维度/双后端状态
POST /api/vector/reconnect 手动重连 Chroma
POST /api/vector/reset 清空双后端 + 删持久化文件

新增文件(vector 包,4 个):

bash 复制代码
src/main/java/com/example/langchain4j/vector/
├── LocalEmbeddingProvider.java    # 本地 384 维嵌入模型 holder(不进容器,避免 bean 冲突)
├── VectorStoreProperties.java     # vector.chroma / vector.local / vector.search 配置
├── VectorStoreService.java        # 双后端核心:懒连接+降级+双写+过滤+对比+持久化
└── VectorController.java          # 7 个 REST 端点

十、总结

  1. EmbeddingStore 一个接口统一所有向量库,先在 InMemory 上把业务跑对,再无缝换 Chroma/PGVector/Milvus------双后端 + compare 接口就是迁移验收工具
  2. 本地嵌入模型(384 维 ONNX)让向量链路离线可验证,不烧 API 钱,重构和回归测试的底气都在这;
  3. 懒连接 + 失败降级:向量库是「增益」不是「命脉」,它挂了应用不能跟着挂;
  4. Windows + chromadb 1.x 的 VC++ 运行库坑,从 ImportError 到段错误到 keep-alive 竞态,一条龙排查思路都在上面,Linux/Docker 环境可跳过但排查方法论通用;
  5. metadata 过滤下推是向量库相对内存方案的真正分水岭------先过滤再检索,规模上去之后差距是数量级的。
相关推荐
2601_9668714011 分钟前
周大都督2026年零基础手写大模型系统课
人工智能
INNOVIX稳石机器人15 分钟前
稳石500强实战验证:从人工仓储迈向AI自主智造,给出仓储自动化升级标准答案
运维·人工智能·自动化
晴天1617 分钟前
Top10 可落地开源 AI Skills-Day31
人工智能·开源
JXJD200424 分钟前
【无标题】
人工智能·ai·自动化
LONGZETECH38 分钟前
低空经济背景下:五组核心数据拆解无人机职业教育的机遇与实训破局
大数据·人工智能·系统架构·无人机
aichitang202441 分钟前
希尔伯特空间中的正交性
人工智能·学习·机器学习·ai·泛函分析
aiqianji44 分钟前
功能全面的教AI短篇小说写作的软件都有哪些?
人工智能·python
格林威1 小时前
C# 相机Burst模式图像采集:使用相机内存配合OpenCvSharp和Halcon实现短时间的高速采集的方法
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·工业相机
轮到我狗叫了1 小时前
Slurm如何使用
人工智能·python·深度学习·机器学习