Vector Databases · 2026-07-18 · Vector Database arc

MCP Tools for Vector Databases: Auth and Connection Spectrum, Schema Design Philosophies, and Query Interface Diversity

Five vector databases — Pinecone, Weaviate, Qdrant, ChromaDB, and Milvus — all solve the same problem (nearest-neighbor search over high-dimensional vectors) while making completely different decisions about authentication, schema design, and query interface. These differences are not superficial. The MCP server initialization code, the data insertion format, and the search invocation pattern look fundamentally different for each database, and the failure modes from conflating them are all silent: wrong auth header returns HTTP 401 with no hint about the header name; missing schema schema setup returns cryptic validation errors; attempting to search an unloaded Milvus collection returns "collection not loaded" with no guidance about the mandatory load() step. Pinecone uses a proprietary Api-Key header (not Authorization: Bearer), resolves a per-index host URL from the control plane, and stores schema-free vectors with arbitrary JSON metadata. Weaviate runs in one of three authentication modes (anonymous, API key, OIDC) set at instance startup — not per-request — and requires a class-based schema where the vectorizer module must be enabled in the server configuration before a class referencing it can be created. Qdrant uses an api-key header (lowercase) that is optional for local Docker deployments but required for Qdrant Cloud, stores arbitrary JSON payloads alongside points, and exposes a REST JSON filter DSL with must/should/must_not combinators. ChromaDB runs in two fundamentally different operational modes — embedded in-process and client/server — that share the same Python API, defaults to no authentication in both modes, requires string IDs (not integers), and ships a default embedding function that pulls a 90MB model on first call. Milvus requires a strict FieldSchema-based collection definition that is immutable after creation, mandates an explicit collection.load() call before any search (and again after every server restart), inserts data in column format (not row format), and uses a string expression DSL for filtering rather than a JSON object. This post covers all three patterns with working code for each database, a composite health probe design for each, and a database selection guide based on the trade-offs each approach makes.

TL;DR

Five vector databases, three patterns. (1) Auth and connection spectrum: PineconeApi-Key: {PINECONE_API_KEY} header (NOT Authorization: Bearer), control plane at api.pinecone.io, resolve index host with GET /indexes/{name} → host, all data-plane calls go to that host; Weaviate — instance-level auth mode set at startup: anonymous (no header), API key (X-Weaviate-Api-Key: {key}), or OIDC (Authorization: Bearer {token}), vectorizer module inference API keys forwarded per-request in X-OpenAI-Api-Key headers separate from Weaviate auth; Qdrantapi-key: {key} header (lowercase, optional for local Docker, required for Qdrant Cloud where omitting it returns HTTP 403); ChromaDB — no auth in default local mode (both embedded and HttpClient), optional token auth when server is configured with CHROMA_SERVER_AUTHN_PROVIDER; Milvus — PyMilvus gRPC on port 19530 with connections.connect(user, password), default credentials root/Milvus, Zilliz Cloud uses MilvusClient(uri, token). (2) Schema and data model spectrum: Pinecone — schema-free, dimension + distance metric at index creation, metadata is arbitrary JSON with no schema (any key, any value type), metadata filtering via MongoDB-style operators; Weaviate — class-based schema with typed properties, vectorizer module bound at class creation (must be enabled in server module config), class_name required on every object, property names are lowercase by convention; Qdrant — schema-free at the collection level (dimension + distance only), payload is arbitrary JSON with dynamic indexing, named vectors allow multiple embedding spaces per point; ChromaDB — schema-free collection with pluggable embedding function and distance metric set at collection creation (hnsw:space in metadata — l2/cosine/ip, default l2), IDs must be strings; Milvus — strict FieldSchema list (one DataType.INT64 primary key, one DataType.FLOAT_VECTOR with dim, VARCHAR fields need max_length), schema is immutable after collection creation, VARCHAR max_length enforced at insert time (not schema creation). (3) Query interface diversity: Pinecone — REST JSON POST /{host}/query with { vector, topK, namespace, filter, includeMetadata }; Weaviate — GraphQL POST /v1/graphql with schema-aware query strings, errors returned as HTTP 200 with errors array (not 4xx); Qdrant — REST JSON POST /collections/{name}/points/search with { vector, limit, with_payload, filter }; ChromaDB — Python/JS client library collection.query(query_embeddings, n_results, where) translating to REST; Milvus — PyMilvus gRPC collection.search(data, anns_field, param, limit, expr, output_fields) with mandatory collection.load() first.

Pattern 1: The Auth and Connection Spectrum

Vector database authentication spans a wider range than almost any other API category. The five databases in this arc don't share a single authentication convention — each represents a different philosophy about who controls the auth boundary, where credentials live, and what "no auth" means.

Pinecone: Proprietary Header and Per-Index Host Resolution

Pinecone uses a proprietary Api-Key header — not the Authorization: Bearer pattern used by most APIs. This applies to both the control plane (api.pinecone.io) and the data plane (each index has its own unique host URL). The most common Pinecone integration error is sending Authorization: Bearer {key} instead of Api-Key: {key}: both return HTTP 401 with a generic "Unauthorized" message, and neither error indicates that the header name is wrong.

