A Database That Asks First and Says No
Most of MDDB's history is about doing more: more search modes, more protocols, more ways to put documents in. This release is mostly about refusing. It asks before acting. It says no before running out. When a feature cannot do something, MDDB now says so. And when a measurement says we were wrong, we change the code.
It came out of one question we kept hearing from people who let agents and tenants loose on a shared server: what stops this from going wrong? Before 2.18.9 the honest answer was "not much, so be careful".
The full list is in the changelog.
Asking before deleting #
An MCP agent with write access could call delete_collection on a whim.
Nobody was asked. With MDDB_MCP_CONFIRM_DESTRUCTIVE=elicit, MDDB uses MCP
elicitation to ask the person behind the client before any of the five tools
that destroy data runs:
delete_collectiondelete_documents_batchdelete_wheretruncate_revisionsrestore_backup
Nothing happens unless the answer is yes. For a whole collection the person also types its name, the way a cloud console asks before dropping a database.
Each transport carries the question its own way. On stdio the server writes
elicitation/create and waits. Over Streamable HTTP the reply to tools/call
becomes a short SSE stream. Stateless 2026-07-28 clients get an
input_required result and retry with the answer.
require refuses outright when a client cannot ask. The default is off.
Saying no before the disk is full #
On a shared server one collection grows until bbolt cannot extend its file, and then every collection's writes fail together.
A collection can now carry a quota: documents, bytes, vectors, and a daily
embedding budget. A write that would cross a storage limit gets
507 Insufficient Storage. The check runs once, inside the write
transaction, so REST, gRPC, MCP, GraphQL, batch and ingest all refuse the same
way, and a batch over the limit is refused whole.
An embedding budget never refuses a document. The document is stored, and its vector waits until midnight UTC or until someone raises the budget.
Two server-wide limits sit on top:
MDDB_DISK_RESERVE_BYTESstops writes while free disk is below a reserve. Reads and backups keep working.MDDB_VECTOR_RAM_LIMIT_BYTESstops vectors from outgrowing memory.
Backups that never eat the good ones #
A cron automation can now take a backup and send it to a directory or to any
S3-compatible bucket. Every file gets a .sha256 beside it, and uploads are
read back and checked.
The order is what matters. Old backups are deleted down to keep only after a
run succeeds. A schedule that has failed all week still leaves you the last
seven good backups.
Building this turned up an older bug. The scheduler accepted only six-field
cron expressions, while the docs showed five. So 0 3 * * * was stored,
listed, and never run. Both forms run now, and an expression that does not
parse is refused when you save the rule.
Saying when a conversion failed #
mddb-cli autoindex indexes a folder in one command, and it had two gaps:
- It skipped every PDF, DOCX and ODT file as "binary".
- It sent HTML, RTF and TeX through raw, tags and all.
Now it recognises a file by its content and hands it to the same converters
/v1/upload uses. Those converters gained spreadsheets (XLSX, ODS, CSV),
slides with speaker notes (PPTX, ODP), EPUB and mail (EML, MBOX, each
attachment its own document). All of it is pure Go, with no new dependency.
The bigger change is honesty. A scanned PDF used to "convert" into almost
nothing, and that nothing went into the index looking like a document. Every
converted document now carries meta.conversionConfidence, from 0 to 1, with
warnings. A scan scores 0. The filter {"conversionConfidence": {"lt": 0.5}}
finds such documents, and the search advisor tells you how many there are.
Images in Markdown and PDF files can now become documents of their own. Their
alt text and captions are searchable, and includeImages: true returns each
hit's pictures with its results. With Voyage's or Cohere's multimodal models,
a text query also finds a picture by what it shows, and queryImage searches
with a picture. Providers that cannot embed images skip them, and
indexing-status says so instead of failing.
Admitting what a feature cannot do #
MDDB_EMBEDDING_PROVIDER=lexical gives vector search with no model and no
network, for CI, air-gapped installs and demos. It matches spelling, not
meaning: it finds replication when you type replicaton, and never finds
copy when you ask for backup. On MDDB's own documentation:
| Search | nDCG@10 |
|---|---|
| keyword (bm25) | 0.719 |
| hybrid, lexical vectors | 0.726 |
hybrid, nomic-embed-text |
0.782 |
The first two differ by noise. So the server reports semantic: false, the
panel shows "Matches by meaning: No", and the advisor recommends keyword
search. It is never chosen automatically.
Letting the benchmark win #
algorithm: "auto" hands the choice of vector index to a query planner, and
explain: true shows its reasons. Before shipping it we measured it at 2,000,
200,000 and 2,000,000 vectors. It picked flat, HNSW and IVF as designed. At 2M
it answered 17× faster than flat at the median. That part went well.
The rest of the benchmark was less flattering:
-
HNSW recall was low and unstable. It found only 0.74 to 0.88 of the true top ten, and the number changed every time the same data was loaded.
- The graph library kept each point's nearest neighbours with no selection heuristic. Documents stored topic by topic became islands that a search could not leave.
- It also ignored one of its own parameters and drew levels from the clock.
MDDB now builds its own graph, following the paper. Recall is 1.000, queries are 3–5× faster, and loading is 6× faster.
-
The exact filtered scan read every vector and then filtered. Its cost grew with the collection, not with the filter. It now reads only the documents the filter admits. p99 at 200k fell from 31 ms to 1.8 ms.
-
Some thresholds were wrong. A filter admitting 5% to a third of a collection left HNSW short of results, so
autonow sends those to IVF: at 10%, 2.5 ms instead of 36.
We also tested an idea and dropped it. Late interaction (ColBERT's MaxSim) scores a document by many query vectors instead of one. Over our existing chunks it changed nothing on ordinary questions. On questions that join two topics, the case it was meant for, it lost 0.12 nDCG, with the whole confidence interval below zero. What did help those questions was a different idea: splitting the query and keeping each document's best part. That has its own ticket. MaxSim is not in this release, and the numbers are in the benchmark notes.
Two smaller steering tools shipped next to the planner:
- Relevance feedback moves a query towards documents you marked right and away from those you marked wrong.
- Summaries that a client writes into
meta.summaryare indexed as a second representation. "What is this about" can then find a document no single chunk answers.
The server never writes summaries itself; there is no language model inside MDDB.
Seeing where the time goes #
With MDDB_OTEL_ENDPOINT set, MDDB sends OpenTelemetry traces. A
traceparent from REST, gRPC or MCP puts MDDB's spans into the caller's
trace, so you can see how long a search spent embedding, scanning, fusing
and loading. The exporter is built in. It adds 45 KB to the binary where
the SDK would add 1.5 MB, and with no endpoint set it costs nothing.
The quiet fixes #
Several bugs returned correct-looking results that were wrong:
- Key case.
README.mdwas not found asreadme.md, although identifiers are case-insensitive. Every database file now records a schema version, and older files are migrated on first open. - Encrypted TTLs. Setting a TTL rewrote an encrypted document in plain text.
- Hybrid order. When scores tied, the order changed on every call. Sometimes that changed which documents made the top k.
- Ingest TTLs. They were applied by position, so one skipped document moved every later TTL onto the wrong document.
- Metadata updates. A metadata-only update left full-text search on the old values.
- Two crashes. A model name exactly two bytes long was misread as a quantized record, and OPQ panicked on short vectors.
Each of these now has a test that fails without the fix.
Upgrading #
Upgrade followers before the leader. To go back to 2.18.8, restore a backup taken before the upgrade.
Everything new is opt-in and off by default:
- confirmation of destructive tools
- quotas
- scheduled backups
- the lexical provider
auto- summaries
- images
- tracing
A server that sets none of them behaves as it did yesterday. Searches are faster and more accurate, and there are fewer bugs.