ES|QL 本地语义检索实战:用 Elasticsearch 稠密向量替代专用向量库
2026/9/15 2:42:31 网站建设 项目流程

上个月接了个内部需求,要在完全不依赖外网的本地环境里给一批中文文档做语义检索。数据量不算大,几万篇文档,但检索完还要按分类过滤、按时间聚合,甚至算一下平均分。我一开始下意识想上向量数据库,但为了这点数据再单独养一套服务,运维成本实在不划算;翻了一圈现有组件,发现 Elasticsearch 其实已经具备了所有条件——于是我把目光锁定在 ES|QL 上,用它的稠密向量检索能力,把原来要拆成好几次调用的活儿,压进了一条查询里。这篇文章就是这次本地部署实操的完整记录,适合那些不想再引入额外专用向量库、打算用现有 ES 集群硬啃向量检索的同学参考。

1. 为什么放着现成的 kNN API 不用,偏要选 ES|QL

1.1 传统 kNN API 检索的割裂体验

先说说我在这个项目里放弃传统搜索 API 的原因。Elasticsearch 从 8.0 开始就支持knn参数,写法其实很简洁:

resp = es.search( index="articles", knn={ "field": "title_vector", "query_vector": query_vec, "k": 5, "num_candidates": 100 }, source=["title", "category", "publish_date"] )

但问题出在"检索完还要继续处理"的时候。比如我在本地文档库里搜"本地部署大模型",要求 categoria 必须是 AI,并且按发布时间倒序排列,最后还要统计每个分类的平均相似度分数。这时候你会发现 kNN API 只能解决召回这一小段:过滤条件可以塞进filter,但聚合统计就得再写一个size: 0的聚合请求;想要在召回集上做二次排序,又得把候选结果取出来在应用层处理一遍。一套流程下来,代码少说七八十行,中间还能踩不少参数嵌套的坑。

如果你换成script_score查询,情况也好不到哪去。要对dense_vector字段做相似度计算,必须写 painless 脚本,把向量传进去再调用cosineSimilarity方法,查询 DSL 又长又不直观。我试过一次之后就明白,这个方向根本不是为"向量检索+业务加工"设计的,它是为底层搜索能力准备的。

1.2 ES|QL 的管道式思维解决了什么

ES|QL 出现后,整个处理链路被重新组织成了一条管道,每一步都按顺序执行,结果像流水线一样往后传。同样一个"先过滤分类、再算向量分、再取 top5"的需求,用 ES|QL 写就是这样的:

FROM articles | WHERE category == "AI" | EVAL score = cosine_similarity(title_vector, ?query_vec) | WHERE score > 0.3 | SORT score DESC | LIMIT 5 | KEEP title, category, publish_date, score

这里每一步的意图都非常直白:先从articles索引里取数据,过滤出 AI 分类,然后给每一行计算向量相似度得分,丢掉得分太低的,按得分倒序,取前 5 条,最后只保留想要的列。

这种管道式写法最舒服的地方在于,我可以自由地把检索、过滤、排序、列裁剪组合在一起,不需要来回切换 API,也不需要把中间结果导出到应用层再加工。对我来说,ES|QL 并不是要替代 kNN API,而是把"向量能力"真正变成了查询语言的一部分,让更多业务逻辑可以直接落到一条语句里。

1.3 适用边界:什么场景才适合用它

不过我得泼一盆冷水:ES|QL 不是万金油,它有自己的适用边界。从我这次本地部署的实测来看,ES|QL 对dense_vector字段做相似度计算的逻辑是逐行读取向量并精确计算,也就是说它做的是全量扫描加 brute-force 计算,而不是 HNSW 的近似最近邻搜索。

这意味着什么?数据量小的时候它比 kNN API 更准,因为结果就是全量比较后真正的 top-k;数据量一大,比如上百万条 1024 维向量,单次查询的耗时就会明显上升,这时候再坚持用 ES|QL 就不理智了。

所以我的建议是:本地环境、中小数据量(几十万条以内)、需要灵活过滤和聚合的场景,果断用 ES|QL;如果是高并发低延迟的线上搜索、数据量在百万级以上,核心召回还是交给 kNN API 或专用向量数据库,ES|QL 更适合做召回后的分析层。

