Article / Field Notes

Designing RAG for 100 Million Documents

Nilay Mallik Aug 13, 2026

A RAG demo is easy.

Take a PDF, split it into chunks, generate embeddings, put them in a vector database, retrieve the five nearest chunks, attach them to a prompt, and call an LLM.

That architecture is perfectly reasonable when you have a few thousand documents.

It starts breaking down when the corpus contains 100 million documents.

At that point, the question is no longer:

Which vector database should I use?

The real question becomes:

How do I reduce billions of possible pieces of evidence to the 10–20 passages that matter, without destroying latency, retrieval quality, availability, or cost?

That is a very different engineering problem.

At 100M documents, RAG becomes a distributed data and search system with an LLM attached to the end.

Before starting,

Here is the requirements:

  • 100M+ documents
  • Documents can be PDFs, HTML, DOCX, etc.
  • Millions of users
  • Users ask natural-language questions
  • Answers must include citations
  • Documents can be updated/deleted
  • Retrieval should be fast: roughly <1–3 seconds before generation
  • High availability
  • Asynchronous document ingestion

The key requirement is:

Retrieve the right 10–50 pieces of information from potentially billions of chunks.

Start With the Math

The first mistake in large-scale RAG design is equating documents with vectors.

Suppose we have:

Documents              = 100,000,000
Average chunks/document = 30
Embedding dimensions = 768
Storage/dimension = 4 bytes (float32)

The number of vectors is therefore:

100,000,000 × 30
= 3,000,000,000 vectors

Three billion vectors.

The raw embedding storage alone is approximately:

3B × 768 × 4 bytes
≈ 9.2 TB

And that is only the raw vector values.

It does not include:

ANN index structures
chunk text
document metadata
ACL metadata
IDs
lexical indexes
replicas
snapshots
WALs
temporary indexes
operational headroom

If the vector index has two replicas, you are already above roughly 18 TB of raw vectors before accounting for the search index itself.

If documents average 50 chunks instead of 30:

100M documents × 50 chunks
= 5 billion vectors

This is why a design that says:

“We’ll put all the chunks in a vector database and run similarity search.”

is not really a large-scale architecture.

It’s the beginning of one.

The Architecture I Would Build

Conceptually, I would separate the system into two independent planes:

The separation matters.

A customer uploading 20 million new documents should not make query latency jump from 300 ms to 8 seconds.

The ingestion system is optimized for throughput.

The retrieval system is optimized for latency.

Treating them as the same pipeline is one of the easiest ways to create operational pain later.

The Source of Truth Is Not Your Vector Database

I would keep the original documents in cheap object storage.

Think: S3 / GCS / Azure Blob

The vector database is an index, not the source of truth.

A useful conceptual separation looks like this:

Why?

Because indexes can be rebuilt.

Suppose six months from now you move from embedding model E1 to E2.

You should be able to do:

without trying to reconstruct your original content from a vector database.

Object versioning is also useful for document pipelines because updates can be represented as new versions instead of destructive overwrites.

Amazon S3, for example, supports retaining multiple versions of an object, while lifecycle policies can transition or expire older objects.

Ingestion Must Be Asynchronous

Imagine a customer uploads 500,000 PDFs.

You absolutely do not want an HTTP request doing this: POST /documents

parse PDF
→ OCR
→ clean
→ chunk
→ embed
→ index
→ return 200

The request could take hours.

Instead:

POST /documents


Store document


Create ingestion event


Return 202 Accepted

Processing happens asynchronously:

Kafka is one possible backbone here because topics are partitioned and consumers can process those partitions in parallel. Kafka also preserves ordering within an individual topic partition, which is useful when events sharing a document or tenant key need predictable ordering.

The exact technology matters less than the architectural property:

ingestion needs buffering.

If your embedding service can process 30,000 chunks per second and suddenly receives work representing 100,000 chunks per second, the system should accumulate backlog rather than collapse.

Every Ingestion Step Should Be Idempotent

At this scale, jobs will run twice.

Messages will be retried.

Workers will crash after writing data but before acknowledging a job.

Network requests will time out even though the remote operation succeeded.

Design for this from day one.

For example, derive deterministic identifiers:

