Skip to content
,
RAG from scratch · Part 4

How to use metadata filters in AI document search to hide private and outdated files

10 min read
How metadata filters keep AI document search (RAG) secure and up to date

Tag every piece of your documents with who owns it, how current it is, and who may see it, then make the search apply those tags while it looks for matches. If you only check the tags after the search, private documents can reach the wrong customer, and users can get empty answers.

Imagine you run an AI assistant that answers questions from many companies’ documents. One customer asks about loan rates, and the closest matches in the whole collection happen to belong to a different customer. If your code throws those away only after the search, the user gets nothing, and another company’s files have already passed through your system.

This post is part of the RAG from scratch series. RAG, short for retrieval-augmented generation, is a setup where an AI first looks up passages in your own documents and then writes an answer from them. The pieces it looks up are called chunks: short passages cut from longer documents. The tags attached to each chunk are called metadata, which simply means data about the data.

Filter during the search, not after it

Filtering after the search checks the rules too late. The search engine has already picked the closest matches from everyone’s documents, and your code then throws away the ones the user may not see. Filtering during the search applies the rules first, so the engine only compares the question against chunks the user is allowed to see.

Here is why that matters. The documents live in a vector database, a database that stores each chunk as a list of numbers (an embedding) that captures its meaning, so it can find chunks with similar meaning to a question. To stay fast, it does not compare the question with every chunk. It walks a shortcut map of the numbers and returns the closest few, for example the top 20.

Now suppose the collection holds documents from fifty client companies, and one of them writes far more about the topic being asked. Its chunks could fill all 20 top spots. If your code then removes every chunk from other companies, nothing is left, even though the right answer for this user was sitting at spot 21. You also spent time on comparisons you threw away, and another customer’s text briefly sat in your application’s memory.

Comparison between post-retrieval application filtering and pre-retrieval index partitioning diagram
Filtering before the search keeps other customers’ documents out of the results. Filtering afterwards lets them pass through and can leave the answer empty. top_k=5 is the example setting from the code in this post.

Filtering during the search fixes both problems. Vector databases such as Qdrant and Pinecone let you pass a filter, such as tenant_id == 'bank_alpha', along with the question. The database keeps an index of the tags next to the vectors, so it skips chunks that fail the filter while it searches, and every result it returns has already passed (both documentation pages checked October 2026).

pgvector, the vector add-on for the PostgreSQL database, works a little differently, so check its behavior before you rely on it. With its fast approximate indexes, the WHERE filter is applied after the index scan, inside the database. That still keeps other customers’ rows out of your application, but it can return fewer results than you asked for. Version 0.8.0 added iterative index scans, which keep scanning until enough rows pass the filter.

The four kinds of metadata every chunk needs

Every chunk should carry four kinds of tags: who it belongs to, how current it is, who may see it, and where it came from. When you load documents, it is tempting to store only the text, an ID, and its embedding. That shortcut is easy at first and painful to fix once the system is live.

The 4 Core Metadata Dimensions Every Chunk Needs matrix diagram
Metadata Taxonomy: Structure every document chunk across multi-tenancy, temporal freshness, role-based ACLs, and source lineage.

Here is what each kind of tag does in practice.

  • Owner (tenant_id, workspace_id): which customer the chunk belongs to. In a product that serves many customers, each customer is called a tenant, and this tag is the line that must never be crossed. A question from one customer should never see another customer’s chunks, and security audits look for exactly this kind of separation.
  • Date and version (created_at, valid_until, version_status): how current the chunk is. Policies and price sheets change, so tagging each chunk with dates and a status such as active or superseded lets search skip old drafts while you keep them on file for audits.
  • Access level (role_required, department_acl): who inside the company may see it. Even within one company, a new hire in marketing should not be able to pull up board minutes or HR complaints. An access control list (ACL) is simply the list of people or roles allowed to see something, and the search can check each chunk against the current user’s list.
  • Source (source_file, page_number, doc_hash): where the chunk came from. The file and page let your app show a real citation. The hash, a short fingerprint of the document’s contents, tells your loading job when a document changed, so it can replace only the affected chunks.

Pre-filtered search in Python

The example below shows the idea in plain Python. It checks each chunk’s tags first, and it only scores the chunks that pass. Scoring uses cosine similarity, a number between -1 and 1 that is higher when two lists of numbers point in the same direction, which here means the chunk’s meaning is close to the question’s.

The data is a small made-up collection for two fictional banks. The user is a junior loan officer at Bank Alpha with access level 1.

import math

def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:
    dot = sum(a * b for a, b in zip(vec_a, vec_b))
    norm_a = math.sqrt(sum(a * a for a in vec_a))
    norm_b = math.sqrt(sum(b * b for b in vec_b))
    return dot / (norm_a * norm_b) if norm_a and norm_b else 0.0

# Mock multi-tenant document corpus with rich metadata payloads
document_database = [
    {
        "id": "chunk_101",
        "tenant_id": "bank_alpha",
        "status": "active",
        "acl_level": 1,
        "title": "Alpha: Commercial Loan Rates 2026",
        "vector": [0.88, 0.21, 0.45]
    },
    {
        "id": "chunk_102",
        "tenant_id": "bank_alpha",
        "status": "deprecated",
        "acl_level": 1,
        "title": "Alpha: Legacy Loan Schedule 2022",
        "vector": [0.91, 0.19, 0.42]  # High similarity, but outdated
    },
    {
        "id": "chunk_201",
        "tenant_id": "bank_beta",
        "status": "active",
        "acl_level": 3,
        "title": "Beta: Executive Incentive Plan 2026",
        "vector": [0.95, 0.15, 0.38]  # Perfect match, but belongs to another tenant!
    },
    {
        "id": "chunk_103",
        "tenant_id": "bank_alpha",
        "status": "active",
        "acl_level": 2,
        "title": "Alpha: Executive Compensation Summary",
        "vector": [0.89, 0.25, 0.39]
    }
]