2. 本地环境准备:ES 8.15 + Ollama bge-m3 组合

2.1 Docker Compose 拉起单节点 ES

既然标题叫"本地部署实操",环境搭建这部分必须跑得通。我建议直接用 Docker Compose 拉起一个单节点的 Elasticsearch 8.15 集群。版本选择 8.15 而不是更早版本,原因后面会详细解释,简单说就是 ES|QL 的向量函数从 8.14 才开始可用,8.15 整体更稳。

services: es: image: docker.elastic.co/elasticsearch/elasticsearch:8.15.3 container_name: es-local environment: - node.name=es-single - cluster.name=es-local-cluster - discovery.type=single-node - xpack.security.enabled=false - "ES_JAVA_OPTS=-Xms2g -Xmx2g" ports: - "9200:9200" volumes: - es_data:/usr/share/elasticsearch/data ulimits: memlock: soft: -1 hard: -1 mem_limit: 4g volumes: es_data:

有几个配置点值得多说一句。discovery.type=single-node是本地单节点调试的关键,不然 ES 会因为找不到其他节点而一直处于yellow状态;xpack.security.enabled=false直接关掉安全认证,省去用户名密码的繁琐,当然这只适合本地开发和内网隔离环境,生产环境必须开启安全配置。

ES_JAVA_OPTS里堆内存设了 2GB,这个值不是拍脑袋定的。ES 本身建议堆内存不超过物理内存的一半,剩下的空间要留给 Lucene 做文件系统缓存,向量检索时这部分缓存对性能影响很大。如果你机器只有 8GB 内存,可以把堆设到 2GB,再限制容器总内存 4GB,这样相对安全。

配置文件写好后,一句命令启动:

docker compose up -d

然后确认集群状态:

curl http://localhost:9200

如果返回了带cluster_nameversion的 JSON,说明 ES 已经就绪。如果你用的是 Linux,启动前可能还需要调整一个系统参数,ES 对内存映射有要求,默认的vm.max_map_count太低会导致启动失败,先执行:

sudo sysctl -w vm.max_map_count=262144

这个坑我第一回跑就踩了,容器日志里报max virtual memory areas vm.max_map_count [65530] is too low,折腾了一会儿才反应过来。

2.2 Ollama 嵌入模型选型与启动

向量从哪来?我选择的方案是本地再跑一个 Ollama 服务,用它加载开源的嵌入模型,应用通过 HTTP 接口生成向量,再灌入 Elasticsearch。这样整套链路完全是本地闭环,不依赖任何外部网络服务,也更贴近"本地部署"这个主题。

嵌入模型我选的是 bge-m3,维度 1024,对中文支持相当不错,是目前本地做中文语义检索综合性价比很高的选择。模型下载和启动都很简单:

ollama pull bge-m3

拉完启动服务后,直接调用接口验证是否可以正常出向量:

curl http://localhost:11434/api/embed -d '{ "model": "bge-m3", "input": "Elasticsearch 向量检索实践" }'

返回的 JSON 里会有一个embeddings数组,里面就是 1024 个浮点数。注意这里用的是新版/api/embed接口,老版本 Ollama 用的是/api/embeddings,返回结构也略有不同,如果你装的 Ollama 版本比较旧,以官方文档为准。

如果你主要处理的是英文内容,也可以换成nomic-embed-text,维度 768,体积更小速度更快。但中文场景我强烈建议直接用 bge-m3,它在中英混合、中文长文本上的效果明显好一截。

2.3 两条向量生成路线,我为什么选外部模型

在本地做向量生成,其实有两条路线。一条是直接把模型导入 Elasticsearch 的 inference API,通过 Eland 把 Hugging Face 上的模型转成 ES 内部模型格式,之后在 ES|QL 里可以直接用ML函数实时生成查询向量。另一条就是我这次采用的外部模型服务方案,用 Ollama 这类工具把模型跑在 ES 之外,应用层先调 Ollama 拿向量,再写入或传给 ES。

