Guide · Amazon DocumentDB · Vector Search · Semantic Retrieval · MCP Server Tools
DocumentDB Vector Search for MCP Servers — $vectorSearch, HNSW Indexes, and Semantic Retrieval
MCP server tools that answer semantic questions — "find documents similar to this concept", "retrieve the most relevant knowledge base chunks for this query", "search product descriptions by meaning not keyword" — need a vector store. DocumentDB 5.0 added native vector search via the $vectorSearch aggregation stage, turning DocumentDB into a combined document and vector database without requiring a separate vector store service. This guide covers DocumentDB 5.0 version requirements, creating HNSW and ivfFlat vector indexes, the $vectorSearch aggregation stage syntax, generating embeddings with Amazon Bedrock, hybrid search with pre-filtering, selecting the right similarity metric, and a comparison against Bedrock Knowledge Bases, OpenSearch, and pgvector for vector workloads.
TL;DR
Vector search requires DocumentDB 5.0 (the docdb5.0 parameter family). Create a vector index with type: 'hnsw' and the exact dimensions count that matches your embedding model (1,536 for Titan Embed V2 at default, 1,024 for Cohere Embed English, 3,072 for text-embedding-3-large). Use similarity: 'cosine' for normalized embeddings (most embedding models produce normalized vectors). Pass your query embedding as a plain JavaScript number[] array — not a Float32Array — to the $vectorSearch stage. Apply $match filters before $vectorSearch for pre-filtering (tenant isolation, category scoping) and project { score: { $meta: 'vectorSearchScore' } } to include relevance scores in the output.
DocumentDB 5.0 requirement
Vector search is only available in DocumentDB 5.0. DocumentDB 4.0 and 3.6 clusters do not support the $vectorSearch aggregation stage or vector index creation. Verify your cluster version before attempting vector index operations:
// Check cluster engine version
const admin = client.db('admin');
const { version } = await admin.command({ buildInfo: 1 });
console.log('DocumentDB engine version:', version);
// Should be 5.0.x for vector search support
// Attempting to create a vector index on DocumentDB 4.0 throws:
// MongoServerError: unknown option: vectorOptions
DocumentDB Elastic Clusters (the sharded, pay-per-use variant) also supports vector search on the 5.0 engine. Elastic Clusters use the endpoint format cluster.xxx.docdb-elastic.us-east-1.amazonaws.com and have higher storage and query throughput limits, making them better suited for large-scale vector search use cases.
Similarity metrics
DocumentDB supports three similarity metrics for vector search. The choice is part of the index definition and cannot be changed without dropping and recreating the index:
| Metric | Formula | Range | Best for |
|---|---|---|---|
cosine |
cos(θ) between two vectors; measures angle, not magnitude | -1 to 1 (higher = more similar); DocumentDB returns distance (1 - cosine similarity) so lower score = more similar in raw form, but the aggregation returns similarity | Text embeddings where magnitude is not meaningful; most embedding models (Bedrock Titan, Cohere, OpenAI) produce L2-normalized vectors where cosine and dot product are equivalent |
euclidean |
L2 distance (straight-line distance in N-dimensional space); smaller = more similar | 0 to ∞ (lower = more similar); DocumentDB returns a similarity score so higher is better | Numeric feature vectors where magnitude matters (prices, counts, physical measurements); image embeddings where scale of representation is meaningful |
dotProduct |
Dot product of two vectors; equivalent to cosine similarity for unit-normalized vectors | -∞ to ∞ (higher = more similar); faster to compute than cosine | Pre-normalized vectors (saves the normalization step inside the similarity computation); marginally faster than cosine; use only when vectors are explicitly normalized to unit length |
Rule of thumb: use cosine for text and language embeddings. The major embedding models (Bedrock Titan Embed, Cohere Embed, OpenAI embeddings) all return L2-normalized vectors, making cosine and dot product numerically equivalent — but cosine is the more conventional choice and clearer to future readers.
HNSW vs ivfFlat index types
| Aspect | HNSW (Hierarchical Navigable Small World) | ivfFlat (Inverted File Index, Flat) |
|---|---|---|
| Algorithm | Graph-based approximate nearest neighbor; builds a multi-layer graph at index creation time; query traverses the graph | Clusters vectors into Voronoi cells at index build time; query searches the nearest cells; exact within each cell |
| Build time | Longer — graph construction is O(n log n) with higher constant; efConstruction controls quality vs build time |
Shorter — k-means clustering is relatively fast |
| Query speed | Fast — typically faster than ivfFlat for the same recall level | Slower — must scan all vectors in the matched cells |
| Memory | Higher — the graph structure plus the original vectors | Lower — only the original vectors plus a small inverted file structure |
| Recall quality | Excellent — typically 95-99% recall at reasonable ef values |
Good — controlled by nProbes (number of cells to search); higher nProbes = higher recall but slower |
| Dynamic updates | Supports online inserts without rebuilding the entire index (new vectors are added to the graph) | Requires periodic full rebuild as the cluster centroids drift with new data; online inserts degrade recall over time |
| Recommended for | Most use cases: collections under 10M vectors, collections with ongoing inserts, latency-sensitive MCP tools | Read-mostly collections loaded in bulk; memory-constrained environments; very large collections where HNSW build cost is prohibitive |
Creating vector indexes
const db = (await getClient()).db('mydb');
const coll = db.collection('documents');
// ---- HNSW index (recommended) ----
await coll.createIndex(
{ embedding: 'vector' },
{
vectorOptions: {
type: 'hnsw',
dimensions: 1536, // must exactly match your embedding model's output dimensions
similarity: 'cosine',
m: 16, // number of bidirectional links per node (default 16)
// higher m = better recall, more memory, slower build
efConstruction: 64, // candidates considered during build (default 64)
// higher = better recall quality, slower index build
},
}
);
// ---- ivfFlat index (alternative) ----
await coll.createIndex(
{ embedding: 'vector' },
{
vectorOptions: {
type: 'ivfflat',
dimensions: 1536,
similarity: 'cosine',
lists: 100, // number of Voronoi cell clusters
// rule of thumb: sqrt(total_vectors)
// must rebuild if significantly off (too few = poor recall)
},
}
);
// Check index was created
const indexes = await coll.listIndexes().toArray();
console.log(indexes.find(i => i.key.embedding === 'vector'));
// { key: { embedding: 'vector' }, name: 'embedding_vector', vectorOptions: { ... } }
Dimension mismatch is a silent failure: if you create a vector index with dimensions: 1536 and then insert a document with an embedding array of 1024 elements, DocumentDB does not reject the document — but the document is not indexed. Queries using $vectorSearch will not return that document, with no error. Validate embedding dimensions before inserting to prevent missing results.
Generating embeddings with Amazon Bedrock
import {
BedrockRuntimeClient,
InvokeModelCommand,
} from '@aws-sdk/client-bedrock-runtime';
const bedrock = new BedrockRuntimeClient({ region: process.env.AWS_REGION });
// Amazon Titan Embed V2 — 1536 dimensions (default)
// Also supports 256 or 1024 dimensions for storage/speed tradeoff
async function embedWithTitanV2(text: string, dimensions = 1536): Promise {
const { body } = await bedrock.send(new InvokeModelCommand({
modelId: 'amazon.titan-embed-text-v2:0',
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({
inputText: text.slice(0, 8192), // Titan V2 max input: 8,192 tokens
dimensions, // 256, 1024, or 1536 (default)
normalize: true, // normalize to unit length (recommended for cosine)
}),
}));
const { embedding } = JSON.parse(Buffer.from(body).toString('utf-8'));
return embedding; // number[] of length `dimensions`
}
// Cohere Embed English — 1024 dimensions
async function embedWithCohere(text: string): Promise {
const { body } = await bedrock.send(new InvokeModelCommand({
modelId: 'cohere.embed-english-v3',
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({
texts: [text.slice(0, 2048)], // Cohere: max 512 tokens per text
input_type: 'search_query', // 'search_query' for queries; 'search_document' for indexing
embedding_types: ['float'],
}),
}));
const response = JSON.parse(Buffer.from(body).toString('utf-8'));
return response.embeddings.float[0]; // number[] of length 1024
}
// Store document with embedding
async function indexDocument(text: string, metadata: Record): Promise {
const embedding = await embedWithTitanV2(text, 1536);
await coll.insertOne({
text,
embedding, // number[] — stored as a BSON array
...metadata,
indexedAt: new Date(),
});
}
input_type for Cohere: always use input_type: 'search_document' when embedding content you are indexing and input_type: 'search_query' when embedding the search query. The two inputs are optimized differently — using search_document for both degrades recall quality. Titan Embed V2 uses a single embedding space for both and does not require separate input types.
$vectorSearch aggregation stage
// Basic vector search — find the 10 most similar documents to a query embedding
async function semanticSearch(queryText: string, limit = 10): Promise {
const queryEmbedding = await embedWithTitanV2(queryText);
const results = await coll.aggregate([
{
$vectorSearch: {
queryVector: queryEmbedding, // number[] — must match index dimensions exactly
path: 'embedding', // field name containing the vector
numCandidates: limit * 10, // candidates to consider before reranking
// higher = better recall, slower query
// recommended: 10x the limit
limit, // number of results to return
// index: 'embedding_vector', // optional: specify index name if multiple vector indexes
},
},
// Project the similarity score using $meta
{
$project: {
_id: 1,
text: 1,
score: { $meta: 'vectorSearchScore' }, // similarity score 0-1 (1 = most similar)
// embedding: 0, // omit the embedding to reduce response size
},
},
// Optional: filter by minimum score threshold
{ $match: { score: { $gte: 0.75 } } },
]).toArray();
return results as ScoredDocument[];
}
numCandidates tradeoff: numCandidates controls the HNSW search quality — the index explores this many candidate vectors before selecting the final limit results. Higher values improve recall (fewer relevant documents missed) at the cost of query latency. A good starting point is numCandidates = limit * 10. For production, benchmark recall vs latency at your specific collection size and query pattern.
Hybrid search — pre-filtering before vector search
For multi-tenant MCP tools or searches within a category scope, apply a $match filter before $vectorSearch to restrict the candidate set. DocumentDB evaluates pre-filters before the vector search, so only documents matching the filter are searched.
// Hybrid search: tenant isolation + semantic search
async function tenantSemanticSearch(
tenantId: string,
queryText: string,
category?: string,
limit = 10,
): Promise {
const queryEmbedding = await embedWithTitanV2(queryText);
// Build pre-filter — this runs BEFORE vector search to restrict candidates
const preFilter: Record = { tenantId };
if (category) preFilter.category = category;
const results = await coll.aggregate([
// 1. Pre-filter — reduces the search space before vector search
{ $match: preFilter },
// 2. Vector search over pre-filtered documents
{
$vectorSearch: {
queryVector: queryEmbedding,
path: 'embedding',
numCandidates: limit * 10,
limit,
},
},
// 3. Project with score
{
$project: {
_id: 1, text: 1, category: 1, tenantId: 1,
score: { $meta: 'vectorSearchScore' },
},
},
// 4. Optional post-filter: exclude low-score results
{ $match: { score: { $gte: 0.70 } } },
]).toArray();
return results as ScoredDocument[];
}
// MCP tool wrapping the semantic search
server.tool('semantic_search', 'Find semantically similar documents', {
query: z.string().describe('Natural language search query'),
category: z.string().optional().describe('Optional category filter'),
limit: z.number().int().min(1).max(50).default(10),
}, async ({ query, category, limit }) => {
const { tenantId } = getRequestContext(); // from MCP request context
const docs = await tenantSemanticSearch(tenantId, query, category, limit);
return {
content: [{
type: 'text',
text: JSON.stringify(docs.map(d => ({
id: d._id.toString(),
text: d.text,
category: d.category,
score: Math.round(d.score * 1000) / 1000,
}))),
}],
};
});
Pre-filter index requirement: for the pre-filter to be efficient, the filtered fields (tenantId, category) must have a standard DocumentDB index. Without an index on tenantId, the pre-filter stage performs a collection scan before the vector search, negating the performance benefit of pre-filtering. Create a compound index: db.collection.createIndex({ tenantId: 1, category: 1 }).
DocumentDB vector search vs alternatives
| Solution | Best for | Key tradeoffs |
|---|---|---|
| DocumentDB 5.0 vector search | MCP servers already using DocumentDB for document storage; teams that want a single data store for documents + vectors; AWS-native compliance requirements | Pro: no separate vector store; documents and embeddings in the same collection; full DocumentDB CRUD + aggregation pipeline + vector search. Con: requires DocumentDB 5.0; limited advanced vector search features vs dedicated systems (no hybrid BM25+vector, no vector filtering on arbitrary nested arrays) |
| Amazon Bedrock Knowledge Bases | Fully managed RAG without managing any vector infrastructure; chat and Q&A MCP tools that need a simple retrieve() API; teams without vector database experience | Pro: zero infrastructure; native Bedrock Retrieve API; built-in chunking and embedding pipeline. Con: limited control over chunking strategy and embedding model; data must be in S3; query API is simpler than raw vector search; no arbitrary filter expressions |
| Amazon OpenSearch with k-NN | Use cases that need both full-text BM25 search and vector search in the same query (hybrid search); rich filtering, scripted scoring, facets | Pro: best full-text + vector hybrid search; rich aggregation; supports approximate and exact k-NN. Con: separate service to operate; higher baseline cost than DocumentDB; Lucene-based full-text is not the MongoDB query language |
| pgvector on Aurora PostgreSQL | MCP servers using relational data with vector search; SQL-native queries combining vectors and relational predicates | Pro: SQL joins between vectors and relational tables; Aurora Serverless v2; full PostgreSQL. Con: Aurora is not MongoDB-compatible — different query language and driver ecosystem; connection pooling via RDS Proxy important for Lambda |
Common errors
| Error | Cause | Fix |
|---|---|---|
MongoServerError: unknown option: vectorOptions |
Attempting to create a vector index on DocumentDB 4.0 or 3.6; vector search requires DocumentDB 5.0 | Upgrade the cluster to DocumentDB 5.0 or switch to DocumentDB Elastic |
MongoServerError: unknown operator: $vectorSearch |
Same — cluster is not running DocumentDB 5.0 | Upgrade to DocumentDB 5.0 |
$vectorSearch returns fewer results than limit |
Pre-filter is too restrictive and reduces the candidate set below numCandidates; or the collection has fewer documents than the limit |
Increase numCandidates; relax the pre-filter; remove the post-filter score threshold temporarily to verify how many candidates exist above each score level |
| Vector search returns documents with wrong tenant's data | Pre-filter on tenantId is missing, or the index on tenantId is missing so pre-filter is applied as post-filter (documents from other tenants are scored and then filtered — not secure) |
Always include tenantId in the pre-filter before $vectorSearch; verify with explain() that the pre-filter stage shows an IXSCAN on the tenantId index |
score is always undefined in projected results |
$meta: 'vectorSearchScore' must be used in a $project stage that immediately follows $vectorSearch in the pipeline |
Place { $project: { score: { $meta: 'vectorSearchScore' }, ... } } as the stage directly after $vectorSearch; the metadata is only accessible in the immediately following $project |
| High query latency on vector search | numCandidates is set too high relative to limit; pre-filter is not selective enough leaving millions of candidates for the vector search phase |
Start with numCandidates = limit * 10 and tune upward only if recall is insufficient; ensure pre-filter uses an index to reduce candidates before the vector phase; consider reducing the vector dimensions (Titan Embed V2 supports 256 and 1024 as alternatives to 1536) |