Guide · Vector Databases
MCP Tools for Qdrant — api-key header auth, payload filtering, HNSW index params, scroll pagination, named vectors
Qdrant has three design decisions that differentiate it from other vector databases: authentication is optional in self-hosted Docker deployments but required in Qdrant Cloud — local development requires zero credentials while the same code talking to a cloud instance fails with HTTP 403 until you add the api-key header; payloads are arbitrary JSON stored alongside each point and are the primary filtering mechanism — unlike Pinecone's separate metadata object, Qdrant payloads support nested JSON, arrays, geo-coordinates, and datetime values with a rich filter DSL using must, should, and must_not combinators; and named vectors allow a single point to carry multiple embedding representations — a document can have both a title vector (short, 256-dim) and a content vector (long, 1536-dim), and you can search against either independently.
TL;DR
Auth: api-key: {QDRANT_API_KEY} header (omit for no-auth local Docker). Upsert: PUT /collections/{name}/points with { points: [{ id, vector, payload }] }. Search: POST /collections/{name}/points/search with { vector, limit, with_payload: true, filter }. Scroll: POST /collections/{name}/points/scroll with offset from previous response's next_page_offset. Health: GET /healthz → HTTP 200. Payload filter: { must: [{ key: "status", match: { value: "active" } }] }.
Authentication — optional locally, required in Qdrant Cloud
Qdrant uses an api-key header (lowercase, not Authorization). In self-hosted mode without the QDRANT__SERVICE__API_KEY environment variable set, the server accepts all requests without authentication — no header needed. In Qdrant Cloud, every request requires the api-key header. Sending requests to a cloud instance without the header returns HTTP 403 Forbidden.
import fetch from 'node-fetch';
const QDRANT_URL = process.env.QDRANT_URL!; // e.g. http://localhost:6333 or https://xyz.qdrant.tech
const QDRANT_API_KEY = process.env.QDRANT_API_KEY; // undefined = no-auth local mode
function qdrantHeaders(extra: Record<string, string> = {}): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...extra,
};
// Only add api-key header when the key is configured
// This allows the same code to work against both local and cloud instances
if (QDRANT_API_KEY) {
headers['api-key'] = QDRANT_API_KEY;
}
return headers;
}
// Verify connectivity at startup
const healthRes = await fetch(`${QDRANT_URL}/healthz`);
if (!healthRes.ok) throw new Error(`Qdrant not reachable: ${healthRes.status}`);
Qdrant also supports gRPC on port 6334 (vs REST on 6333). For MCP tools, REST is typically more practical — the gRPC client libraries add dependency weight and the performance difference is only meaningful for extremely high-throughput insert workloads (>10k vectors/second). Use REST unless you've profiled and confirmed gRPC is necessary.
Collection creation — vector config, distance metric, HNSW params
Qdrant collections are created with an explicit vector configuration that specifies the embedding dimension, distance metric, and HNSW index parameters. The distance metric cannot be changed after collection creation. Unlike Pinecone and Weaviate, Qdrant does not manage embeddings — you always provide vectors explicitly on every upsert.
// Create a collection with single vector configuration
async function createCollection(name: string, dimension: number) {
const res = await fetch(`${QDRANT_URL}/collections/${name}`, {
method: 'PUT',
headers: qdrantHeaders(),
body: JSON.stringify({
vectors: {
size: dimension,
distance: 'Cosine', // Cosine | Dot | Euclid | Manhattan
},
// HNSW index parameters — tune accuracy vs. speed tradeoff
hnsw_config: {
m: 16, // number of neighbors in HNSW graph (higher = more accurate, slower build)
ef_construct: 100, // candidates during indexing (higher = more accurate, slower insert)
full_scan_threshold: 10000, // switch to flat scan for small collections
},
// Quantization reduces memory at cost of accuracy
quantization_config: {
scalar: {
type: 'int8',
quantile: 0.99,
always_ram: true, // keep quantized vectors in RAM for faster search
},
},
// on_disk: true persists vectors to disk, reducing RAM usage at cost of query speed
}),
});
return res.json();
}
// Named vectors — one point, multiple embedding spaces
async function createNamedVectorCollection(name: string) {
const res = await fetch(`${QDRANT_URL}/collections/${name}`, {
method: 'PUT',
headers: qdrantHeaders(),
body: JSON.stringify({
vectors: {
title: { size: 256, distance: 'Cosine' },
content: { size: 1536, distance: 'Cosine' },
image: { size: 512, distance: 'Dot' },
},
}),
});
return res.json();
}
HNSW m controls graph connectivity: higher values improve recall at search time but increase memory usage (approximately m * 2 * 8 bytes per vector). The default m=16 works well for most use cases. Increase to m=32 or m=64 when recall above 99% is required. The ef_construct value only affects index build time — it does not affect search quality once the index is built.
Payload filtering — must, should, must_not with nested conditions
Qdrant's filter DSL uses three combinators: must (AND), should (OR), and must_not (NOT). Each combinator takes an array of conditions. Conditions can be field comparisons (match, range, geo_bounding_box, geo_radius, values_count, is_null, is_empty) or nested filter objects. Payload fields used in filters do not need to be declared in advance — Qdrant indexes payload fields dynamically.
// Filter: category is "article" AND (language is "en" OR language is "de")
// AND published_at is NOT null AND score >= 0.5
const filter = {
must: [
{
key: 'category',
match: { value: 'article' }, // exact match on a scalar field
},
{
key: 'score',
range: { gte: 0.5, lte: 1.0 }, // numeric range
},
{
should: [ // nested OR inside the outer AND
{ key: 'language', match: { value: 'en' } },
{ key: 'language', match: { value: 'de' } },
],
},
],
must_not: [
{ is_null: { key: 'published_at' } }, // exclude records with null published_at
],
};
// Match any value in an array — equivalent to SQL IN (...)
const inFilter = {
must: [
{ key: 'status', match: { any: ['active', 'pending'] } },
],
};
// Full-text match on a payload field (requires creating a text index first)
const textFilter = {
must: [
{ key: 'content', match: { text: 'authentication bearer token' } },
],
};
For full-text filtering to work efficiently, you must create a payload index on the field: PUT /collections/{name}/index with { field_name: "content", field_schema: "text" }. Without a payload index, Qdrant performs a full collection scan for filter conditions. Use GET /collections/{name}/index to list current payload indexes.
Upsert and search — point IDs, batch size, with_payload
Qdrant point IDs can be unsigned 64-bit integers or UUID strings. Both formats work in the same collection. The upsert endpoint (PUT /collections/{name}/points) is idempotent — inserting a point with an existing ID overwrites it completely. The recommended batch size for upserts is 100–256 points per request, matching Qdrant's internal HNSW graph update batch.
// Upsert points with payload
async function upsertPoints(
collection: string,
points: Array<{
id: string | number;
vector: number[];
payload?: Record<string, unknown>;
}>
) {
const res = await fetch(`${QDRANT_URL}/collections/${collection}/points`, {
method: 'PUT',
headers: qdrantHeaders(),
body: JSON.stringify({
points,
// wait: true — block until indexing is complete (default: false = async)
// For MCP tools, wait: true provides read-after-write consistency
}),
});
if (!res.ok) throw new Error(`Upsert failed: ${await res.text()}`);
return res.json(); // { result: { operation_id, status: "completed" } }
}
// Search
async function searchPoints(
collection: string,
vector: number[],
limit: number,
filter?: object
) {
const res = await fetch(`${QDRANT_URL}/collections/${collection}/points/search`, {
method: 'POST',
headers: qdrantHeaders(),
body: JSON.stringify({
vector,
limit,
filter,
with_payload: true, // include payload in results
with_vector: false, // exclude vectors from results (reduces payload size)
score_threshold: 0.5, // omit results below this similarity score
}),
});
return (await res.json()).result;
// Each result: { id, version, score, payload, vector (if with_vector: true) }
}
By default, Qdrant upserts are asynchronous — the call returns immediately after the operation is queued, before HNSW indexing is complete. For read-after-write consistency in MCP tools (especially search operations immediately after upsert), add the query parameter ?wait=true to the upsert URL or set wait: true in the request body. This blocks until the operation is fully indexed, with a small latency cost.
Scroll API — full collection iteration without cursor drift
Qdrant's scroll API (POST /collections/{name}/points/scroll) iterates all points in a collection using an offset-based pagination mechanism. Unlike cursor-based pagination, the offset is a point ID — the scroll always starts after the provided ID. The response includes a next_page_offset field that is null when all points have been returned.
// Iterate all points in a collection
async function* scrollCollection(
collection: string,
filter?: object,
batchSize = 100
): AsyncGenerator<Array<{ id: string | number; payload: unknown }>> {
let offset: string | number | null = null;
while (true) {
const res = await fetch(`${QDRANT_URL}/collections/${collection}/points/scroll`, {
method: 'POST',
headers: qdrantHeaders(),
body: JSON.stringify({
limit: batchSize,
offset, // null on first call, then next_page_offset from previous response
filter,
with_payload: true,
with_vector: false,
}),
});
const data = await res.json();
const { points, next_page_offset } = data.result;
yield points;
if (next_page_offset === null) break; // no more pages
offset = next_page_offset;
}
}
// Usage
for await (const batch of scrollCollection('my-collection')) {
for (const point of batch) {
// process point.payload
}
}
Important: scroll offsets are point IDs, not page numbers. If you delete points while scrolling, you may skip or re-visit points depending on the IDs relative to the current offset. For stable full-table scans, take a collection snapshot first or ensure no concurrent deletes during iteration.
Named vector search — multiple embeddings per point
When a collection has named vectors, search operations must specify which vector to search against using the vector field with a named-vector object rather than a plain array. This enables multi-representation search — for example, searching only the short title embeddings for speed, or searching the full content embeddings for recall.
// Search using a specific named vector
async function searchNamedVector(
collection: string,
vectorName: string, // 'title' | 'content' | 'image'
queryVector: number[],
limit: number
) {
const res = await fetch(`${QDRANT_URL}/collections/${collection}/points/search`, {
method: 'POST',
headers: qdrantHeaders(),
body: JSON.stringify({
vector: {
name: vectorName,
vector: queryVector,
},
limit,
with_payload: true,
}),
});
return (await res.json()).result;
}
// Upsert a point with named vectors
const pointWithNamedVectors = {
id: 'doc-001',
vector: {
title: [/* 256-dim title embedding */],
content: [/* 1536-dim content embedding */],
},
payload: {
title: 'Authentication patterns',
content: 'Bearer tokens, OAuth2 flows...',
category: 'security',
},
};
Named vectors can have different sizes and distance metrics within the same collection. A point does not need to supply all named vectors — you can insert with only title and add the content vector later via a partial update (PUT /collections/{name}/points/vectors). Searching on a vector name that a point doesn't have simply excludes that point from results.
Health probe — /healthz, /readyz, and collection info
Qdrant exposes GET /healthz for liveness and GET /readyz for readiness — both return HTTP 200 with plain text "ok" when healthy. For collection-level health (vector count, index status, disk usage), use GET /collections/{name}.
async function checkQdrantHealth(collection: string): Promise<{
alive: boolean;
collectionStatus: string;
vectorCount: number;
indexedVectorCount: number;
}> {
// Liveness
const healthRes = await fetch(`${QDRANT_URL}/healthz`);
if (!healthRes.ok) return { alive: false, collectionStatus: 'unknown', vectorCount: 0, indexedVectorCount: 0 };
// Collection-level status
const collRes = await fetch(`${QDRANT_URL}/collections/${collection}`, {
headers: qdrantHeaders(),
});
if (collRes.status === 404) throw new Error(`Collection "${collection}" not found`);
const collData = await collRes.json();
const info = collData.result;
return {
alive: true,
collectionStatus: info.status, // "green" | "yellow" | "red" | "grey"
vectorCount: info.vectors_count,
indexedVectorCount: info.indexed_vectors_count,
// status "yellow" means HNSW indexing is in progress — searches still work but may be slower
// status "red" means the collection has an error — requires investigation
};
}
Collection status "grey" means no vectors have been indexed yet (empty or newly created). Status "yellow" is normal immediately after a large batch upsert while HNSW background indexing catches up — queries still work using a flat scan for un-indexed vectors. Status "red" indicates a storage or indexing error requiring attention.
Related guides
- MCP Tools for Pinecone — Api-Key header auth, namespace-scoped vectors, batch upsert limits
- MCP Tools for Weaviate — OIDC/API key auth, class schema, GraphQL vs REST, hybrid search
- MCP Tools for ChromaDB — embedded vs client/server mode, no-auth local, distance metrics
- MCP Tools for Milvus — schema-first collections, explicit index creation, partition management
- MCP server health check patterns
- MCP server uptime monitoring