document_id
document_version
chunk_index
embedding_model_version

A chunk identifier could conceptually be:

hash(
tenant_id +
document_id +
document_version +
chunk_index
)

Now receiving the same indexing event twice doesn’t necessarily create duplicate content.

Instead:

upsert(chunk_id, ...)

becomes safe to retry.

Exactly-once execution is difficult.

Idempotent execution is often the more practical engineering target.

Chunking Becomes an Infrastructure Decision

At small scale, changing chunk size feels harmless.

At billion-vector scale, it can be expensive.

Consider: 100M documents

If your strategy creates: 20 chunks/document

you get: 2B vectors

If another strategy produces: 60 chunks/document

you get: 6B vectors

A seemingly innocent chunking change just tripled:

embedding compute
vector storage
indexing work
retrieval fan-out
reindexing time
replication traffic

So chunking isn’t only an NLP parameter anymore.

It is a capacity-planning parameter.

I would store enough information with each chunk to reconstruct its origin:

{
"chunk_id": "c_982...",
"document_id": "doc_51...",
"document_version": 8,
"tenant_id": "tenant_17",
"page": 43,
"section": "Risk Factors",
"language": "en",
"created_at": "...",
"acl_groups": ["finance", "executives"],
"embedding_version": "embed_v4"
}

The text itself may live separately depending on storage strategy, but retrieval must preserve that lineage.

Otherwise citations eventually become painful.

Do Not Search the Entire Corpus Unless You Actually Have To

This is probably the most important part of the architecture.

A user asks: What changed in our European refund policy this quarter?

A naive system effectively asks: Search 3,000,000,000 vectors

But the query already gives us several possible constraints:

organization = current tenant
region = Europe
topic = refund policy
time = current quarter
permissions = documents user may access

A good retrieval architecture uses those constraints aggressively.

Conceptually: 3 billion chunks

Those numbers are illustrative, not universal.

The principle is what matters: route first, retrieve second.

Sharding Is How the Vector Layer Becomes Distributed

Eventually one node cannot reasonably own the entire searchable corpus.

The vector index must therefore be partitioned.

Distributed vector systems such as Qdrant implement horizontal scaling by distributing points across shards, while replication can place copies of those shards on multiple nodes for availability and additional read capacity.

But there is an important detail.

If every query has to fan out across every shard, adding shards can eventually increase coordination work.

The better design is to make as many queries as possible routable.

For example:

tenant_id
region
document namespace
language
time partition

might help determine which subset of shards actually needs to participate.

This is especially powerful for multi-tenant systems.

Instead of:

Query

128 shards

you might be able to do:

tenant_id = 429

Shard group 18

4 shards

Qdrant’s current distributed design, for example, supports user-defined sharding so operations can be targeted to selected shard keys rather than necessarily touching an entire collection.

The larger lesson is vendor-independent: Good partitioning reduces query fan-out.

Replication Solves a Different Problem

Sharding and replication are often confused.

Sharding answers: How do I split a dataset that is too large for one machine?

Replication answers: What happens if one of those machines disappears?

Imagine:

Shard A → Node 1
Shard B → Node 2
Shard C → Node 3

If Node 2 fails, part of the corpus disappears.

With replication:

Shard A → Node 1 + Node 4
Shard B → Node 2 + Node 5
Shard C → Node 3 + Node 6

the system can continue serving queries when individual nodes fail.

Replication costs storage, write bandwidth, and operational complexity, but without redundancy a large production search system has uncomfortable failure modes.

Vector Search Alone Is Not Enough

Suppose the user asks: What does policy HR-48291 say?

Semantic search might find content about HR policies.

But lexical retrieval is extremely good at noticing: HR-48291

Now consider:

Can employees take extra leave after adopting a child?

Semantic search is much more valuable.

These retrieval methods solve overlapping but different problems.

That is why I would normally build hybrid retrieval:

Elastic’s current hybrid-search guidance similarly combines lexical and vector retrieval and recommends Reciprocal Rank Fusion as one way to merge their rankings.

A useful aspect of RRF is that you don’t have to pretend a BM25 score and cosine similarity are directly comparable.

You combine rank positions instead.

