How to Build a Scalable Full Stack App with React and Node.js from Scratch
Build a production-ready React and Node.js full stack app from scratch. Covers project structure, API design,...
Your LlamaIndex RAG demo impressed the stakeholders. It retrieves accurately, responds fast, and fits neatly in a Jupyter notebook. Then you ship it — and everything quietly falls apart.
The problems that surface aren’t bugs. They’re architectural assumptions that work fine with one user and one static dataset, but break systematically once a Python RAG application using LlamaIndex faces real production conditions. This post covers the three operational gaps that catch most teams off guard: stale indexes, vector store ceilings, and latency under concurrent load — with concrete strategies for each.
Prototypes lie. Not maliciously, but structurally.
Your prototype indexed a fixed corpus. Production has documents that change — new support articles, updated policies, revised product specs. LlamaIndex’s default indexing is a one-shot operation; once the index is built, it has no awareness of what’s drifted in the underlying data.
I’ve seen this play out painfully: a customer support team built a RAG bot off their documentation, shipped it, then did a major product release two weeks later. The bot kept confidently citing the old UI. Support tickets spiked. The team spent a weekend doing a manual full rebuild because they had no refresh pipeline in place.
If your documents change more than once a week, you need a refresh strategy on day one.
The gap between notebook performance and production performance is wider than most people expect. Here’s a rough approximation of what teams typically see:
| Concurrent Users | p50 Latency | p99 Latency |
|---|---|---|
| 1 | ~1.2s | ~1.8s |
| 10 | ~2.1s | ~4.3s |
| 20 | ~3.8s | ~9.1s |
That’s not a linear degradation — it’s a cliff. The Python GIL, synchronous embedding calls, and blocking LLM inference don’t compose gracefully under concurrent load. Your notebook benchmark is essentially measuring a best-case, single-threaded scenario.
Python local LLM development with Ollama is genuinely excellent for iteration. Fast setup, easy model swaps, no API keys. But Ollama processes one request at a time by default, which means your local latency numbers are misleading. You’re measuring uncontested single-request performance — not what happens when twenty users hit your endpoint simultaneously. Teams that build their production latency budget around Ollama benchmarks almost always underestimate what they actually need.
The answer to stale retrieval isn’t “rebuild the index more often.” That approach breaks down fast.
LlamaIndex does provide VectorStoreIndex.refresh_ref_docs(), and it’s more useful than it looks. It compares document hashes against what’s already indexed and only re-embeds documents that have changed. Here’s the minimal pattern:
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
reader = SimpleDirectoryReader(
input_dir="./docs",
required_exts=[".md", ".txt"],
recursive=True,
)
documents = reader.load_data()
# Refreshes only changed/new documents; leaves unchanged ones alone
refreshed_docs = index.refresh_ref_docs(documents)
print(f"Refreshed: {sum(refreshed_docs)} documents")
The catch: deleted documents aren’t automatically removed. You need to handle those separately by tracking document IDs and calling index.delete_ref_doc() for anything that’s disappeared.
| Corpus Size | Update Frequency | Recommended Strategy |
|---|---|---|
| < 10k docs | Daily or less | Full nightly rebuild |
| 10k–100k docs | Hourly or daily | Incremental refresh |
| > 100k docs | Continuous | Event-driven incremental + weekly full rebuild |
Full rebuilds are the safest option. You know exactly what’s in the index when it’s done. But at scale, a full rebuild can take hours — and you can’t block query traffic while it runs.
Here’s the pattern that actually works in production: shadow index swap.
# Build into a separate namespace/collection
new_namespace = f"index_v{timestamp}"
new_index = build_index(documents, collection_name=new_namespace)
# Validate the new index (optional but recommended)
validate_index(new_index)
# Atomically swap the pointer
config.active_collection = new_namespace
cleanup_old_collection(old_namespace)
You’re building the new index in parallel while the old one continues serving queries. The swap is a config write — near-instant. With Qdrant, this maps cleanly to named collections; with Chroma in server mode, you’d use collection aliases or handle it at the application layer.
Most teams don’t think about their vector store until it becomes the bottleneck. By then, migrating under load is a miserable experience.
It’s not just disk space. The real limits are:
If you’re running embedded Chroma or FAISS locally, you cannot add more instances to handle load. Full stop. That’s a single-process bottleneck, and it will become your ceiling before you expect it to. And when it does, you’ll be migrating an index while it’s actively serving traffic — not a situation you want to engineer yourself into.
The good news: LlamaIndex’s VectorStore abstraction means swapping stores is mostly a configuration change, not a rewrite. But “mostly” does real work in that sentence — you’ll need to re-index and handle any metadata schema differences.
For a Python RAG application using LlamaIndex in production, the three stores worth evaluating seriously are Qdrant, Weaviate, and Pinecone. Qdrant is my preference for self-hosted setups — it has strong filtering performance and the payload index feature makes a meaningful difference:
# Without payload index on metadata fields
Query latency (filtered): ~45ms
# With payload index on 'category' field in Qdrant
Query latency (filtered): ~8ms
Do the filtering at the vector store layer, not after retrieval. Post-retrieval filtering wastes embedding computation on documents you’re going to discard anyway.
Indexing a large corpus synchronously is slow. Async batching helps significantly:
import asyncio
from llama_index.core import VectorStoreIndex
async def ingest_batch(documents, batch_size=64):
batches = [documents[i:i+batch_size] for i in range(0, len(documents), batch_size)]
tasks = [index.ainsert_nodes(batch) for batch in batches]
await asyncio.gather(*tasks)
asyncio.run(ingest_batch(all_documents))
In practice, async batching cuts indexing time by 60–70% on large corpora. The exact improvement depends on your embedding model and batch size, but it’s the kind of gain that’s hard to justify skipping.
Most teams catch the first two gaps during QA or shortly after launch. This one announces itself differently — usually during a traffic spike or a live demo, which is the worst possible moment to find it.
Breaking down a typical RAG query:
Retrieval is almost never the problem. LLM inference is where your latency lives. Optimizing retrieval when inference is already taking 1–2 seconds is polishing the wrong end of the pipeline.
Ollama is great for development. It shouldn’t be in your production serving path.
It doesn’t implement continuous batching — which means request #2 waits for request #1 to finish, even if request #1 has already generated half its tokens. Under concurrent load, that serialization is exactly where the latency cliff comes from.
Python LLM inference optimization with vLLM solves this with PagedAttention and continuous batching. The short version: vLLM interleaves token generation across multiple concurrent requests, sharing GPU memory efficiently. For a RAG workload with short prompts and medium-length outputs, vLLM typically handles 4–8x more concurrent requests at equivalent latency compared to a naive single-process server.
Swapping LlamaIndex’s backend to point at a local vLLM server is one config change:
from llama_index.llms.openai_like import OpenAILike
llm = OpenAILike(
model="mistral-7b-instruct",
api_base="http://localhost:8000/v1",
api_key="placeholder",
max_tokens=512,
)
vLLM exposes an OpenAI-compatible API, so OpenAILike handles the integration. Your existing query engine code doesn’t need to change.
LlamaIndex’s async query engine is underused. If your application server handles concurrent requests, you want this:
async def handle_queries(queries: list[str]):
tasks = [query_engine.aquery(q) for q in queries]
results = await asyncio.gather(*tasks)
return results
And configure your vector store client with a connection pool sized for your expected concurrency. Most client libraries default to a pool size of 5–10, which becomes a bottleneck at higher load even if the store itself can handle more QPS.
You can’t fix what you can’t see. But most RAG observability setups focus on the wrong thing.
Latency matters, but retrieval quality is what determines whether your application is actually useful. Track hit rate and mean reciprocal rank (MRR) alongside latency. A fast response with bad retrieval is worse than a slow one with good retrieval — at least the slow one might answer the question.
LlamaIndex’s CallbackManager lets you trace retrieval and generation steps. For production, wire it into Arize Phoenix or LangFuse — both have first-class LlamaIndex integrations and give you the per-query tracing you need to diagnose retrieval failures.
Alert on p99 latency, not average. Averages flatten the tail. Your worst 1% of requests tells you what happens when the system is under pressure — that’s the number users actually complain about.
LlamaIndex is an open-source Python framework for building RAG (Retrieval-Augmented Generation) applications that handles document ingestion, chunking, embedding, indexing, and query orchestration in a single cohesive system. It abstracts the complexity of connecting LLMs to external data sources — but that abstraction also means it makes default assumptions that work well in prototypes and break quietly once you’re serving real users at scale.
Move before you need to, not after. If you’re planning a production deployment and expect more than a few concurrent users, start with a server-mode vector store (Qdrant, Weaviate, or Pinecone). Migrating under load — with re-indexing, schema changes, and live traffic — is a weekend you don’t want to spend.
Not automatically. refresh_ref_docs() handles updates and additions, but deleted documents remain in the index until you explicitly call index.delete_ref_doc(doc_id). You need to diff your current document set against what’s indexed and handle deletions yourself. Tracking document IDs in a lightweight database makes this straightforward.
No. Continuous batching and PagedAttention are relevant when you’re hosting your own model. If you’re using a hosted API, the provider handles inference optimization on their end. vLLM matters when you’re running open-weight models (Mistral, Llama, Qwen, etc.) on your own infrastructure and need to handle concurrent load efficiently.
It depends on how fast your source data changes. A daily full rebuild works well for corpora under ~10k documents that update infrequently. For larger or more frequently updated corpora, an event-driven incremental refresh — triggered when documents change — is more practical. The shadow index swap pattern keeps refresh from affecting query latency regardless of strategy.
Scaling a Python RAG application using LlamaIndex is infrastructure work, not framework work. The framework does its job. The gaps are in the surrounding system — how you keep the index fresh, how you handle concurrent inference load, and whether you can see what’s failing when something goes wrong.
Start with the index refresh pipeline. It’s the change with the lowest implementation cost and the highest immediate impact on retrieval quality. Then sort out your vector store before load becomes a problem, swap Python LLM inference optimization with vLLM into your serving layer, and add observability before you need to debug a production incident at 2am.
Most of this work isn’t glamorous. But it’s what separates a demo from a system people can actually rely on.
Tell us about your product, platform, or business software requirement. We will suggest the right team structure, roadmap, and engagement model within 48 hours.