def search_with_pre_filter(
    query_vector: list[float],
    user_tenant: str,
    user_acl: int,
    top_k: int = 2
) -> list[dict]:
    valid_candidates = []

    # Step 1: Pre-filtering phase (enforces tenant isolation and security ACLs)
    for doc in document_database:
        if doc["tenant_id"] != user_tenant:
            continue
        if doc["status"] != "active":
            continue
        if doc["acl_level"] > user_acl:
            continue

        # Step 2: Compute vector similarity only on authorized candidates
        score = cosine_similarity(query_vector, doc["vector"])
        valid_candidates.append({**doc, "similarity": score})

    valid_candidates.sort(key=lambda x: x["similarity"], reverse=True)
    return valid_candidates[:top_k]

# User session: Junior Loan Officer at Bank Alpha (tenant: bank_alpha, ACL level: 1)
query_vec = [0.92, 0.20, 0.40]
results = search_with_pre_filter(query_vec, user_tenant="bank_alpha", user_acl=1, top_k=2)

print(f"{'Chunk ID':<10} | {'Title':<38} | {'Score':<8} | {'Security Check'}")
print("-" * 75)
for r in results:
    print(f"{r['id']:<10} | {r['title']:<38} | {r['similarity']:.4f} | PASSED (Tenant + ACL 1)")

When you run it, three chunks are removed before any scoring happens. chunk_201 belongs to the other bank, chunk_102 is an outdated draft, and chunk_103 needs access level 2. Only one chunk is left to score. The table below shows each chunk and why it passed or failed.

Chunk IdentifierDocument TitleCosine ScoreSecurity & Freshness Result
chunk_101Alpha: Commercial Loan Rates 20260.9980Authorized (Active · Bank Alpha · ACL 1)
chunk_103Alpha: Exec Compensation SummaryExcludedRejected (Requires ACL 2; User has ACL 1)
chunk_201Beta: Exec Incentive Plan 2026ExcludedRejected (Belongs to Bank Beta; Cross-Tenant)
chunk_102Alpha: Legacy Loan Schedule 2022ExcludedRejected (Status is Deprecated)
Pre-Filtered Search Results: Unauthorized cross-tenant records and stale policies are pruned at the database level before prompt construction.

Let the system add the security filter, not each developer

In a real product, the security filter should be added automatically from the user’s login, not typed by each developer. If someone forgets the filter in an ordinary database query, the database returns rows the user should not see. With an AI assistant the damage can be worse, because the leaked text ends up inside a confident, well-written answer.

Multi-Tenant Vector Search with Role-Based Access Control swimlane diagram
The API gateway adds the user’s tenant and role to every search, so the vector database only searches passages that user may see. The audit step counts any passage from another tenant that slips through.

Production systems usually handle this in four steps.

  • Check the login. Each request arrives with a signed login token, often a JWT, which is a small signed file that says who the user is. The front door of your system, called the API gateway, checks the signature and reads the user’s customer, role, and access level from it.
  • Add the filter automatically. The gateway wraps the user’s question with a filter that application code cannot remove, for example {"tenant_id": "fin_corp_04", "acl_level": {"$lte": 2}, "is_active": True}.
  • Search only allowed chunks. The vector database applies that filter while it searches, so it only compares the question with chunks this user may see.
  • Build the answer and log it. The matching passages go into the AI’s prompt, and a log records the user, the time, and the exact chunks used, so you can answer an auditor’s questions later.
The Metadata Lifecycle & Security Enforcement Loop diagram
The Metadata Lifecycle: Reliably tracking document ingestion, index partitioning, session token inspection, and constrained traversal.

Rules to put in place this week

Require the same set of tags on every chunk, apply security filters inside the database search, and retire old document versions automatically. If you are reviewing your own setup this week, start with these four rules.

  • Require five tags. Every chunk you load should have tenant_id, document_id, created_at (a date in the standard year-month-day format), status (active or archived), and acl_level. Reject any loading job that leaves one out, because a missing tag is a gap nobody notices until something leaks.
  • Never rely on after-the-search filtering for security. Use your database’s built-in filtering during the search. If it cannot do that, consider an engine that can, such as Qdrant or Pinecone, or turn on pgvector’s iterative scans.
  • Retire old versions automatically. When someone uploads a new version of a handbook, do not rely on a person to remove the old one. Have the upload trigger an automatic step that marks every chunk from the previous version as superseded. The old chunks stay on file for audits but drop out of search right away.
  • Index the tags you filter on. Tell the database to build an index on fields such as tenant_id and status. Without an index, it has to check every chunk to apply the filter, which slows down every question.

The next post in this series covers citations: how to link each claim in an AI answer back to the exact document and page it came from, so the reader can check it.

Sources and reference reading

Sources and reference reading

Series notes

This is Part 4 of RAG from scratch. Previous: embeddings. Next: citations and show-me-the-source UX.

Written by

Jose S

Founder & Lead Analyst · Analytics Made Simple

Hands-on data strategist, analytics engineering lead, and educator. Writing practical, no-fluff guides to help everyday teams, analysts, and engineers master SQL, AI systems, and modern data architectures.

Keep going

Same lessons in your feed

Short diagrams, hooks, and weekly tutorials on Substack, Instagram, X, and Facebook.

Google Search Prefer our practical guides in Google Search & Top Stories: