Episode 4 — Generative AI Engineering / 4.12 — Integrating Vector Databases

4.12.c — Metadata Filters

In one sentence: Metadata filters let you attach structured data (tags, categories, dates, source info) to every vector and then constrain your similarity search to only vectors matching specific criteria — turning a pure "find similar meaning" query into a precise "find similar meaning within these constraints" query that powers real-world RAG applications.

Navigation: <- 4.12.b — Querying Similar Vectors | 4.12 Overview ->


1. What Is Metadata?

When you store a vector in a database, the embedding captures the semantic meaning of the text. But meaning alone is not enough. You also need to know where the text came from, when it was written, what category it belongs to, and other structured facts. This structured information is called metadata.

┌──────────────────────────────────────────────────────────────────────┐
│  Vector Record with Metadata                                          │
│                                                                        │
│  id:      "doc_4821"                                                  │
│  vector:  [0.023, -0.041, 0.087, ...]     ← Semantic meaning          │
│  metadata: {                               ← Structured facts          │
│    "text": "Refunds are processed within 5-7 business days...",       │
│    "source": "help-center",                ← Where it came from        │
│    "category": "billing",                  ← Topic classification      │
│    "date": "2026-03-15",                   ← When it was written       │
│    "author": "support-team",               ← Who wrote it              │
│    "language": "en",                       ← What language              │
│    "version": 3,                           ← Which version              │
│    "is_published": true,                   ← Whether it's live          │
│    "tags": ["refund", "payment", "billing"]← Searchable tags           │
│  }                                                                     │
└──────────────────────────────────────────────────────────────────────┘

Why metadata matters

Without metadata filtering, every query searches your entire vector collection. This creates problems:

Scenario: Customer support chatbot with 500,000 vectors

Without metadata filtering:
  User asks: "What is your refund policy?"
  Search: Find top-5 most similar across ALL 500,000 vectors
  Problem: Results might include:
    - Internal engineering docs about "refund microservice"
    - Deprecated policy from 2023 (no longer accurate)
    - Spanish-language version of the policy
    - Draft article that was never published

With metadata filtering:
  User asks: "What is your refund policy?"
  Search: Find top-5 most similar WHERE source="help-center"
          AND language="en" AND is_published=true AND date >= "2026-01-01"
  Result: Only current, published, English help articles about refunds

2. Types of Metadata

Metadata falls into several categories, each serving different filtering purposes:

CategoryExamplesFilter Use Case
Source/Originsource: "help-center", url: "...", file_name: "..."Restrict search to specific data sources
Classificationcategory: "billing", department: "engineering", type: "faq"Filter by topic or content type
Temporaldate: "2026-03-15", created_at: "...", updated_at: "..."Only recent documents, time-range queries
Author/Ownerauthor: "jane", team: "support", tenant_id: "cust_123"Multi-tenant isolation, ownership filtering
Statusis_published: true, status: "active", version: 3Only live/active content
Languagelanguage: "en", locale: "en-US"Language-specific search
Structuralchunk_index: 2, section: "pricing", parent_doc: "doc_100"Navigate document structure
Tags/Labelstags: ["refund", "payment"]Flexible multi-label filtering
Numericprice: 49.99, word_count: 350, relevance_score: 0.8Range queries on numeric values

3. Filtering During Search: The Core Concept

Metadata filtering happens during the vector search, not after. This is an important distinction:

Approach 1: Post-filtering (INEFFICIENT)
  1. Search all 1M vectors for top-100 most similar
  2. Filter the 100 results by metadata
  3. Return the filtered results
  Problem: If only 3 out of 100 results match the filter,
           you get only 3 results instead of the 10 you wanted.

Approach 2: Pre-filtering (WHAT VECTOR DBs DO)
  1. Apply metadata filter to narrow the candidate set
  2. Search only within matching vectors for top-10 most similar
  3. Return results that are BOTH similar AND match the filter
  Benefit: You always get the right number of results,
           and search is faster because the candidate set is smaller.

