向量数据库实战: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();
接口的意义 :InMemoryEmbeddingStore、ChromaEmbeddingStore、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,改用工程里已有的进程内模型 AllMiniLmL6V2EmbeddingModel (langchain4j-embeddings-all-minilm-l6-v2,ONNX 推理,384 维)。
两个理由:
- 离线可验证------不依赖 API Key,向量检索全链路在本机真实跑通(分数是真实算出来的,不是 mock);
- 避免 bean 冲突 ------这是前面多模态的文章中踩过的坑的翻版:工程里已有 8 处按类型注入
EmbeddingModel(全部装配 DashScope 的qwenEmbeddingModel),再注册一个EmbeddingModelbean 会全部注入失败。所以本地模型用 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: 384、hnsw: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: 找不到指定的模块
排查三步:
- 解析 PE 导入表定位缺失 DLL (机器上没有 dumpbin,用 20 行 Python 解析 pyd 的 import table):发现它依赖
MSVCP140.dll、VCRUNTIME140.dll、VCRUNTIME140_1.dll; - 本机 System32 里只有 .NET 用的
_clr0400变体,标准版 VC++ 运行库压根没装过; - 从 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 端点
十、总结
EmbeddingStore一个接口统一所有向量库,先在 InMemory 上把业务跑对,再无缝换 Chroma/PGVector/Milvus------双后端 + compare 接口就是迁移验收工具;- 本地嵌入模型(384 维 ONNX)让向量链路离线可验证,不烧 API 钱,重构和回归测试的底气都在这;
- 懒连接 + 失败降级:向量库是「增益」不是「命脉」,它挂了应用不能跟着挂;
- Windows + chromadb 1.x 的 VC++ 运行库坑,从 ImportError 到段错误到 keep-alive 竞态,一条龙排查思路都在上面,Linux/Docker 环境可跳过但排查方法论通用;
- metadata 过滤下推是向量库相对内存方案的真正分水岭------先过滤再检索,规模上去之后差距是数量级的。