The second unusual property: all data-plane operations (upsert, query, fetch, delete) must go to an index-specific host URL, not the control-plane base URL. The host is returned by the describe-index endpoint and takes the form {index-name}-{project-id}.svc.{environment}.pinecone.io for pod-based indexes, or a generated URL for serverless indexes. MCP servers must resolve this host at startup and cache it — fetching it on every query adds unnecessary latency and a potential failure point.

const PINECONE_API_KEY = process.env.PINECONE_API_KEY!;
const PINECONE_CONTROL_PLANE = 'https://api.pinecone.io';

// All Pinecone requests use Api-Key header (not Authorization: Bearer)
const pineconeHeaders = {
  'Api-Key': PINECONE_API_KEY,
  'Content-Type': 'application/json',
};

// Resolve index host at MCP server startup — cache result
async function resolveIndexHost(indexName: string): Promise<string> {
  const res = await fetch(`${PINECONE_CONTROL_PLANE}/indexes/${indexName}`, {
    headers: pineconeHeaders,
  });
  if (!res.ok) throw new Error(`Pinecone describe index HTTP ${res.status}`);
  const data = await res.json();
  if (!data.status.ready) {
    throw new Error(`Pinecone index not ready: state=${data.status.state}`);
  }
  return data.host; // e.g. "my-index-abc123.svc.us-east1-aws.pinecone.io"
}

// Startup: resolve host once
const indexHost = await resolveIndexHost(process.env.PINECONE_INDEX_NAME!);

// All data-plane calls go to indexHost, not PINECONE_CONTROL_PLANE
async function upsertVectors(points: Array<{ id: string; values: number[]; metadata?: object }>) {
  const res = await fetch(`https://${indexHost}/vectors/upsert`, {
    method: 'POST',
    headers: pineconeHeaders,
    body: JSON.stringify({ vectors: points, namespace: process.env.PINECONE_NAMESPACE ?? '' }),
  });
  if (!res.ok) throw new Error(`Pinecone upsert HTTP ${res.status}`);
  return res.json();
}

Namespaces in Pinecone partition vectors within an index with complete isolation. A query against namespace "users-prod" cannot see vectors in namespace "users-dev", and vector IDs are namespace-scoped — the same ID can exist in two namespaces without collision. A query without a namespace parameter searches only the default (empty string) namespace, not all namespaces. This is one of the most common Pinecone integration bugs: developers upsert into namespace "main" and query against the default namespace, finding nothing.

Weaviate: Instance-Level Auth Mode, Separate Inference API Keys

Weaviate's authentication model is set at instance startup via environment variables, not per-request. An instance runs in exactly one of three modes: anonymous (no credentials required), API key (clients send X-Weaviate-Api-Key: {key}), or OIDC (clients send Authorization: Bearer {token}). You cannot mix modes within a single instance — a request that works against an anonymous instance will fail against the same instance reconfigured with API key auth, and the error is HTTP 401 with no indication of which header was expected.

Weaviate Cloud (managed service) always uses API key mode with the X-Weaviate-Api-Key header. Self-hosted instances with OIDC use Authorization: Bearer. MCP servers must detect or configure the auth mode at startup rather than trying both.

A separate credential concern: if you use a vectorizer module (e.g., text2vec-openai), the inference API key for the external service (OpenAI API key) is forwarded per-request in a separate header (X-OpenAI-Api-Key), not stored in Weaviate's configuration. This means your MCP server must pass both the Weaviate auth credential and the inference service credential on every request.

const WEAVIATE_URL = process.env.WEAVIATE_URL!;
const WEAVIATE_API_KEY = process.env.WEAVIATE_API_KEY;     // Weaviate Cloud / API key mode
const WEAVIATE_OIDC_TOKEN = process.env.WEAVIATE_OIDC_TOKEN; // self-hosted OIDC mode
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;         // forwarded to vectorizer module

function weaviateHeaders(): Record<string, string> {
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  if (WEAVIATE_API_KEY) {
    headers['X-Weaviate-Api-Key'] = WEAVIATE_API_KEY;  // Weaviate Cloud
  } else if (WEAVIATE_OIDC_TOKEN) {
    headers['Authorization'] = `Bearer ${WEAVIATE_OIDC_TOKEN}`;  // self-hosted OIDC
  }
  // Anonymous mode: no auth headers
  if (OPENAI_API_KEY) {
    headers['X-OpenAI-Api-Key'] = OPENAI_API_KEY;  // forwarded to text2vec-openai module
  }
  return headers;
}

// Weaviate class creation — vectorizer module must be enabled at instance level
async function createClassIfNeeded(className: string, vectorizer: string) {
  const checkRes = await fetch(`${WEAVIATE_URL}/v1/schema/${className}`, {
    headers: weaviateHeaders(),
  });
  if (checkRes.status === 200) return; // already exists
  if (checkRes.status !== 404) throw new Error(`Schema check HTTP ${checkRes.status}`);

  const createRes = await fetch(`${WEAVIATE_URL}/v1/schema`, {
    method: 'POST',
    headers: weaviateHeaders(),
    body: JSON.stringify({
      class: className,
      vectorizer,
      // text2vec-openai module must be in ENABLE_MODULES server config or creation returns 422
      properties: [
        { name: 'content', dataType: ['text'] },
        { name: 'source', dataType: ['text'], skip: true },  // skip:true = exclude from embedding
        { name: 'createdAt', dataType: ['date'] },
      ],
    }),
  });
  if (!createRes.ok) {
    const body = await createRes.text();
    throw new Error(`Weaviate class creation HTTP ${createRes.status}: ${body}`);
  }
}

