我把真实的 ES 查询怼到 7059 星的 Rust 搜索引擎上:1366/1369 的兼容承诺,裂了 3 个缝

7 阅读9分钟

为什么我会盯着一个 ES 平替看

给公司一个内部检索服务续费之前,我看了一眼它的资源账单。那个服务跑在 Elasticsearch 8 上,JVM 堆 + 堆外 + Lucene 页缓存,一台 4G 的小机器里 ES 自己就要吃掉 2G 多,剩下的空间连日志都转不开。

ES 换 license 那波(SSPL,2021 年)之后我就留意过 OpenSearch,但 OpenSearch 本质上还是那一套 JVM 栈,内存账单一分没少。所以前几天刷 GitHub Trending 看到 XERJ 的时候,我停下来了——它号称自己是 Rust 写的单二进制搜索引擎,没有 JVM,对外直接讲 Elasticsearch 的 REST API,README 里写着一个很具体的数字:1366/1369 条 ES 一致性用例通过,还有一组对照基准,对 ES 8.13.4 是 55 胜 26 平 4 负。

这个数字具体到让我想亲手验一下。10 月 16 日它发了 v1.0.0-rc.96,仓库一天涨了 1600 多颗星。今天上午,我把它塞进了手头最烂的一台环境:1 核 CPU、2G 内存的老旧云机。ES 在这种机器上根本起不来,正好。

装起来比我想的简单,也比我想的胖

安装是官方的一行脚本,curl -fsSL https://xerj.org/get | sh。脚本会检测系统架构,从 GitHub Releases 下载对应的包,跑一遍 SHA-256 校验,放进 ~/.local/bin。

第一个意外是二进制大小:139MB。我本来以为"单二进制、无 JVM"意味着十几 MB 的轻巧玩意儿,结果它把 tree-sitter 解析器、Lucene 兼容层、一堆格式提取器全打包进去了。它对标的本来就是 ES 那个几百 MB 的发行包,139MB 在这个语境下算克制,但如果你把它当 ripgrep 那类工具,会愣一下。

我的环境还有个小插曲:脚本最后解压时 chown 报错(容器里没有权限改属主),我手动 tar --no-same-owner 解压绕过去了。这不算它的锅,但如果你也在容器里装,会撞到。 启动之后我习惯性地摸了一下根端点,然后笑出了声:

{
  "name": "local",
  "cluster_name": "xerj",
  "version": { "number": "8.13.0", "lucene_version": "9.10.0" },
  "tagline": "You Know, for Search"
}

连 ES 那句 "You Know, for Search" 都原样搬过来了,版本号也伪装成 8.13.0。这是刻意的:一些客户端启动时会先读根端点确认版本,它连这一步都替你演好了。

76 个文件,1.8 秒

传统 ES 玩法是先定义 mapping、再灌数据。XERJ 有一条捷径叫 autoindex,指一个目录它自己看文件类型索引。我把 axios 源码的 lib/ 目录(76 个文件,纯 JavaScript)扔给它:

$ xerj autoindex ~/axsrc/lib
scanning .. 76 files .. 486 records .. 1 dataset
indexed in 1.8s (1 scan threads, 1 index workers, --bulk-mb 8)

速度本身不是重点(语料小),重点是那行括号:它检测到我只有 1 核、可用内存 1250MB,自动把并发降到 1 scan / 1 index worker、bulk 缓冲 8MB。这种在小机器上自动降配的行为,我在 ES 上从未体验过——ES 在 2G 机器上给我的从来是 java.lang.OutOfMemoryError。 它还输出了一句提示,告诉我用 ax_format 字段可以区分 code / txt-prose / yaml。后面查询的时候这个字段确实好用。

能对上的部分:ES 肌肉记忆全部通过

我按自己的 ES 使用习惯列了一份操作清单,逐条怼。先说通过的部分,这部分占了大头。 URI 查询和 query DSL 没有任何磕绊。GET /ax-docs/_search?q=interceptor 直接回结果,20 条命中,33ms。换成 DSL 写 bool 嵌 match 加 range 过滤,也一次通过,filter 上下文的行为都对。

搜索结果质量让我挑不出毛病。查 "cancel token",返回的前两条是 cancel/CancelToken.js 和 cancel/CanceledError.js——axios 里管取消逻辑的恰好就是这个目录。查询词和文件位置的对应关系,跟我用 ES 搜业务代码时的体感一致。 聚合是全的。terms 按 language 分桶,javascript 406 条;对自建索引跑数值聚合,avg 算出来 2.0、max 3.0,都对。highlight 也是标准的 <em> 标签输出,不用改代码。

文档 CRUD 连细节都对。我用 _bulk 灌了两条自建文档,返回的 _seq_no、_primary_term、_version、201 状态码,每个字段都在。然后 PUT 更新、GET 读回,_version 从 1 涨到 2。这套 wire 协议的细节是很多"兼容层"项目偷工减料的地方,它没偷。

向量搜索是完整的。我建了一个 dense_vector(dims 4)的测试索引,写三条向量,拿向量 [1, 0.1, 0, 0] 做 kNN 查询:最近邻 [0.9, 0.1, 0, 0] 得 1.0 分,次近的 [1, 0, 0, 0] 得 0.9975 分,排序与余弦相似度严格一致。对 ax-docs 这种 autoindex 出来的索引,还能直接发 semantic 查询——我拿"how to retry failed requests"这种自然语言问句去搜,top 3 是 adapters/fetch.js、adapters/xhr.js、core/AxiosError.js。重试逻辑确实就住在 adapter 里,语义搜索不是摆设。

