
上个月接了个内部需求要在完全不依赖外网的本地环境里给一批中文文档做语义检索。数据量不算大几万篇文档但检索完还要按分类过滤、按时间聚合甚至算一下平均分。我一开始下意识想上向量数据库但为了这点数据再单独养一套服务运维成本实在不划算翻了一圈现有组件发现 Elasticsearch 其实已经具备了所有条件——于是我把目光锁定在 ES|QL 上用它的稠密向量检索能力把原来要拆成好几次调用的活儿压进了一条查询里。这篇文章就是这次本地部署实操的完整记录适合那些不想再引入额外专用向量库、打算用现有 ES 集群硬啃向量检索的同学参考。1. 为什么放着现成的 kNN API 不用偏要选 ES|QL1.1 传统 kNN API 检索的割裂体验先说说我在这个项目里放弃传统搜索 API 的原因。Elasticsearch 从 8.0 开始就支持knn参数写法其实很简洁resp es.search( indexarticles, 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.namees-single - cluster.namees-local-cluster - discovery.typesingle-node - xpack.security.enabledfalse - 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.typesingle-node是本地单节点调试的关键不然 ES 会因为找不到其他节点而一直处于yellow状态xpack.security.enabledfalse直接关掉安全认证省去用户名密码的繁琐当然这只适合本地开发和内网隔离环境生产环境必须开启安全配置。ES_JAVA_OPTS里堆内存设了 2GB这个值不是拍脑袋定的。ES 本身建议堆内存不超过物理内存的一半剩下的空间要留给 Lucene 做文件系统缓存向量检索时这部分缓存对性能影响很大。如果你机器只有 8GB 内存可以把堆设到 2GB再限制容器总内存 4GB这样相对安全。配置文件写好后一句命令启动docker compose up -d然后确认集群状态curl http://localhost:9200如果返回了带cluster_name和version的 JSON说明 ES 已经就绪。如果你用的是 Linux启动前可能还需要调整一个系统参数ES 对内存映射有要求默认的vm.max_map_count太低会导致启动失败先执行sudo sysctl -w vm.max_map_count262144这个坑我第一回跑就踩了容器日志里报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_productES 内部默认假设传入的向量已经归一化如果你传入的向量模长没有归一最终分数会和预期差很多。l2_norm则更适用于图像特征、物品向量这类对绝对距离更敏感的领域。还有一点要注意similarity不仅仅是给分方式它会影响索引构建时的向量预处理逻辑。比如cosine类型的字段ES 会在索引时对向量做归一化这跟你在 ES|QL 里用cosine_similarity函数算出来的分数之间可能存在细微的数值差异。后面第 5 节我会展开讲这个坑。3.3 HNSW 参数取舍与内存守恒index_options里有两个建索引阶段的参数m和ef_construction。m是图中每个节点的最大连接数可以粗暴理解成每个人最多认识几个朋友值越大图越稠密召回越准但内存和建索引时间也会涨。ef_construction是构建图时的候选队列大小决定建索引时搜索的广度越大质量越高但越慢。本地部署场景建议按这个基准来m: 16、ef_construction: 100这是内存和召回之间的均衡点。如果你的机器内存很紧张可以降到m: 8、ef_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.bulkimport 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(indexarticles)这段脚本会把六篇测试文档的标题向量化后写入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)结果是我造的小数据集看起来大概是这个样子titlecategoryscoreDify 本地部署教程AI0.86使用 Docker 部署 AI 应用OPS0.79DeepSeek 大模型量化与部署AI0.74Ollama 嵌入模型实践AI0.68Elasticsearch 查询优化SEARCH0.41这条查询的核心逻辑在EVAL和SORT两步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 里的模型 IDtext_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_similarity、dot_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 字节单条向量就是 4KB1 万条数据约 40MB10 万条就是 400MB。这还没算 HNSW 图结构本身的额外开销而图结构消耗的内存经常比向量本身还要高不少。本地机器如果只有 16GB 内存建议先把数据量控制在二三十万条以内否则查询延迟会明显上升。如果确实要在更大量级上做实验可以考虑几个手段一是把index_options.type换成int8_hnsw向量内存直接降到四分之一二是把number_of_replicas保持为 0本地不搞副本三是调低m和ef_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跑通一条完整的向量检索链路再根据实际数据量决定要不要换更重的武器。