Qdrant: Optional Auth Locally, Required in Cloud

Qdrant uses an api-key header (lowercase — not Api-Key and not Authorization). In a self-hosted Docker deployment without the QDRANT__SERVICE__API_KEY environment variable set, the server accepts all requests without authentication. Qdrant Cloud requires the header on every request — omitting it returns HTTP 403 Forbidden.

This creates an environment-specific auth trap: code that works perfectly against a local Docker instance fails in production Qdrant Cloud with the same 403 you'd get from a bad API key. The fix is to always include the header when the key is configured, and test with a key even in development by setting QDRANT__SERVICE__API_KEY in your local Docker environment.

const QDRANT_URL = process.env.QDRANT_URL!;
const QDRANT_API_KEY = process.env.QDRANT_API_KEY; // optional locally, required for Qdrant Cloud

function qdrantHeaders(extra: Record<string, string> = {}): Record<string, string> {
  const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extra };
  if (QDRANT_API_KEY) {
    headers['api-key'] = QDRANT_API_KEY; // lowercase — not 'Api-Key' or 'Authorization'
  }
  return headers;
}

// Upsert — async by default, add ?wait=true for read-after-write consistency
async function upsertPoints(collectionName: string, points: Array<{ id: string | number; vector: number[]; payload?: object }>) {
  const url = `${QDRANT_URL}/collections/${collectionName}/points?wait=true`;
  const res = await fetch(url, {
    method: 'PUT',
    headers: qdrantHeaders(),
    body: JSON.stringify({ points }),
  });
  if (!res.ok) throw new Error(`Qdrant upsert HTTP ${res.status}: ${await res.text()}`);
  return res.json();
}

Qdrant is the only database in this arc that supports both string and integer point IDs natively. Pinecone and Chroma require string IDs; Weaviate assigns UUIDs; Milvus requires integer primary keys in PyMilvus. Qdrant's dual ID type support is useful when migrating from a numeric-ID relational database without a UUID mapping layer.

ChromaDB: No-Auth Default, Two Operational Modes, 90MB Model Surprise

ChromaDB's auth story is the simplest in this arc: the default configuration has no authentication at all. The embedded client (PersistentClient, EphemeralClient) runs in-process with no network layer to authenticate against. The HTTP client (HttpClient) connects to a Chroma server that also has no auth by default — a fresh chroma run server accepts all requests from any IP without credentials.

The auth trap here is not a wrong header but a deployment assumption. Developers build against a local embedded or HTTP client, ship to production, and discover that their Chroma server is accessible without credentials from the public internet. Adding token authentication requires setting CHROMA_SERVER_AUTHN_PROVIDER and CHROMA_SERVER_AUTHN_CREDENTIALS environment variables on the server and passing headers on the client — it's not enabled or documented by default in the same way Qdrant's cloud auth is.

The second ChromaDB surprise is the default embedding function. If you create a collection without specifying an embedding function, Chroma uses DefaultEmbeddingFunction which loads sentence-transformers/all-MiniLM-L6-v2 from Hugging Face on first call. This downloads approximately 90MB of model weights. In a Docker container without the model pre-cached, the first collection.add() or collection.query() call hangs for 30–60 seconds while the model downloads. In a serverless or short-lived MCP server process, this download may happen on every cold start.

import chromadb
from chromadb.utils import embedding_functions

# Development: persistent local storage (one process at a time — exclusive lock)
client = chromadb.PersistentClient(path="./chroma-db")

# Production: remote server with optional token auth
# client = chromadb.HttpClient(
#     host=os.environ["CHROMA_HOST"],
#     port=int(os.environ.get("CHROMA_PORT", "8000")),
#     headers={"Authorization": f"Bearer {os.environ['CHROMA_TOKEN']}"}
# )

# Always specify embedding function explicitly — avoid the 90MB default download
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.environ["OPENAI_API_KEY"],
    model_name="text-embedding-3-small",
)

# Get or create collection — distance metric set at creation, immutable
collection = client.get_or_create_collection(
    name="documents",
    embedding_function=openai_ef,
    metadata={"hnsw:space": "cosine"},  # l2 (default), cosine, or ip
)

# IDs must be strings — integers raise ValueError
collection.add(
    ids=["doc-001", "doc-002"],    # NOT [1, 2]
    documents=["first document", "second document"],
    metadatas=[{"category": "blog"}, {"category": "article"}],
)

# client.reset() destroys all collections — guard with environment check
if os.environ.get("ALLOW_RESET") == "true":
    client.reset()

Milvus: gRPC by Default, Schema-First, No Public REST API

Milvus's connection model is unlike the other four: PyMilvus uses gRPC on port 19530 by default, not HTTP. The connections.connect() function manages a global connection registry by alias — creating a Collection object without specifying an alias automatically uses the "default" connection. The REST HTTP port (9091) exposes only health endpoints and limited management APIs; MCP servers built with PyMilvus should not use the REST port for data operations.

