Your production RAG is slow because of index sprawl: how to unify code, logs, PDFs and CSVs with one local MCP search layer
I’m Denis Shokhirev, an Agentic AI Systems Architect in Freiburg im Breisgau, Germany, and at DennisCraft AI Studio I ship production AI systems on Claude, Supabase, n8n, Doppler, and self-hosted Postgres. The most common reason I see production RAG slow down is not the model, and not even embeddings quality. It’s index sprawl: code in one retriever, logs in another, PDFs in a separate parser stack, and CSVs pushed through a side ETL that nobody fully trusts. That architecture looks reasonable
I’m Denis Shokhirev, an Agentic AI Systems Architect in Freiburg im Breisgau, Germany, and at DennisCraft AI Studio I ship production AI systems on Claude, Supabase, n8n, Doppler, and self-hosted Postgres. The most common reason I see production RAG slow down is not the model, and not even embeddings quality. It’s index sprawl: code in one retriever, logs in another, PDFs in a separate parser stack, and CSVs pushed through a side ETL that nobody fully trusts.
That architecture looks reasonable in a demo because each data type gets its own “best” pipeline. In production, it breaks at the exact point where users ask real questions. They do not ask for “semantic search over PDFs.” They ask why an invoice workflow failed, why an agent chose a supplier, or why a rule fired on one customer but not another. The answer usually spans code, runtime logs, documents, and tabular data at the same time.
Why index sprawl becomes a production problem
When retrieval is split by storage or file type, your agent has to decide where truth probably lives. That means more tool calls, more retries, more prompt tokens, and worse traceability. It also means inconsistent ranking. Your log search might prefer exact matches, your code retriever might prefer symbol locality, your PDF flow might rank by chunk similarity, and your CSV path may not rank at all.
Anthropic’s documentation on tool use recommends giving models clear tools with well-defined contracts rather than fuzzy overlapping capabilities, 2024, docs.anthropic.com. In practice, I’ve found the same thing: if the model sees one search tool with one interface, behavior is easier to test and easier to explain. If it sees four search tools that all look similar, it starts making routing decisions you now have to debug in production.
| Pattern | Looks fine in a demo | Fails in production because | Better option |
|---|---|---|---|
| Separate retriever per source | Fast to prototype | Routing logic multiplies | One local search contract |
| SQL only for CSVs | Good exact lookups | No shared context with code or docs | Normalize metadata across all sources |
| Standalone PDF service | Easy extraction | Version drift and weak audit trail | Single tool layer with checksums |
| Log search outside agent stack | Ops already has it | Agent cannot correlate runtime facts | Bring logs into the same retrieval contract |
What I mean by one local MCP search layer
I’m not talking about another branded retrieval product. I mean a pattern: expose one local tool to the model, usually via MCP, and let that tool fan out internally to code, logs, PDFs, and CSVs. The model should call search(query, source_types, limit). It should not care which parser, table, or ranker sits behind that interface.
Local matters for two reasons. First, latency stays predictable when the tool runs close to Postgres, local file storage, and your logs. Second, for DACH B2B work in regulated settings, fewer external hops make audits and data handling simpler. If the answer path stays inside your controlled environment, your story to compliance, legal, and security gets shorter.
A minimal tool contract
from typing import List, Optional
from pydantic import BaseModel
class SearchRequest(BaseModel):
query: str
source_types: Optional[List[str]] = None
limit: int = 8
class SearchHit(BaseModel):
source_type: str
uri: str
title: str
snippet: str
score: float
def search_local(req: SearchRequest) -> List[SearchHit]:
types = req.source_types or ["code", "logs", "pdf", "csv"]
hits = []
if "code" in types:
hits += search_code(req.query, req.limit)
if "logs" in types:
hits += search_logs(req.query, req.limit)
if "pdf" in types:
hits += search_pdf(req.query, req.limit)
if "csv" in types:
hits += search_csv(req.query, req.limit)
return sorted(hits, key=lambda h: h.score, reverse=True)[:req.limit]The important part is not Python. The important part is that every hit comes back in the same shape. Once logs, code chunks, PDF paragraphs, and CSV rows all return a title, snippet, URI, score, and source type, the next reasoning step gets much simpler.
How I handle the four source types
Code
For code, I start with repository-aware chunking: functions, classes, config files, and selected docs. Before anything becomes retrievable, I usually run semgrep, bandit, and gitleaks. That reduces the chance of pushing secrets or dangerous patterns into model context. OWASP lists prompt injection and sensitive information disclosure in the OWASP Top 10 for LLM Applications, 2025, genai.owasp.org. Retrieval is part of that attack surface, not a separate concern.
Logs
Logs are often the highest-value source in production incidents, but only if they are normalized. I treat timestamp, service, severity, correlation_id, and message as mandatory fields. That allows the agent to answer questions like “why did the approval chain skip step 3?” with runtime evidence instead of just code guesses.
PDFs
PDF ingestion should be boring. Extract text, store page number, heading if available, and a file checksum. The checksum matters more than teams expect. It prevents the system from quoting yesterday’s contract appendix or an outdated operating procedure after a file was replaced under the same name.
CSVs
CSV rows should not be treated as plain text blobs. I store the file name, row number, typed columns when possible, and a compact text rendering of the row. That lets one query return the row itself and the documentation explaining what the row means.
python ingest_code.py --repo /srv/app --out postgres
python ingest_logs.py --input /var/log/app.jsonl --out postgres
python ingest_pdf.py --input /srv/docs --out postgres
python ingest_csv.py --input /srv/data --out postgres
psql "$DATABASE_URL" -c "create index if not exists idx_docs_type on docs(source_type);"
psql "$DATABASE_URL" -c "create index if not exists idx_docs_uri on docs(uri);"
psql "$DATABASE_URL" -c "analyze docs;"Where the gains actually show up
The first gain is latency. Instead of letting the model call multiple tools and then reconcile four ranking schemes, you merge results once inside the local search layer and return one top-k list. The second gain is observability. You can log query, filters, retrieved items, and final answer in one trace. The third gain is access control. Restricting one local search tool by path, table, or source type is easier than trying to keep four retrieval services aligned.
OpenAI’s Cookbook examples on retrieval and tool use consistently push toward simple, explicit interfaces between the model and external systems, 2024, cookbook.openai.com. That matters even more in production than in examples. Every extra tool decision creates more room for latency, inconsistency, and hard-to-explain failures.
tools:
- name: local_search
description: Search code, logs, pdf and csv from local sources
input_schema:
type: object
properties:
query: { type: string }
source_types:
type: array
items: { type: string }
limit: { type: integer, default: 8 }
required: [query]What not to do
Do not start by adding a vector database for every format before you have a stable search contract. Do not force the model to choose between code_search, pdf_search, csv_search, and logs_search unless there is a strong reason. Do not split metadata in one store and raw content in another without durable identifiers. And do not index repositories into RAG without secret scanning first. Gitleaks during ingestion is cheaper than incident response later.
FAQ
Do I need MCP for this pattern?
No. The core idea is a single local search contract. MCP is a good standard way to expose that tool to an agent, but the architectural benefit comes from unification, not from the protocol alone.
Should every source use vector search?
No. Logs and CSVs often perform better with metadata filters and exact or hybrid matching. I only add embeddings where recall improves enough to justify the complexity.
Can Postgres handle this by itself?
In many B2B systems, yes. Postgres is often enough for ingestion tracking, metadata, access control, auditing, and a meaningful portion of retrieval if the schema is disciplined.
How does this help with security?
A single search layer makes access control, secret filtering, and trace logging easier to enforce. It also makes it easier to explain exactly which sources influenced an answer.
When are separate indexes still justified?
When sources differ heavily in retention, query volume, or permissions. Even then, I still prefer one external search interface for the model and hide internal complexity behind it.
Which part of your production RAG is creating more pain right now: split retrieval across sources, metadata normalization, or explaining answer provenance to auditors and customers? I’d genuinely like to know.
I run a free 30-min stack audit for DACH founders building AI in regulated markets. DM me on LinkedIn or write to @ger_dennis_ai.
Turn your process into an AI system
Production quality. DACH B2B focus.