Spring AI集成Elasticsearch向量检索时filter过滤失效问题排查与解决方案
使用vectorStore.similaritySearch遇到问题
最近需要做一个功能,用到了es做向量数据库。在使用vectorStore.similaritySearch
查询的时候,发现filterExpression
中加的条件并没有完全生效,导致查询出来的数据不准确,出现了不符合metadata
筛选条件的数据。然后研究了一下,发现了问题所在。
先说结论,Spring AI调用es
elasticsearchClient.search
方法查询的时候,使用的是filter
过滤,用的是queryString
。导致出现特殊字符的时候,没有转义的话,会出现歧义调用或者报错。
org.springframework.ai.vectorstore.elasticsearch.ElasticsearchVectorStore#doSimilaritySearch
插入数据
下面是添加数据到es的部分代码,实际代码是批量处理,这里改了一些。text
做完向量化之后,会存到embedding
字段。而metadata
部分会存到metadata
字段,是一个对象类型。这一部分没有遇到问题,数据都正常插入了。
Document document = Document.builder().id(entity.bizid()).text(entity.description());.metadata("a1", entity.a1()).metadata("a2", entity.a2()).build();
// 使用vectorStore.add的时候,会自动调用embedding模型
vectorStore.add(documentList);
查询数据
我现在的需求是metadata
里面的数据,都需要精确查询(完全匹配),就好比数据库中的where a1 = 'xxx'
。当我a1
加上了某08_1表啥≠“2”(调)或 “7”(叠加)时
条件时,发现查询出来的数据,出现了a1
为其他值的情况,这明显不符合项目要求。
查询数据的代码,做了部分修改:
public List<Document> query(@RequestBody QueryDTO query) {SearchRequest.Builder searchBuilder = SearchRequest.builder().query(query.description()).similarityThreshold(0.7);FilterExpressionBuilder b = new FilterExpressionBuilder();FilterExpressionBuilder.Op finalOp = null;// 构建过滤表达式// 如果a1有值,就加上a1条件,key实际上会被处理成metadata.a1.keywordif (query.a1() != null) {finalOp = b.eq("a1.keyword", query.a1());}// 同上,但是可能会存在a1也有值的情况,所以下面要做个判断if (query.a2() != null && !query.a2().isEmpty()) {finalOp = (finalOp != null) ? b.and(finalOp, b.eq("a2.keyword", query.a2())) : b.eq("a2.keyword", query.a2());}// 最后传入过滤表达式if (finalOp != null) {searchBuilder.filterExpression(finalOp.build());}return vectorStore.similaritySearch(searchBuilder.build());}
定位问题
最后源码定位到org.springframework.ai.vectorstore.elasticsearch.ElasticsearchVectorStore#doSimilaritySearch
方法,里面使用了filter
过滤,用的是queryString
最后请求的body
体,query_vector
太长做了删减,原本是1024维
{"knn":[ {"field":"embedding","query_vector":[-0.043929047882556915, 0.015229480341076851],"k":4,"num_candidates":6,"filter":[ {"query_string": {"query": "metadata.errorMessage.keyword:某08_1表啥≠“2”(调)或 “7”(叠加)时"}}],"similarity":0.699999988079071}],"size":4
}
co.elastic.clients.transport.rest_client.RestClientHttpClient#performRequest
处打个断点,执行new String(restRequest.getEntity().getContent().readAllBytes())
就可以拿到请求体内容
不管是代码还是最后发送的请求体来看,都确定了使用的是query_string
,而query_string
对特殊字符是有要求的,这就是前面查询出其他数据的原因。
query_string和term区别
问了AI,AI的答复:
特点 | query_string | term |
---|---|---|
用途 | 搜一句话、一段话,支持复杂搜索(像百度搜索) | 精确查找一个完全一样的词、数字或状态 |
怎么用 | 写一个“搜索命令”:字段:要搜的内容 | 直接告诉它值:字段: 完全一样的值 |
搜什么 | text 类型的长文本(如文章内容、错误信息) | keyword 类型的短词、数字、状态(如状态码、ID) |
是否分词 | 会把“要搜的内容”拆开(分词)再找 | 不分词,必须完全一样才能找到 |
性能 | 较慢(要分析、计算相关度) | 很快(直接匹配,结果可缓存) |
对特殊字符 @ , # , ! , * , ( , ) 等的处理 | 非常麻烦! 这些符号有特殊含义(如 AND , OR )。如果当普通字用,必须: 1. 用 双引号 " " 把整个词或句子括起来,或者2. 用 反斜杠 \ 一个个转义(在JSON里要写 \\ )。否则会报错! | 完全不用管! 直接把包含特殊字符的完整字符串写进去就行。 因为它不分词,也不解析语法,就把整个值当普通文本比对。 |
例子 | 找包含 user@abc 的文档:"query_string": { "query": "email:\"user@abc.com\"" } (必须加引号) | 找邮箱是 user@abc.com 的文档:"term": { "email.keyword": "user@abc.com" } (直接写,无需处理) |
一句话总结:
query_string
:用来全文搜索,功能强但复杂,遇到特殊字符容易出错,必须小心处理。term
:用来精确匹配,简单、快速、可靠,特殊字符不是问题,直接用就行。
es官网query-string-syntax中也有相关介绍,遇到这些特殊字符,都要进行处理。注意官网的NOTE,我这边还没有试这种情况。
也就是说,符合我要求的,实际上是
term
,使用query_string
的话,还要转义,就算不用转义,速度也更慢。
解决办法
- 转义
所有可能出现的特殊字符,就是官网提到的那些,都加反斜杠转义 - 双引号包裹
某08_1表啥≠“2”(调)或 “7”(叠加)时
改成"某08_1表啥≠“2”(调)或 “7”(叠加)时"
- 改源码
复制ElasticsearchVectorStore
代码,建一个全类名一样的类,拷贝过去。query_string改成term
。这种有个缺点,就是限制死了term
查询,不友好。更倾向于其他的方式。
改源码的话,需要从getFilterExpression
里面拿到过滤表达式,自行用term
重新拼装,处理起来比较复杂,这种不推荐 - 不使用
vectorStore.similaritySearch
,自行调用es代码查询
注入EmbeddingModel
、ElasticsearchClient
,然后自己实现这个调用过程,这种是最灵活的,推荐使用,因为有些场景就是需要使用term
。metadata.别忘了加
需要注意的一点是,// 先做向量搜索float[] vectors = embeddingModel.embed(query.description());// 下面三个参数是配置的,ElasticsearchVectorStore的options属性对象里面可以拿到,但是是private的String index = "jap-index";Integer topK = 4;String embeddingFieldName = "embedding";// 查询esSearchResponse<Document> res = this.elasticsearchClient.search(sr -> sr.index(index).knn(knn -> knn.queryVector(EmbeddingUtils.toList(vectors)).similarity(query.similarityThreshold()).k(topK).field(embeddingFieldName).numCandidates((int) (1.5 * topK)).filter(fl -> fl.term(t ->// metadata.别忘了加t.field("metadata.a1.keyword").value(query.errorMessage())))).size(topK), Document.class);// 拿结果List<Hit<Document>> hits = res.hits().hits();
index
等参数因为options
是private
的,所以需要通过其他方式拿到。- 配置文件拿,这种前提是通过application配置文件方式配置的向量数据库(我不是这种)
- 自行创建bean方式,可以把这个配置类存放到某个地方或者注入到容器(我是这种)
@Beanpublic VectorStore vectorStore(RestClient restClient, EmbeddingModel embeddingModel) {// 可以把这个类也存起来,或者注册成beanElasticsearchVectorStoreOptions options = new ElasticsearchVectorStoreOptions();options.setIndexName("jap-index"); // Optional: defaults to "spring-ai-document-index"options.setSimilarity(cosine); // Optional: defaults to COSINEoptions.setDimensions(1024); // Optional: defaults to model dimensions or 1536return ElasticsearchVectorStore.builder(restClient, embeddingModel).options(options) // Optional: use custom options.initializeSchema(true) // Optional: defaults to false.batchingStrategy(new TokenCountBatchingStrategy()) // Optional: defaults to TokenCountBatchingStrategy.build();}
- 反射方式拿
ElasticsearchVectorStore
(也就是注入的VectorStore
)的options
属性,不推荐 - 复制类,全类名一样的,拷贝代码,改成
options
改成public
,不推荐