For Zilliz Cloud (managed Milvus), use MilvusClient with a URI and token instead of connections.connect(). The MilvusClient API is newer and handles connection management automatically, but the PyMilvus connections.connect() pattern remains more common in existing documentation and examples.

from pymilvus import connections, MilvusClient

# Self-hosted Milvus: gRPC on port 19530
connections.connect(
    alias="default",
    host=os.environ.get("MILVUS_HOST", "localhost"),
    port=os.environ.get("MILVUS_PORT", "19530"),
    user=os.environ.get("MILVUS_USER", "root"),
    password=os.environ.get("MILVUS_PASSWORD", "Milvus"),
)

# Zilliz Cloud: MilvusClient with URI + token
zilliz = MilvusClient(
    uri=os.environ["ZILLIZ_URI"],    # e.g. https://in03-xyz.api.gcp-us-west1.zillizcloud.com
    token=os.environ["ZILLIZ_TOKEN"], # API key or user:password string
)

# Health check: Milvus REST port 9091 exposes /healthz
import requests
health = requests.get(f"http://{MILVUS_HOST}:9091/healthz", timeout=3)
health.raise_for_status()  # 200 = ready

Pattern 2: The Schema and Data Model Spectrum

The five databases make fundamentally different decisions about what structure the stored data must have before the first insert. These decisions propagate through every subsequent operation — filtering, search result shapes, index configuration, and schema migration. Getting the wrong model leads to silently incorrect behavior rather than helpful error messages.

Pinecone: Schema-Free with Arbitrary JSON Metadata

Pinecone is schema-free at the data level. At index creation you specify two things: the vector dimension and the distance metric (cosine, dotproduct, or euclidean). From that point forward, any vector with the correct dimension can be stored, and each vector can carry any JSON metadata with any combination of string, number, boolean, and string-array fields. There is no metadata schema declaration, no property type validation, and no error if different vectors in the same index have completely different metadata keys.

Metadata filtering uses MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $and, $or. For pod-based indexes, metadata fields must be declared in metadata_config.indexed at index creation or filtering on those fields performs a silent full-scan (no error, just slow). For serverless indexes, all metadata fields are automatically indexed.

// Pinecone upsert with heterogeneous metadata — no schema, any key/value
await fetch(`https://${indexHost}/vectors/upsert`, {
  method: 'POST',
  headers: pineconeHeaders,
  body: JSON.stringify({
    vectors: [
      { id: 'doc-1', values: embedding, metadata: { source: 'blog', category: 'tech', score: 0.92, tags: ['mcp', 'api'] } },
      { id: 'doc-2', values: embedding2, metadata: { source: 'docs', version: 3, active: true } },
      // Different metadata shapes — no error
    ],
    namespace: 'production',
  }),
});

// Metadata filter in query — MongoDB-style operators
const queryRes = await fetch(`https://${indexHost}/query`, {
  method: 'POST',
  headers: pineconeHeaders,
  body: JSON.stringify({
    vector: queryEmbedding,
    topK: 10,
    namespace: 'production',
    filter: {
      $and: [
        { category: { $in: ['tech', 'api'] } },
        { score: { $gte: 0.8 } },
      ],
    },
    includeMetadata: true,
  }),
});
const { matches } = await queryRes.json();

The serverless vs pod-based index choice affects both the distance metrics available and the operational model. Serverless indexes support only cosine and dotproduct (not euclidean), bill per query unit rather than per provisioned replica, and have no indexFullness concern. Pod-based indexes support all three metrics, have predictable and lower tail latency (no cold-start), and expose indexFullness in describe_index_stats — an indexFullness > 0.8 means you're approaching pod capacity and need to scale up or add a new index.

Weaviate: Class-Based Schema with Vectorizer Module Binding

Weaviate uses a class-based schema where every stored object belongs to exactly one class. Each class definition specifies: the class name (capitalized by convention), the vectorizer module that generates embeddings for objects in this class, and the property list with typed fields. Properties have data types: text, int, number, boolean, date, uuid, and cross-references to other classes.

The critical constraint: the vectorizer module specified in the class definition must be enabled in the Weaviate instance's ENABLE_MODULES environment variable. If you define a class with "vectorizer": "text2vec-openai" and the instance doesn't have the text2vec-openai module enabled, class creation returns HTTP 422 with an error about the unknown module. This cannot be fixed without restarting the Weaviate instance with the module enabled.

The skip: true property flag excludes a property from the embedding — useful for metadata fields (source URL, created timestamp, category tag) that should be filterable but should not affect the semantic embedding. Without skip: true, all text properties are concatenated and sent to the vectorizer, which can dilute the semantic signal with noise from metadata.

// Weaviate class schema — vectorizer module must be in ENABLE_MODULES at startup
const classSchema = {
  class: 'Article',
  vectorizer: 'text2vec-openai',  // requires ENABLE_MODULES=text2vec-openai in server config
  moduleConfig: {
    'text2vec-openai': {
      model: 'text-embedding-3-small',
      dimensions: 1536,
    },
  },
  properties: [
    { name: 'content', dataType: ['text'] },      // included in embedding
    { name: 'title', dataType: ['text'] },         // included in embedding
    { name: 'source', dataType: ['text'], moduleConfig: { 'text2vec-openai': { skip: true } } }, // excluded
    { name: 'category', dataType: ['text'], moduleConfig: { 'text2vec-openai': { skip: true } } },
    { name: 'publishedAt', dataType: ['date'] },
    { name: 'wordCount', dataType: ['int'] },
  ],
};

