Elasticsearch-Compatible Subset
v2.18.10+ (INT-019). Off by default:
MDDB_ES_COMPAT=trueturns it on.
Code written for Elasticsearch — the official SDKs, LangChain's
ElasticsearchStore, scripts — can index, search and delete MDDB documents
without being rewritten. This is a subset mapped onto MDDB's own engine,
not a second one: every request is translated into the requests /v1 already
serves, and anything that cannot be translated faithfully is refused with a
400 that names it. Nothing is dropped silently.
It is not Elasticsearch, and does not try to be. No mappings, no analyzers, no cluster API, no scripts. If you need those, you need Elasticsearch.
Turning it on #
MDDB_ES_COMPAT=true mddbd
from elasticsearch import Elasticsearch, helpers
es = Elasticsearch("http://localhost:11023/es", api_key="mddb_...") # or bearer_auth=...
es.index(index="books", id="fox", document={"content": "The quick brown fox", "genre": "fable"})
es.search(index="books", query={"bool": {"must": {"match": {"content": "fox"}},
"filter": {"term": {"genre": "fable"}}}})
The surface lives under /es on the HTTP listener. A prefix rather than a
separate listener: it shares the port, TLS, CORS and every middleware with
/v1, and it cannot collide with /v1, /health, /metrics, /graphql,
/mcp or /debug — a collection named metrics is /es/metrics/_search.
Enabling it adds routes under /es and changes nothing under /v1.
Endpoints #
| Request | What it does in MDDB |
|---|---|
GET /es, HEAD /es |
info and ping (see Client negotiation) |
HEAD /es/{index} |
200 when the collection has a configuration or a document |
GET|POST /es/{index}/_search |
the search translation below |
GET|POST /es/{index}/_count |
the same search, hits.total only; the body may hold query only |
POST /es/{index}/_refresh |
waits up to 30 s for the collection's queued embeddings |
PUT|POST /es/{index}/_doc/{id}, POST /es/{index}/_doc |
write one document (addDocumentIf, the single write path) |
PUT|POST /es/{index}/_create/{id}, ?op_type=create |
write, or 409 if it exists |
GET|HEAD /es/{index}/_doc/{id} |
read one document |
DELETE /es/{index}/_doc/{id} |
delete; 404 not_found when absent |
PUT|POST /es/_bulk, /es/{index}/_bulk |
index, create, delete, through the batch path |
{index} is a collection; an alias resolves to its collection.
Every other path is a 400 naming the method and path.
Documents #
_source field |
MDDB document |
|---|---|
content (string) |
the Markdown body (contentMd) |
lang (string) |
the language; else ?lang=, else en |
| any other field: string, number, boolean, or a list of them | a metadata key; values are stored as strings |
| a nested object | refused (400, naming the field) |
null |
skipped |
_id is the document key. Keys are case-insensitive in MDDB (Fox and fox
are one document). A document in another language is read and deleted with
?lang=. _version is always 1 and _seq_no 0: optimistic concurrency
parameters (if_seq_no, if_primary_term, version, version_type) are
refused rather than ignored, and so are pipeline and routing.
Writes go through the same code as /v1/add and /v1/add-batch, so a
schema violation is a 400, a quota a 507 (or 429 for
the embedding budget, with Retry-After), and a write to a read-only
corpus pack a 403 cluster_block_exception — the error
Elasticsearch gives for a blocked index. ?refresh=true|wait_for on a write
waits for the collection's embeddings, as _refresh does.
Search translation #
| Elasticsearch | MDDB |
|---|---|
match (operator or), the only text clause |
/v1/fts, mode simple, BM25 |
match (and), match_phrase, multi_match (best_fields, most_fields, cross_fields, phrase), several text clauses |
/v1/fts, mode expression (AND, OR, NOT, phrases) |
term, terms (in the query, bool.must, bool.filter) |
operator filter in |
term, terms in bool.must_not |
operator filter not |
range on a metadata key |
operator filter gt/gte/lt/lte |
range on addedAt / updatedAt |
rangeMeta |
bool.must, bool.filter |
AND |
bool.should |
a required OR group (minimum_should_match: 1) |
text in bool.must_not |
NOT in the expression |
match_all, or no query |
metadata listing (/v1/search), score 1.0 |
top-level knn |
/v1/vector-search, algorithm: auto, retrievalMode: parent |
knn plus a single match |
/v1/hybrid-search, strategy: rrf, vectorAlgorithm: auto |
knn.query_vector |
queryVector |
knn.query_vector_builder.text_embedding.model_text |
query, embedded with the collection's provider |
knn.filter |
ANDed into the operator filter |
size, from (body or URL) |
limit, then the page is cut from the result |
sort: _score; _id/_doc; addedAt, updatedAt |
score order; sort: key; sort: addedAt/updatedAt (filter-only queries) |
_source: false, a field, a list, {"includes": [...]} |
projection of the response |
highlight on content with one pre_tags/post_tags pair, fragment_size, number_of_fragments |
highlight, highlightTag, fragmentSize, maxHighlights |
aggs / aggregations → terms (field, size) |
facetBy, facetScope: all; over the knn hits for knn |
field.keyword |
field (metadata values are exact already) |
The field a text clause names does not narrow it: MDDB has one full-text
index per document, covering the body and the metadata values the collection
indexes. match: {title: "x"} searches the same index as
match: {content: "x"}. Use term to filter on one metadata key.
hits.total.value is exact (relation: "eq"): for full text it is every
match, before paging; for knn it is the hits returned (at most k).
Refused, by name #
Each of these is a 400 whose error.reason names the clause, for example
[fuzzy] query clause is not supported by the MDDB Elasticsearch subset:
- any query clause not listed above:
fuzzy,prefix,wildcard,regexp,query_string,simple_query_string,exists,ids,nested,function_score,script,knninsidequery, ...; - options a clause does not translate:
match.fuzziness,range.format,multi_match.slop, ...; term/terms/range/match_allunderbool.shouldor inside a bool nested inshould/must_not(MDDB filters are ANDed); twotermclauses on one field;rangeundermust_not;bool.shouldnext to a textmust/filter(in Elasticsearch it only ranks there); text only undermust_not;minimum_should_matchother than 1;knnwith a text query that is not a singlematch, or withquery_vector, or withmodel_textdifferent from the match text (hybrid search embeds its one query);knn.similarity; more than one knn;sorton any other field, by more than one key,_scoreascending, or a field sort with text or knn;highlightwithout text or with knn, on a field other thancontent, or with per-field options;- aggregations other than
terms, sub-aggregations,terms.orderand the othertermsoptions; - top-level keys
post_filter,search_after,collapse,rescore,suggest,script_fields,docvalue_fields,fields,explain,pit,retriever,rank,profile, ...; URL parametersq,sort,_source*,typed_keys,rest_total_hits_as_int,search_type,scroll,routing; _bulkactions other thanindex,create,delete, and action options other than_index/_id— the whole request is refused before anything is written.
Out of scope for good: mappings and index settings, analyzers, scripts,
scroll, aggregations beyond terms, index templates, the cluster, _cat and
nodes APIs, _update, _update_by_query, ILM and the security API.
Limits #
- Page size.
from + sizegoes through the server's cap (#368):MDDB_SEARCH_MAX_LIMIT, lowered by the collection'sretrieval.maxTopK. A capped page returns fewer hits and says so three ways:X-Limit-Capped: trueandX-Limit: nheaders,"limitCapped": true, "limit": nin the body (clients keep unknown top-level fields), and aWarning: 299 MDDB "..."header, which elasticsearch-py raises as anElasticsearchWarning— a warning, not an error. Elasticsearch itself answers a page beyondindex.max_result_windowwith a 400; MDDB caps instead, as on/v1. - Bulk. At most 10 000 operations per request; the body is bounded by
MDDB_MAX_BODY_BYTES(413 above it). Runs of writes on one collection and language are one batch; a second write to the same_id, a delete, or a change of collection ends a run, so operations apply in order. - Concurrency.
_searchand_counttake a slot ofMDDB_SEARCH_MAX_CONCURRENTlike/v1/fts.
Authentication and permissions #
/es sits behind the same middleware as /v1: there is no unauthenticated
door. With MDDB_AUTH_ENABLED=true a request needs a JWT or an API key, and
the collection permissions apply — read for _search, _count, _doc GET,
HEAD and _refresh; write for _doc writes and _bulk (checked per
_index; an item without permission is a 403 item). The rate limiter and the
body cap apply too.
| Client option | Header sent | Accepted |
|---|---|---|
api_key="mddb_..." |
Authorization: ApiKey mddb_... |
on /es only |
bearer_auth="mddb_..." or a JWT |
Authorization: Bearer ... |
everywhere |
headers={"X-API-Key": ...} |
X-API-Key |
everywhere |
basic_auth=(user, password) |
Authorization: Basic ... |
no — use an API key |
The ApiKey scheme is read only under /es, so /v1 accepts exactly the
credentials it did before. An api_key=(id, key) tuple is base64-encoded by
the client and is not an MDDB key; pass the key string.
Client negotiation #
Official clients 8.x and 9.x check one thing about the server: every 2xx
response must carry X-Elastic-Product: Elasticsearch, or they raise
UnsupportedProductError before the caller sees the answer. They do not read
the version from GET / (clients 7.14–7.17 did). So every /es response sends
the header — it is what lets an unmodified client talk to MDDB at all. MDDB
does not otherwise claim to be Elasticsearch: GET /es reports
version.number: "8.19.0" (the API level the subset follows, which tools that
read it can reason about), build_type: "mddb", the MDDB version in
build_hash, and a tagline that says it is MDDB. The compatibility media
types (application/vnd.elasticsearch+json; compatible-with=8 or =9) are
accepted; responses are application/json, which the clients parse.
Compatibility test #
test/es-compat/ runs the official elasticsearch-py (version locked in
uv.lock), unmodified, against a freshly built mddbd with the lexical
embedding provider: info and ping, index, bulk, get, search with match,
bool and knn, aggregations, count, a refused clause, delete. CI runs it as
the es-compat job; locally:
make test-es-compat
The Go tests (services/mddbd/es_*_test.go) replay the requests the client
sends — the compatibility media types, PUT /_bulk with NDJSON, the ApiKey
header — through the full middleware chain, in both storage layouts.
opensearch-py #
opensearch-py is not in CI. It was run by hand (3.2.0, against a v2.18.10
build with MDDB_ES_COMPAT=true and the lexical provider), with
OpenSearch(hosts=[{"host": "localhost", "port": 11023, "url_prefix": "es"}]).
info, ping, index, helpers.bulk, indices.refresh, indices.exists,
get, search with match, with bool + term and with a top-level knn
using query_vector_builder, a terms aggregation, count and delete all
worked. OpenSearch's k-NN plugin syntax, knn inside query, is refused
with a 400 naming it: write it as a top-level knn instead. Moving from
OpenSearch is covered in INTEGRATIONS.