两条路线各有利弊。ES 内置模型的优点是查询时能在一条 ES|QL 语句里完成"生成向量+算相似度"两件事,链路更短;但缺点是模型管理太重,一个几个 GB 的模型文件要导入、注册、分配内存,对于本地探索阶段来说负担不小。Ollama 方案则非常轻量,模型独立部署、独立升级,生成查询向量的逻辑和应用代码放在一起,调试起来很直观。

我最后选了 Ollama 外部模型路线,后面 4.4 节再演示 ES 内置模型的 ES|QL 写法,方便两种路线都了解。

3. 稠密向量索引 mapping 设计:维度、度量和 HNSW 参数

3.1 可直接复用的 mapping 模板

建索引是整个流程里最值得花时间的一环,因为 mapping 一旦写错,后面灌数据、跑查询都会跟着出问题。这里给出一个我测试后可以直接复用的模板:

PUT /articles { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "title": { "type": "text" }, "category": { "type": "keyword" }, "publish_date": { "type": "date" }, "title_vector": { "type": "dense_vector", "dims": 1024, "index": true, "similarity": "cosine", "index_options": { "type": "hnsw", "m": 16, "ef_construction": 100 } } } } }

单节点本地部署,副本数直接设为 0,省一半空间;分片数 1 足够,分片多了反而增加查询开销。title_vector是稠密向量字段,dims必须和嵌入模型的输出维度一致,这里对应 bge-m3 的 1024 维。

index: true表示需要为向量字段构建索引,这样 kNN API 才能用 HNSW 做近似检索;同时 ES|QL 读取向量做精确计算也不受影响。如果你是低版本 ES 或不需要近似检索,可以把这个开关设为 false,能省不少内存,但代价是无法用 kNN API,只能靠脚本或 ES|QL 全量扫描。

3.2 similarity 度量选错会怎样

similarity参数指定用什么度量计算向量距离。常用值有三个:l2_norm对应欧氏距离,dot_product对应点积,cosine对应余弦相似度。

语义检索场景里,我优先推荐cosine,因为它在向量模长不同的情况下依然能反映方向上的相似度,对文本向量尤其友好。如果用了dot_product,ES 内部默认假设传入的向量已经归一化,如果你传入的向量模长没有归一,最终分数会和预期差很多。l2_norm则更适用于图像特征、物品向量这类对绝对距离更敏感的领域。

还有一点要注意,similarity不仅仅是"给分方式",它会影响索引构建时的向量预处理逻辑。比如cosine类型的字段,ES 会在索引时对向量做归一化,这跟你在 ES|QL 里用cosine_similarity函数算出来的分数之间,可能存在细微的数值差异。后面第 5 节我会展开讲这个坑。

3.3 HNSW 参数取舍与内存守恒

index_options里有两个建索引阶段的参数:mef_constructionm是图中每个节点的最大连接数,可以粗暴理解成"每个人最多认识几个朋友",值越大图越稠密,召回越准,但内存和建索引时间也会涨。ef_construction是构建图时的候选队列大小,决定建索引时搜索的广度,越大质量越高,但越慢。

本地部署场景建议按这个基准来:m: 16ef_construction: 100,这是内存和召回之间的均衡点。如果你的机器内存很紧张,可以降到m: 8ef_construction: 50,召回效果会有一定损失但不会崩。

版本在 8.15 以上的话,还可以把index_options.type改成int8_hnsw,用 int8 量化把向量内存压到原来的四分之一。代价是精度损失,但本地验证、原型阶段完全够用。我实测过同样的数据,int8_hnsw 的召回率和 float 版本相比差别很小,内存占用却低了一大截。

另外要记住,HNSW 还有一个查询阶段的参数叫ef_search,它不写在 mapping 里,而是在 kNN API 查询时通过num_candidates传入。ES|QL 这边没有暴露这个参数,内部策略也不透明,所以用 ES|QL 做精确计算时,某种程度上你反而绕开了 HNSW 参数调优的麻烦。

3.4 用 Python 批量灌入向量数据

索引建好后,用 Python 脚本批量生成向量并写入。这里我用elasticsearch官方客户端加helpers.bulk