Reality: Most vector DBs use a hybrid approach — they interleave
filtering and search in their index traversal for optimal performance.

4. Filter Syntax Across Different Vector Databases

Each vector database has its own filter syntax. Here is a comprehensive comparison.

4.1 Pinecone Filters

Pinecone uses a MongoDB-like query syntax:

// ─── Pinecone Filter Operators ───

const index = pinecone.index('knowledge-base');

// Equality
await index.query({
  vector: queryVector,
  topK: 5,
  filter: {
    category: { $eq: 'billing' },        // category equals "billing"
  },
});

// Not equal
await index.query({
  vector: queryVector,
  topK: 5,
  filter: {
    status: { $ne: 'draft' },            // status is NOT "draft"
  },
});

// Numeric comparisons
await index.query({
  vector: queryVector,
  topK: 5,
  filter: {
    version: { $gt: 2 },                 // version greater than 2
    price: { $lte: 100.00 },             // price less than or equal to 100
  },
});

// In / Not In (match against a list)
await index.query({
  vector: queryVector,
  topK: 5,
  filter: {
    category: { $in: ['billing', 'account', 'payment'] },  // category is one of these
    source: { $nin: ['deprecated', 'internal'] },           // source is NOT one of these
  },
});

// Combining conditions (AND — all conditions must match)
await index.query({
  vector: queryVector,
  topK: 5,
  filter: {
    category: { $eq: 'billing' },
    language: { $eq: 'en' },
    date: { $gte: '2026-01-01' },
    is_published: { $eq: true },
  },
});

// Explicit AND / OR with $and / $or
await index.query({
  vector: queryVector,
  topK: 5,
  filter: {
    $or: [
      { category: { $eq: 'billing' } },
      { category: { $eq: 'payment' } },
    ],
  },
});

// Complex nested filter
await index.query({
  vector: queryVector,
  topK: 5,
  filter: {
    $and: [
      {
        $or: [
          { category: { $eq: 'billing' } },
          { category: { $eq: 'account' } },
        ],
      },
      { date: { $gte: '2026-01-01' } },
      { is_published: { $eq: true } },
    ],
  },
});

Pinecone filter operators reference

OperatorDescriptionExample
$eqEquals{ category: { $eq: "billing" } }
$neNot equals{ status: { $ne: "draft" } }
$gtGreater than{ version: { $gt: 2 } }
$gteGreater than or equal{ date: { $gte: "2026-01-01" } }
$ltLess than{ price: { $lt: 50 } }
$lteLess than or equal{ score: { $lte: 0.5 } }
$inIn list{ category: { $in: ["a", "b"] } }
$ninNot in list{ source: { $nin: ["x", "y"] } }
$existsField exists{ author: { $exists: true } }
$andAll conditions must match{ $and: [...conditions] }
$orAny condition must match{ $or: [...conditions] }

4.2 Chroma Filters

Chroma uses a where clause with its own syntax:

const collection = await chroma.getCollection({ name: 'knowledge-base' });

// Basic equality
const results = await collection.query({
  queryEmbeddings: [queryVector],
  nResults: 5,
  where: {
    category: 'billing',                  // Shorthand for equals
  },
});

// Comparison operators
const results2 = await collection.query({
  queryEmbeddings: [queryVector],
  nResults: 5,
  where: {
    version: { $gt: 2 },                 // Greater than
  },
});

// Combining with $and / $or
const results3 = await collection.query({
  queryEmbeddings: [queryVector],
  nResults: 5,
  where: {
    $and: [
      { category: 'billing' },
      { language: 'en' },
      { version: { $gte: 2 } },
    ],
  },
});

// OR conditions
const results4 = await collection.query({
  queryEmbeddings: [queryVector],
  nResults: 5,
  where: {
    $or: [
      { category: 'billing' },
      { category: 'payment' },
    ],
  },
});

// Chroma also supports filtering on the document text itself
const results5 = await collection.query({
  queryEmbeddings: [queryVector],
  nResults: 5,
  whereDocument: {
    $contains: 'refund',                  // Document text contains "refund"
  },
});

