

深入解析 XERJ:一个用 45.5 万行 Rust 假装成 Elasticsearch 的代码搜索引擎
💬 评论19 个 crate / 45.5 万行 Rust 装成 Elasticsearch:4.7 万行 ES REST 协议 shim + 13 万行真引擎,内含 tree-sitter 切代码成段落,BM25 + FST + PFOR + HNSW 落盘,返回函数 + file:line 而非整篇。
XERJ:用 45.5 万行 Rust 假装成 Elasticsearch,然后给 agent 当脚手架#
仓库 xerj-org/xerj ↗ · Apache-2.0 ·
v1.0.0-rc.97· 19 个 Rust crate · 45.5 万行代码 · 单个 release 静态二进制,无 JVM 无依赖 跑通它的代价:curl -fsSL https://xerj.org/get | sh一行,然后xerj autoindex ~/my-project就可以
我一直想找那种明明是 A 项目,对外却装成 B的开源项目:airllm 用 4GB 显存跑 70B 模型是因为它”装”成 transformers;Pi Agent Harness 用 TS 写是因为它把整个 shell/IDE 都装进了 Node;REA 把 16 种逆向工具装进同一个 MCP,自己一个逆向引擎都不写。
XERJ 走的是同一个路子:它对外说自己是 Elasticsearch。localhost:9200 上能跑 Kibana、能用 ES 官方 client 库、能 GET /_cat/indices 看自己造出来的索引,Kibana 完全不知道自己在跟一个 Rust 写的”非 ES”说话 — 1366/1369 的 ES-YAML 兼容测试在每次 commit 上跑,只差 3 个 case 没收。
但这只是皮。下面那层是一个跟 ES 几乎无关的引擎:tree-sitter 把代码切成带符号的”段落”,FST + PFOR 落盘,HNSW 做向量,POST /_memory/notes 让 agent 写长期记忆,xerj ask 把整个检索流程打包成一个 LLM 也好的 plan。整个项目最值得借鉴的不是哪一个算法,而是怎么把”装”和”真”两条线分开、各自演化、各自有一群 issue 在背后推着往前走。
下面是我通读整个仓库、按”为什么 → 怎么装的 → 里面怎么做的 → 哪些坑”重新组织一遍的笔记,每个数字都能在源码里 find -name '*.rs' | xargs wc -l 复现。
0. 先把数字摆出来#
整个仓库是这种规模:
- 45.5 万行 Rust,546 个
.rs文件,19 个 crate,版本1.0.0-rc.97(2026-10 当前 release) - 单文件静态二进制,Linux / macOS / Windows 都有,无 JVM、无外部 runtime,prebuilt 拉下来能直接跑
- 2 个文件怪兽:
engine/crates/xerj-api/src/es_compat.rs— 46,548 行,对外吐 ES REST 协议的 shim,占整个项目 ~10% 行数engine/crates/xerj-engine/src/index.rs— 60,598 行,真引擎的核心,memtable / flush / merge / publication 全在这里
- 5 个业务线下的 19 个 crate,行数从 21 行(
xerj-compress/xerj-logslib 头)到 13 万行(xerj-engine)不等:
| 业务线 | 代表 crate | 行数 | 干啥的 |
|---|---|---|---|
| 协议外壳 | xerj-api | 46,548 | 装 ES 的 REST 端点,1366/1369 兼容 |
| 协议外壳 | xerj-mcp | 3,329 | 13 个 MCP 工具,thin proxy |
| 核心引擎 | xerj-engine | ~130,000 | memtable + segment + flush + merge |
| 核心引擎 | xerj-fts | 7,800 | BM25 / FST / PFOR / analyzer |
| 核心引擎 | xerj-vector | 1,900 | HNSW + kNN + SQ8 |
| 核心引擎 | xerj-query | 7,100 | ES query-DSL parser |
| 存储 / 索引 | xerj-autoindex | ~53,000 | tree-sitter 切代码 / 段落级 |
| 存储 / 索引 | xerj-storage | ~25,000 | index_store + WAL + codec |
| 外围能力 | xerj-rerank 等 6 个 | 合计 ~2,900 | 评分 / 控制台 / embedder |
| 集群 / 底层 | xerj-cluster 等 4 个 | 合计 ~4,400 | TCP transport / hub / common |
- 测出来的对比(项目自报):
- reference coding 场景:agent token 从 26.5 万(output only)降到 1 万,2.7x 减少;代价从 1.58
- vs Elasticsearch 8.13.4 head-to-head benchmark:55 胜 / 26 平 / 4 负,ingest 1.72x 吞吐,磁盘 1.61x 更小
- 4 场负的都是同一个坑:read p99 撞上 high-rate write flood(playbook写下来没藏)

