- What You’ll Learn
- Prerequisites
- 1. Enable chunking with embeddings
- 2. Create a GridFS bucket and upload some documents
- 3. Semantic search with
$vectorScanβ no mongot, no index - 4. The same data with
$vectorSearchβ indexed, needs mongot - 5. Bonus: rerank either one
- 6. Bonus: context-aware embeddings with Voyage AI
- Which one should you use?
Semantic Search Tutorial β $vectorScan & $vectorSearch
RESTHeart|
Note
|
restheart-ai is available starting from RESTHeart v9.9.
|
π§ Configuration
β‘ Setup Guide
To run the examples on this page, you need a RESTHeart instance.
Option 1: Use RESTHeart Cloud (Recommended)
The fastest way to get started is with RESTHeart Cloud. Create a free service in minutes:
-
Sign up at cloud.restheart.com
-
Create a free API service
-
Set up your root user following the Root User Setup guide
-
Use the configuration panel above to set your service URL and credentials
|
Tip
|
All code examples on this page will automatically use your configured RESTHeart Cloud credentials. |
Option 2: Run RESTHeart Locally
If you prefer local development, follow the Setup Guide to install RESTHeart on your machine.
|
Note
|
Local instances run at http://localhost:8080 with default credentials admin:secret
|
This tutorial builds a full-featured semantic search API: upload files, have restheart-ai chunk and embed them automatically β with either independent or context-aware embeddings β then query them two different ways β $vectorScan, which needs no mongot and no index at all, and $vectorSearch, which needs mongot and a vector search index but scales to large collections with approximate nearest-neighbor search β and refine either one with reranking. All of it runs against the exact same chunks, so you can compare every approach directly on the same data.
Uploads are extracted with Apache Tika, so PDFs, Word documents, HTML, and other common formats all work exactly the same way as the plain-text files used below β those are just the easiest to paste directly into a terminal command.
See the Vector Search & AI overview for the full feature reference this tutorial pulls from.
What You’ll Learn
-
Enable document chunking with automatic embeddings on upload
-
Upload files to GridFS and watch them turn into searchable, embedded chunks
-
Query those chunks semantically with
$vectorScanβ zero extra infrastructure -
Create a vector search index and query the same chunks with
$vectorSearch -
Add reranking to either kind of search
-
Switch to Voyage AI’s context-aware chunk embeddings, and why they make
chunk-overlapunnecessary
Prerequisites
-
RESTHeart 9.9+ running locally (
restheart-aiis included by default β see the overview page if you need to build it from source) -
An OpenAI API key, to embed chunks and queries (any other supported provider β Voyage AI, Ollama β works the same way, just swap the plugin name)
-
For the
$vectorSearchpart only: MongoDB Atlas, or MongoDB Community/Enterprise 8.2+ with mongot installed. The$vectorScanpart needs none of this. -
A tool for making HTTP requests β examples below are given for cURL and HTTPie
|
Note
|
In all examples below:
The interactive examples on this page can automatically substitute the RESTHeart URL and credentials. |
1. Enable chunking with embeddings
RESTHeart ships with a built-in default configuration, so there’s no restheart.yml to edit β you activate plugins with a small configuration override file instead. Save this as overrides.yml (each plugin is overridden at its own xpath, /<name>, matching the name it registers with):
/openAIEmbeddingProvider:
enabled: true
api-key: <your-key>
model: text-embedding-3-small # 1536 dimensions
/documentChunkingInterceptor:
enabled: true
embedding-provider: openAIEmbeddingProvider
/vectorizeOperator:
enabled: true
embedding-provider: openAIEmbeddingProvider
/vectorScanInterceptor:
enabled: true
Start (or restart) RESTHeart with it:
java -jar restheart.jar -o overrides.yml
-
documentChunkingInterceptorsplits every file you upload to a GridFS bucket into overlapping text chunks and, becauseembedding-provideris set, embeds each one immediately with a single batched call. -
vectorizeOperatorlets an aggregation turn a search query’s text into a vector at query time (the$vectorizeoperator used below). -
vectorScanInterceptoris only needed for the$vectorScanhalf of this tutorial β leave it out if you only want$vectorSearch.
2. Create a GridFS bucket and upload some documents
RESTHeart’s default configuration mounts the restheart database at the URL root (/), so collections and GridFS buckets live directly under / β no separate "create a database" step, and _chunks (documentChunkingInterceptor’s default `target-collection) is reachable straight at /_chunks. If your own deployment mounts things differently, adjust the paths below accordingly.
Create a GridFS bucket named docs:
cURL
curl -X PUT [RESTHEART-URL]/docs.files \
-H "Authorization: Basic [BASIC-AUTH]"
HTTPie
http PUT [RESTHEART-URL]/docs.files \
Authorization:"Basic [BASIC-AUTH]"
Upload a few short files β one per topic keeps this tutorial’s results easy to eyeball (a real PDF or Word document works identically, just swap the uploaded file):
echo "Electric cars use rechargeable batteries and electric motors instead of a gasoline engine. Battery technology and charging infrastructure are the main constraints on their range." > ev.txt
echo "The Great Wall of China is an ancient series of fortifications stretching thousands of kilometers, built over centuries to protect Chinese states from invasions." > wall.txt
echo "Sourdough bread is made by fermenting dough using naturally occurring lactobacilli and wild yeast, giving it a distinctive tangy flavor." > bread.txt
cURL
curl -X POST [RESTHEART-URL]/docs.files -H "Authorization: Basic [BASIC-AUTH]" -F file=@ev.txt
curl -X POST [RESTHEART-URL]/docs.files -H "Authorization: Basic [BASIC-AUTH]" -F file=@wall.txt
curl -X POST [RESTHEART-URL]/docs.files -H "Authorization: Basic [BASIC-AUTH]" -F file=@bread.txt
HTTPie
http --form POST [RESTHEART-URL]/docs.files Authorization:"Basic [BASIC-AUTH]" file@ev.txt
http --form POST [RESTHEART-URL]/docs.files Authorization:"Basic [BASIC-AUTH]" file@wall.txt
http --form POST [RESTHEART-URL]/docs.files Authorization:"Basic [BASIC-AUTH]" file@bread.txt
Each upload triggers documentChunkingInterceptor: the file’s text is extracted, split into chunks, embedded, and stored in _chunks. Check what landed there:
cURL
curl '[RESTHEART-URL]/_chunks?rep=s' \
-H "Authorization: Basic [BASIC-AUTH]"
HTTPie
http GET [RESTHEART-URL]/_chunks rep==s \
Authorization:"Basic [BASIC-AUTH]"
Each chunk looks like this β short files like these produce one chunk each:
{
"_id": "...",
"source": "restheart/docs.files/...",
"fileId": "...",
"chunkIndex": 0,
"text": "Electric cars use rechargeable batteries and electric motors instead of a gasoline engine...",
"vector": [0.0123, -0.0456, "... 1536 numbers total ..."]
}
That vector field is what both $vectorScan and $vectorSearch will search against below.
3. Semantic search with $vectorScan β no mongot, no index
Define an aggregation pipeline on _chunks that turns the query text into a vector with $vectorize, then scans and ranks chunks by cosine similarity with $vectorScan:
cURL
curl -X PATCH [RESTHEART-URL]/_chunks \
-H "Authorization: Basic [BASIC-AUTH]" \
-H "Content-Type: application/json" \
-d '{
"aggrs": [{
"uri": "semanticScan",
"type": "pipeline",
"stages": [
{ "$vectorScan": {
"path": "vector",
"queryVector": { "$vectorize": { "$var": "q" } },
"similarity": "cosine",
"limit": 3
}},
{ "$project": { "text": 1, "score": 1 } }
]
}]
}'
HTTPie
echo '{
"aggrs": [{
"uri": "semanticScan",
"type": "pipeline",
"stages": [
{ "$vectorScan": {
"path": "vector",
"queryVector": { "$vectorize": { "$var": "q" } },
"similarity": "cosine",
"limit": 3
}},
{ "$project": { "text": 1, "score": 1 } }
]
}]
}' | http PATCH [RESTHEART-URL]/_chunks Authorization:"Basic [BASIC-AUTH]"
Query it:
cURL
curl '[RESTHEART-URL]/_chunks/_aggrs/semanticScan?avars={"q":"electric+vehicles+and+battery+technology"}&rep=s' \
-H "Authorization: Basic [BASIC-AUTH]"
HTTPie
http GET '[RESTHEART-URL]/_chunks/_aggrs/semanticScan?avars={"q":"electric+vehicles+and+battery+technology"}&rep=s' \
Authorization:"Basic [BASIC-AUTH]"
The electric cars chunk comes back first with the highest score, even though the query text ("electric vehicles and battery technology") shares barely any words with the stored text ("Electric cars use rechargeable batteries…") β this is semantic, not keyword, matching. No vector search index was created, and mongot never entered the picture: $vectorScan computed every distance itself.
4. The same data with $vectorSearch β indexed, needs mongot
Now create a real vector search index on the same vector field:
cURL
curl -X PUT [RESTHEART-URL]/_chunks/_indexes/chunk_vectors \
-H "Authorization: Basic [BASIC-AUTH]" \
-H "Content-Type: application/json" \
-d '{
"type": "vectorSearch",
"fields": [
{ "type": "vector", "path": "vector", "numDimensions": 1536, "similarity": "cosine" }
]
}'
HTTPie
echo '{
"type": "vectorSearch",
"fields": [
{ "type": "vector", "path": "vector", "numDimensions": 1536, "similarity": "cosine" }
]
}' | http PUT [RESTHEART-URL]/_chunks/_indexes/chunk_vectors Authorization:"Basic [BASIC-AUTH]"
numDimensions must match your embedding model β 1536 for text-embedding-3-small. Building the index takes a few seconds; check its status with a GET on /_chunks/_indexes.
Define an equivalent aggregation using $vectorSearch:
cURL
curl -X PATCH [RESTHEART-URL]/_chunks \
-H "Authorization: Basic [BASIC-AUTH]" \
-H "Content-Type: application/json" \
-d '{
"aggrs": [{
"uri": "semanticSearch",
"type": "pipeline",
"stages": [
{ "$vectorSearch": {
"index": "chunk_vectors",
"path": "vector",
"queryVector": { "$vectorize": { "$var": "q" } },
"numCandidates": 100,
"limit": 3
}}
]
}]
}'
HTTPie
echo '{
"aggrs": [{
"uri": "semanticSearch",
"type": "pipeline",
"stages": [
{ "$vectorSearch": {
"index": "chunk_vectors",
"path": "vector",
"queryVector": { "$vectorize": { "$var": "q" } },
"numCandidates": 100,
"limit": 3
}}
]
}]
}' | http PATCH [RESTHEART-URL]/_chunks Authorization:"Basic [BASIC-AUTH]"
Query it with the same text as before:
cURL
curl '[RESTHEART-URL]/_chunks/_aggrs/semanticSearch?avars={"q":"electric+vehicles+and+battery+technology"}&rep=s' \
-H "Authorization: Basic [BASIC-AUTH]"
HTTPie
http GET '[RESTHEART-URL]/_chunks/_aggrs/semanticSearch?avars={"q":"electric+vehicles+and+battery+technology"}&rep=s' \
Authorization:"Basic [BASIC-AUTH]"
Same top result, same underlying vector field β the difference is entirely in how the match was found: $vectorSearch used mongot’s indexed approximate-nearest-neighbor search instead of a full scan.
5. Bonus: rerank either one
A rerank block works the same way on top of both aggregations, since it re-scores whatever result array the pipeline already produced. Add these two entries to the same overrides.yml from step 1 and restart:
/voyageRerankProvider:
enabled: true
api-key: <your-voyage-key>
/rerankingInterceptor:
enabled: true
rerank-provider: voyageRerankProvider
cURL
curl -X PATCH [RESTHEART-URL]/_chunks \
-H "Authorization: Basic [BASIC-AUTH]" \
-H "Content-Type: application/json" \
-d '{
"aggrs": [{
"uri": "semanticScan",
"type": "pipeline",
"stages": [
{ "$vectorScan": {
"path": "vector",
"queryVector": { "$vectorize": { "$var": "q" } },
"similarity": "cosine",
"limit": 10
}}
],
"rerank": { "model": "rerank-2.5", "query": { "$var": "q" }, "topK": 3 }
}]
}'
HTTPie
echo '{
"aggrs": [{
"uri": "semanticScan",
"type": "pipeline",
"stages": [
{ "$vectorScan": {
"path": "vector",
"queryVector": { "$vectorize": { "$var": "q" } },
"similarity": "cosine",
"limit": 10
}}
],
"rerank": { "model": "rerank-2.5", "query": { "$var": "q" }, "topK": 3 }
}]
}' | http PATCH [RESTHEART-URL]/_chunks Authorization:"Basic [BASIC-AUTH]"
See the Reranking section of the overview for the full set of rerank providers.
6. Bonus: context-aware embeddings with Voyage AI
openAIEmbeddingProvider in step 1 embeds each chunk on its own β the model never sees the rest of the file. chunk-overlap: 200 compensates for that: it duplicates a slice of text at each chunk boundary so a chunk doesn’t lose the sentence that trails off just before it starts.
voyageContextualEmbeddingProvider (Voyage’s voyage-context-4 model) removes the need for that trick. documentChunkingInterceptor detects that the configured provider implements ContextualEmbeddingModel and, instead of embedding each chunk in isolation, sends all of a file’s chunks together in one call β every chunk’s vector is computed already aware of the chunks around it. With that awareness coming from the embedding call itself, duplicating text at the boundaries buys nothing, so set chunk-overlap: 0:
Update overrides.yml to swap the embedding provider and restart:
/voyageContextualEmbeddingProvider:
enabled: true
api-key: <your-voyage-key>
model: voyage-context-4 # default
/documentChunkingInterceptor:
enabled: true
embedding-provider: voyageContextualEmbeddingProvider
chunk-overlap: 0 # the contextual call already sees neighboring chunks β no need to duplicate text between them
Re-upload the same files from step 2 with this configuration active β the resulting _chunks documents have the exact same shape, still with a plain vector field, so everything in steps 3, 4 and 5 ($vectorScan, $vectorSearch, reranking) queries them exactly the same way. Only how the vectors were computed changed.
Which one should you use?
$vectorScan |
$vectorSearch |
|
|---|---|---|
Infrastructure |
Any MongoDB, no extra components |
mongot: Atlas, or Community/Enterprise 8.2+ with mongot installed |
Index |
None |
A vector search index ( |
Search |
Brute-force, scores every matched candidate |
Indexed approximate nearest-neighbor |
Best for |
No mongot available, smaller candidate sets, getting started fast |
Large collections, production-scale search |
Both read the same vector field and compose with $match/$sort before them and rerank after β start with $vectorScan while you’re building, and switch to $vectorSearch once you have mongot and need to scale.