import requests from elasticsearch import Elasticsearch, helpers OLLAMA_URL = "http://localhost:11434/api/embed" es = Elasticsearch("http://localhost:9200") def embed(text): resp = requests.post(OLLAMA_URL, json={"model": "bge-m3", "input": text}) return resp.json()["embeddings"][0] docs = [ {"title": "Dify 本地部署教程", "category": "AI", "publish_date": "2025-01-10"}, {"title": "Ollama 嵌入模型实践", "category": "AI", "publish_date": "2025-01-15"}, {"title": "DeepSeek 大模型量化与部署", "category": "AI", "publish_date": "2025-01-20"}, {"title": "Elasticsearch 查询优化", "category": "SEARCH", "publish_date": "2025-01-12"}, {"title": "ComfyUI 短视频生成流程", "category": "CREATIVE", "publish_date": "2025-01-18"}, {"title": "使用 Docker 部署 AI 应用", "category": "OPS", "publish_date": "2025-01-22"}, ] actions = [] for doc in docs: actions.append({ "_index": "articles", "_source": { "title": doc["title"], "category": doc["category"], "publish_date": doc["publish_date"], "title_vector": embed(doc["title"]) } }) helpers.bulk(es, actions) es.indices.refresh(index="articles")

这段脚本会把六篇测试文档的标题向量化后写入articles索引,最后显式 refresh 一下确保数据可查。注意embed函数每次调用都会走一次 HTTP 请求,如果文档量大,建议批量调用或缓存向量,否则生成向量这一步会成为瓶颈。

4. ES|QL 跑通稠密向量检索:从全量算分到业务过滤

4.1 第一条可执行的 ES|QL 查询

数据灌进去之后,开始验证 ES|QL 的向量检索能力。第一个场景:给定一个查询文本"本地部署大模型",找出语义上最接近的标题。

先在 Python 里调用 Ollama 生成查询向量,再把向量作为参数传给 ES|QL 查询:

query_vec = embed("本地部署大模型") resp = es.esql.query( query=""" FROM articles | EVAL score = cosine_similarity(title_vector, ?query_vec) | SORT score DESC | LIMIT 5 | KEEP title, category, score """, params={"query_vec": query_vec} ) for row in resp["values"]: print(row)

结果是我造的小数据集,看起来大概是这个样子:

titlecategoryscore
Dify 本地部署教程AI0.86
使用 Docker 部署 AI 应用OPS0.79
DeepSeek 大模型量化与部署AI0.74
Ollama 嵌入模型实践AI0.68
Elasticsearch 查询优化SEARCH0.41

这条查询的核心逻辑在EVALSORT两步:EVAL给每一行新增一个score列,值来自cosine_similarity(title_vector, ?query_vec)的计算结果;然后SORT score DESC按分数从高到低排列;LIMIT 5取前五;KEEP控制最终返回的列。

如果你用的是 Kibana Dev Tools,可以直接在编辑器里跑 ES|QL,不需要写 Python 壳子。硬编码向量时,为了排版方便我会用一个短向量示意,实际查询时必须使用完整的 1024 维向量:

FROM articles | EVAL score = cosine_similarity(title_vector, [0.12, -0.23, 0.34, 0.45]) | SORT score DESC | LIMIT 3 | KEEP title, score

注意这只是示意,如果title_vector是 1024 维而传入的是 4 维数组,ES|QL 会直接报维度不匹配的错误。

4.2 向量相似度 + 业务条件混合过滤

实际业务里很少只做纯向量检索,更常见的需求是"在某个分类下的语义检索"。这时候 ES|QL 的管道顺序就成了性能优化的关键:

FROM articles | WHERE category == "AI" | EVAL score = cosine_similarity(title_vector, ?query_vec) | WHERE score > 0.5 | SORT score DESC | LIMIT 10 | KEEP title, publish_date, score

注意我把WHERE category == "AI"放在了EVAL前面。这样引擎会先把不满足分类条件的行丢掉,再对剩下的行计算向量相似度,能省掉不少无用计算。

反过来,如果先把所有行都算一遍 score,再在WHERE里过滤分类,计算量会大幅增加。尤其是几万条数据时,这个顺序差异直接决定了查询是毫秒级还是秒级。这也是 ES|QL 管道式设计带来的一个天然优化思路——每一步都缩小数据集,后面的计算就越轻。