// Combine metadata filter AND document filter
const results6 = await collection.query({
  queryEmbeddings: [queryVector],
  nResults: 5,
  where: { category: 'billing' },
  whereDocument: { $contains: 'refund' },
});

Chroma filter operators reference

OperatorDescriptionExample
$eqEquals (or shorthand: { key: value }){ category: "billing" }
$neNot equals{ status: { $ne: "draft" } }
$gtGreater than{ version: { $gt: 2 } }
$gteGreater than or equal{ version: { $gte: 2 } }
$ltLess than{ price: { $lt: 50 } }
$lteLess than or equal{ price: { $lte: 50 } }
$inIn list{ category: { $in: ["a", "b"] } }
$ninNot in list{ source: { $nin: ["x", "y"] } }
$andAll conditions match{ $and: [...] }
$orAny condition matches{ $or: [...] }
$containsDocument text contains (whereDocument){ $contains: "refund" }
$not_containsDocument text doesn't contain{ $not_contains: "draft" }

4.3 Qdrant Filters

Qdrant uses a structured filter format with must, should, and must_not clauses:

const qdrant = new QdrantClient({ url: 'http://localhost:6333' });

// Basic filter
const results = await qdrant.search('knowledge-base', {
  vector: queryVector,
  limit: 5,
  filter: {
    must: [
      { key: 'category', match: { value: 'billing' } },
    ],
  },
});

// Multiple conditions (AND = must)
const results2 = await qdrant.search('knowledge-base', {
  vector: queryVector,
  limit: 5,
  filter: {
    must: [
      { key: 'category', match: { value: 'billing' } },
      { key: 'language', match: { value: 'en' } },
      { key: 'is_published', match: { value: true } },
    ],
  },
});

// OR conditions (should — at least one must match)
const results3 = await qdrant.search('knowledge-base', {
  vector: queryVector,
  limit: 5,
  filter: {
    should: [
      { key: 'category', match: { value: 'billing' } },
      { key: 'category', match: { value: 'payment' } },
    ],
  },
});

// NOT conditions (must_not — none can match)
const results4 = await qdrant.search('knowledge-base', {
  vector: queryVector,
  limit: 5,
  filter: {
    must_not: [
      { key: 'status', match: { value: 'draft' } },
      { key: 'source', match: { value: 'deprecated' } },
    ],
  },
});

// Range filter
const results5 = await qdrant.search('knowledge-base', {
  vector: queryVector,
  limit: 5,
  filter: {
    must: [
      {
        key: 'version',
        range: { gte: 2, lt: 5 },        // version >= 2 AND version < 5
      },
    ],
  },
});

// Complex combined filter
const results6 = await qdrant.search('knowledge-base', {
  vector: queryVector,
  limit: 5,
  filter: {
    must: [
      { key: 'is_published', match: { value: true } },
      { key: 'date', range: { gte: '2026-01-01' } },
    ],
    should: [
      { key: 'category', match: { value: 'billing' } },
      { key: 'category', match: { value: 'account' } },
    ],
    must_not: [
      { key: 'source', match: { value: 'internal' } },
    ],
  },
});

Qdrant filter clauses reference

ClauseDescriptionBehavior
mustAll conditions must matchAND logic
shouldAt least one condition must matchOR logic
must_notNo condition can matchNOT logic
matchExact value match{ key: "field", match: { value: "x" } }
rangeNumeric/string range{ key: "field", range: { gte: 1, lt: 10 } }

4.4 pgvector Filters (SQL)

pgvector uses standard SQL WHERE clauses — the most familiar syntax if you know SQL:

-- Basic vector search with metadata filter
SELECT id, text, category,
       1 - (embedding <=> query_embedding) AS similarity
FROM documents
WHERE category = 'billing'
  AND language = 'en'
  AND is_published = true
  AND date >= '2026-01-01'
