Edit Page

Vector Search

RESTHeart Cloud
Prefer not to run it yourself? Ulabase 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.
  • 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

A bucket says in its chunking metadata which of its files are split into text chunks, how, and into which collection. Apache Tika extracts the text: PDF, Office, HTML, plain text, and more. The chunker only splits; the collection the chunks go to embeds them with its own vectorSearch rules, as any collection does.

documentChunkingInterceptor:
  enabled: true
  chunk-size: 1000          # default for a rule that sets none
  chunk-overlap: 200        # default for a rule that sets none
PATCH /mydb/docs.files
{ "chunking": [
    { "name": "manuals",
      "filter": { "contentType": ["application/pdf", "application/vnd.openxmlformats-officedocument.*"] },
      "target-collection": "manuals_chunks",
      "chunk-size": 1000, "chunk-overlap": 200 },
    { "name": "code",
      "filter": { "extension": [".java", ".ts", ".py"] },
      "target-collection": "code_chunks",
      "splitter": "text" }
] }

PATCH /mydb/manuals_chunks
{ "vectorSearch": { "textField": "text", "embeddingField": "vector",
    "provider": "voyageContextualEmbeddingProvider", "model": "voyage-context-4", "groupBy": "fileId" } }
curl -X POST http://localhost:8080/mydb/docs.files \
  -u admin:secret -F file=@manual.pdf -F 'metadata={"owner": "docs-team"}'

Which files. The first rule a file matches is applied; a file that matches none is not chunked, and a bucket without chunking is never chunked. The filter has three keys, all optional and case-insensitive, all required to match when present: contentType, the type Tika detects, where a trailing * matches any suffix; extension, of the uploaded filename; metadata, a MongoDB filter on the file’s metadata, so your app routes a file by what it sets at upload, e.g. { "metadata": { "kind": "manual" } }. A rule without a filter matches every file.

How. chunk-size and chunk-overlap default to the interceptor’s configuration, both in characters. splitter chooses how the text is cut: auto, the default, at function and class boundaries for a recognized source file and as prose otherwise; text, always as prose, when the model embeds code better that way; code, at code boundaries whenever the language is recognized. Code gets no overlap unless the rule sets chunk-overlap.

Prose is cut the way LangChain’s RecursiveCharacterTextSplitter does: at paragraphs; a paragraph longer than chunk-size at lines, then sentences, then words; the pieces are put back together into chunks as full as chunk-size allows, and the overlap is made of whole pieces of the chunk before. A word is never cut: one longer than a chunk, a URL or a hash, is a chunk of its own. A stretch of whitespace alone, such as a blank page of a PDF, makes no chunk, and a chunk with fewer than 20 letters and digits, a page number or a running header, is joined to its neighbour. The chunk indexes have no gaps.

In the background. With async: true in the interceptor’s configuration, the upload answers as soon as the file is stored, and the file is chunked afterwards: extraction and embedding take seconds on a large PDF. What the chunker did is written on the file’s own document, beside metadata:

"chunking": { "status": "pending", "job": { "$oid": "…" }, "warnings": [], "at": { "$date": … } }

pending is there as soon as the upload has answered, running once the file’s turn comes; then done, with the rule applied and the number of chunks, skipped when no rule matched, or failed, with the reason in warnings. Read it with GET /mydb/docs.files?filter={"_id":…}. max-concurrent caps how many files the node chunks at once; a multi-tenant deployment can also cap one database with override-ai-chunking-max-concurrent, and turn the background on per tenant with override-ai-chunking-async. A job a node loses by stopping is not lost for good: every resume-scan-minutes the files still pending or running after resume-after-minutes are claimed, by one node each, and chunked again. Without async the upload waits, its response carries the warnings, and the status is written too.

The chunks. Each one is { "source", "fileId", "chunkIndex", "text", "filename", "contentType", "metadata", "rule" }, where metadata is the file’s own, so a search can narrow by what your app set at upload: a $match before $vectorScan, or the filter of $vectorSearch. All the chunks of a file are written in one request, so the collection’s embedding rule sees them together: with "groupBy": "fileId" and voyageContextualEmbeddingProvider, they are embedded as one document, each vector aware of the others. A collection without an embedding rule stores the chunks without vectors; one that does not exist is created. The account that uploads needs to be able to write to the bucket, not to the chunks collection.

Their lifecycle. Replacing a file with PUT replaces its chunks; deleting it, one by one or in bulk, deletes them.

Checked when written. A rule without target-collection, an unknown key, or an embedding key such as provider or model, which belongs to the chunks collection’s vectorSearch rule, is refused with a 400 that names it.

Source code is chunked as code. 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 the chunks collection 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

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). With "groupBy": "fileId" in a collection’s embedding rule, the documents of one write that share a fileId, such as the chunks of a file, are embedded together, each vector aware of the others. A document longer than the model’s 32,000-token window is embedded in consecutive windows, each chunk with the context of its own.

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-3", "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 the chunks of 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

Split GridFS uploads into chunks, as each bucket’s chunking rules say; code-aware for recognized source files. chunk-size, chunk-overlap, async, max-concurrent, resume-after-minutes, resume-scan-minutes.

chunkingMetadataChecker

Refuses malformed chunking rules when a bucket’s metadata is written. Enabled by default, no config.

chunkedFilesDeleteCollector

Lets a bulk delete of files delete their chunks too. Enabled by default, no config.

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.