A simplified version looks like:

RRF(d) = Σ 1 / (k + rank_i(d))

where rank_i(d) is the document's position in retrieval system i.

Be Careful With Sequential Prefiltering

There is a tempting optimization:

3B documents

BM25 returns 10,000

vector search only those

This can be useful in some workloads.

But it can also destroy semantic recall.

Imagine the query:

What are the rules for employees who become parents through adoption?

A relevant passage might say:

Staff welcoming a child through non-birth placement receive sixteen weeks of family leave.

It may have weak keyword overlap with the user’s exact wording.

If BM25 eliminates it before semantic retrieval ever sees it, the embedding model cannot rescue it.

For that reason, I generally prefer parallel candidate generation after hard routing constraints:

Then rerank the combined candidate set.

Hard constraints such as:

tenant
permissions
document status

are different.

Those should usually be enforced before retrieval.

Authorization Must Happen Before Evidence Reaches the LLM

Consider an enterprise corpus containing:

Engineering documents
HR documents
Board documents
Legal documents
Executive compensation
Customer contracts

Semantic relevance is not authorization.

A CFO document might be the mathematically closest embedding to an employee’s question.

That doesn’t mean the employee gets to see it.

The wrong architecture is:

Vector Search

Retrieve confidential chunk

Send chunk to LLM

Check permission

The information has already entered a downstream component.

Instead:

User

Identity

Groups / roles / ACL

Authorized search space

Retrieval

This also means your access-control metadata needs to participate efficiently in retrieval.

At very large scale, an ACL implementation that requires scanning millions of records after retrieval can become both a security and performance problem.

Retrieval Should Produce Candidates, Not Context

Suppose hybrid retrieval gives us 400 candidates.

Do not immediately place 400 chunks in the prompt.

First rerank them.

Vector candidates: 200
Lexical candidates: 200


Fusion


~250 unique chunks


Reranker


top 20–40

The first-stage search system is optimized for:

Find potentially relevant things quickly.

The reranker is optimized for:

Given a much smaller candidate set, estimate relevance more carefully.

This two-stage pattern lets an expensive relevance model operate on hundreds of items instead of billions.

Then Remove Redundancy

Reranking alone can produce something like:

Chunk 1 → page 14
Chunk 2 → page 14
Chunk 3 → page 15
Chunk 4 → page 14
Chunk 5 → page 15

All five may explain essentially the same point.

Sending them all wastes context tokens.

Before constructing the final prompt, I would apply some combination of:

deduplication
document diversity
section diversity
near-duplicate detection
adjacent-chunk merging
token budgeting

Sometimes one retrieved chunk also benefits from its neighbors.

If chunk 47 contains the answer but starts halfway through an explanation, the context builder may fetch:

chunk 46
chunk 47
chunk 48

rather than expecting vector search to retrieve every contiguous piece independently.

Context Construction Is Its Own Layer

A surprisingly common RAG implementation treats context as:

context = "\n\n".join(retrieved_chunks)

Production systems benefit from a dedicated context builder.

Its job is to take ranked evidence and create an efficient prompt payload while preserving citation information.

Conceptually:

Retrieved Evidence

Deduplicate

Merge related chunks

Enforce token budget

Preserve source IDs

Order evidence

Generate context

The LLM might receive something structurally equivalent to:

SOURCE S1
Document: Employee Handbook
Page: 42
Version: 18
Text: ...
SOURCE S2
Document: European Leave Addendum
Page: 7
Version: 4
Text: ...
QUESTION
...

Then the model is instructed to cite S1, S2, etc.

That is much safer than asking the model to reconstruct URLs or document identifiers from memory.

Query Understanding Should Be Cheap

Before retrieval, some queries benefit from normalization.

Take:

How much did revenue grow last year in Europe?

Depending on the current date and company terminology, the retrieval layer may benefit from deriving:

metric = revenue
region = Europe
year = 2025
intent = financial comparison

For a conversational system:

What about Asia?

might need rewriting using previous conversation state:

How much did revenue grow in Asia in 2025?

The important architecture decision is that these operations usually don’t need your most expensive reasoning model.

Use the cheapest component that performs the job reliably.

