Edit Page

Vector Search

RESTHeart Cloud
Prefer not to run it yourself? RESTHeart Cloud is this, hosted, with sign-up, payments and an MCP server already on. Free to start. Get started →

This 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 /_indexes endpoint. 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 $vectorSearch results 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.

  • $vectorScan computes 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

vectorSearchIndexCreateInterceptor / …​ListInterceptor / …​DeleteInterceptor

Vector index CRUD on /_indexes. Enabled by default, no config.

vectorScanInterceptor

$vectorScan brute-force similarity search, no mongot or index required. default-max-candidates (10000), default-limit (10), max-candidates-cap and max-limit-cap (unset: no cap).

documentChunkingInterceptor

Chunk GridFS uploads — code-aware (function/class boundaries) for recognized source file extensions, character-window otherwise. chunk-size, chunk-overlap, target-collection, embedding-provider.

autoEmbeddingInterceptor

Embed on write, one or more rules per collection. embedding-provider.

vectorSearchMetadataChecker

Refuses malformed vectorSearch rules when the metadata is written. Enabled by default, no config.

vectorizeOperator

$vectorize aggregation operator. embedding-provider.

rerankingInterceptor

Rerank $vectorSearch results. atlas-api-key, rerank-api-url, rerank-provider.

openAIEmbeddingProvider

OpenAI/OpenRouter/any OpenAI-wire-compatible embeddings. api-key, model (text-embedding-3-small), base-url, dimensions (a shorter vector, from 9.9).

voyageEmbeddingProvider

Voyage AI embeddings. api-key, model (voyage-4), base-url, input-type, output-dimension (256, 512, 1024 or 2048, from 9.9).

voyageContextualEmbeddingProvider

Voyage AI contextualized chunk embeddings. api-key, model (voyage-context-4), input-type, output-dimension.

ollamaEmbeddingProvider

Local embeddings via Ollama. base-url (http://localhost:11434), model (nomic-embed-text).

voyageRerankProvider

Voyage AI reranking. api-key, model (rerank-2.5), base-url.

cohereRerankProvider

Cohere reranking. api-key, model (rerank-v3.5), base-url.

All plugins above default to enabled: false except the three index-management interceptors.