第二个WHERE score > 0.5是在向量相似度结果上做阈值过滤,可以过滤掉那些语义关联不强的噪声结果。这个阈值需要根据你的数据分布调,我本地测试时发现 0.3 到 0.6 之间通常比较合理,低于 0.3 会混入大量无关结果,高于 0.6 则可能漏掉一些相关文档。

4.3 在召回集上直接做统计分析

ES|QL 比传统搜索 API 更吸引我的一点,是它可以在召回结果上直接做聚合统计,不用再发第二次查询。比如我想看看不同分类下向量相似度分数的平均值和最大值,可以这样写:

FROM articles | EVAL score = cosine_similarity(title_vector, ?query_vec) | WHERE score > 0.3 | STATS avg_score = AVG(score), max_score = MAX(score) BY category | SORT avg_score DESC

这条语句会在召回的文档里按category分组,计算出每个分类的平均相似度和最高相似度,然后再按平均分排序。放在以前,想拿到这个统计结果,你得先把候选文档取出来,在 Python 里用 pandas 或者手写循环去算,代码又多又容易出错。

当然,聚合本身也是一个比较重的操作,如果你的场景对延迟很敏感,建议只在离线分析或者管理后台使用这类带STATS的 ES|QL,线上实时查询还是保持轻量。

4.4 进阶:ML 函数直接生成查询向量

前面提过另一条路线是 ES 内置模型,如果你已经通过 inference API 部署好了嵌入模型,ES|QL 里可以直接用ML函数实时把查询文本变成向量:

FROM articles | EVAL query_emb = ML(text_embedding, 'my-bge-m3-model', '本地部署大模型') | EVAL score = cosine_similarity(query_emb, title_vector) | SORT score DESC | LIMIT 5 | KEEP title, score

这里'my-bge-m3-model'是你注册在 ES 里的模型 ID,text_embedding表示这个模型的任务类型是文本嵌入。这个写法的好处是查询语义非常完整,不需要应用层先单独生成向量,一次查询就能完成"文本→向量→相似度→排序"的全过程。

但代价也很明显:ES 内置模型的部署比较重,尤其 bge-m3 这种体量的模型在本地导入、注册、加载需要不少内存和操作步骤。我在本次实操中没有走这条路,只把它作为方案备选。如果你只是验证 ES|QL 能力,Ollama 外部生成向量是成本最低的路径。

4.5 与 kNN API 的精确度和性能对比

我把同样的查询用 kNN API 和 ES|QL 各跑了一遍,结果很有意思。小数据集上两者返回的 top-k 大体接近,但 ES|QL 的结果更稳定,因为它是全量精确计算,而 kNN API 是 HNSW 的近似搜索,理论上存在漏召回的可能。

| 对比项 | kNN API | ES|QL 向量函数 | | --- | --- | --- | | 召回方式 | HNSW 近似搜索 | 全量精确计算 | | 过滤能力 | 支持 filter 参数 | 管道内任意位置 WHERE | | 聚合统计 | 需二次查询 | STATS 一条语句 | | 适用数据量 | 百万级以上 | 几十万以内更合适 | | 查询参数 | 可调 num_candidates | 不直接暴露 ef_search |

这个对比不是说谁取代谁,而是要看场景。本地几万条文档,ES|QL 的精确计算完全能扛,而且少了一层 ANN 近似误差;线上百万级向量,ANN 是刚需,ES|QL 的扫描式计算会使不上劲。理解这层差异,你就知道什么时候该用哪个了。

5. 本地部署踩坑记录:版本、内存与一致性

5.1 版本太老,函数直接不可用

最容易踩的坑其实是版本。ES|QL 是 8.11 才正式引入的,但初期的 ES|QL 根本不支持向量函数。cosine_similaritydot_product这些向量计算函数,以及?name命名参数化查询,都是 8.14 之后才出现的功能。

所以如果你还在用 8.11 到 8.13 的版本,把上面的查询粘进去,大概率会报Unknown function cosine_similarity之类的错误。这不是 ES|QL 写错了,而是版本太老。

我的建议是:本地新部署直接用 8.15.x 或更高版本,没必要跟旧版本较劲。如果你公司已经有老集群,需要先确认版本再决定是否在它上面跑 ES|QL 向量检索。