A mature AI stack often looks more like:

small model

classification
small model

query rewriting
embedding model

retrieval
reranker

relevance
large model

final reasoning

rather than:

largest model everywhere

Query Decomposition Helps With Multi-Hop Questions

Some questions cannot be answered reliably from one passage.

For example:

Which European product had the largest revenue decline, and what reason did management give for it?

This may require:

Question 1:
Which European product had the largest decline?
Question 2:
What explanation did management provide for that product?

Now retrieval can gather evidence for each subproblem.

Complex Query


Query Decomposer
/ \
/ \
▼ ▼
Subquery A Subquery B
│ │
▼ ▼
Retrieval Retrieval
\ /
\ /
▼ ▼
Evidence Join


LLM

This is where large-scale RAG starts resembling a query-planning system rather than simple nearest-neighbor search.

Freshness Changes the Indexing Strategy

Suppose the corpus contains 100M documents but 2M documents change every day.

A nightly rebuild is probably not the architecture you want.

You need incremental indexing.

Document updated


new document version


parse changed document


generate new chunks


embed


write new index entries


mark previous version inactive

I prefer logical versioning over immediate destructive replacement.

For example:

document_id = D17
version 41 → status=inactive
version 42 → status=active

Queries only search:

status = active

Old entries can be garbage-collected asynchronously.

This allows an update to be committed atomically from the application’s perspective without requiring every storage system to mutate simultaneously.

Model Versioning Matters Too

Eventually you will change embedding models.

Never store vectors without knowing what generated them.

At minimum:

embedding_model
embedding_version
embedding_dimensions
chunking_version
parser_version

should be traceable.

Otherwise six months later you will discover that 15% of an index was generated using an older pipeline and have no easy way to identify which 15%.

A migration can use parallel indexes:

Documents

┌─────────┴─────────┐
▼ ▼
embedding model V3 embedding model V4
│ │
▼ ▼
Index V3 Index V4


shadow traffic


evaluate


switch alias

Do not migrate billions of vectors and simply hope the new retrieval system is better.

Measure it first.

Caching Should Exist at Several Levels

Not every query deserves the full pipeline.

Suppose thousands of employees ask:

How many vacation days do we get?

Running:

embedding
→ distributed retrieval
→ reranking
→ large LLM

every single time is wasteful.

You may have several caches:

User Query


Exact Response Cache
│ miss

Semantic Cache
│ miss

Retrieval Cache
│ miss

Search

There is also an obvious embedding cache:

hash(normalized_query)

query embedding

Caching gets complicated when content changes.

A cached answer based on:

Policy version 17

may become incorrect once:

Policy version 18

goes live.

So the cache key or invalidation mechanism needs awareness of corpus/version boundaries.

Caching AI outputs without freshness semantics is dangerous.

Failure Handling Matters More Than Happy-Path Latency

Large systems fail partially.

Your architecture should expect:

embedding provider unavailable
one vector shard unavailable
search cluster degraded
reranker timeout
LLM rate limited
LLM provider outage
queue backlog growing
parser crashing on malformed PDF
OCR worker running out of memory

Each failure should have an explicit policy.

For example:

Vector search fails

Can lexical search answer?

yes

return degraded retrieval mode

Or:

Primary LLM unavailable

Fallback model

Lower quality but service continues

Or:

Document parser fails 5 times

Dead-letter queue

record failure reason

operator / automated remediation

Retrying everything forever is not resilience.

Sometimes it is just an infinite loop with a cloud bill.

Backpressure Is Essential

Imagine ingestion normally receives:

20M chunks/hour

and embedding capacity handles:

25M chunks/hour

Everything works.

Then a customer performs a migration and produces:

200M chunks/hour

Without backpressure:

embedding service overloaded

timeouts

retries

more load

more failures

retry storm

With buffering:

incoming workload

durable queue

workers consume at safe rate

backlog temporarily grows

Now capacity can scale independently.

This is one reason the ingestion plane deserves its own architecture.

Observability Has to Measure Retrieval Quality

Traditional monitoring tells you:

HTTP 200 rate
CPU
memory
disk
p95 latency
error rate
requests/second

All useful.