ORDER BY embedding <=> query_embedding
LIMIT 5;

-- OR conditions
SELECT id, text, category,
       1 - (embedding <=> query_embedding) AS similarity
FROM documents
WHERE (category = 'billing' OR category = 'payment')
  AND is_published = true
ORDER BY embedding <=> query_embedding
LIMIT 5;

-- Range + LIKE + IN
SELECT id, text,
       1 - (embedding <=> query_embedding) AS similarity
FROM documents
WHERE version >= 2
  AND source IN ('help-center', 'docs', 'faq')
  AND text LIKE '%refund%'
ORDER BY embedding <=> query_embedding
LIMIT 5;

4.5 Filter syntax comparison table

OperationPineconeChromaQdrantpgvector (SQL)
Equals{ $eq: "x" }{ $eq: "x" } or "x"match: { value: "x" }= 'x'
Not equals{ $ne: "x" }{ $ne: "x" }must_not + match!= 'x'
Greater than{ $gt: 5 }{ $gt: 5 }range: { gt: 5 }> 5
In list{ $in: [...] }{ $in: [...] }match: { any: [...] }IN (...)
AND$and or top-level$andmust: [...]AND
OR$or$orshould: [...]OR
NOT$ne / $nin$ne / $ninmust_not: [...]NOT / !=

5. Real-World Use Cases for Metadata Filtering

5.1 Multi-tenant SaaS application

Each customer's data is isolated by tenant_id:

async function queryForTenant(tenantId, queryText) {
  const queryVector = await generateEmbedding(queryText);

  const results = await index.query({
    vector: queryVector,
    topK: 5,
    includeMetadata: true,
    filter: {
      tenant_id: { $eq: tenantId },       // CRITICAL: Tenant isolation
    },
  });

  return results.matches;
}

// Customer A can only search their own data
const customerAResults = await queryForTenant('tenant_abc', 'refund policy');

// Customer B's data is completely invisible to Customer A
const customerBResults = await queryForTenant('tenant_xyz', 'refund policy');

5.2 Time-sensitive knowledge base

Only search recent, current documents:

async function queryRecentDocs(queryText, daysBack = 30) {
  const queryVector = await generateEmbedding(queryText);

  // Calculate the cutoff date
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - daysBack);
  const cutoffString = cutoffDate.toISOString().split('T')[0]; // "2026-03-12"

  const results = await index.query({
    vector: queryVector,
    topK: 5,
    includeMetadata: true,
    filter: {
      date: { $gte: cutoffString },       // Only docs from last 30 days
      is_published: { $eq: true },         // Only published
    },
  });

  return results.matches;
}

// Search only recent docs
const recentResults = await queryRecentDocs('shipping policy update', 30);

5.3 Category-scoped search

Let users search within a specific section of your knowledge base:

async function categorySearch(queryText, category, options = {}) {
  const { language = 'en', includeArchived = false } = options;
  const queryVector = await generateEmbedding(queryText);

  const filter = {
    category: { $eq: category },
    language: { $eq: language },
  };

  if (!includeArchived) {
    filter.status = { $ne: 'archived' };
  }

  const results = await index.query({
    vector: queryVector,
    topK: 5,
    includeMetadata: true,
    filter: filter,
  });

  return results.matches;
}

// Search only billing articles, in English, excluding archived
const billingResults = await categorySearch('payment methods', 'billing');

// Search engineering docs, including archived
const engineeringResults = await categorySearch(
  'database migration',
  'engineering',
  { includeArchived: true }
);

5.4 Document version control

Only search the latest version of each document:

async function queryLatestVersions(queryText) {
  const queryVector = await generateEmbedding(queryText);

  const results = await index.query({
    vector: queryVector,
    topK: 10,
    includeMetadata: true,
    filter: {
      is_latest_version: { $eq: true },   // Only current versions
    },
  });

  return results.matches;
}

5.5 Permission-aware search

Only return documents the current user has access to:

