Spunto Searchthe Elasticsearch API, small

Elasticsearch’s API,
in 2.4 megabytes.

Elasticsearch is very good, and for most of what it gets deployed for it is enormous: a JVM, a gigabyte of memory and half a minute of startup, to index a few hundred thousand documents and answer a bool with a sort on the end. Spunto Search speaks the same HTTP API — your existing client code doesn’t change a line — from a static Rust binary in an otherwise empty container.

ES 8.15 API2.4 MB imageready in 11 mssingle node
one command · your machine
$ curl -sSL https://github.com/spuntodotnet/search/releases/download/v0.1.0/ferrite-v0.1.0-x86_64-unknown-linux-musl.tar.gz | tar xz && ./ferrite

static musl binary, x86-64 or arm64 — then point an Elasticsearch client at :9200

01What you get

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.

index_and_search.py
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, -js and -go, unmodified. The API version announced is 8.15.0, and every response carries the X-elastic-product header they check for.
ingestion
_doc, _create, _update (partial merge and upsert), _mget, _bulk NDJSON, refresh semantics, optimistic concurrency with if_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 _source filtering.
aggregations
The metrics, plus terms, range, histogram and date_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.
02What it costs to run

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.0Spunto Search 0.1.0
image638 MB2.4 MB
memory at rest1.02 GiB2.9 MB
docker run → first response22.9 s232 ms
runtimeJVM, heap to tuneone 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.

docker-compose.yml
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.

03What it refuses

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.

04Where it earns its keep

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

also from Spunto