But an RAG service can return HTTP 200 in 800 ms and still be completely wrong.

You also need AI-specific telemetry.

A useful request trace might look like:

request_id: rq_912
query rewrite:
"parental policy?" → "parental leave policy"
vector retrieval:
200 candidates
145 ms
lexical retrieval:
200 candidates
91 ms
fusion:
278 unique candidates
reranking:
278 → 20
212 ms
context:
13 chunks
8,420 tokens
LLM:
input 9,104 tokens
output 681 tokens
1.8 sec
citations:
3 documents
total:
2.4 sec

Then offline evaluation needs metrics around questions such as:

Was the correct evidence retrieved?
Did it rank high enough?
Was the answer actually supported by the evidence?
Did adding reranking improve enough queries to justify its latency?
Which query classes fail most often?

That’s more actionable than simply tracking hallucination as one global percentage.

Evaluate the Retrieval Pipeline Separately From the LLM

Suppose the final answer is wrong.

There are at least two fundamentally different failure classes.

Retrieval failure

The required evidence never entered the context.

Correct document exists

retriever misses it

LLM never sees it

wrong answer

Changing the prompt probably won’t fix that.

Generation failure

The correct evidence was retrieved:

correct evidence

context

LLM

incorrect interpretation

Now prompt design, model choice, context organization, or reasoning strategy might matter.

If you only evaluate final answers, these failure modes look identical.

A serious RAG evaluation pipeline therefore needs to inspect intermediate stages.

Latency Should Have a Budget

Instead of saying:

The system needs to be fast.

Give every stage a budget.

For illustration, suppose we want a roughly 2.5-second first-answer path.

We might budget something like:

API + auth                    50 ms
query understanding 100 ms
retrieval 300 ms
fusion 20 ms
reranking 250 ms
context construction 30 ms
LLM time-to-first-token 1,200 ms
network / safety margin 550 ms
-----------------------------------
target ~2,500 ms

These aren’t universal numbers.

The value of the exercise is discovering where latency is actually spent.

If retrieval consumes 1.8 seconds, changing LLM providers won’t solve your primary bottleneck.

If the LLM consumes 90% of latency, spending a month shaving 40 ms from vector search probably isn’t the highest-value optimization.

Cost Needs the Same Treatment

A large RAG system has several independent cost centers:

object storage
document parsing
OCR
embedding generation
vector storage
vector replicas
lexical search
metadata database
network transfer
reranking
LLM input tokens
LLM output tokens
observability
backups

This is why cost should be measured per useful unit.

For example:

cost / 1,000 indexed documents
cost / million chunks
cost / query
cost / successful answer
cost / tenant

The final one is particularly important in SaaS.

You may discover:

Tenant A
5% of revenue
42% of LLM spend

Without tenant-level metering, you’ll see only an unexpectedly large monthly infrastructure bill.

Don’t Put Everything in Expensive Storage

A useful mental model is:

expensive / fast


vector indexes
search indexes
Redis caches

metadata DB

object storage


cheap / large

The complete PDF doesn’t need to live inside your vector index.

Keep expensive storage focused on the information needed for retrieval.

Object storage handles the source document.

The search infrastructure stores searchable representations.

This distinction matters significantly once the corpus becomes hundreds of terabytes or more.

Hot and Cold Data May Need Different Treatment

Not all 100M documents are queried equally.

Imagine:

Last 30 days        → 70% of queries
Last 12 months → 25%
Older archive → 5%

Treating all documents identically may waste infrastructure.

One possible architecture:

Query

├────→ Hot index

└────→ Archive index when necessary

Similarly, time-based routing can prevent a question asking about this week’s events from touching ten years of historical shards.

Large-scale systems become efficient by avoiding work, not merely by doing unnecessary work faster.

Multi-Tenancy Changes Everything

Suppose your platform serves:

10,000 organizations

You probably don’t want:

10,000 completely independent vector clusters

But putting every tenant blindly in one namespace can create noisy-neighbor and authorization problems.

A practical architecture may classify tenants by size.

Small tenants

shared shard groups
Medium tenants

partitioned shared infrastructure
Very large tenants

dedicated shard groups