裂缝在哪:从一次 400 说起

以下是我实测到的边界,也是你真把客户端切过来之前必须知道的部分。 最先撞上的是 _source_includes。我在查询里带了 "_source_includes": ["ax_path"]——这是 ES 的标准参数,我在老项目脚本里到处在用。XERJ 直接回了 400:

{"error":{"type":"parsing_exception",
  "reason":"Unknown key for a START_ARRAY in [_source_includes]."}}

换成 ES 的对象写法 "_source": ["ax_path"] 就通过了。所以严格说这是个参数兼容缺口,不是功能缺失——但如果你手头有一堆老脚本用着 _source_includes,每个都得改一遍。

比 400 更麻烦的是另一种失败方式:安静地返回 0。同一个 ax-docs 索引、同一个字段 body,match 查 "request interceptor" 返回 20 条,core/README.md、core/dispatchRequest.js 全是对的;我换成 match_phrase 查同样的词组,0 条。我第一反应是分词配置错了,翻出来对照才发现问题只在 autoindex 建的索引上——后来在自己 _bulk 建的普通 text 字段索引上试 phrase,能正常命中。也就是说:标准 text 字段的 phrase 没问题,autoindex 推断出来的字段上有问题,而且没有任何报错和提示。你的 ES 客户端里如果有依赖 phrase 的精确匹配逻辑(比如日志检索按报错原句搜),切过来之前先用你的真实语料验一遍。

还有一个更轻的:融合查询(RRF)不在 ES 兼容层里。我按直觉在 9200 端口发 {"hybrid": {...}},回来的是 Unknown key for a START_OBJECT in [hybrid],翻了文档才知道它挂在 8080 端口的 native API 上。ES 兼容层只开放了 knn 和 semantic 两个向量入口。

顺带一提它的 scroll。文档里明说这是有界快照而不是游标,整个结果集超过 10000 条直接 400 拒绝,绝不静默截断。我的语料只有 486 条,scroll 正常返回,但响应里带了一条提示,说这次响应携带了 702KB 的 _source,建议我加 fields:["_passage"] 省流量。用 elasticsearch-py 的 helpers.scan() 做过导出的朋友都知道这个 10000 的坎意味着什么——官方文档连"用 search_after 接力"和"_id 是字典序排序、'1000' < '999'"这种暗坑都写明白了。

它让我意外的地方:查询失败时先递一张小抄

上面都是"对不对"的维度。真正让我觉得这个项目作者想得多的,是一个 ES 完全没有的东西:_xerj.hints。 第一次踩到是在语义字段上跑词法查询。body 字段被 autoindex 推断成了 semantic_text,我用 match 去查它,返回结果的同时多了这么一段:

body is semantic_text, but this query scored it with BM25 over the analysed text — the embedding written at ingest was NOT consulted, so these hits are lexical and a paraphrase sharing no words with the target will not find it.

翻译过来:你拿到的是词法匹配的结果,索引里写好的向量没有被用到,换个说法、不共享词面的查询会搜不到。这等于主动告诉你"这个结果集有语义盲区,建议改用 semantic 查询"。真 ES 里你犯这个错,它一声不吭给你返回"看起来正常"的结果。

第二处就是 scroll 那个 702KB 提示。两处 hints 合起来给我的感觉是:它把"LLM 时代你怎么会误用搜索引擎"这件事当成了产品设计的一部分,而不只是把 ES 协议抄完。

2G 机器上的 OOM,来的比预想中早

写这篇文章之前我索引过一次 axios 全仓库(533 个文件),然后让服务挂着,去整理其他查询结果。等我想起来再发查询时,服务已经没了。dmesg 里的记录很直接:

Out of memory: Killed process 4731 (xerj) total-vm:2106204kB,
anon-rss:1307120kB, oom_score_adj:-997

anon-rss 涨到 1.31GB,被内核收走。有意思的是最后那个 oom_score_adj:-997——进程自己把 OOM 优先级调到了几乎最高(尽量别杀我),但在总共 2G 内存的机器上,内核没得选,全局 OOM 一样杀。 后来我把语料缩回 lib/ 目录重新来,就再没复现过。结论是它的内存模型对语料规模敏感,2G 这种机器适合小语料加小并发,全仓库索引老老实实给 4G 往上。README 的基准环境是 8 核,它自我标榜的"轻"是相对 JVM 而言,不是相对 2G 而言——这个区分在踩坑之前我是没有概念的。

所以它到底适合谁

我给不出"通用替代 ES"的结论,实测数据也不支持这个说法。它适合的场景我亲测了一个:把某个目录的代码、文档变成可搜索、可语义查询、可被 Agent 通过 _passage 按块取用的索引——1.8 秒建完,一条命令,不写一行 mapping,查询姿势全是 ES 肌肉记忆。它不适合的场景我也踩了一个:把你现有 ES 集群的完整流量切过去。_source_includes 这种参数级缺口、autoindex 字段上 phrase 的静默失效,还有 10000 条的 scroll 上限,都够你先在测试环境跑一周真实查询再决定。

1366/1369 那个数字,我的实测结论是它没撒谎——我的查询清单大半通过,剩下的缺口都是"能修但有迁移成本"的类型。不过一致性用例通过不等于你的查询都覆盖了:把你线上跑得最多的二十条查询原样怼过去,比看任何基准报告都准。 踩坑的每一类失败,原始响应和复现命令都贴在文中了,照着跑就能对上。