LangChain4j + Elasticsearch 实现企业级向量存储(支持混合检索、元数据过滤)
一、前言
在Java RAG项目开发中,很多项目不想额外部署 Milvus、Qdrant 等专用向量库,希望复用现有Elasticsearch集群实现向量存储+语义检索+全文检索。
本文基于 ES8.14.3 + LangChain4j 自研实现一套完整的 EmbeddingStore 向量存储实现类,替代官方简陋实现,支持动态索引创建、余弦向量检索、BM25全文检索、元数据过滤、批量删除,完全满足企业级知识库RAG场景。
二、技术栈说明
-
核心框架:LangChain4j(标准EmbeddingStore接口)
-
存储引擎:Elasticsearch 8.x(dense_vector 原生向量类型)
-
检索能力:余弦相似度向量检索 + BM25关键词检索(混合检索)
-
扩展能力:支持元数据过滤、按知识库批量删除、单条删除
三、核心设计亮点(区别官方原生实现)
1. 动态自适应索引创建
官方ES向量存储需要手动指定向量维度,极易报错。本实现:
-
首次写入向量时自动获取真实向量维度创建索引
-
双重检查锁保证线程安全,避免多服务启动重复创建索引
-
索引默认开启 余弦相似度(cosine) 匹配
2. 双检索模式(向量+全文混合)
-
语义检索:基于 script_score 实现余弦相似度检索,解决语义匹配问题
-
关键词检索:自定义BM25接口,支持传统文本召回,弥补向量检索关键词丢失问题
-
分数归一化处理,统一返回结构,适配RAG召回逻辑
3. 完善的元数据过滤能力
原生LangChain4j过滤器支持不完善,本实现手动适配:
-
支持 IsEqualTo 精准匹配(知识库隔离、文件过滤)
-
支持 IsIn 多值匹配
-
自动映射 metadata 层级字段,业务隔离更方便
4. 完善的数据管理能力
-
按知识库ID批量删除向量数据
-
按单文档ID精准删除
-
全覆盖异常捕获+日志打点,线上问题好排查
四、ES索引结构设计
自定义结构化文档,兼顾可读性与检索性能:
json
{
"id": "文档唯一UUID",
"text": "文本分片内容",
"embedding": [向量浮点数组],
"metadata": {
"knowledgeBaseId": "知识库ID",
"fileName": "文件名",
"chunkIndex": "分片序号"
}
}
-
id:keyword 唯一主键
-
text:text 分词字段,用于BM25检索
-
embedding:dense_vector 向量字段,余弦相似度检索
-
metadata:object 扩展字段,用于业务过滤隔离
五、核心功能原理
1. 余弦向量检索原理
通过 script_score 自定义打分公式,解决ES原生向量打分范围为[-1,1]的问题,统一转为[0,2]后业务使用更友好:
cosineSimilarity\(params\.queryVector, \&\#39;embedding\&\#39;\) \+ 1\.0
2. 线程安全索引初始化
使用 volatile + 双重检查锁,保证多线程、多实例部署下,索引只会初始化一次,避免索引重复创建、mapping冲突问题。
3. 兼容LangChain4j标准接口
完全实现 EmbeddingStore<TextSegment> 所有抽象方法,可无缝对接:
-
向量单条/批量新增
-
带元数据文本分片存储
-
标准EmbeddingSearchRequest检索
六、解决的业务痛点
-
无需新增中间件:复用现有ES集群,降低运维成本
-
解决维度固定问题:官方实现需固定维度,本代码动态适配
-
解决知识库隔离问题:通过metadata过滤完美实现多租户、多知识库隔离
-
解决召回单一问题:向量语义召回 + BM25关键词召回混合使用,准确率更高
七、适配场景
-
Java SpringBoot + LangChain4j 企业级RAG知识库系统
-
不想部署 Milvus/Weaviate 等专用向量库的项目
-
需要 全文检索+向量检索混合召回 的AI问答场景
-
多知识库、多租户隔离的业务系统
八、核心代码逐段深度解析
本节针对上述完整源码,拆解核心模块、关键方法、技术细节,帮助大家读懂每一段代码的设计思路,方便二次开发与改造。
8.1 基础类结构与依赖注入
当前类实现 LangChain4j 标准 EmbeddingStore 接口,遵循框架规范,可无缝接入RAG框架,同时基于Spring容器托管,统一依赖注入。
对应核心代码片段:
java
import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.store.embedding.EmbeddingMatch;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingSearchResult;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.filter.Filter;
import dev.langchain4j.store.embedding.filter.comparison.IsEqualTo;
import dev.langchain4j.store.embedding.filter.comparison.IsIn;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch.core.*;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.elasticsearch.indices.CreateIndexRequest;
import co.elastic.clients.elasticsearch.indices.ExistsRequest;
import co.elastic.clients.json.JsonData;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
/**
* 自研ES向量存储实现类
* 支持动态索引创建、余弦向量检索、BM25全文检索、元数据过滤、批量删除
* 适配ES8.x + LangChain4j,替代官方简陋实现
*/
@Slf4j
@Component
public class ElasticsearchEmbeddingStore implements EmbeddingStore<TextSegment> {
private final ElasticsearchClient esClient;
@Value("${elasticsearch.vector.index:langchain4j-vector}")
private String indexName;
/**
* 索引是否已初始化,volatile保证可见性
*/
private volatile boolean indexInitialized = false;
private final Object lock = new Object();
public ElasticsearchEmbeddingStore(ElasticsearchClient esClient) {
this.esClient = esClient;
}
// 核心实体类
@Data
@Builder
private static class VectorDocument {
private String id;
private String text;
private List<Float> embedding;
private Map<String, String> metadata;
}
}
-
注入ES官方客户端
ElasticsearchClient:适配ES8.x新版API,摒弃废弃的RestHighLevelClient -
配置化索引名称:通过
@Value读取配置,支持多环境动态切换索引 -
线程安全变量:
volatile修饰索引初始化状态,搭配锁对象实现安全初始化 -
注入ES官方客户端
ElasticsearchClient:适配ES8.x新版API,摒弃废弃的RestHighLevelClient -
配置化索引名称:通过
@Value读取配置,支持多环境动态切换索引 -
线程安全变量:
volatile修饰索引初始化状态,搭配锁对象实现安全初始化
内部自定义 VectorDocument 实体,完全对齐ES索引文档结构,实现数据序列化与反序列化统一,字段涵盖id、文本、向量、元数据四大核心模块。
8.2 核心亮点:动态索引初始化方法
ensureIndexExists\(\) 是整个工具类最核心的方法,解决官方插件维度固定、索引手动创建的痛点。
对应核心代码片段:
java
/**
* 索引初始化(动态适配向量维度)
* 双重检查锁保证多线程、多实例线程安全
*/
private void ensureIndexExists(int dimension) {
// 双重检查锁,保证线程安全
if (indexInitialized) {
return;
}
synchronized (lock) {
if (indexInitialized) {
return;
}
try {
// 判断索引是否存在
ExistsRequest existsRequest = new ExistsRequest.Builder().index(indexName).build();
boolean exists = esClient.indices().exists(existsRequest).value();
if (!exists) {
// 构建索引Mapping,动态适配向量维度
String mappingJson = String.format("""
{
"mappings": {
"properties": {
"id": {"type": "keyword"},
"text": {"type": "text"},
"embedding": {
"type": "dense_vector",
"dims": %d,
"index": true,
"similarity": "cosine"
},
"metadata": {"type": "object"}
}
}
}
""", dimension);
CreateIndexRequest request = new CreateIndexRequest.Builder()
.index(indexName)
.withJson(mappingJson)
.build();
esClient.indices().create(request);
log.info("ES向量索引创建成功,索引名:{},向量维度:{}", indexName, dimension);
}
indexInitialized = true;
} catch (IOException e) {
log.error("ES向量索引初始化失败", e);
throw new RuntimeException("ES索引初始化异常", e);
}
}
}
-
双重检查锁机制:先判断状态、再竞争锁、再次校验状态,保证多实例、多线程场景下,索引仅初始化一次,杜绝重复创建、Mapping冲突报错
-
动态维度适配 :从当前写入的向量中实时获取维度
embedding\.dimension\(\),无需代码硬编码维度 -
索引参数定制:强制开启向量索引、指定余弦相似度算法,适配语义检索场景
-
容错机制:索引创建失败捕获异常,抛出自定义业务异常,方便全局异常处理
-
双重检查锁机制:先判断状态、再竞争锁、再次校验状态,保证多实例、多线程场景下,索引仅初始化一次,杜绝重复创建、Mapping冲突报错
-
动态维度适配 :从当前写入的向量中实时获取维度
embedding\.dimension\(\),无需代码硬编码维度 -
索引参数定制:强制开启向量索引、指定余弦相似度算法,适配语义检索场景
-
容错机制:索引创建失败捕获异常,抛出自定义业务异常,方便全局异常处理
8.3 向量写入逻辑(单条/批量)
统一入口 addInternal\(\) 封装所有向量写入逻辑,所有重载的add方法最终都会进入该内部方法,代码复用性极高。
对应核心代码片段:
java
// ==================== 向量写入核心方法 ====================
@Override
public String add(Embedding embedding) {
return addInternal(null, embedding, null);
}
@Override
public String add(Embedding embedding, TextSegment textSegment) {
return addInternal(textSegment.text(), embedding, textSegment.metadata().toMap());
}
@Override
public List<String> addAll(List<Embedding> embeddings) {
return embeddings.stream().map(this::add).collect(Collectors.toList());
}
@Override
public List<String> addAll(List<Embedding> embeddings, List<TextSegment> textSegments) {
List<String> idList = new ArrayList<>();
for (int i = 0; i < embeddings.size(); i++) {
TextSegment segment = textSegments.get(i);
idList.add(addInternal(segment.text(), embeddings.get(i), segment.metadata().toMap()));
}
return idList;
}
/**
* 统一向量写入内部方法
*/
private String addInternal(String text, Embedding embedding, Map<String, Object> metadata) {
// 动态初始化索引
ensureIndexExists(embedding.dimension());
String docId = UUID.randomUUID().toString();
// 格式转换
List<Float> floatList = toFloatList(embedding.vector());
Map<String, String> stringMetadata = convertMetadataToString(metadata);
VectorDocument document = VectorDocument.builder()
.id(docId)
.text(text)
.embedding(floatList)
.metadata(stringMetadata)
.build();
// 写入ES
try {
IndexRequest<VectorDocument> request = new IndexRequest.Builder<VectorDocument>()
.index(indexName)
.id(docId)
.document(document)
.build();
esClient.index(request);
return docId;
} catch (IOException e) {
log.error("ES向量数据写入失败", e);
throw new RuntimeException("向量数据写入异常", e);
}
}
-
自动触发索引校验:每次写入前自动判断索引是否存在,不存在则动态创建
-
数据格式兼容:将LangChain4j的
Embedding向量数组转为ES支持的List\<Float\>集合 -
元数据统一序列化:将所有元数据统一转为String类型,规避ES字段类型不匹配导致的查询失效问题
-
唯一ID生成:默认使用UUID作为文档主键,保证分布式场景数据唯一性
-
自动触发索引校验:每次写入前自动判断索引是否存在,不存在则动态创建
-
数据格式兼容:将LangChain4j的
Embedding向量数组转为ES支持的List\<Float\>集合 -
元数据统一序列化:将所有元数据统一转为String类型,规避ES字段类型不匹配导致的查询失效问题
-
唯一ID生成:默认使用UUID作为文档主键,保证分布式场景数据唯一性
同时重载了单条、批量、带文本分片、无文本分片等多种写入方法,完全适配LangChain4j所有写入场景。
8.4 余弦向量检索核心逻辑
向量检索基于ES script\_score 实现,是语义匹配的核心,解决原生向量分数区间为[-1,1]的负数问题。
对应核心代码片段:
java
// ==================== 向量检索(余弦相似度) ====================
@Override
public EmbeddingSearchResult<TextSegment> search(EmbeddingSearchRequest request) {
Embedding queryEmbedding = request.queryEmbedding();
int maxResults = request.maxResults();
double minScore = request.minScore();
Filter filter = request.filter();
try {
// 构建script_score余弦检索语句
String script = "cosineSimilarity(params.queryVector, 'embedding') + 1.0";
co.elastic.clients.elasticsearch.core.SearchRequest.Builder searchBuilder =
new co.elastic.clients.elasticsearch.core.SearchRequest.Builder()
.index(indexName)
.size(maxResults)
.query(q -> q.scriptScore(s -> s
.script(sc -> sc.inline(i -> i.source(script)
.params("queryVector", JsonData.of(toFloatList(queryEmbedding.vector())))))
));
// 拼接元数据过滤条件
if (filter != null) {
searchBuilder.postFilter(buildFilterQuery(filter));
}
co.elastic.clients.elasticsearch.core.SearchResponse<VectorDocument> response =
esClient.search(searchBuilder.build(), VectorDocument.class);
// 结果封装
List<EmbeddingMatch<TextSegment>> matchList = response.hits().hits().stream()
.filter(hit -> hit.score() >= minScore)
.map(hit -> convertToMatch(hit))
.collect(Collectors.toList());
return new EmbeddingSearchResult<>(matchList);
} catch (IOException e) {
log.error("ES向量检索失败", e);
return new EmbeddingSearchResult<>(Collections.emptyList());
}
}
-
打分公式优化:
cosineSimilarity \+ 1\.0,将分数归一化到 [0,2],完美适配框架minScore阈值过滤 -
结果封装适配:将ES查询结果反向解析,重构
TextSegment、Metadata、Embedding对象,完全对齐LangChain4j返回规范 -
异常兜底:检索异常时返回空集合,避免RAG流程报错中断
-
打分公式优化:
cosineSimilarity \+ 1\.0,将分数归一化到 [0,2],完美适配框架minScore阈值过滤 -
结果封装适配:将ES查询结果反向解析,重构
TextSegment、Metadata、Embedding对象,完全对齐LangChain4j返回规范 -
异常兜底:检索异常时返回空集合,避免RAG流程报错中断
8.5 元数据过滤器适配逻辑
重写框架原生不完善的过滤器能力,通过 buildFilterQuery 自定义过滤器解析逻辑,支撑业务数据隔离。
对应核心代码片段:
java
// ==================== 过滤器解析 ====================
private co.elastic.clients.elasticsearch._types.query_dsl.Query buildFilterQuery(Filter filter) {
if (filter instanceof IsEqualTo isEqualTo) {
String field = "metadata." + isEqualTo.key();
String value = String.valueOf(isEqualTo.comparisonValue());
return co.elastic.clients.elasticsearch._types.query_dsl.Query.of(
q -> q.term(t -> t.field(field).value(value))
);
}
if (filter instanceof IsIn isIn) {
String field = "metadata." + isIn.key();
List<String> values = isIn.comparisonValues().stream()
.map(String::valueOf)
.collect(Collectors.toList());
return co.elastic.clients.elasticsearch._types.query_dsl.Query.of(
q -> q.terms(t -> t.field(field).terms(v -> v.value(values)))
);
}
log.warn("未适配的过滤器类型:{}", filter.getClass().getName());
return co.elastic.clients.elasticsearch._types.query_dsl.Query.of(q -> q.matchAll(m -> m));
}
-
适配
IsEqualTo精准过滤:支持按知识库ID、文件ID等字段精准匹配数据 -
适配
IsIn多值过滤:满足多条件批量筛选场景 -
默认兜底策略:未知过滤器类型自动走全量匹配,保证程序不报错
-
层级字段映射:自动拼接
metadata\.xxx路径,适配ES嵌套对象查询规则 -
适配
IsEqualTo精准过滤:支持按知识库ID、文件ID等字段精准匹配数据 -
适配
IsIn多值过滤:满足多条件批量筛选场景 -
默认兜底策略:未知过滤器类型自动走全量匹配,保证程序不报错
-
层级字段映射:自动拼接
metadata\.xxx路径,适配ES嵌套对象查询规则
8.6 自定义BM25全文检索实现
拓展框架原生能力,新增独立BM25检索方法,实现「向量语义+关键词」混合召回。
对应核心代码片段:
java
// ==================== 自定义BM25全文检索 ====================
public List<EmbeddingMatch<TextSegment>> searchByBM25(String keyword, Filter filter, int maxResults) {
try {
co.elastic.clients.elasticsearch.core.SearchRequest.Builder searchBuilder =
new co.elastic.clients.elasticsearch.core.SearchRequest.Builder()
.index(indexName)
.size(maxResults)
.query(q -> q.match(m -> m.field("text").query(keyword)));
if (filter != null) {
searchBuilder.postFilter(buildFilterQuery(filter));
}
co.elastic.clients.elasticsearch.core.SearchResponse<VectorDocument> response =
esClient.search(searchBuilder.build(), VectorDocument.class);
// BM25分数归一化 0-1
return response.hits().hits().stream()
.map(hit -> {
EmbeddingMatch<TextSegment> match = convertToMatch(hit);
return new EmbeddingMatch<>(hit.score() / 20.0, match.embeddingId(),
match.embedding(), match.embedded());
})
.collect(Collectors.toList());
} catch (IOException e) {
log.error("ES全文检索失败", e);
return Collections.emptyList();
}
}
-
布尔组合查询:通过bool+filter实现「关键词匹配+元数据隔离」双重筛选
-
分数归一化:ES原生BM25分数区间不固定,通过
score/20\.0归一化到0-1区间,统一检索分数标准 -
兼容通用返回体:复用
EmbeddingMatch返回结构,上层业务无需适配多套返回逻辑 -
布尔组合查询:通过bool+filter实现「关键词匹配+元数据隔离」双重筛选
-
分数归一化:ES原生BM25分数区间不固定,通过
score/20\.0归一化到0-1区间,统一检索分数标准 -
兼容通用返回体:复用
EmbeddingMatch返回结构,上层业务无需适配多套返回逻辑
8.7 数据删除能力实现
针对性适配知识库业务场景,提供两种删除逻辑,覆盖日常运维需求。
对应核心代码片段:
java
// ==================== 数据删除方法 ====================
@Override
public void remove(String embeddingId) {
try {
DeleteRequest request = new DeleteRequest.Builder()
.index(indexName)
.id(embeddingId)
.build();
esClient.delete(request);
log.info("单条向量数据删除成功,ID:{}", embeddingId);
} catch (IOException e) {
log.error("单条向量删除失败,ID:{}", embeddingId, e);
}
}
/**
* 根据知识库ID批量删除向量
*/
public long removeByKnowledgeBaseId(String knowledgeBaseId) {
try {
co.elastic.clients.elasticsearch.core.DeleteByQueryRequest request =
new co.elastic.clients.elasticsearch.core.DeleteByQueryRequest.Builder()
.index(indexName)
.query(q -> q.term(t -> t.field("metadata.knowledgeBaseId").value(knowledgeBaseId)))
.build();
DeleteByQueryResponse response = esClient.deleteByQuery(request);
log.info("批量删除知识库向量成功,知识库ID:{},删除数量:{}", knowledgeBaseId, response.deleted());
return response.deleted();
} catch (IOException e) {
log.error("批量删除知识库向量失败,知识库ID:{}", knowledgeBaseId, e);
return 0;
}
}
@Override
public void removeAll() {
try {
co.elastic.clients.elasticsearch.core.DeleteByQueryRequest request =
new co.elastic.clients.elasticsearch.core.DeleteByQueryRequest.Builder()
.index(indexName)
.query(q -> q.matchAll(m -> m))
.build();
esClient.deleteByQuery(request);
log.info("清空所有向量数据成功");
} catch (IOException e) {
log.error("清空向量数据失败", e);
}
}
-
批量删除(按知识库ID) :基于ES
deleteByQuery,批量清空指定知识库所有向量,适配知识库重置、删除场景,返回实际删除数量 -
单条删除(按文档ID):精准删除单条分片向量,适配单文件更新、局部数据刷新场景
-
全链路日志打点:记录删除条件、删除数量,方便线上问题追溯
-
批量删除(按知识库ID) :基于ES
deleteByQuery,批量清空指定知识库所有向量,适配知识库重置、删除场景,返回实际删除数量 -
单条删除(按文档ID):精准删除单条分片向量,适配单文件更新、局部数据刷新场景
-
全链路日志打点:记录删除条件、删除数量,方便线上问题追溯
8.8 工具方法封装
统一封装向量格式转换工具方法,解耦业务逻辑,代码更整洁,同时补充结果转换核心方法,适配ES查询结果解析:
对应核心代码片段:
java
// ==================== 工具转换方法 ====================
private List<Float> toFloatList(float[] array) {
if (array == null || array.length == 0) {
return Collections.emptyList();
}
List<Float> list = new ArrayList<>(array.length);
for (float v : array) {
list.add(v);
}
return list;
}
private float[] toFloatArray(List<Float> list) {
if (list == null || list.isEmpty()) {
return new float[0];
}
float[] array = new float[list.size()];
for (int i = 0; i < list.size(); i++) {
array[i] = list.get(i);
}
return array;
}
private Map<String, String> convertMetadataToString(Map<String, Object> metadata) {
if (metadata == null || metadata.isEmpty()) {
return Collections.emptyMap();
}
Map<String, String> stringMap = new HashMap<>();
metadata.forEach((k, v) -> stringMap.put(k, String.valueOf(v)));
return stringMap;
}
/**
* ES查询结果转为LangChain4j标准返回体
*/
private EmbeddingMatch<TextSegment> convertToMatch(Hit<VectorDocument> hit) {
VectorDocument doc = hit.source();
Embedding embedding = Embedding.from(toFloatArray(doc.getEmbedding()));
Metadata metadata = new Metadata();
if (doc.getMetadata() != null) {
doc.getMetadata().forEach(metadata::add);
}
TextSegment textSegment = TextSegment.from(doc.getText(), metadata);
return new EmbeddingMatch<>(hit.score(), doc.getId(), embedding, textSegment);
}
-
toFloatList:原生float数组转ES存储的Float集合 -
toFloatArray:ES查询结果集合转回框架所需数组格式 -
元数据统一转换:规避不同类型元数据导致的查询异常
-
结果自动封装:标准化适配LangChain4j返回结构,上层业务无需额外处理
-
toFloatList:原生float数组转ES存储的Float集合 -
toFloatArray:ES查询结果集合转回框架所需数组格式
九、项目配置文件(application.yml)
如需正常运行上述代码,需引入以下核心依赖,适配 SpringBoot 2.7+/3.x 版本:
xml
<!-- LangChain4j 核心依赖 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>1.0.0-beta1</version>
</dependency>
<!-- Elasticsearch 8.x 官方客户端 -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.14.3</version>
</dependency>
<!-- SpringBoot Web 基础依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Lombok 简化代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
十、总结
该自研 ES 向量存储框架,补足了 LangChain4j 官方ES插件的短板,实现了动态索引、混合检索、元数据过滤、便捷数据管理全套能力。在中小体量RAG项目中,完全可以替代专业向量数据库,兼顾性能、成本、可维护性,是Java AI项目低成本落地RAG的最优方案之一。
十一、常见踩坑记录
-
ES向量索引一旦创建不支持修改维度,所以首次初始化必须用真实向量维度
-
余弦相似度存在负值,必须 +1.0 归一化,否则 minScore 过滤失效
-
metadata全部转为String存储,避免不同类型字段导致查询匹配失败
-
批量插入建议外层批量封装,原生单条写入性能一般
-
ES8.x 默认开启SSL,本地测试可关闭SSL校验,线上务必开启安全配置
-
多实例部署时,依靠双重检查锁彻底杜绝索引重复创建报错问题
(注:文档部分内容可能由 AI 生成)