Vector Search
RESTHeart CloudThis is the semantic-search half of restheart-ai — see the module overview for how it fits together with MCP.
restheart-ai adds vector search to any RESTHeart deployment: manage vector search indexes, turn uploaded files into searchable chunks, generate embeddings, and rerank results. It’s included by default in the RESTHeart distribution — no separate install, no extra JAR to add.
|
Note
|
restheart-ai is available starting from RESTHeart v9.9.
|
|
Warning
|
RESTHeart 9.9 has not been released yet — restheart-ai currently only exists in development snapshot builds. Until 9.9 is out, build RESTHeart yourself from the 9.x branch: git clone https://github.com/SoftInstigate/restheart.git && cd restheart && git checkout 9.x && ./mvnw clean package. The restheart-ai.jar you need is produced at core/target/plugins/restheart-ai.jar.
|
-
Vector search indexes — create, list and delete via the existing
/_indexesendpoint. Works out of the box, no configuration needed. -
$vectorScan— brute-force semantic search with no mongot and no index at all. -
Document chunking — upload a PDF, Word doc, or any Tika-supported format to GridFS and it’s automatically split into searchable text chunks; source code files are split at function/class boundaries instead.
-
Embeddings — either let MongoDB generate them for you (
autoEmbed, zero setup), or plug in your own provider (OpenAI, Voyage AI, Ollama) when you need a specific model or don’t have Atlas/mongot autoEmbed available. -
Reranking — refine
$vectorSearchresults with a dedicated rerank model (Voyage AI, Cohere, or the Atlas Reranking API).
Do you need mongot?
Only for two things: managing vector search indexes (/_indexes with "type": "vectorSearch", including autoEmbed) and running $vectorSearch itself. Both are executed by mongot, not by mongod alone — you need either MongoDB Atlas or MongoDB Community/Enterprise 8.2+ with mongot installed. autoEmbed additionally needs a Voyage AI key configured on Atlas (Integrations) or passed to your mongot instance.
Everything else in this page runs on any plain MongoDB — no mongot, no Atlas, no extra setup:
-
Document chunking just extracts text and inserts plain documents into a collection.
-
RESTHeart’s own embedding providers (OpenAI, Voyage AI, Ollama) and everything built on them — embed-on-write,
$vectorize— call an external HTTP API directly; MongoDB is not involved in generating the vector. -
$vectorScancomputes vector similarity directly, without an index or mongot at all — see section 2 below. -
Reranking re-scores whatever document array an aggregation already returned — it doesn’t care whether those documents came from
$vectorSearch,$vectorScan, or a plain query.
In practice: you can chunk files, embed them with your own provider, and semantically search them with $vectorScan on a stock MongoDB instance with zero extra setup — and only bring in mongot once you need `$vectorSearch’s indexed, approximate-nearest-neighbor search at scale.
1. Vector search indexes — works immediately
PUT, GET and DELETE on /_indexes handle "type": "vectorSearch" bodies automatically. No config, no enabled: true — just use it.
curl -X PUT http://localhost:8080/mydb/articles/_indexes/article_vectors \
-u admin:secret -H 'Content-Type: application/json' \
-d '{
"type": "vectorSearch",
"fields": [
{ "type": "vector", "path": "embedding", "numDimensions": 1536, "similarity": "cosine" }
]
}'
GET /mydb/articles/_indexes lists it alongside regular indexes; DELETE /mydb/articles/_indexes/article_vectors removes it.
|
Tip
|
Use "type": "autoEmbed" instead of "type": "vector" to have MongoDB generate embeddings for you — no embedding provider, no vector field to populate yourself.
|
{ "type": "vectorSearch",
"fields": [
{ "type": "autoEmbed", "path": "description", "model": "voyage-3-large" }
]
}
Query an autoEmbed index with queryString instead of queryVector in your $vectorSearch stage — mongot embeds the query text for you.
2. $vectorScan — semantic search with no mongot and no index
$vectorSearch needs mongot and a vector search index. $vectorScan needs neither: it’s a brute-force aggregation stage that computes similarity directly against a plain array field, on any MongoDB — including MongoDB Community with no search component installed at all.
vectorScanInterceptor:
enabled: true
default-max-candidates: 10000 # candidates scanned when maxCandidates is omitted
default-limit: 10 # results returned when limit is omitted
# max-candidates-cap: 2000 # no stage may scan more than this; unset = no cap
# max-limit-cap: 100 # no stage may return more than this; unset = no cap
Use real $match/$sort stages to narrow candidates first — unlike $vectorSearch’s restricted `filter, this is full MQL. $vectorScan itself takes a path to the vector field, a queryVector, a similarity (cosine, dotProduct, or euclidean), and an optional limit:
{ "aggrs": [{ "uri": "semantic-search", "type": "pipeline", "stages": [
{ "$match": { "category": "articles" } },
{ "$vectorScan": {
"path": "embedding",
"queryVector": { "$vectorize": { "$var": "q" } },
"similarity": "cosine",
"limit": 10,
"maxCandidates": 5000
}},
{ "$project": { "text": 1, "score": 1 } }
]}]}
curl 'http://localhost:8080/mydb/articles/_aggrs/semantic-search?avars={"q":"electric+cars"}' -u admin:secret
Stages placed after $vectorScan — like the $project above — run for real against MongoDB, bridged back in via $documents (requires MongoDB 6.0+). Reranking composes automatically too: attach a rerank block exactly as in section 5 below, since it scores whatever result array the pipeline produced regardless of how it got there.
maxCandidates bounds how many documents (after $match) are scanned and scored before ranking and truncating to limit — there’s no index, so this is the whole cost control. Reach for $vectorScan when you don’t have or don’t want mongot, on candidate sets small enough that a full scan is cheap; move to $vectorSearch once you need indexed approximate-nearest-neighbor search at scale.
maxCandidates and limit are set by whoever defines the aggregation. max-candidates-cap and max-limit-cap are set by whoever runs the node, and the stage cannot exceed them: the scan uses min(what the stage asks for, cap). Scoring runs in RESTHeart, not in MongoDB, so on a node shared by many tenants the caps bound how many vectors a single query pulls into memory. Both are unset by default, which means no cap. When a request is capped, the response carries a Warning: 299 header naming the requested and the effective value, so results that differ from an uncapped run are explained (the body of an aggregation is a plain array, so there is no _warnings field to put it in). Aggregation responses list Warning in Access-Control-Expose-Headers, so a browser on another origin can read it too. A multi-tenant deployment sets the caps per request instead, attaching override-ai-max-candidates-cap and override-ai-max-limit-cap from its own interceptor; an attached value wins over the configured one, and 0 lifts the cap.
3. Chunk documents for RAG
Enable chunking and every file uploaded to any GridFS bucket is automatically extracted (via Apache Tika — PDF, Office, HTML, plain text, and more) and split into overlapping text chunks:
documentChunkingInterceptor:
enabled: true
chunk-size: 1000 # characters per chunk (default)
chunk-overlap: 200 # characters of overlap (default)
target-collection: _chunks # where chunks are stored (default)
curl -X POST http://localhost:8080/mydb/docs.files \
-u admin:secret -F file=@article.pdf
Each chunk lands in _chunks as { "source", "fileId", "chunkIndex", "text" }. Add an autoEmbed index on _chunks and MongoDB embeds them for you — or see the next section to have restheart-ai embed them itself.
Uploading source code instead of prose just works — RAG over your own codebase, no extra configuration. A filename with a recognized extension (.java, .kt, .py, .go, .rs, .ts/.tsx, .js/.jsx, .c/.cpp, .cs, .swift) is chunked at function/class boundaries instead of arbitrary character counts — and with zero overlap between chunks, unlike prose: a method is already a self-contained unit, so there’s nothing useful to carry over into the next chunk.
curl -X POST http://localhost:8080/mydb/docs.files \
-u admin:secret -F file=@UserService.java
A large class lands in _chunks as one chunk per method, each still carrying the enclosing class declaration for context — so a retrieved chunk is never a floating method body with no idea which class it belongs to:
{
"source": "mydb/docs.files/...", "fileId": "...", "chunkIndex": 3,
"text": "public class UserService {\n public User findById(String id) {\n ...\n }\n}"
}
Brace counting is comment- and string-literal-aware (a { inside a "…" or // … doesn’t confuse it), and a class small enough to fit in one chunk stays as a single chunk rather than being split per method for no reason.
4. Embeddings without MongoDB autoEmbed
No Atlas, no mongot, or you just want a specific model? Enable a provider and point the pieces you need at it:
openAIEmbeddingProvider: # or voyageEmbeddingProvider, ollamaEmbeddingProvider
enabled: true
api-key: <your-key>
model: text-embedding-3-small # default
documentChunkingInterceptor:
enabled: true
embedding-provider: openAIEmbeddingProvider # chunks get a real `vector` field
autoEmbeddingInterceptor:
enabled: true
embedding-provider: openAIEmbeddingProvider
Embed on write — autoEmbeddingInterceptor embeds a text field automatically whenever a document is written, once a collection opts in:
curl -X PATCH http://localhost:8080/mydb/articles \
-u admin:secret -H 'Content-Type: application/json' \
-d '{ "vectorSearch": { "textField": "description", "embeddingField": "embedding" } }'
From then on, any description you write to articles gets an embedding field computed for free.
The model is a property of the vector field, from 9.9: a rule may name its own provider, model and dimensions, and they win over the provider’s configuration and over any override-ai-* the request carries. voyage-law-2 on /legal and voyage-code-4 on /src, with one key. $vectorize follows the same rule, so a question is embedded with the model of the vectors it is searched against:
{ "vectorSearch": { "textField": "text", "embeddingField": "embedding",
"provider": "voyageEmbeddingProvider", "model": "voyage-law-2", "dimensions": 1024 } }
provider names a configured embedding provider (voyageEmbeddingProvider, voyageContextualEmbeddingProvider, openAIEmbeddingProvider, ollamaEmbeddingProvider); dimensions applies where the provider takes a vector length.
A collection can hold more than one vector field, each with its own model: vectorSearch is then a list of rules, one per vector field. On every write, each rule whose text field the document carries embeds it; a write with only summary recomputes summaryVector and leaves bodyVector as it is. The object form above is still read, as a list of one.
{ "vectorSearch": [
{ "textField": "summary", "embeddingField": "summaryVector",
"provider": "voyageEmbeddingProvider", "model": "voyage-law-2" },
{ "textField": "body", "embeddingField": "bodyVector",
"provider": "voyageContextualEmbeddingProvider", "model": "voyage-context-4" }
] }
The metadata is checked when it is written: a rule without textField or embeddingField, or two rules on the same embeddingField, is refused with a 400 that names the rule, not at the first document write. With more than one rule, a $vectorize has to say which vector field the question is for; an aggregation that does not is refused, with the collection’s vector fields in the message.
Embed inline in a pipeline — the $vectorize operator turns text into a vector at query time, anywhere in an aggregation:
{ "aggrs": [{ "uri": "semantic-search", "type": "pipeline", "stages": [
{ "$vectorSearch": {
"index": "article_vectors", "path": "embedding",
"queryVector": { "$vectorize": { "$var": "q" } },
"numCandidates": 100, "limit": 10
}}
]}]}
curl 'http://localhost:8080/mydb/articles/_aggrs/semantic-search?avars={"q":"electric+cars"}' -u admin:secret
Requires vectorizeOperator enabled with its own embedding-provider set, unless the collection’s embedding rules name a provider.
The question is embedded with the model of the vectors it is searched against. As the queryVector of a $vectorSearch or $vectorScan stage, $vectorize reads the stage’s path and uses the rule that writes that field: the aggregation above on embedding embeds with the embedding rule, one on bodyVector with the bodyVector rule, and the two questions can have different lengths. Anywhere else, in a $set or a $match on a precomputed vector, the long form names the field:
{ "$set": { "queryVector": { "$vectorize": { "text": { "$var": "q" }, "field": "bodyVector" } } } }
With one rule on the collection, $vectorize uses it wherever it is, as before; with none, it uses the deployment’s default provider, for vectors written by something else. A path or field that no rule writes is refused with a 400 that names it and lists the collection’s vector fields: a question embedded with the wrong model would make the search silently wrong. So is a $vectorize on a collection with several rules that says nothing about which. With a Voyage provider, $vectorize sends input_type: query unless the configuration or a request override sets one.
|
Tip
|
voyageEmbeddingProvider also has a contextual sibling, voyageContextualEmbeddingProvider (Voyage’s voyage-context-4 model) — used automatically by documentChunkingInterceptor when configured, it embeds every chunk of a file together so each vector is aware of the chunks around it, instead of in isolation.
|
5. Reranking
Refine $vectorSearch results by adding a rerank block to a predefined aggregation:
{ "aggrs": [{ "uri": "vector-search", "type": "pipeline",
"stages": [
{ "$vectorSearch": { "index": "article_vectors", "path": "embedding",
"queryVector": { "$vectorize": { "$var": "q" } },
"numCandidates": 100, "limit": 50 } }
],
"rerank": { "model": "rerank-2.5", "query": { "$var": "q" }, "topK": 10 }
}]}
By default this calls the Atlas Reranking API (rerankingInterceptor: { enabled: true, atlas-api-key: <key> }). To use Voyage AI or Cohere instead:
voyageRerankProvider: # or cohereRerankProvider
enabled: true
api-key: <your-key>
rerankingInterceptor:
enabled: true
rerank-provider: voyageRerankProvider
Reranking scores each result’s text field against the query — this is exactly what _chunks documents from section 3 have, so chunking and reranking pair naturally.
Multi-tenancy
Every value above can be overridden per request instead of set once for the whole server — different API keys, models, or providers per tenant. Attach override-ai-embedding-provider, override-ai-openai-api-key, override-ai-chunk-size, and so on to the request (typically from your own tenant-resolving interceptor); anything not overridden falls back to the static config shown above.
Configuration reference
| Plugin | Purpose |
|---|---|
|
Vector index CRUD on |
|
|
|
Chunk GridFS uploads — code-aware (function/class boundaries) for recognized source file extensions, character-window otherwise. |
|
Embed on write, one or more rules per collection. |
|
Refuses malformed |
|
|
|
Rerank |
|
OpenAI/OpenRouter/any OpenAI-wire-compatible embeddings. |
|
Voyage AI embeddings. |
|
Voyage AI contextualized chunk embeddings. |
|
Local embeddings via Ollama. |
|
Voyage AI reranking. |
|
Cohere reranking. |
All plugins above default to enabled: false except the three index-management interceptors.