// GraphQL search — note: errors come back as HTTP 200 with errors array, not 4xx
const graphqlQuery = `{
  Get {
    Article(
      nearVector: { vector: [${queryEmbedding.join(',')}], certainty: 0.7 }
      where: { path: ["category"], operator: Equal, valueText: "tech" }
      limit: 10
    ) {
      content
      title
      source
      _additional { certainty id }
    }
  }
}`;

const searchRes = await fetch(`${WEAVIATE_URL}/v1/graphql`, {
  method: 'POST',
  headers: weaviateHeaders(),
  body: JSON.stringify({ query: graphqlQuery }),
});
const body = await searchRes.json();
// ALWAYS check body.errors even when HTTP 200 — Weaviate returns errors as HTTP 200
if (body.errors) throw new Error(`Weaviate GraphQL errors: ${JSON.stringify(body.errors)}`);
const results = body.data.Get.Article;

Qdrant: Schema-Free Collections with Named Vectors

Qdrant collections have a minimal schema: vector dimension and distance metric at creation. The payload stored with each point is arbitrary JSON with no schema declaration. Unlike Pinecone, Qdrant supports dynamic payload indexing after collection creation — you can call PUT /collections/{name}/index to add a payload field index later without recreating the collection or reindexing all vectors.

Named vectors are Qdrant's most distinctive schema feature. A collection can define multiple vector spaces with different dimensions and distance metrics. Each point can carry a vector for each named space. Search queries specify which vector space to search. This enables a single point to have both a short title embedding (256-dim, cosine) and a long content embedding (1536-dim, cosine), with search queries targeting either independently. No other database in this arc supports this natively without creating separate collections.

// Qdrant collection with named vectors
const collectionBody = {
  vectors: {
    title: { size: 256, distance: 'Cosine' },
    content: { size: 1536, distance: 'Cosine' },
  },
  // Payload index for filtering — can also be added later via PUT /collections/{name}/index
  payload_schema: {
    category: { data_type: 'keyword' },
    publishedAt: { data_type: 'datetime' },
    score: { data_type: 'float' },
  },
};

// Insert with named vectors — column format supported, object format also works
const upsertBody = {
  points: [
    {
      id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', // string UUID or unsigned integer
      vector: {
        title: titleEmbedding,    // 256-dim
        content: contentEmbedding, // 1536-dim
      },
      payload: { category: 'tech', publishedAt: '2026-07-18T00:00:00Z', score: 0.92 },
    },
  ],
};

// Search against a specific named vector space
const searchBody = {
  vector: { name: 'content', vector: queryEmbedding },
  limit: 10,
  with_payload: true,
  filter: {
    must: [
      { key: 'category', match: { value: 'tech' } },
      { key: 'score', range: { gte: 0.8 } },
    ],
    must_not: [
      { is_null: { key: 'publishedAt' } },
    ],
  },
};

const res = await fetch(`${QDRANT_URL}/collections/articles/points/search`, {
  method: 'POST',
  headers: qdrantHeaders(),
  body: JSON.stringify(searchBody),
});
const { result } = await res.json();

ChromaDB: Schema-Free Collections with Distance Metric Lock-In

ChromaDB collections are schema-free: any string IDs, any lists of embeddings (or documents for the embedding function to convert), any JSON metadata objects. The only schema decision made at collection creation is the distance metric (hnsw:space in the collection metadata: "l2" (default), "cosine", or "ip"). This is immutable after creation — you cannot change the distance metric without recreating the collection and re-inserting all vectors.

Distance convention warning: ChromaDB's distance conventions are inverted from Pinecone and Qdrant for cosine. In Pinecone and Qdrant, cosine similarity results return higher values for more similar vectors. In ChromaDB with hnsw:space: "cosine", the distance returned is 1 - cosine_similarity — so 0.0 means identical vectors and 1.0 means completely dissimilar. If you're filtering results by a distance threshold, this inversion matters.

import chromadb
from chromadb.utils import embedding_functions

openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.environ["OPENAI_API_KEY"],
    model_name="text-embedding-3-small",
)

# hnsw:space set at collection creation — cannot be changed later
collection = client.get_or_create_collection(
    name="articles",
    embedding_function=openai_ef,
    metadata={"hnsw:space": "cosine"},
)

# ChromaDB upsert — use upsert() not add() for idempotent inserts
# add() raises DuplicateIDError if ID already exists
collection.upsert(
    ids=["article-001", "article-002"],           # strings only — not integers
    documents=["first article content", "second"],  # embedding function converts these
    metadatas=[
        {"category": "tech", "score": 0.92},
        {"category": "finance", "score": 0.71},
    ],
)

# Query — results include distances (0.0 = identical, 2.0 = maximum distance for cosine)
results = collection.query(
    query_embeddings=[queryEmbedding],
    n_results=10,
    where={"category": "tech"},           # MongoDB-style filter on metadata
    where_document={"$contains": "MCP"},   # filter on document text content
    include=["metadatas", "documents", "distances"],
)
# results["distances"][0] are ChromaDB cosine distances (1 - similarity), NOT similarities

