Module 3 · Vector Stores

Metadata Filtering

Lesson 10 of 23 10 min read Updated

Metadata filtering lets you restrict a vector search to documents whose metadata matches a condition, such as department == 'hr' or year >= 2024. Similarity search alone only knows about meaning. Real applications also need rules: which tenant owns the data, which document version is current, which user may see a file. Metadata filtering gives you those rules, and it is one of the most useful tools for building accurate and secure RAG systems.

Overview: how metadata filtering works

Every LangChain Document has two fields: page_content (the text) and metadata (a dictionary). When you add documents to a vector store, the store embeds the text into a vector. It also saves the metadata next to that vector. Metadata is not embedded. It is plain structured data, like columns in a database row.

At query time, you pass a filter together with your query. The store then does two jobs: it finds the vectors closest to the query, and it keeps only the records whose metadata satisfy the filter. The result is the top k nearest documents among the allowed ones.

Why does this matter?

  • Relevance: a question about HR leave should not return engineering runbooks, even if the wording is similar.
  • Freshness: you can ignore old versions of a policy.
  • Security and multi-tenancy: you can force every query to include tenant_id so one customer never sees another customer’s chunks.
  • Cost and speed: a smaller candidate set means less irrelevant text sent to the LLM.

Pre-filtering vs post-filtering

Stores apply filters in two ways. With pre-filtering, the filter is applied inside the search, so you still get k results if enough matching records exist. With post-filtering, the store first gets the nearest k (or some larger number) and then removes the ones that do not match. This can return fewer than k results, or even none. Read your vector store’s documentation to learn which behaviour it has.

Filter syntax differs between stores

There is no single filter language shared by all stores. LangChain passes your filter argument to the store’s own implementation:

Store Filter form
InMemoryVectorStore A Python function that takes a Document and returns True or False
Chroma A dictionary with operators such as $eq, $gte, $in, $and, $or
Other stores (Qdrant, pgvector, Pinecone, and others) Their own dictionary or object format; check the integration page

In this lesson we use InMemoryVectorStore with fake embeddings, so you can run everything offline with no API key.

Syntax and key concepts

store.similarity_search(query, k=4, filter=...)
store.similarity_search_with_score(query, k=4, filter=...)
retriever = store.as_retriever(search_kwargs={'k': 4, 'filter': ...})
  • query: the text to search for. It is embedded and compared with stored vectors.
  • k: the maximum number of results to return.
  • filter: the condition on metadata. Its type depends on the store (see the table above).
  • search_kwargs: extra keyword arguments that a retriever forwards to the store’s search method. This is how you add a filter to a retriever used in a chain.

Good metadata is designed at indexing time. Decide which fields you will filter on (source, department, year, language, access level) and add them when you create the documents. You cannot filter on a field you never stored.

Examples

Example 1: equality and combined conditions

We build a small store of five company documents, then filter by department, and then by year and department together. DeterministicFakeEmbedding creates repeatable fake vectors, so the ranking has no real meaning, but the filter still works exactly as in production. We sort the sources before printing so the output is stable.

from langchain_core.documents import Document
from langchain_core.embeddings import DeterministicFakeEmbedding
from langchain_core.vectorstores import InMemoryVectorStore

docs = [
    Document(page_content='Employees get 24 days of paid leave per year.',
             metadata={'department': 'hr', 'year': 2024, 'source': 'leave.pdf'}),
    Document(page_content='Remote work requires manager approval.',
             metadata={'department': 'hr', 'year': 2023, 'source': 'remote.pdf'}),
    Document(page_content='Production deploys happen on Tuesdays.',
             metadata={'department': 'engineering', 'year': 2024, 'source': 'deploy.md'}),
    Document(page_content='Rollbacks must be announced in the incident channel.',
             metadata={'department': 'engineering', 'year': 2023, 'source': 'incident.md'}),
    Document(page_content='Travel expenses must be filed within 30 days.',
             metadata={'department': 'finance', 'year': 2024, 'source': 'expenses.pdf'}),
]

store = InMemoryVectorStore(embedding=DeterministicFakeEmbedding(size=64))
store.add_documents(docs)

hr_only = store.similarity_search(
    'policy', k=10, filter=lambda d: d.metadata['department'] == 'hr'
)
print(sorted(d.metadata['source'] for d in hr_only))

recent = store.similarity_search(
    'policy',
    k=10,
    filter=lambda d: d.metadata['year'] >= 2024
    and d.metadata['department'] in ('hr', 'engineering'),
)
print(sorted(d.metadata['source'] for d in recent))
['leave.pdf', 'remote.pdf']
['deploy.md', 'leave.pdf']

The filter is a normal Python function, so you can use ==, comparisons, in, and, or and not. We set k=10, which is larger than the number of documents, so we can see every document that passes the filter.

Example 2: a reusable filter builder and a retriever

Writing lambdas everywhere gets repetitive. A small helper can turn keyword conditions into a filter function. A list value means "any of these values". We use .get() so a document with a missing field simply does not match instead of raising an error.

from langchain_core.documents import Document
from langchain_core.embeddings import DeterministicFakeEmbedding
from langchain_core.vectorstores import InMemoryVectorStore


def make_filter(**conditions):
    def check(doc):
        for key, wanted in conditions.items():
            value = doc.metadata.get(key)
            if isinstance(wanted, (list, tuple, set)):
                if value not in wanted:
                    return False
            elif value != wanted:
                return False
        return True

    return check