async function permissionAwareSearch(queryText, userRoles) {
  const queryVector = await generateEmbedding(queryText);

  // User has roles like ["employee", "engineering", "admin"]
  // Documents have access_level: "public", "internal", "engineering", "admin"

  const results = await index.query({
    vector: queryVector,
    topK: 10,
    includeMetadata: true,
    filter: {
      access_level: { $in: userRoles },   // Only docs the user can access
      is_published: { $eq: true },
    },
  });

  return results.matches;
}

// Regular employee sees public + internal docs
const employeeResults = await permissionAwareSearch(
  'company policies',
  ['public', 'internal']
);

// Admin sees everything
const adminResults = await permissionAwareSearch(
  'company policies',
  ['public', 'internal', 'engineering', 'admin']
);

5.6 Chunked document reassembly

When a document is split into chunks, retrieve the surrounding chunks for context:

async function queryWithSurroundingChunks(queryText) {
  const queryVector = await generateEmbedding(queryText);

  // First, find the most relevant chunk
  const results = await index.query({
    vector: queryVector,
    topK: 1,
    includeMetadata: true,
  });

  if (results.matches.length === 0) return null;

  const bestMatch = results.matches[0];
  const parentDocId = bestMatch.metadata.parent_doc_id;
  const chunkIndex = bestMatch.metadata.chunk_index;

  // Then, fetch the surrounding chunks by metadata
  // (This requires a separate fetch, not a vector search)
  const surroundingChunks = await index.query({
    vector: queryVector,             // Still use the query vector for relevance
    topK: 5,
    includeMetadata: true,
    filter: {
      parent_doc_id: { $eq: parentDocId },
      chunk_index: {
        $gte: chunkIndex - 1,        // One chunk before
        $lte: chunkIndex + 1,        // One chunk after
      },
    },
  });

  return {
    mainChunk: bestMatch,
    context: surroundingChunks.matches,
  };
}

6. Best Practices for Metadata Schema Design

How you design your metadata schema has a direct impact on query flexibility, performance, and maintainability.

6.1 Design principles

┌──────────────────────────────────────────────────────────────────────┐
│  METADATA SCHEMA DESIGN PRINCIPLES                                    │
│                                                                        │
│  1. FILTER-FIRST: Only store metadata you will FILTER on.             │
│     Don't store fields you'll never query — they waste space.         │
│                                                                        │
│  2. FLAT OVER NESTED: Most vector DBs don't support nested objects.   │
│     Use flat key-value pairs, not deeply nested structures.           │
│                                                                        │
│  3. CONSISTENT TYPES: Use the same type for the same field across     │
│     all vectors. Don't mix strings and numbers for "version".         │
│                                                                        │
│  4. NORMALIZE VALUES: "Billing", "billing", "BILLING" are different.  │
│     Normalize to lowercase before storing.                             │
│                                                                        │
│  5. DATE AS STRING: Store dates as ISO strings ("2026-03-15") for     │
│     consistent range queries across all vector databases.              │
│                                                                        │
│  6. SIZE LIMITS: Pinecone limits metadata to 40KB per vector.         │
│     Don't store full document text in metadata — store a reference.   │
└──────────────────────────────────────────────────────────────────────┘

6.2 Good vs bad metadata schemas

// ─── BAD: Over-engineered, nested, inconsistent ───

const badMetadata = {
  content: {
    text: 'Full 10,000 character article text here...',  // Too large
    html: '<div>Full HTML version...</div>',              // Redundant
  },
  info: {
    created: new Date(),           // Date object — not portable
    author: {                      // Nested — most DBs can't filter on this
      name: 'Jane',
      email: 'jane@example.com',
    },
  },
  category: 'Billing',            // Not normalized (uppercase B)
  tags: { primary: 'refund', secondary: 'payment' }, // Nested tags
};


// ─── GOOD: Flat, normalized, filterable ───