Milvus: Strict FieldSchema, Immutable After Creation

Milvus requires the most explicit schema definition of any database in this arc. A collection schema consists of a list of FieldSchema objects with explicit data types: DataType.INT64 for the required primary key, DataType.FLOAT_VECTOR with a dim parameter for the vector field, DataType.VARCHAR with a max_length parameter for strings, DataType.FLOAT for scalar floats, and so on. Once the collection is created, the schema is immutable — you cannot add, remove, or rename fields without dropping and recreating the collection.

The max_length on VARCHAR fields is enforced at insert time, not schema creation time. If you specify max_length=256 but insert a string with 300 characters, the insert fails with a DataTypeNotMatch error — the schema creation succeeds without warning. This makes schema planning more important in Milvus than in any other database in this arc.

from pymilvus import FieldSchema, CollectionSchema, DataType, Collection

# Schema definition — immutable after collection creation
fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=False),
    FieldSchema(name="content_vector", dtype=DataType.FLOAT_VECTOR, dim=1536),
    FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=512),
    FieldSchema(name="category", dtype=DataType.VARCHAR, max_length=64),
    FieldSchema(name="score", dtype=DataType.FLOAT),
    FieldSchema(name="published_at", dtype=DataType.INT64),  # Unix timestamp
]

schema = CollectionSchema(fields=fields, description="Article collection")
collection = Collection(name="articles", schema=schema, consistency_level="Strong")

# Index must be created before load() — HNSW is common for high-recall use cases
index_params = {
    "metric_type": "COSINE",
    "index_type": "HNSW",
    "params": {"M": 16, "efConstruction": 200},
}
collection.create_index(field_name="content_vector", index_params=index_params)

# load() is mandatory before any search — also required after MCP server restart
collection.load()

# Insert uses column format — list of field arrays, not list of row objects
collection.insert([
    [1, 2, 3],                        # id column
    [vec1, vec2, vec3],               # content_vector column
    ["Article One", "Two", "Three"],  # title column
    ["tech", "finance", "tech"],       # category column
    [0.92, 0.71, 0.88],               # score column
    [1721260800, 1721347200, 1721433600],  # published_at column
])
collection.flush()  # ensure data is persisted and searchable

Pattern 3: Query Interface Diversity

The query interfaces across these five databases vary more than any other API category. They don't even share the same protocol: REST JSON, GraphQL, Python/JS client library, and gRPC are all represented. The failure modes from using the wrong interface assumptions — treating a GraphQL error like an HTTP error, treating a gRPC column insert like a REST row insert — are silent in most cases.

Pinecone: REST JSON with Namespace Isolation

Pinecone's query interface is the closest to a conventional REST API: POST https://{indexHost}/query with a JSON body containing the query vector, result count, namespace, metadata filter, and response shape flags. The response returns a matches array with id, score (cosine similarity, higher is better), and optionally values (the stored vector) and metadata. Similarity scores are normalized to [0, 1] for cosine, where 1.0 is identical and 0.0 is completely dissimilar.

The upsert batch limit is 100 vectors per request or 2MB, whichever is smaller. For large imports, batch the vectors and insert sequentially or with controlled concurrency — Pinecone's serverless tier has per-project concurrency limits that are not documented upfront.

Weaviate: GraphQL with HTTP 200 Errors

Weaviate's search interface is GraphQL — a schema-aware query language where the query structure mirrors the class schema. Every query must name the class, specify the search operator (nearVector, nearText, hybrid, bm25), include optional where filters, set a limit, and list the properties to return in the result. Getting any part of the query structure wrong — misspelling a property name, using a wrong data type in a filter, querying a property that has skip: true — produces an error.

The most important Weaviate-specific convention: errors are returned as HTTP 200 with a JSON body containing an errors array. The HTTP status code is always 200 for GraphQL responses, whether the query succeeded or failed. Code that only checks res.ok will silently miss all query errors.

// Weaviate hybrid search — combines BM25 keyword and nearVector semantic search
// alpha=0 is pure BM25, alpha=1 is pure vector, values in between blend both
const hybridQuery = `{
  Get {
    Article(
      hybrid: {
        query: "MCP server health monitoring"
        vector: [${queryEmbedding.join(',')}]
        alpha: 0.75
      }
      where: {
        operator: And
        operands: [
          { path: ["category"], operator: Equal, valueText: "tech" }
          { path: ["wordCount"], operator: GreaterThan, valueInt: 500 }
        ]
      }
      limit: 20
    ) {
      content
      title
      category
      wordCount
      _additional { score explainScore id }
    }
  }
}`;

const res = await fetch(`${WEAVIATE_URL}/v1/graphql`, {
  method: 'POST',
  headers: weaviateHeaders(),
  body: JSON.stringify({ query: hybridQuery }),
});
const body = await res.json();

// Check for errors even when res.ok is true
if (body.errors?.length) {
  throw new Error(`Weaviate query failed: ${body.errors.map((e: any) => e.message).join('; ')}`);
}
const articles = body.data?.Get?.Article ?? [];

Qdrant: REST JSON with Scroll API for Full Collection Iteration