rows = [
    ('Leave is 24 days.', 'hr', 2024, 'leave.pdf'),
    ('Remote work needs approval.', 'hr', 2023, 'remote.pdf'),
    ('Deploys happen on Tuesdays.', 'engineering', 2024, 'deploy.md'),
    ('Announce rollbacks in the incident channel.', 'engineering', 2023, 'incident.md'),
    ('File travel expenses in 30 days.', 'finance', 2024, 'expenses.pdf'),
]
docs = [
    Document(page_content=text,
             metadata={'department': dept, 'year': year, 'source': src})
    for text, dept, year, src in rows
]

store = InMemoryVectorStore(embedding=DeterministicFakeEmbedding(size=64))
store.add_documents(docs)

retriever = store.as_retriever(
    search_kwargs={
        'k': 10,
        'filter': make_filter(year=2023, department=['hr', 'engineering']),
    }
)
found = retriever.invoke('what is the policy?')
print(len(found))
print(sorted(d.metadata['source'] for d in found))
2
['incident.md', 'remote.pdf']

The retriever is a normal LangChain runnable. You can place it in an LCEL chain, for example {'context': retriever, 'question': RunnablePassthrough()} | prompt | model, and the filter will be applied every time. For per-request filters (for example, the current user’s tenant), build the retriever inside the request handler, or pass the filter in search_kwargs at call time as your store supports.

Example 3: filter dictionaries (Chroma style)

Most real vector databases use dictionaries instead of Python functions. This example shows the Chroma style. It needs the langchain-chroma package and an embedding model, so it is a fragment for reading.

from langchain_chroma import Chroma

# 'embeddings' is any Embeddings object, e.g. built from an API key in an environment variable
store = Chroma(collection_name='policies', embedding_function=embeddings)
store.add_documents(docs)

results = store.similarity_search(
    'how many leave days do I get?',
    k=3,
    filter={
        '$and': [
            {'department': {'$eq': 'hr'}},
            {'year': {'$gte': 2024}},
        ]
    },
)

Chroma supports comparison operators such as $eq, $ne, $gt, $gte, $lt, $lte, $in and $nin, plus logical operators $and and $or. A simple {'department': 'hr'} also works for a single equality check. Always check your own store’s page for the exact operators.

Under the hood: step by step

  1. You call add_documents. The store embeds each page_content and saves the vector, the text and the metadata under an ID.
  2. You call similarity_search with a query and a filter. The query is embedded with the same embedding model.
  3. The store scans or uses its index for candidate vectors. In InMemoryVectorStore, your function is called on each stored document, and only documents that return True are scored.
  4. The remaining candidates are sorted by similarity and the top k are returned as Document objects, with their metadata intact.

Because the returned documents still carry metadata, you can show citations such as source and page in the final answer.

Common Mistakes

Mistake 1: type mismatch in metadata

# stored: metadata={'year': 2024}
store.similarity_search('leave', k=3, filter=lambda d: d.metadata['year'] == '2024')
# returns [] because 2024 (int) is not equal to '2024' (str)

The integer 2024 and the string '2024' are different values. Pick one type for each field at indexing time and use the same type in filters. Corrected: compare with == 2024.

Mistake 2: assuming every document has the field

filter=lambda d: d.metadata['department'] == 'hr'
# raises KeyError if any stored document has no 'department' key

Documents loaded from different sources often have different metadata keys. Use d.metadata.get('department') == 'hr', or make sure your ingestion code always adds the same keys.

Mistake 3: using the wrong filter style for the store

InMemoryVectorStore(...).similarity_search('x', filter={'department': 'hr'})
# a dict is not callable, so this fails

A dictionary filter works in Chroma but not in InMemoryVectorStore, which wants a function. Always match the filter format to the store.

Mistake 4: relying on the prompt for access control

Telling the LLM "do not reveal other tenants’ data" is not security. Filter on tenant_id at retrieval time so the other tenant’s text never reaches the model.

Best Practices

  • Design a small, stable metadata schema (for example source, department, year, language, tenant_id) before indexing.
  • Keep metadata values simple: strings, numbers and booleans. Many stores do not accept nested objects or lists in filterable fields.
  • Use consistent types and spellings. Normalise case ('hr' not 'HR' in some places).
  • Always apply security filters such as tenant or permission level in your own code, not from user text.
  • Check whether your store pre-filters or post-filters, and test that you still get enough results.
  • Add indexes on filtered fields in production databases when the store supports them, to keep searches fast.
  • Test with fake embeddings first: filter results must be correct no matter what the vectors are.
  • Return metadata (such as source) with answers, so users can verify them.

Practice Exercises

  1. Using the data from Example 1, write a filter that returns documents that are not from the finance department and are from year 2023 or later. Print the sorted sources. (Hint: use != and >=.)
  2. Extend make_filter so a condition can be a dictionary like {'gte': 2024} for numeric comparison. Test it by selecting documents from 2024 or later.
  3. Add a tenant_id field to your documents with two tenants. Write a function retriever_for(tenant) that returns a retriever locked to that tenant, and confirm that each tenant sees only its own sources.

Summary

  • Metadata is structured data stored next to each vector. It is not embedded.
  • A filter limits a search to documents whose metadata match a condition.
  • Filter syntax depends on the vector store: a Python function for InMemoryVectorStore, dictionaries with operators for Chroma and many others.
  • Pass filters through search_kwargs to use them in retrievers and LCEL chains.
  • Keep types consistent, handle missing keys, and enforce security filters in code.
  • Know whether your store pre-filters or post-filters, since that affects how many results you get.

Progress is saved in this browser.