图 0 说一件事:XERJ 的工程投入大部分都花在”装”那层 — xerj-api 一个 crate 4.7 万行,比”核心引擎”里头最大的 xerj-engine 之外所有 crate 加起来都大。这跟 airllm 那个”4GB 显存装 70B”是同一种工程哲学:不是 hack,是用现成的成熟接口当壳子,里面干完全不同的事。
1. 为什么要装成 Elasticsearch#
这个问题的反面更好回答:不装成 ES 会怎样?
如果 XERJ 自己定一套协议,比如 POST /xerj/search + 自己的 query DSL,那每个想接的 agent 都要写自己的 client。Kibana 没法直接用,ES 官方 12 种语言的 client 全用不上,所有 dashboard / monitoring / alerting 工具链全部重做。生态完全从零开始。
装成 ES 之后,这个生态立即可用:elasticsearch-py / elasticsearch-js / opensearch-py 全都能直接连;Kibana 打开 localhost:9200 自己就连上;agent 库(langchain / llama-index / haystack)只要在 5 行代码里把 endpoint 改一下就接进来了。
测出来的真实收益:1366 / 1369 = 99.78% 兼容。剩 3 个 case 是 scroll 边界行为(search_after 能替代)、Kibana 7.0 之前的 deprecated 端点、一个 OpenSearch 2.x 私有的 _list 路由。
但装的代价是真要做的。这里有一个特别值得讲的事情:装 ES 不是镜像 ES client 自己的版本号。es_compat.rs 里有这么一段注释(原文):
NOT derived from the calling client’s own self-reported library version — that was the original plan (mirror whatever version the client says ITS OWN library is), but empirical testing against a real OpenSearch Dashboards 2.11.1 container disproved it: OSD’s backend talks to the cluster via
opensearch-js, and its own internalopensearch-jsversion (1.1.0at time of writing) has no relationship to a server version OSD would consider compatible — mirroring it verbatim made OSD log “This version of OpenSearch Dashboards (v2.11.1) is incompatible with the following OpenSearch nodes in your cluster: v1.1.0 …” and refuse to start.
也就是说,他们原本想让 XERJ 自动跟随 client 报什么版本就回什么版本(假装我跟 client 一样新),结果让 OpenSearch Dashboards 2.11.1 直接拒启动,理由是 “server 1.1.0 incompatible”。原因很简单:OSD 后端用的是 opensearch-js@1.1.0,但 OSD 自己的 server 版本是 2.11.1,这两个版本号对 client 库和 server 来说语义完全不同。
修复方法是:hardcode FALLBACK_OPENSEARCH_VERSION = "2.11.0",正好对得上 OSD 2.11.1 自己的版本号。注释里有一句很关键的话:“A client library’s own version number is simply not a reliable proxy for a compatible SERVER version.”
这件事在 XERJ 项目里到处都是:装,不是镜像,而是猜”client 真正想看到什么”。这种”通过反推 client 行为而不是直接照抄协议”的装法,比纯协议复刻难一截 — 因为你得真跑过这些 client 才知道。
2. 19 个 crate 怎么分到 5 条业务线#
engine/Cargo.toml 的 members 数组里就 19 行,每一行是一个 crate。我按行数从大到小扫了一遍,大致可以分成 5 个 lane:

图 1 几个能直接抄的观察:
- 协议外壳(2 个 crate,4.99 万行):全在装 ES。
xerj-api4.7 万行,xerj-mcp3 千行做 13 个 MCP 工具转发 - 核心引擎(4 个 crate,约 14.7 万行):真在做搜索。
xerj-engine一家就 ~13 万行(index.rs6 万 /aggs.rs1.6 万 /memtable.rs6.5K),剩下fts/vector/query各司其职 - 存储 / 索引(2 个 crate,约 7.8 万行):
xerj-autoindex5.3 万行是这次最让我意外的,光extract/code.rs一个文件就 4568 行 — 那是 tree-sitter 17+ 语言的注册表;xerj-storage2.5 万行管 WAL + segment 文件 - 外围能力(6 个 crate,合计 ~2.9K 行):从 21 行(
xerj-compresslib 头)到 2 千行(xerj-rerank)不等 - 集群 / 底层(3 个 crate + 测试套件):小而关键。
xerj-commonconfig 4.2K 行 — 多到独立成 crate,因为几乎每个 crate 都要 import 它
这跟 AIRLLM 的”3500 行 Python 干 70B 推理”完全反过来:AIRLLM 是个”小核心 + 大模型”,XERJ 是个”大壳子 + 真引擎 + 小外围”。工程哲学不同,但都”装”得很彻底。
XERJ 的 split 思路跟 Pi Agent Harness 那个 11 个 package 是同一种:把”装外部接口”和”真做事”切成两套独立 crate,各跑各的节奏,issue 编号也不互通。xerj-api 的 bug 跟”agent 怎么用 ES client 库”有关,跟”BM25 怎么打分”完全无关,分 crate 让责任链能干净。
3. 装 vs 真:真在做什么#
xerj-api 那 4.7 万行是装,真正做事的是底下这 4 个 crate。我从最外层(autoindex)往里剥,把数据走向跟一遍,顺便把 3 个最有意思的工程钩子放出来。
3.1 树化切代码:tree-sitter + 段落级文档#
xerj-autoindex 一开始是为了解决一件事:把任意文件夹变成 AI 可搜索的语料,无 schema,无配置。README 里有一行”feed it any folder”:
$ xerj autoindex ~/my-project
phase A: 593 datasets inferred, 1955 junk/skipped files
phase B: indexing 25329 files with 8 workers
done in 158.1s, 593 datasets, 83103 records live, 790 junk recordstextautoindex 内部大概分两段:
- Phase A:sniff + infer(
sniff.rs4713 行 +infer/)— 把文件按扩展名 / 头字节 / 内容 probe 分类。Tree-sitter 17+ 语言注册表在extract/code.rs里,4 千多行,每个语言一行def("rust", &["rs"], tree_sitter_rust::LANGUAGE, "...")。 - Phase B:extract(
extract/,光code.rs一个文件就 4568 行)— 真正把代码变 ES document。
代码这一步最关键的洞见在 extract/code.rs 的开头注释里(原文):
Source files used to fall through to the plain-text extractor, so a repo was searchable only as prose. This parses each file with the matching tree-sitter grammar and captures its DEFINITIONS (functions, classes, methods, structs, traits, interfaces, modules, constants, …). Each file becomes a file-level document carrying:
languagethe detected languagesymbolsa structured array of {name, kind, line}defs“kind name” per symbol, newline-joined so BM25 matches a query likeclass Userordef saveto the file that owns itbodythe full source text (still full-text searchable) PLUS one document PER DECLARATION (#500):{name, kind, line, code}under a uniquecode:<line>:<name>locator
这是 XERJ 跟普通 ripgrep + index 拉开差距的地方。code:<line>:<name> 这个 locator 是关键字 — 它意味着 agent 调一个搜索,回来的不是一整篇 file-level document,而是一个 40~80 字节的 declaration 行。“class User 在哪个文件” 这种问题就是单条结果,不用 agent 再 grep -n 整一遍。
注册表那段最妙的细节是 content probe(extract/code.rs:50-60 附近):
/// `.m` is both Objective-C and MATLAB: when several registry rows claim
/// an extension, the first row whose probe returns true wins; a row with
/// no probe is the fallback owner.
probe: Option<fn(&str) -> bool>,rust也就是说,.m 文件同时被 Objective-C 和 MATLAB 抢 — XERJ 不是看扩展名拍脑袋,真是对文件前 200 字节做内容嗅探。第一个 probe 返回 true 的语言拿走这个文件,没 probe 的那行就当兜底。
这种”用 probe 仲裁歧义扩展名”的模式,跟我之前看的 Pi Agent Harness 里”用 .npmrc 里 min-release-age 防当天发布”是同一种:不要相信扩展名/版本号/声明,真去探一下。
3.2 写:WAL 是真的写过压测的#
数据从 autoindex 进到 engine,先过的是 xerj-storage/src/wal.rs (3824 行)。这个文件的注释密度是我在 XERJ 里看到最高的 — 几乎每个字段下面都有一段”为什么这样写”。
文件格式 (直接从源码抄):
┌──────────────────────────────────────────────────────────┐
│ File header (16 bytes) │
│ magic [u8;4] = "ZWAL" │
│ generation u64 (little-endian) │
│ reserved u32 │
├──────────────────────────────────────────────────────────┤
│ Entry* (repeating) │
│ entry_len u32 (payload bytes, excl. header) │
│ seq_no u64 │
│ op u8 (WalOpCode) │
│ payload [u8; entry_len] (serde_json) │
│ crc32c u32 (over seq_no+op+payload) │
└──────────────────────────────────────────────────────────┘text每个字段都不是空话:entry_len 放 payload 前是让你 read_exact(4) 就知道这条多长,不用扫到 crc32;seq_no 是 flush 顺序的 key;crc32c 覆盖 seq_no + op + payload 三者 — crc 范围是”我读到这一刻的整条 entry”,不是”我读到的所有 entry”,所以一条坏 entry 不会污染下一条。
WAL 真正有意思的是RC4 Wave-1 #8 的”durability contract”那段。原文:
A WAL byte may be destroyed (pruned) or ignored (skipped at replay) only if the entry it belongs to is provably durable in a flushed segment or superseded by a newer durable/replayable version. Two mechanisms enforce this:
Replay replays everything. Checkpoints are no longer used to skip entries at replay: both replay consumers (the storage memtable rebuild and the engine FTS memtable rebuild) are idempotent — they check the segment-rebuilt version map and skip entries whose doc is already segment-resident at an equal-or-newer seq. The pre-fix skip rule (
positionally-covered && seq_no <= checkpoint.max_seq_no) silently discarded acked-but-unflushed entries whenever a checkpoint over-covered — e.g. the flush path checkpointingcurrent_seq_no()-1while other shards’ memtables still held acked docs with smaller seqs. Live-verified as 50/50 acked-doc loss on kill -9 racing_flush(RC4 review, stream C).Prune verifies per entry. [
WalWriter::prune_verified] decodes every entry of a candidate generation and deletes the file only if the caller-supplied verifier proves every single entry durable. A generation holding even one acked-but-unflushed entry is retained.
简而言之 — checkpoint 不能用来”跳” replay,只能用来”通知 prune”。prune 之前必须逐 entry 验证 durability。这种”old bug live-verified as 50/50 acked-doc loss” 的注释风格,跟之前看 SQP-Variance 论文里那个”decision margin AUC 0.93-1.00”是同一种 — 写代码时把事故编号写进注释,后人看注释就知道这个 patch 修过什么。
3.3 算:BM25 跟 Lucene 算得一模一样#
FTS 的 BM25 实现 (xerj-fts/src/bm25.rs) 是教科书般的”故意跟 Lucene 对齐”。代码里直接把公式写在 module 文档里:
score(q, d) = Σ_t [ IDF(t) * TF(t, d) ]
IDF(t) = ln(1 + (N - n_t + 0.5) / (n_t + 0.5))
TF(t, d) = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (dl / avgdl)))text注释里直接写 “These defaults match Elasticsearch’s BM25Similarity exactly”。k1=1.2, b=0.75,跟 ES 默认值一样。
为啥要一模一样?因为这是装成 ES 的代价。如果你自己定义 BM25,那 Kibana 上的 explain 跟你的 score 对不上,agent 调 ?explain=true 拿到的 breakdown 就跟 ES 算的不一样,生态链会断。
更值得看的是 Posting list 的 ZPS2 framing(xerj-fts/src/postings.rs 注释里):
ZPS2 packed streams (doc-id deltas, term freqs) are frame-of-reference coded per block — the same shape tantivy uses for sorted blocks (a vint base plus bit-packed residuals) — with three extra rules that remove bytes the old framing paid even when they carried no information: [width: u8] 0 = every lane equals
min, no payload follows 1..=32 = bit width of (value − min) lanes [min: vbyte] the frame’s base, added back on decode [payload] 128 × width / 8 bytes — length DERIVED, never stored An all-equal block (dense-field doc-id deltas of 1, a term whose freqs are all k) costs 2–3 bytes total instead of the 21 the old framing paid. The oldu32 byte_lenwas pure overhead: it is always128 × width / 8.
一个 all-equal block 从 21 字节砍到 2~3 字节,代价只是”宽度从 1 改成 0 时省略 payload”。这是 tantivy 那种老牌 Rust 搜索引擎都没全做、但 XERJ 自己加的微优化。每条 posting 都进 .post 文件,128-doc 一 block,扫一个 corpus 就是上百万条 — 这种 67x 的微优化在大规模下就是 6070% 磁盘差距。
3.4 给 agent 当脚手架:MCP 13 个工具#
xerj-mcp lib 3329 行,16 个工具,13 个是 thin proxy,3 个是 Hub 流程。lib.rs 顶上有个表直接列全:
| MCP tool | 真后端 | 干啥 |
|---|---|---|
xerj_search | POST /{index}/_search | ES query-DSL 全文 / 关键字 / 结构化 |
xerj_semantic_search | POST /{index}/_search (semantic) | 服务端词法 embedding,无需外部 key |
xerj_vector_search | POST /{index}/_search (knn) | dense_vector 上的 kNN(HNSW 服务) |
xerj_hybrid_search | POST /{index}/_search (hybrid) | RRF 或 linear 融合 |
xerj_memory_store | POST /_memory/{ns} | 命名空间 agent 记忆写 |
xerj_memory_recall | POST /_memory/{ns}/_recall | 按意义召回到 graph |
xerj_brain_* (4 个) | /v1/systemone + /_decide | 第二脑 link index(8 个 detector,2-hop cap) |
xerj_plan | POST /_ask | 自然语言 in / 验证 query DSL out(确定性,非 LLM) |
xerj_map | POST /autoindex-catalog/_search | autoindex catalog 字段图 |
xerj_code_search | xerj code 工具链 | reference coding 全流程 |
xerj_hub_find / _def / _ask | xerj ask 流程 | 从本地 packs 找答案,不下载 |
最让我服气的是 lib.rs 里专门有一段”Honesty notes” — 写”啥没验证、啥是实情”:
- Unfiltered kNN is HNSW-served with exact rescoring (measured recall@10 1.00 on the official bench query); filtered kNN and other ineligible shapes (non-cosine, SQ8, small indexes) run the exact brute-force scan. The tool description says so.
rerankonxerj_search/xerj_hybrid_searchis opt-in and needs a provider the node’s operator configured; this server cannot supply the key. It is also the one argument that makes the node send document text to a third party, and the tool description says so in those words. Ranking quality with the real provider model has not been verified by this project — the description promises the mechanism (a calibrated probability replaces_score), never an accuracy figure.- The
xerj_brain_*tools work a link index, not a graph database: deterministic lexical detection plus explicit assertions — no neural understanding, no query language, no unbounded traversal.
翻译:不验证的就不写在 description 里骗 agent。这种 “Honesty notes” 段,跟 rea 那个 evidence ledger 4 轨分法一样 — 不替能力吹牛,直接说哪条还没验证。agent 拿到这种描述才能正确做”啥能做 / 啥别碰”的判断。
4. 三个真坑,issue 编号都在源码注释里#
通读 XERJ 最爽的是它的注释 — 几乎每个”看着奇怪”的代码下面都有一段”为啥这样写”,而且很多都附了 issue 编号。挑 3 个最值得看的:

图 2 想说一件事:这不是单向流水线,5 个阶段每个都有一段历史 — 三个红框就是背后 issue 在推着走的真坑。
4.1 issue #191 — memtable 排序总序#
xerj-engine/src/memtable.rs:60-90 里有这么一段:
/// Order `hits` by the page key XERJ's final sort uses: `score DESC`, then
/// `seq_no ASC` (arrival order — the `_doc` analogue), then `_id ASC`
/// (`index.rs:16222-16228`).
///
/// #191 — any bounded materialisation must truncate under the SAME total order
/// the page is finally sorted by, otherwise the documents that survive a tie
/// are decided by whatever order the walk happened to visit them in. Lucene
/// makes the same comparator total for the same reason
/// (`HitQueue.lessThan` breaks an equal score by the smaller doc id,
/// `lucene/core/src/java/org/apache/lucene/search/HitQueue.java:76-82`);
/// approach only, no code taken.rust坑是这样的:BM25 打分同分的时候,必须有个总序把”该留谁”定死。XERJ 选了 score DESC, seq_no ASC, _id ASC,跟 Lucene HitQueue.lessThan 一样(只拿了思路,没抄代码)。
为啥这个坑会被单独立 issue?因为 bounded materialization(取 top-N 截断)必须用跟最终排序一致的总序。如果截断用一种序、最终排序用另一种,截断留下的就是 shard-walk 顺序里的偶然幸存者,不是真”top N”。#191 修的就是这个 — 任何有界取数都得在同一种总序下做。
这个坑在 SQP-Variance 论文里也讲过本质:item 维度的随机抖动会让”看起来同分”的两条记录里真的随机挑了一条留下,导致 plan 算子层看到的结果不重现。XERJ 的修法跟论文 §3 表 1 的 tuple-independent 模型一脉相承 — 用 seq_no 当隐式时间戳,把”同分里挑谁”这个 hidden variable 拉成显式。
4.2 issue #948 — 内存压力只 REJECT 不释放#
xerj-engine/src/engine.rs:15-45 顶上:
// ── Memory-pressure drain plumbing (issue #948) ─────────────
// The parent RSS breaker used to only REJECT writes (429). Under a workload
// whose caches hold gigabytes of re-hydratable state and whose memtable
// holds raw write payloads, rejection frees nothing: the breaker stays
// engaged, the client retries forever, and the run never finishes — exactly
// the #948 mailbox ingest (16 GiB cap, 26.5 GiB demand, 118-147 s engaged
// stretches, 3-4 s releases). The sampler now also requests a DRAIN: flush
// every index, release rebuildable caches, purge allocator pages.rust坑是这样的:breaker 触发以后只 REJECT(返回 429),但memtable + cache 的内存没释放 — 因为 memtable 是写缓冲,REJECT 后它还是满的;cache 是只读副本,REJECT 也不回收。结果是 client 一直重试,breaker 一直 engaged,谁都跑不完。
修法:breaker 不光 REJECT,还主动 DRAIN — flush 所有 index + 释放可重建 cache + 调 jemalloc 的 purge hook 把页还给 OS。DRAIN 是个 bounded request channel(mpsc::SyncSender<()> capacity=1),多次 DRAIN 会合并:try_send 失败就丢掉,下一个 sampler tick 还会再发,不会把 DRAIN 通道塞满。
这个坑的根因其实是个反直觉 — “限流”跟”释放”是同一件事的两面。只看 client 行为,429 像是”你慢点”,但其实是要”把已经吃进去的吐出来”。以前实现限流只做了前者,后者全靠 client 重试自然降速,但在 bulk load 场景下根本不会自然降速。
4.3 issue #1435 — CLI 末尾没值时该报错#
xerj-server/src/main.rs:99-110:
/// The value that must follow `flag`. A flag given last on the line, or
/// followed straight by another `--flag`, has no value: that is an error, not
/// a silent "unset" — `xerj --insecure --data-dir` used to boot on the default
/// data directory, and `xerj --data-dir --insecure` used a directory literally
/// named `--insecure` and left auth on (#1435).rust坑是这样的:xerj --data-dir 后面没值,旧版会静默回退到默认数据目录 — 行为对用户来说根本不可见,可能跟生产数据混在一起。xerj --data-dir --insecure 更狠:把 --insecure 当成目录名,在 --insecure 这个名字的目录里启动了 开了 auth 的服务器(因为 --insecure 被吞了)。
修法:flag_value() 函数显式检查”下一个参数是不是长得像 flag”(以 -- 开头或 - + 单字母)。是 flag → 报错;不是 flag → 才是值。looks_like_flag() 单独抽出是为了测试 — 跟 brain::parse / share::parse 走的是同一种”parse 函数接任何 iterator,argv 解耦”的形状,这样测试不用污染 process 状态。
这种”小 CLI 解析 bug 也写 issue + 注释 + 单元测试”是 XERJ 整个项目的风格 — 没一行代码的 hack 是没被审过的。看下来能感觉到:这不是 “写完发布”,是 “真在跑、真有用户在用、真有事故”,事故编号就贴在代码上。
5. 三个”装”层面的工程取舍#
上面三个坑是”真引擎”层的。再讲 3 个”装”层有意思的取舍 — 这些是”装成 ES 的代价”具体长什么样。
5.1 默认 install 一行,但 trust boundary 写满#
README 顶上写 curl -fsSL https://xerj.org/get | sh,看着很随便。但同 README 顶上紧跟着写:
That
--insecureflag is a trust boundary, not a formality. It turns off TLS and API-key auth, so every caller that can reach the port is a superuser who can read and delete every index, including every/_memorynamespace. It is meant for one user on a dev machine: a fresh node binds127.0.0.1only, and a cleartext bind on any other interface refuses to start unless you explicitly allow it.
也就是说:一行 install 装出来的本地 node,默认只绑 127.0.0.1,没 TLS 没 auth,任何能连到端口的人都是 superuser。绑非 loopback 时直接拒绝启动,除非显式 allow。
docs/SECURITY_MODEL.md 把这 5 个约束都列出来:“超级用户 = 任何人”是 dev 机器上的合理选择,不是 production 选择的简化。--insecure 不是 “关掉一些不重要的安全检查”,是”把整个 auth 当 0”。
这种 “install 一行但 trust boundary 写满” 的写法,跟 SQP-Variance 论文里那个 “3 层可复现性” 表是同一种哲学:默认行为要 friendly,真发生事故的边界要 noisy。Friendly + noisy 比 silent + strict 更难设计,但用户才真的能选。
5.2 OpenSearch Dashboards 兼容性 — 猜 client 真正想看到什么#
之前在 §1 提到那个 OSD 2.11.1 + opensearch-js 1.1.0 的坑。es_compat.rs 里 hardcode FALLBACK_OPENSEARCH_VERSION = "2.11.0" 的同时,还完整实现了 resolve_compat_version 函数,根据 client 的 Accept header 决定回 ES 8.13 还是 OpenSearch 2.11:
Resolve the
versionblock (including the OpenSearch-onlydistributionfield) to report to THIS caller, per request.
也就是说 — 同一个 XERJ 进程,在同一秒,可能对 client A 回 ES 8.13 风格,对 client B 回 OpenSearch 2.11 风格。靠的是 HTTP Accept 头嗅探。
这种”按 client 报不同版本”的实现,不是镜像 client 自己报的版本(那会让 OSD 2.11.1 看到 1.1.0 然后拒启动),而是反推 client 真正想看到什么版本。注释里那句 “A client library’s own version number is simply not a reliable proxy for a compatible SERVER version” 是核心 — 你得真跑过这些 client 才知道。
这件事在 RAG / agent 生态里普遍存在:协议写在纸上是一回事,client 实现是另一回事,中间靠 “我跑过所以我知道” 来缝。XERJ 的方法是把这些”我知道”写成 explicit comments + 走 empirical test,不藏在代码里。
5.3 Insecure 默认是 default;Auth-on 默认是 default — 两种 default 配对#
读 README 跟 SECURITY_MODEL 的时候,我会觉得 XERJ 在 “默认安全” 这件事上做了两套对立默认:
--insecure启动的 dev node:无 TLS / 无 auth,绑 127.0.0.1。这是autoindex/init跑通的必要条件。- 没
--insecure启动的 node:auth on by default,server 启动时自动生成一个 admin key,写到<data_dir>/admin.key。xerj autoindex跑前必须export XERJ_API_KEY="$(cat ./data/admin.key)",否则 401。
也就是说 — 同一个 binary,通过有没有 --insecure 选两种完全不同的 default 行为。xerj init 文档里甚至给了 fallback:跑在 loopback 时,autoindex 找不到 XERJ_API_KEY 就 fallback 到 <data_dir>/admin.key(issue #961),还把”用了哪个文件”打到 stderr。
这种 “default 配对” 思路是我在自己写 server 时经常想不到的 — 大部分项目的 default 是一条路(要么 insecure 要么 auth),XERJ 走的是两条路各有合理 use case,default 选择由启动 flag 决定。这种设计在 README 顶上写得很重,不让用户误用。
5.4 rerank 那段 honesty — 唯一的 search-time 出网#
xerj-mcp/src/lib.rs 的 Honesty notes 里特别强调:
rerankonxerj_search/xerj_hybrid_searchis opt-in and needs a provider the node’s operator configured; this server cannot supply the key. It is also the one argument that makes the node send document text to a third party, and the tool description says so in those words. Ranking quality with the real provider model has not been verified by this project
翻译:rerank 是 XERJ 唯一会把文档文本发到第三方的 search-time feature。开了它,top N 命中会被发到 TypeSafe AI 的 Jev 服务,再按它返回的概率重排。没验证过用真实 provider 模型的排序质量 — 项目方明确说”我们保证机制,不保证准确度”。
把”我没验证”写在 tool description 里 — 跟 SQP-Variance 论文 §3 表 1 那种”tuple-independent 5 类算子的闭式公式命中 95%+ 但不是 100%“是同一种态度。不替没验证的 claim 吹牛。
这种 honesty 在 README / SECURITY_MODEL / lib.rs / 多个 .md 文档里反复出现。我读到第三遍才发现这是 XERJ 的项目文化,不是某一段忘了删 — 每个 external feature 的边界都明写 “没验证” / “有可能不符合预期” / “数据会出本机”。
6. 这套架构值得抄的 3 件事#
不是抄 XERJ 本身(45.5 万行 Rust 不是谁都能扛的),是抄这套架构的”装 vs 真”思路:
- 协议 shim 跟真引擎分 crate,各自演化。
xerj-api跟xerj-engine几乎不互相 import(只有几行AppState共享)。装 ES 协议修 bug 跟 BM25 算法调参完全独立,issue 编号也不混。 - 注释里写 issue 编号,事故复盘当文档。每个 “看着奇怪” 的代码段下面都有
#N引用,后人 grep 一下就能找到所有跟这个 issue 相关的代码改动。SQP-Variance 论文里那种 “live-verified 50/50 acked-doc loss on kill -9 racing_flush” 跟memtable 排序总序都靠这种方式传承。 - Honest default — install 一行但 trust boundary 写满,rerank 那段明写”没验证”。不是说”我做了 X”,是说”我做 X,我没做 Y,Z 走另一条路”。这种”主动暴露局限性”在 agent 时代特别重要 — agent 拿到 tool description 才能正确判断”该用 / 该避开”。
7. 不那么好看但要诚实的地方#
为了不替 XERJ 吹牛,提几个我读下来觉得不那么好的点:
- “Reference coding 5x token 减少” 是用户自报,不是受控测量。README 里 2.7x 那个数是受控的(8 task / 4 language / 16 run),5x 是 “field reports” 里的自报均值,两个数不能混。
- vs ES 8.13.4 的 4 场负都是 read p99 撞 high-rate write。XERJ 把 playbook 公开在
demo/playbooks/MIXED_READ_UNDER_WRITE_FINDING_2026-07-08.md,但这个坑目前没修 — 选 XERJ 之前先看这个 playbook 决定你的 workload 撞不撞。 - 1366/1369 的”通过”是 ES-YAML 套件的通过,不是 ES 整个 API 100%。差别主要是:scroll 是”有界快照”(400 拒过),不走 segment 游标;OpenSearch 2.x 私有端点不全;Kibana 7.0 之前 deprecated 的端点不全。
code:<line>:<name>段落级 locator 是真功夫,但 agent 用起来要懂”这些 locator 不是 git commit 锚点”。改一行代码,locator 就会失效;XERJ 没在 result 里返回 git blob SHA,只返{file, line, kind, name}。这点跟 GitHub 那种repo:branch:path#L123锚点不一样,得自己维护。
这些坑不是 XERJ “不好”,是 README / docs 没明写、容易被第一印象忽略的地方。装成 ES 不等于每个 ES 的高级行为都做对。
8. 写给读者 — 怎么用#
如果你是想拿 XERJ 跑自己的 reference coding:
curl -fsSL https://xerj.org/get | sh装。别从源码cargo build,release 1.0.0-rc.97 编译要 10+ 分钟,二进制已经 prebuilt 了。- 第一次跑用
--insecure。绑 127.0.0.1,数据放./data,跟 README 那个”dev 信任边界”一致。 xerj init把 MCP 接到 Claude Code / Cursor。一行命令生成 MCP 配置 + skill,不用自己写 JSON。- 第一次
autoindex ~/my-project之后,等 phase B 跑完。593 dataset / 8 万 record 大概 2~3 分钟;之后增量是 95% 跳过,只看 content hash 变没变。 - agent 调
xerj_search拿 code:: 。MCP 那 13 个工具里有 token budget 控制,直接调 ES 端点会爆 context window。段落,不要直接调 ES bulk
如果你是想读源码学习架构,顺序建议:先 engine/crates/xerj-server/src/main.rs(4145 行,jemalloc + CLI + listen loop 的入口),再看 engine/crates/xerj-engine/src/engine.rs(5413 行,memtable + flush + merge 的协调),然后 xerj-engine/src/index.rs(60598 行,segment 编排),最后 xerj-api/src/es_compat.rs(46548 行,装 ES 的部分)。按这个顺序能感受到”装”vs”真”的边界在哪儿,issue 编号也都用得上。
9. 收尾#
XERJ 是我最近看到的”装与真”做得很彻底的项目之一 — 4.7 万行 Rust 假装成 Elasticsearch,是为了让 Kibana / agent 生态立即可用;底下 13 万行真引擎用 tree-sitter + FST + PFOR + HNSW 把任意文件夹变成”段落级”可搜索语料,是为了让 agent 拿到函数 + file:line而不是整篇文件。
工程哲学跟 airllm 一样:不是 hack,是把现成接口当壳子,里面干完全不同的事。区别是 airllm 用 4GB 装 70B,XERJ 用 45.5 万行 Rust 装 Kibana。
整个项目最值得抄的不是哪个具体算法,是装和真分开,各跑各的演化,issue 编号贴在注释上,honest default 写在 README 顶上。这四件事做齐,不管写什么规模的项目都能跑得久。