Qdrant's query interface is REST JSON like Pinecone's, but with a richer filter DSL. Filters use must (AND), should (OR), and must_not (NOT) combinators with condition types: match (exact value), range (numeric range with gt/gte/lt/lte), geo_bounding_box, geo_radius, is_null, and is_empty. Text matching requires explicit payload field indexing via PUT /collections/{name}/index.

For iterating all points in a collection (e.g., for export, reindexing, or audit), Qdrant provides the scroll API (POST /collections/{name}/points/scroll). Unlike most pagination APIs, the offset parameter is a point ID, not a page number. Each response includes a next_page_offset field — pass this as the offset on the next request. When next_page_offset is null, you've reached the end. A numeric offset of 0 or passing a specific point ID are both valid starting points.

// Qdrant scroll — iterate entire collection for export
async function* scrollCollection(collectionName: string, batchSize = 100) {
  let offset: string | number | null = null;
  while (true) {
    const body: any = { limit: batchSize, with_payload: true, with_vector: false };
    if (offset !== null) body.offset = offset;

    const res = await fetch(`${QDRANT_URL}/collections/${collectionName}/points/scroll`, {
      method: 'POST',
      headers: qdrantHeaders(),
      body: JSON.stringify(body),
    });
    if (!res.ok) throw new Error(`Qdrant scroll HTTP ${res.status}`);
    const { result } = await res.json();
    yield result.points;
    if (result.next_page_offset === null) break; // null means no more pages
    offset = result.next_page_offset;            // pass this as offset on next request
  }
}

ChromaDB: Client Library Abstracting REST

ChromaDB's query interface is a client library (chromadb Python, chromadb-client TypeScript) that translates method calls into REST API requests internally. The public interface is collection.query() and collection.add()/collection.upsert() — the underlying REST endpoints are an implementation detail. This abstraction makes ChromaDB the easiest to use among the five databases for simple use cases, but it means the failure mode when something goes wrong is a Python or JavaScript exception from the client library, not a raw HTTP error with a status code and body.

The collection.query() method accepts either query_embeddings (pre-computed float arrays) or query_texts (strings that the embedding function converts). Use query_embeddings in production to control embedding model, caching, and batching — using query_texts delegates these concerns to the ChromaDB client and its configured embedding function, which may not match your production embedding pipeline.

Milvus: gRPC + Mandatory load() + Column-Format Insert

Milvus has the highest operational complexity query interface of any database in this arc. Three requirements catch developers who approach it with Pinecone or Qdrant assumptions:

Mandatory collection.load() before search. After creating a collection and creating an index, you must call collection.load() before any search. This loads the collection's vectors and index into memory. If the MCP server restarts, the collection returns to the NotLoad state — you must call collection.load() again at startup. Check the load state with utility.load_state(collection_name) and load if not already in memory. Attempting to search without loading returns an error message about the collection not being loaded, with no suggestion about the load() call.

Column-format insert. collection.insert() takes a list of field arrays, not a list of row objects. The outer list has one element per field (in schema order), and each inner list has one element per row. This is the opposite of what most REST and SQL APIs use. The correct format for inserting three rows into a collection with fields [id, vector, category] is [[id1, id2, id3], [vec1, vec2, vec3], [cat1, cat2, cat3]], not [{id: id1, vector: vec1, category: cat1}, ...].

String expression DSL for filtering. Milvus uses a string expression DSL for filters, not a JSON object. The expression syntax is: "category == 'tech' and score >= 0.8", "status in ['active', 'pending']", "metadata[\"key\"] == 'value'". The HNSW ef parameter in the search params must be >= the limit (topK) — setting ef to a value less than limit produces inconsistent results or an error.

from pymilvus import Collection, utility
from pymilvus.orm.types import LoadState

# At MCP server startup — check and load if needed
def ensure_collection_loaded(collection_name: str) -> Collection:
    state = utility.load_state(collection_name)
    collection = Collection(name=collection_name)
    if state != LoadState.Loaded:
        collection.load()
        utility.wait_for_loading_complete(collection_name)
    return collection

collection = ensure_collection_loaded("articles")

# Search — expr is a string DSL, ef must be >= limit
search_params = {
    "metric_type": "COSINE",
    "params": {"ef": 64},   # ef >= limit (topK) — HNSW search-time parameter
}
results = collection.search(
    data=[query_embedding],        # list of query vectors
    anns_field="content_vector",   # the FLOAT_VECTOR field to search
    param=search_params,
    limit=10,
    expr="category == 'tech' and score >= 0.8",  # string DSL, not JSON
    output_fields=["title", "category", "score"],
)

for hit in results[0]:  # results[0] = hits for first query vector
    print(f"id={hit.id}, distance={hit.distance}, title={hit.entity.get('title')}")

Composite Health Probes for Each Database

A health probe that only tests "does the server accept a TCP connection?" reports healthy for all the failure modes described above — wrong API key, unloaded collection, misconfigured vectorizer module, empty namespace, wrong collection metadata. Composite probes that exercise the actual search path are required for each database.

