JAVA程序员自救之路——Elasticsearch向量搜索
什么是向量搜索
向量搜索是一种基于向量相似度的信息检索技术,它通过将数据表示为高维向量,然后计算这些向量之间的距离或相似度来找到最相关的结果。适用于相似商品推荐,语义搜索,个性化匹配等场景。常用的数据工具有Milvus,Pinecone,ES,Redis Vector等。此篇文章重点介绍ES的向量搜索功能。
ES向量搜索
Elasticsearch 从 7.x 版本开始逐步引入了向量搜索功能,并在后续版本中不断增强。Elasticsearch 的向量搜索功能使其成为 传统搜索 + AI 搜索 的混合解决方案,适用于:
- 已经使用 ES 的用户,希望平滑过渡到向量搜索。
- 需要结合关键词 + 语义搜索的场景。
- 推荐系统、多模态搜索等 AI 驱动应用。
场景 | 示例 |
语义搜索 | 搜索 "苹果" 同时返回 "iPhone" 和 "水果" |
推荐系统 | "看了 A 电影的用户也喜欢 B" |
图像/视频搜索 | 上传图片搜索相似图片 |
异常检测 | 查找与正常模式差异较大的数据 |
向量类型
ES支持两种向量类型,一种是稠密向量(dense_vector),用于存储浮点数数组(如神经网络生成的嵌入向量)。BERT/Word2Vec 生成的文本嵌入、CLIP 生成的图像嵌入、openai的embending嵌入等。另一种是稀疏向量(sparse_vector),这个是8.0+版本后新加入的类型,适用于高维但大部分值为 0 的向量(如 TF-IDF、BM25 生成的向量),传统信息检索模型生成的向量。
"embedding": {
"type": "dense_vector",
"dims": 384, // 向量维度(如 OpenAI text-embedding-ada-002 是 1536 维)
"index": true, // 是否建立索引(用于近似搜索)
"similarity": "cosine" // 相似度计算方式
}
"sparse_embedding": {
"type": "sparse_vector"
}
向量索引方式
(1) 精确搜索(Exact k-NN)
- 计算查询向量与索引中所有向量的距离,返回最相似的 k 个结果。
- 优点:结果精确。
- 缺点:计算量大,不适合大规模数据。
(2) 近似最近邻搜索(Approximate k-NN,8.0+)
- 使用 HNSW(Hierarchical Navigable Small World) 算法加速搜索。
- 核心参数:
- m("index_options": { "type": "hnsw", "m": 16 }):影响索引构建速度和搜索精度。
- ef_construction:控制索引构建时的候选数量,影响召回率。
- 优点:搜索速度快,适用于大规模数据。
- 缺点:牺牲少量精度换取性能。
相似度计算方式
ES 支持多种向量相似度计算方式:
相似度类型 | 计算方式 | 适用场景 |
cosine | 余弦相似度 | 文本相似度(默认推荐) |
dot_product | 点积(需归一化) | 推荐系统 |
l2_norm | 欧几里得距离 | 图像检索 |
l1_norm | 曼哈顿距离 | 较少使用 |
向量搜索查询方式
(1) 纯向量搜索(knn查询)
GET /my_vector_index/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ..., 0.9], // 查询向量
"k": 5, // 返回 Top 5 结果
"num_candidates": 100 // 近似搜索的候选数量
}
}
(2) 混合搜索(结合关键词 + 向量)
GET /my_vector_index/_search
{
"query": {
"match": { "title": "手机" } // 传统关键词搜索
},
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ..., 0.9],
"k": 5,
"boost": 0.5 // 向量搜索的权重
}
}
(3) 多向量搜索(8.9+)
GET /my_vector_index/_search
{
"knn": [
{
"field": "text_embedding",
"query_vector": [0.1, 0.2, ...],
"k": 3
},
{
"field": "image_embedding",
"query_vector": [0.5, 0.6, ...],
"k": 2
}
]
}
性能优化建议
1.调整 HNSW 参数:
- m(默认 16):增大可提升召回率,但会降低索引速度。
- ef_construction(默认 100):增大可提升索引质量,但会占用更多内存。
2.使用量化(PQ):减少内存占用,适用于超大规模数据。(8.4+)
3.结合过滤:先使用 filter 减少搜索范围,再执行向量搜索。
ES在Spring中如何使用向量
- 首先引入spring-boot-starter-data-elasticsearch,本文使用的jdk版本是17,springboot版本是3.4.4,ES版本的8.15.5。如果是其他版本,需要对应其他版本的实现方式。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
- 然后在已有的ES实体中加入向量字段List<Float> vector。
注意:有的SDK版本是Float[],新版本是List<Float>。
package cn.pt.search.star.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Transient;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import java.util.Date;
import java.util.List;
@Data
@Document(indexName = "custom_index_search")
public class CustomerDTO {
@Id
@Field(type = FieldType.Long)
private Long id;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String name;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String description;
@Field(type = FieldType.Text, analyzer = "whitespace", searchAnalyzer = "whitespace")
private String label;
@Field(name = "detail_address", type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String detailAddress;
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String remark;
@Field(type = FieldType.Dense_Vector, dims = 1024, index = true, similarity = "cosine")
private List<Float> vector;
}
- 重建索引,我们会看到ES的索引映射会多一个vector的字段。
"vector": {
"type": "dense_vector",
"dims": 1024,
"index": true,
"similarity": "cosine",
"index_options": {
"type": "int8_hnsw",
"m": 16,
"ef_construction": 100
}
}
- 使用自建BERT计算向量,或者使用大模型embending,将数据向量化。这里使用了硅基流动的api。
public static List<Float> callSiJiAPI(String text) {
String result = HttpRequest.post("https://api.siliconflow.cn/v1/embeddings")
.header("Authorization", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
.header("Content-Type", "application/json")
.body("{\n \"model\": \"xxxxxxxxxxxxxxxx\",\n \"input\": \""+text+"\",\n \"encoding_format\": \"float\"\n}")
.execute().body();
JSONObject jsonObject = JSONUtil.parseObj(result);
JSONArray embedding = jsonObject.getJSONArray("data").getJSONObject(0).getJSONArray("embedding");
List<Float> vector = new ArrayList<>();
for(int i=0;i<embedding.size();i++){
vector.add(embedding.getFloat(i));
}
return vector;
}
- 将生成的vector,赋值到CustomerDTO中,直接保存。这样具有向量字段的索引就建好并且有数据了。接下来,我们就可以搜索了。
- 搜索的api有点恶心,吐槽一下SpringDataElasticsearch的sdk,每个版本的实现方式都不太一样,从网上找到的代码COPY到本地就行不通。问deepseek就是版本不一致,或者直接就瞎说。走了很多弯路。其实还是看官方文档比较靠谱。下面是代码,还是比较简单的。
首先,需要把搜索条件向量化,然后构造搜索条件。
List<Float> queryVector = VectorUtil.callSiJiAPI(keyword);
BoolQuery boolQuery = BoolQuery.of(bq -> bq
.should(
Query.of(q -> q.match(MatchQuery.of(mq-> mq
.field("name")
.query(keyword).analyzer("ik_smart")
.minimumShouldMatch("1")
.boost(1.0f)
))),
Query.of(q -> q.match(MatchQuery.of(mq-> mq
.field("description")
.query(keyword).analyzer("ik_smart")
.minimumShouldMatch("1")
.boost(1.0f)
))),
Query.of(q -> q.match(MatchQuery.of(mq-> mq
.field("label")
.query(keyword).analyzer("whitespace")
.minimumShouldMatch("1")
.boost(1.0f)
))),
Query.of(q -> q.match(MatchQuery.of(mq-> mq
.field("detailAddress")
.query(keyword).analyzer("ik_smart")
.minimumShouldMatch("1")
.boost(1.0f)
)))
)
);
// 构建 NativeQuery
NativeQuery query = NativeQuery.builder()
.withQuery(Query.of(q -> q.bool(boolQuery)))
.withKnnSearches(Lists.newArrayList(
KnnSearch.of(kq -> kq
.field("vector").queryVector(queryVector)
.k(10)
.numCandidates(100).boost(0.5f).similarity(0.8f)
)))
.build();
log.info("查询条件: {},{}", query.getQuery(), query.getKnnSearches());
// 执行搜索
SearchHits<CustomerDTO> searchHits = elasticsearchTemplate.search(query, CustomerDTO.class);
这样,带有分词+向量的混合检索就实现好了。
效果如下:返回的内容并没有传媒两字,肯定不是传统分词查出来的,所以应该是向量搜索的效果。