const goodMetadata = {
  text: 'Refunds are processed within 5-7 business days...',  // Truncated to ~500 chars
  source: 'help-center',          // Normalized lowercase
  category: 'billing',            // Normalized lowercase
  date: '2026-03-15',             // ISO string
  author: 'jane',                 // Flat string
  language: 'en',                 // ISO language code
  is_published: true,             // Boolean
  version: 3,                     // Number
  chunk_index: 0,                 // Number
  parent_doc_id: 'doc_100',       // Reference to parent document
  tags: 'refund,payment,billing', // Comma-separated string (for $contains)
  word_count: 85,                 // Number (enables range queries)
};

6.3 Metadata schema template

Here is a template you can adapt for most use cases:

// ─── Universal metadata schema template ───

function createMetadata(doc) {
  return {
    // ─── Identity ───
    doc_id: doc.id,                           // Original document ID
    chunk_index: doc.chunkIndex || 0,         // Position within parent document
    parent_doc_id: doc.parentId || doc.id,    // Parent document reference

    // ─── Content preview ───
    text: doc.text.slice(0, 500),             // Truncated text for display
    title: doc.title || '',                   // Document title

    // ─── Classification ───
    category: (doc.category || '').toLowerCase(),
    type: (doc.type || 'article').toLowerCase(), // article, faq, guide, etc.
    tags: (doc.tags || []).join(','),          // Comma-separated tags

    // ─── Source ───
    source: (doc.source || '').toLowerCase(),
    url: doc.url || '',

    // ─── Temporal ───
    date: doc.date || new Date().toISOString().split('T')[0],
    updated_at: doc.updatedAt || new Date().toISOString().split('T')[0],

    // ─── Access control ───
    access_level: (doc.accessLevel || 'public').toLowerCase(),
    tenant_id: doc.tenantId || 'default',

    // ─── Status ───
    is_published: doc.isPublished !== false,   // Default true
    is_latest_version: doc.isLatest !== false,  // Default true
    version: doc.version || 1,

    // ─── Language ───
    language: doc.language || 'en',
  };
}

6.4 Metadata size limits

DatabaseMax Metadata SizeNotes
Pinecone40 KB per vectorTotal metadata JSON must be under 40KB
ChromaNo hard limitBut large metadata slows queries
QdrantNo hard limitPayload can be any size; large payloads affect memory
pgvectorPostgreSQL column limitsEffectively unlimited (JSONB column)

Strategy for large text: Don't store the full document text in metadata. Instead, store a truncated preview (200-500 chars) in metadata and keep the full text in a separate database (PostgreSQL, Redis, or a file store). Use the vector ID or doc_id metadata field to join them.

// ─── Pattern: Metadata preview + full text in separate store ───

// Store in vector DB (lightweight metadata)
await index.upsert([{
  id: 'doc_001',
  values: embedding,
  metadata: {
    text_preview: doc.text.slice(0, 300),     // Short preview
    doc_id: 'doc_001',                        // Reference to full text
    category: 'billing',
  },
}]);

// Store full text in PostgreSQL
await db.query(
  'INSERT INTO documents (id, full_text) VALUES ($1, $2)',
  ['doc_001', doc.fullText]
);

// At query time: get IDs from vector search, fetch full text from Postgres
const vectorResults = await index.query({ vector: queryVector, topK: 5 });
const docIds = vectorResults.matches.map(m => m.metadata.doc_id);
const fullTexts = await db.query(
  'SELECT id, full_text FROM documents WHERE id = ANY($1)',
  [docIds]
);

7. Performance Impact of Metadata Filters

Metadata filters affect query performance. Understanding the trade-offs helps you design efficient schemas.

7.1 How filters affect performance

No filter:
  Vector DB searches entire ANN index
  Speed: Fastest (pure ANN search)

Selective filter (matches 50%+ of vectors):
  Minor impact — ANN index still effective
  Speed: ~1.1-1.5x slower

Moderate filter (matches 10-50% of vectors):
  ANN index partially effective, some post-filtering
  Speed: ~1.5-3x slower

Highly selective filter (matches <10% of vectors):
  ANN index less effective, more candidates needed
  Speed: ~2-5x slower

