← Back to DevBytes

How to Use Metadata Filtering to Improve RAG Precision

How to Use Metadata Filtering to Improve RAG Precision

Retrieval-Augmented Generation (RAG) has become the go-to pattern for building LLM applications that ground their answers in private or domain-specific data. But as your corpus grows, plain semantic similarity search starts to break down. Embeddings are great at capturing topical meaning, but they are poor at enforcing hard constraints like "only documents from 2024" or "only the HR policy for the UK office." That is where metadata filtering comes in — a technique that lets you combine the fuzzy power of vector search with the precision of structured queries.

What Is Metadata Filtering?

Metadata filtering is the practice of attaching structured key-value pairs to each document in your vector store and then applying filter expressions at query time to narrow the candidate pool before (or alongside) the similarity search. Instead of searching across every chunk in your index, you restrict the search to chunks that match specific attributes such as source, date, author, department, language, access level, or document type.

Most modern vector databases — Pinecone, Weaviate, Qdrant, Milvus, Chroma, pgvector, and others — support some form of metadata filtering. The filters are typically applied either before the vector search (pre-filtering) or after it (post-filtering). Pre-filtering is generally preferable because it guarantees that the top-k results all satisfy your constraints, whereas post-filtering can leave you with fewer than k results if too many candidates are filtered out.

Why Metadata Filtering Matters

Without metadata filtering, your RAG pipeline is essentially doing a global semantic search over the entire corpus. This creates several problems:

By layering metadata filters on top of vector search, you get the best of both worlds: the semantic flexibility of embeddings and the deterministic guarantees of structured queries.

Designing Your Metadata Schema

The first step is deciding which metadata fields to attach to each document. A good schema captures the dimensions along which users will naturally want to filter. Think about the questions a user might implicitly be asking: "Is this current?", "Is this from a trusted source?", "Does this apply to my region?", "Am I allowed to see this?"

A typical metadata payload for a corporate knowledge base might look like this:

{
  "source": "confluence",
  "space": "engineering",
  "doc_id": "ENG-1042",
  "title": "Deployment Runbook",
  "author": "jane.doe@company.com",
  "created_at": "2024-03-15T09:30:00Z",
  "updated_at": "2024-09-02T14:12:00Z",
  "tags": ["deploy", "kubernetes", "prod"],
  "language": "en",
  "access_level": "internal",
  "tenant_id": "acme-corp",
  "version": 4
}

Keep a few principles in mind when designing your schema. First, store dates as ISO 8601 strings or timestamps so range queries work correctly. Second, use consistent enum values for fields like access_level rather than free-text. Third, avoid storing huge blobs in metadata — it should be lightweight and queryable, not a second copy of the document. Fourth, plan for multi-tenancy from day one by including a tenant_id even if you only have one tenant today.

Ingesting Documents with Metadata

Let's look at a practical ingestion pipeline using LangChain and Chroma. The same pattern applies to other vector stores with minor syntax differences.

from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from datetime import datetime, timezone

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma(
    collection_name="kb",
    embedding_function=embeddings,
    persist_directory="./chroma_db",
)

chunks = [
    {
        "content": "The Q3 2024 revenue was $42.1M, up 18% year over year.",
        "metadata": {
            "source": "finance",
            "doc_type": "earnings_report",
            "period": "2024-Q3",
            "created_at": "2024-10-15T00:00:00Z",
            "access_level": "confidential",
            "tenant_id": "acme",
            "language": "en",
        },
    },
    {
        "content": "The Q3 2021 revenue was $28.4M, up 9% year over year.",
        "metadata": {
            "source": "finance",
            "doc_type": "earnings_report",
            "period": "2021-Q3",
            "created_at": "2021-10-15T00:00:00Z",
            "access_level": "confidential",
            "tenant_id": "acme",
            "language": "en",
        },
    },
    {
        "content": "Employees in the UK office are entitled to 28 days of paid leave.",
        "metadata": {
            "source": "hr",
            "doc_type": "policy",
            "region": "UK",
            "created_at": "2024-01-10T00:00:00Z",
            "access_level": "internal",
            "tenant_id": "acme",
            "language": "en",
        },
    },
]

documents = [
    Document(page_content=c["content"], metadata=c["metadata"])
    for c in chunks
]

vectorstore.add_documents(documents)

Notice that every chunk carries a full metadata payload. This is what makes filtering possible later. If you ingest documents without metadata, you are stuck — you cannot retroactively filter on fields you never stored.

Querying with Metadata Filters

Now let's retrieve documents using filters. Chroma supports a dictionary-based filter syntax that supports equality, $in, $gte, $lte, and other operators.