Database Minimal Health Probe Catches
Pinecone GET /indexes/{name} → check status.ready === true Index ready state, API key valid, host resolution working
Weaviate GET /.well-known/ready (public, no auth) + POST /v1/graphql with { Get { ClassName(limit: 1) { _additional { id } } } } Server up, auth credentials valid, class exists, vectorizer module loaded
Qdrant GET /healthz + GET /collections/{name} → check status: "green" Server up, API key valid, collection healthy (green=indexed, yellow=building, red=error)
ChromaDB GET /api/v1/heartbeat (public) + collection.count() Server up, collection exists and accessible, embedding function not broken
Milvus GET :9091/healthz + utility.load_state(name) + collection.search([zero_vector], ...) Server up, credentials valid, collection loaded, index built, search path functional

For Milvus specifically, the HTTP health endpoint on port 9091 tests server availability, but the collection load state and index state must be checked separately via the gRPC connection. A Milvus server that returns HTTP 200 on /healthz can simultaneously have all collections in NotLoad state (after a restart), causing every search to fail. The composite probe must exercise all three layers.

Database Selection Guide for MCP Integrations

The choice between these five databases should be driven by operational requirements, not by which API is most familiar. The schema, query interface, and auth model each database imposes are load-bearing design decisions — switching databases later requires rewriting the MCP server's data layer.

Use case Best fit Why
Fast prototype, no infrastructure ops Pinecone (serverless) or ChromaDB (embedded) Pinecone serverless: zero ops, pay-per-query. ChromaDB embedded: zero server, in-process.
Multi-representation search (title + content vectors per doc) Qdrant Named vectors: multiple embedding spaces per point, single collection, query either independently.
Hybrid keyword + semantic search without separate Elasticsearch Weaviate First-class BM25 + vector hybrid search via GraphQL with alpha weighting.
High-volume production with predictable latency SLAs Pinecone (pod) or Milvus Pod indexes: fixed replicas, no cold-start. Milvus: high-throughput insert, HNSW/IVF index options.
Self-hosted with Python ecosystem integration Qdrant or Milvus Qdrant: Docker single binary, REST JSON, easy ops. Milvus: PyMilvus native, LangChain/LlamaIndex first-class support.
Multi-tenant isolation per customer or workspace Weaviate or Milvus Weaviate: multi-tenancy as first-class schema feature. Milvus: partition keys for hash-based routing.

For MCP servers specifically, the operational mode matters as much as the feature set. An MCP server that runs as a short-lived process (spawned per-session by a Claude client) must handle ChromaDB's 90MB model download on cold start, Milvus's collection reload after process exit, and Pinecone's index host resolution at startup. A long-lived MCP server process avoids these cold-start costs but must handle connection drops, token expiry (Weaviate OIDC), and collection reload after Milvus server restarts. The MCP server health monitoring pattern applies here: each database needs a different probe shape to catch its specific failure modes.

The AliveMCP public dashboard monitors MCP endpoints across all five vector database integrations as part of its registry crawl. The most common failure pattern we see: MCP servers that return HTTP 200 on their status endpoint while the underlying vector database search path has been broken since the last container restart — the process is alive but the search functionality is not.

Cross-Database Comparison: What Each Fixes and What Each Breaks

Each design decision in this arc is solving a real problem while creating a new one. Understanding the tradeoff makes the failure modes predictable rather than surprising.

Pinecone's proprietary Api-Key header exists because the API key is a Pinecone-specific concept (tied to a project, not a user), and the engineers chose not to overload Authorization: Bearer with a credential that has different semantics than an OAuth bearer token. The cost: every developer building against Pinecone for the first time makes the same wrong-header mistake. Pinecone's own SDKs handle this correctly, which is why the official client libraries hide the header name — but MCP servers often talk directly to the REST API.

Weaviate's instance-level auth mode exists because mixing auth modes per-request would require the server to try multiple auth methods for every unauthenticated request, creating a security model where the server behavior depends on which credential type an attacker presents. The cost: deployment configuration errors (forgetting to set API keys on the instance) expose a fully-anonymous endpoint until the next restart.

Qdrant's optional local auth exists because developer experience matters — requiring authentication for local Docker development adds friction that slows adoption. The cost: production deployments that mirror the development setup ship without authentication.

ChromaDB's no-auth default exists because the primary use case (embedded in a RAG pipeline alongside the application code) has no authentication boundary. The cost: server-mode deployments that inherit the embedded-mode assumption end up unauthenticated.

Milvus's mandatory load() exists because loading a collection into memory is expensive — on a large collection with a complex HNSW index, this can take minutes — and Milvus doesn't want to do it automatically on first search (which would cause unpredictable latency spikes). The cost: every MCP server that uses Milvus must implement the load-state-check-and-reload pattern, and every deployment that forgets it silently breaks after the first Milvus server restart.

For MCP server reliability monitoring, these are the patterns that matter: the first three failures (wrong auth header, wrong auth mode, missing auth header) will appear as repeated 401/403 errors in your Caddy access logs or in the MCP monitoring tool alert stream. The Milvus load failure and the ChromaDB embedding model download failure are silent at the HTTP layer — the MCP server returns a 500 or a timeout with no indication of the underlying cause. A composite health probe that exercises the search path end-to-end is the only way to catch these before your users do.

Monitor your MCP server's vector database health

AliveMCP pings your MCP endpoint every 60 seconds and surfaces health probe failures before they affect users. The composite probe tests the full search path — not just TCP connectivity — so you catch unloaded collections, broken vectorizer modules, and expired auth tokens automatically. See pricing.