版本行为我用一张表总结一下:

| ES 版本 | ES|QL 向量函数 | 命名参数 | 建议 | | --- | --- | --- | --- | | 8.11 - 8.13 | 不支持 | 不支持 | 避开 | | 8.14 | 基础函数已可用 | 支持 | 可用 | | 8.15+ | 稳定 | 支持 | 推荐 |

5.2 向量维度和内存占用失控

向量检索是个吃内存的活,这个坑往往在数据量涨上来之后才暴露。1024 维的 float 向量,每个维度占 4 字节,单条向量就是 4KB;1 万条数据约 40MB,10 万条就是 400MB。这还没算 HNSW 图结构本身的额外开销,而图结构消耗的内存经常比向量本身还要高不少。

本地机器如果只有 16GB 内存,建议先把数据量控制在二三十万条以内,否则查询延迟会明显上升。如果确实要在更大量级上做实验,可以考虑几个手段:一是把index_options.type换成int8_hnsw,向量内存直接降到四分之一;二是把number_of_replicas保持为 0,本地不搞副本;三是调低mef_construction,用少量召回损失换取内存空间。

还有一点容易被忽略:ES_JAVA_OPTS不是越大越好。堆内存设太大反而挤压了文件系统缓存,而 Lucene 在读取向量索引时非常依赖 OS 缓存,堆和缓存的比例失衡会让性能掉得很快。

5.3 相似度度量不一致导致结果偏

这个坑很隐蔽,我也是对比结果时发现的。映射里similarity设成cosine后,底层索引做归一化处理;而 ES|QL 的cosine_similarity函数是独立的向量函数,两者在具体实现上并不完全等价,算出来的分数会有细微差别。

如果你的字段声明的是dot_product,就要格外注意:ES|QL 里用dot_product函数时,引擎不会自动帮你判断输入向量是否归一化。如果查询向量没归一化,分数就没有余弦相似度的语义,排序结果可能和预期不符。

所以我在本地实操时遵循一个简单原则:mapping 里的similarity和 ES|QL 里用的函数保持一致,同时保证查询向量和文档向量来自同一个模型、同一套处理流程,不要混用不同模型的输出。

5.4 中文检索效果不理想的调优思路

最后聊一个"效果层面"的问题。如果你完整跑通之后发现检索结果不那么智能,先别急着怀疑向量检索这条路,很多时候是细节没做到位。

一是查询文本的指令前缀。bge 系列模型对"要给全文生成向量"和"给短查询生成向量"这两类场景比较敏感,ollama pull下来的 bge-m3 如果没做特别优化,可能在文档端和查询端的效果会有偏差。遇到效果不佳时,可以给查询文本加一个前缀,比如"为这个句子生成表示以用于检索相关文章:",再生成向量。

二是混合检索。纯向量检索对专有名词、精确 ID、代码片段这类内容往往表现不稳定。如果你处理的是技术文档,建议把 BM25 文本检索和向量检索的结果做融合(RRF),通常能得到更好的整体效果。ES|QL 目前没有内置 RRF,但你可以分别用传统_search和 ES|QL 跑一遍,再在应用层把两个结果合并。

三是检查向量质量。用 bge-m3 对中文长段落生成向量时,如果段落过长,嵌入效果会打折扣。建议把文档切分成合适的 chunk 再向量化,而不是整篇塞进模型。这个细节对检索效果的影响比调 HNSW 参数大得多。


最后说点我自己的体感。这套方案真正打动我的地方,不是它比专用向量库快,而是它让"向量检索"这件事变得很轻——不需要装新组件,不需要记一堆新 API,顺着 ES|QL 的管道一步步写下去,检索、过滤、统计全在一句话里完成。当然它也有明显的天花板:数据涨到百万级以后,全量算相似度那一下会越来越吃力,届时就该把核心召回切回 HNSW 的 kNN API,再用 ES|QL 做分析层。先跑通,再优化,这是我折腾本地语义检索最想分享的一句话。如果你也正在本地环境里纠结怎么给文档做语义搜索,不妨就从这套组合开始:ES 8.15 + Ollama bge-m3 + ES|QL,跑通一条完整的向量检索链路,再根据实际数据量决定要不要换更重的武器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询