# Filter: only Q3 2024 earnings reports for the acme tenant
results = vectorstore.similarity_search(
    query="What was our Q3 revenue?",
    k=4,
    filter={
        "doc_type": "earnings_report",
        "period": "2024-Q3",
        "tenant_id": "acme",
    },
)

for r in results:
    print(r.metadata["period"], "->", r.page_content)

Without the period filter, the retriever would have returned both the 2024 and 2021 earnings chunks, since both are semantically near-identical. The filter eliminates the stale result deterministically.

For range queries, such as "documents updated in the last 90 days," you can combine operators:

from datetime import datetime, timedelta, timezone

cutoff = (datetime.now(timezone.utc) - timedelta(days=90)).isoformat()

results = vectorstore.similarity_search(
    query="current leave policy",
    k=4,
    filter={
        "doc_type": "policy",
        "tenant_id": "acme",
        "created_at": {"$gte": cutoff},
    },
)

Using Pinecone for More Expressive Filters

Pinecone supports a richer filter syntax with $and, $or, and nested conditions. This is useful when your filtering logic is more complex than simple equality.

from pinecone import Pinecone
from langchain_openai import OpenAIEmbeddings

pc = Pinecone(api_key="YOUR_API_KEY")
index = pc.Index("kb")
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

query = "What is the UK leave policy?"
query_vector = embeddings.embed_query(query)

response = index.query(
    vector=query_vector,
    top_k=5,
    include_metadata=True,
    filter={
        "$and": [
            {"tenant_id": {"$eq": "acme"}},
            {"access_level": {"$in": ["public", "internal"]}},
            {
                "$or": [
                    {"region": {"$eq": "UK"}},
                    {"region": {"$exists": False}},
                ]
            },
            {"doc_type": {"$eq": "policy"}},
        ]
    },
)

for match in response["matches"]:
    print(match["score"], match["metadata"])

This filter says: give me policy documents for the acme tenant that are either public or internal, and that either apply to the UK region or have no region specified (meaning they are global). This kind of expressive logic is impossible with embeddings alone.

Extracting Filters from the User Query

Hardcoding filters works for demos, but in production you usually need to derive filters from the user's natural language query. The standard approach is to use an LLM to extract structured filter parameters before retrieval. This is sometimes called "query analysis" or "query planning."

from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

class QueryFilters(BaseModel):
    """Filters extracted from a user question."""
    doc_type: str | None = Field(
        default=None,
        description="Document type: earnings_report, policy, runbook, etc."
    )
    period: str | None = Field(
        default=None,
        description="Reporting period like 2024-Q3, or None if not specified."
    )
    region: str | None = Field(
        default=None,
        description="Region like UK, US, EU, or None if not specified."
    )
    min_date: str | None = Field(
        default=None,
        description="ISO date string for the earliest acceptable document date."
    )

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(QueryFilters)

prompt = ChatPromptTemplate.from_messages([
    ("system",
     "Extract search filters from the user's question. "
     "Only set a field if the user clearly implies it. "
     "Today's date is {today}."),
    ("human", "{question}"),
])

today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
chain = prompt | structured_llm

filters = chain.invoke({
    "question": "What was our UK leave policy updated last year?",
    "today": today,
})

print(filters)
# Example output:
# QueryFilters(doc_type='policy', period=None, region='UK',
#              min_date='2023-11-01')

Once you have the structured filters, convert them into the vector store's filter syntax and pass them to your retriever. This gives you a fully dynamic pipeline where the user's intent drives both the semantic search and the structured constraints.

def build_filter(f: QueryFilters, tenant_id: str) -> dict:
    conditions = [{"tenant_id": tenant_id}]
    if f.doc_type:
        conditions.append({"doc_type": f.doc_type})
    if f.period:
        conditions.append({"period": f.period})
    if f.region:
        conditions.append({"region": f.region})
    if f.min_date:
        conditions.append({"created_at": {"$gte": f.min_date}})
    if len(conditions) == 1:
        return conditions[0]
    return {"$and": conditions}

metadata_filter = build_filter(filters, tenant_id="acme")

results = vectorstore.similarity_search(
    query="What was our UK leave policy updated last year?",
    k=5,
    filter=metadata_filter,
)

Best Practices

Conclusion

Metadata filtering is one of the highest-leverage improvements you can make to a RAG system. It is conceptually simple, supported by every major vector database, and it addresses the most common failure mode in production RAG: retrieving topically similar but contextually wrong documents. By designing a thoughtful metadata schema, attaching rich payloads at ingestion time, and dynamically extracting filters from user queries, you can dramatically improve precision without retraining embeddings or swapping models. Start by adding a few core fields — tenant, date, document type, and access level — and expand from there as your application's filtering needs become clearer. The result is a RAG pipeline that is not just semantically smart but also structurally trustworthy.

— Ad —

Google AdSense will appear here after approval

← Back to all articles