Your client can’t tell the difference.
Not a new SDK, not a « mostly compatible » dialect: the official Elasticsearch libraries connect to it, the handshake they perform on connect succeeds, and the requests you already send come back in the shape they already parse.
The inverted index itself isn’t reinvented — that’s Tantivy, the Rust equivalent of Lucene. The work is the compatibility layer on top of it, and it’s checked rather than claimed: on a 600-document corpus and 138 queries, this and a real Elasticsearch 8.15 return the same documents in the same order.
from elasticsearch import Elasticsearch # the official client, unmodified
es = Elasticsearch("http://localhost:9200")
es.indices.create(index="books", mappings={"properties": {
"title": {"type": "text"}, "author": {"type": "keyword"},
"year": {"type": "integer"}}})
es.index(index="books", id="1", refresh=True,
document={"title": "Bel-Ami", "author": "Maupassant", "year": 1885})
es.search(index="books", query={"match": {"title": "bel ami"}})
# {'took': 3, 'hits': {'total': {'value': 1, 'relation': 'eq'}, ...- clients
- The official
elasticsearch-py,-jsand-go, unmodified. The API version announced is 8.15.0, and every response carries theX-elastic-productheader they check for. - ingestion
_doc,_create,_update(partial merge and upsert),_mget,_bulkNDJSON,refreshsemantics, optimistic concurrency withif_seq_no.- queries
bool,match,multi_match,match_phrase,term(s),range,exists,prefix,wildcard,fuzzy,ids,constant_score,dis_max— with BM25 scoring, sorting, paging and_sourcefiltering.- aggregations
- The metrics, plus
terms,range,histogramanddate_histogram, nested up to three levels — enough to build facets. Compared field by field against a real ES across 34 queries. - mappings
- Explicit mappings, dynamic mapping, multi-fields (
title.keyword), declarative analyzers and_analyze. You can replay the mapping of an existing index, or index nothing declared at all.
A container you forget is running.
The final image is a scratch holding one statically linked binary. Nothing else — no libc, no shell, no JVM, nothing to patch on a Tuesday.
| Elasticsearch 8.15.0 | Spunto Search 0.1.0 | |
|---|---|---|
| image | 638 MB | 2.4 MB |
| memory at rest | 1.02 GiB | 2.9 MB |
| docker run → first response | 22.9 s | 232 ms |
| runtime | JVM, heap to tune | one static binary |
Both columns are measured by tests/compat/measure_container.sh on the same machine, in the same conditions, on every run of the repo’s CI. The 232 ms is the whole docker run round trip to a first served request; the binary itself is listening after 11 ms, and the rest is Docker creating the container.
what that changes
Not the query — the deployment. A search engine that boots instantly and fits in a few megabytes stops being a piece of infrastructure with its own capacity plan and becomes a detail: a sidecar next to the app, a service in a CI job that isn’t worth caching, a container in a dev environment that’s already up before you’ve switched windows.
It is a single node holding a single shard on a single volume. That is the whole trade: no replication, no failover, no rebalancing — and none of the machinery that makes those cost what they cost.
services:
search:
build: https://github.com/spuntodotnet/search.git
ports:
- "9200:9200"
volumes:
- search-data:/data
volumes:
search-data:No image is published yet, so this builds from the repo — the same scratch image the table measures. Prefer no Docker at all? The releases carry a static musl binary for x86-64 and arm64.
Nothing fails quietly.
The failure mode that matters in a compatibility layer isn’t a missing feature, it’s a missing feature that answers 0 hits. Every clause, field type and route that isn’t implemented returns an error in Elasticsearch’s own format, with a type — not_implemented_in_ferrite_exception — that means precisely « ES can do this, we can’t yet ».
Some refusals are permanent and deliberate, and they’re the interesting ones: cardinality is refused because Tantivy’s estimate diverges from Elastic’s (582 distinct values against 598, measured), and slop is refused because the two engines count phrase moves differently past two terms. Both would have been easy to accept and quietly wrong.
out of scope
- sharding, replication, recovery
- one node, one shard, zero replicas — and it says so
- Painless scripting
- no script queries, no script sorting, no script fields
- ML, alerting, role-based security
- there is no auth layer here at all
- highlight, search_after, scroll, PIT
- not there yet — each returns an explicit error, not an empty page
- language analyzers (french, english…)
- refused on purpose: tantivy's stemmer is not Lucene's
read this bit
This is version 0.1.0, single-node, with no authentication. Anyone who can reach port 9200 can read and write every index, and there is no replica anywhere — the durability of your data is the durability of that one volume. Treat it as a search index you can rebuild from your source of truth, on a private network, and it is exactly the right size. Treat it as a database and it will disappoint you.
The search box, not the search cluster.
Most products that need search need one index, a few hundred thousand documents and a facet or two — and end up running a distributed system designed for petabytes because that’s what the client library talks to. This talks to the same library.
- sidecar
- in the same compose file as the app, on the same volume, with no capacity plan
- CI
- a service container that starts faster than the checkout step it follows
- dev environments
- a Spunto worker that comes up with search already answering
- the edge of a product
- search inside an appliance, an on-prem install, a Raspberry Pi