Extremely selective filter (matches <1% of vectors):
  May degrade to near-brute-force on matching subset
  Speed: Significantly slower — consider using namespaces instead

7.2 Optimization strategies

StrategyWhen to UseExample
Use namespaces/collectionsFilter always selects the same groupOne namespace per tenant instead of tenant_id filter
Avoid highly selective filtersFilter matches <1% of vectorsPre-filter by moving data to separate collections
Index filterable fieldsQdrant/Weaviate support field indexesCreate payload index on frequently filtered fields
Combine fewer filtersMany filters compound the performance hitMerge related filters into a single field
Use numeric ranges wiselyDate/version range queriesStore dates as sortable strings

7.3 Creating payload indexes (Qdrant)

// Qdrant lets you create indexes on specific payload fields for faster filtering

await qdrant.createPayloadIndex('knowledge-base', {
  field_name: 'category',
  field_schema: 'keyword',        // keyword | integer | float | bool | datetime
});

await qdrant.createPayloadIndex('knowledge-base', {
  field_name: 'date',
  field_schema: 'keyword',        // Dates as strings use keyword index
});

await qdrant.createPayloadIndex('knowledge-base', {
  field_name: 'version',
  field_schema: 'integer',
});

// After creating indexes, filtered queries on these fields are significantly faster

8. Building a Complete Filtered Search Function

Here is a production-ready search function that combines semantic search, metadata filtering, and score thresholds:

import { Pinecone } from '@pinecone-database/pinecone';
import OpenAI from 'openai';

const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY });
const openai = new OpenAI();

/**
 * Production-ready filtered vector search.
 *
 * @param {string} queryText - The user's search query
 * @param {object} filters - Metadata filters to apply
 * @param {object} options - Search configuration
 * @returns {object} Search results with metadata
 */
async function filteredSearch(queryText, filters = {}, options = {}) {
  const {
    indexName = 'knowledge-base',
    namespace = '',
    topK = 5,
    scoreThreshold = 0.70,
    embeddingModel = 'text-embedding-3-small',
  } = options;

  // Step 1: Build the metadata filter
  const metadataFilter = buildFilter(filters);

  // Step 2: Embed the query
  const embeddingResponse = await openai.embeddings.create({
    model: embeddingModel,
    input: queryText,
  });
  const queryVector = embeddingResponse.data[0].embedding;

  // Step 3: Query with filters
  const index = pinecone.index(indexName);
  const ns = namespace ? index.namespace(namespace) : index;

  const queryParams = {
    vector: queryVector,
    topK: topK,
    includeMetadata: true,
    includeValues: false,
  };

  // Only add filter if there are actual filter conditions
  if (metadataFilter && Object.keys(metadataFilter).length > 0) {
    queryParams.filter = metadataFilter;
  }

  const results = await ns.query(queryParams);

  // Step 4: Apply score threshold
  const filteredResults = results.matches.filter(
    (match) => match.score >= scoreThreshold
  );

  // Step 5: Format response
  return {
    query: queryText,
    filtersApplied: filters,
    totalResults: filteredResults.length,
    totalCandidates: results.matches.length,
    results: filteredResults.map((match) => ({
      id: match.id,
      score: match.score,
      metadata: match.metadata,
    })),
  };
}

/**
 * Build a Pinecone-compatible filter object from user-friendly filter params.
 */