This prevents one customer importing 500M chunks from overwhelming thousands of smaller customers.

It also gives you a path for enterprise isolation requirements.

There is rarely one universal tenant-partitioning strategy.

The LLM Should Be Near the End of the Architecture

Notice how much engineering we have discussed before reaching the LLM.

That’s intentional.

A mature RAG request looks more like:

User

API Gateway

Authentication

Authorization

Rate Limiter

Query Understanding

Shard Routing

Hybrid Candidate Retrieval

Rank Fusion

Reranker

Deduplication

Context Builder

LLM

Citation Validation

Response

The LLM is important.

But at 100M documents, it is only one component.

The Full Production Architecture

Putting the pieces together:

CLIENTS


┌──────────────┐
│ API Gateway │
└──────┬───────┘

┌──────▼───────┐
│ Auth / ACL │
│ Rate Limits │
└──────┬───────┘


┌────────────────────┐
│ Query Orchestrator │
└─────────┬──────────┘

┌─────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Cache Query Rewrite Metadata
Filters
│ │ │
└─────────────┼──────────────┘

Shard Router

┌────────────┴────────────┐
▼ ▼
Lexical Search Vector Search
│ │
└───────────┬─────────────┘

Rank Fusion


Reranker


Deduplicate


Context Builder


LLM


Citation Validation


RESPONSE
================================================================
INGESTION SYSTEM
Documents


Object Store


Event Queue

┌────────────┼────────────┐
▼ ▼ ▼
Parser Parser Parser
│ │ │
└────────────┼────────────┘

Normalization


Chunker

┌──────────────┴──────────────┐
▼ ▼
Embedding Queue Text Index Queue
│ │
┌─────┼─────┐ ▼
▼ ▼ ▼ Lexical Index
GPU GPU GPU
│ │ │
└─────┼─────┘

Vector Index
================================================================
SUPPORTING SYSTEMS
Metadata DB         → document state, versions, ownership
Object Storage → original source documents
Vector Cluster → semantic retrieval
Search Cluster → lexical retrieval
Redis → caches and rate limiting
Message Queue → asynchronous ingestion
Observability → traces, metrics, evaluation
Secrets / IAM → service authorization
Evaluation System → retrieval + answer-quality tests

What I Would Not Do

If I were reviewing this architecture, these would immediately concern me:

One giant vector collection with no routing strategy
Synchronous PDF ingestion
Only vector retrieval, no lexical search
ACL filtering after retrieval
No document versioning
No embedding model version
No reranking
Sending top-100 chunks directly to the LLM
Using the LLM for every trivial classification task
No dead-letter queue
No retrieval-level evaluation
No tenant-level cost attribution
Treating the vector DB as permanent document storage
Re-embedding the entire corpus for every small content change
Benchmarking only average latency instead of tail latency

Individually, some may be acceptable in a prototype.

Together, they become extremely painful at billion-vector scale.

The Core Design Principle

The most useful way to think about large-scale RAG is not:

question
→ vector search
→ LLM

It is:

billions of possible evidence units

routing constraints

relevant partitions

cheap candidate search

hundreds of items

expensive reranking

tens of passages

context optimization

LLM

grounded answer

The system is a funnel.

At every stage we spend more computation on fewer candidates.

That idea appears repeatedly:

Cheap operation      → huge search space
Moderate operation → smaller candidate set
Expensive operation → tiny candidate set
Most expensive model → final context only

And that is ultimately how the architecture stays economically viable.

Final Takeaway

A RAG system with a few thousand documents is mostly an application-engineering problem.

A RAG system with 100 million documents is a search infrastructure problem, a distributed-systems problem, a data-engineering problem, a security problem, and a cost-engineering problem at the same time.

The vector database matters.

The embedding model matters.

The LLM matters.

But the architecture around them matters more than most RAG tutorials suggest.

The scalable mental model is:

store the source cheaply, process asynchronously, version everything, partition deliberately, enforce authorization early, retrieve with multiple signals, rerank aggressively, send only high-quality evidence to the LLM, and measure every stage independently.

Because when the corpus reaches billions of chunks, your biggest optimization is no longer making search faster.

It is making sure most of the system never has to search most of the corpus in the first place.