function buildFilter(filters) {
  const conditions = [];

  if (filters.category) {
    if (Array.isArray(filters.category)) {
      conditions.push({ category: { $in: filters.category } });
    } else {
      conditions.push({ category: { $eq: filters.category } });
    }
  }

  if (filters.source) {
    conditions.push({ source: { $eq: filters.source } });
  }

  if (filters.language) {
    conditions.push({ language: { $eq: filters.language } });
  }

  if (filters.dateFrom) {
    conditions.push({ date: { $gte: filters.dateFrom } });
  }

  if (filters.dateTo) {
    conditions.push({ date: { $lte: filters.dateTo } });
  }

  if (filters.isPublished !== undefined) {
    conditions.push({ is_published: { $eq: filters.isPublished } });
  }

  if (filters.tenantId) {
    conditions.push({ tenant_id: { $eq: filters.tenantId } });
  }

  if (filters.accessLevel) {
    if (Array.isArray(filters.accessLevel)) {
      conditions.push({ access_level: { $in: filters.accessLevel } });
    } else {
      conditions.push({ access_level: { $eq: filters.accessLevel } });
    }
  }

  if (conditions.length === 0) return {};
  if (conditions.length === 1) return conditions[0];
  return { $and: conditions };
}

// ─── Usage examples ───

// Search all published billing articles from the last 3 months
const billingResults = await filteredSearch(
  'How do refunds work?',
  {
    category: 'billing',
    isPublished: true,
    dateFrom: '2026-01-11',
    language: 'en',
  }
);

// Multi-tenant search
const tenantResults = await filteredSearch(
  'API rate limits',
  {
    tenantId: 'customer_abc',
    category: ['docs', 'guides'],
  },
  { scoreThreshold: 0.75 }
);

// Permission-aware search
const userResults = await filteredSearch(
  'Company roadmap',
  {
    accessLevel: ['public', 'internal'],
    isPublished: true,
  }
);

// No filters — pure semantic search
const openResults = await filteredSearch('machine learning basics');

console.log(`Found ${billingResults.totalResults} results`);
billingResults.results.forEach((r, i) => {
  console.log(`  ${i + 1}. [${r.score.toFixed(3)}] ${r.metadata.text}`);
});

9. Common Mistakes and How to Avoid Them

MistakeProblemSolution
Inconsistent casing"Billing" vs "billing" are different valuesNormalize to lowercase before storing
Dates as various formats"03/15/2026" vs "2026-03-15" break range queriesAlways use ISO 8601: "YYYY-MM-DD"
Storing full text in metadataExceeds size limits, wastes memoryStore preview (300-500 chars), full text elsewhere
Not storing text at allCan't display results without a separate DB lookupAlways store at least a text preview
Missing tenant_id filterData leaks between customersALWAYS include tenant filter for multi-tenant apps
Too many filter fieldsComplex filters slow queriesOnly store fields you actually filter on
Nested metadata objectsMost vector DBs don't support nested filtersFlatten: author_name instead of author.name
Boolean as string"true" (string) vs true (boolean) behave differentlyUse actual booleans
Forgetting to update metadataStale metadata returns wrong resultsUpdate metadata when source documents change

10. Key Takeaways

  1. Metadata is structured data attached to every vector — it describes the source, category, date, permissions, and other facts about what the vector represents.
  2. Metadata filters constrain vector search so results are both semantically similar AND match structured criteria — essential for production RAG systems.
  3. Filter syntax differs across databases — Pinecone uses MongoDB-like operators, Chroma uses where clauses, Qdrant uses must/should/must_not, and pgvector uses standard SQL WHERE.
  4. Design metadata schemas to be flat, normalized, and filter-oriented — only store fields you will actually filter on, normalize casing and date formats, and respect size limits.
  5. Highly selective filters can degrade performance — if a filter matches less than 1% of vectors, consider using separate namespaces or collections instead.
  6. Multi-tenant isolation is a critical security concern — always filter by tenant_id and never rely on semantic search alone to separate customer data.

Explain-It Challenge

  1. A product manager asks: "Why can't we just filter search results after the vector search returns them?" Explain the difference between pre-filtering and post-filtering, and why it matters.
  2. Your team is building a knowledge base for 50 enterprise customers. Design the metadata schema and explain how you ensure one customer never sees another customer's data.
  3. A developer stores dates as "March 15, 2026" in metadata and wonders why their date range filter ($gte: "2026-01-01") doesn't work. Explain the problem and the fix.

Navigation: <- 4.12.b — Querying Similar Vectors | 4.12 Overview ->