cd /news/artificial-intelligence/anatomy-of-a-full-rag-application-ev… Β· home β€Ί topics β€Ί artificial-intelligence β€Ί article
[ARTICLE Β· art-56246] src=dev.to β†— pub= topic=artificial-intelligence verified=true sentiment=↑ positive

Anatomy of a Full RAG Application: Every Concept, One Self-Hosted Stack

A developer built myRAG, a fully self-hosted RAG stack combining FastAPI, React, Qdrant, PostgreSQL, and Neo4j. The system uses hybrid search with dense and sparse embeddings, cross-encoder reranking, and knowledge graph triples to improve retrieval accuracy for complex queries. The project demonstrates a complete pipeline from document ingestion to streaming LLM responses.

read6 min views1 publishedJul 12, 2026

"Chat with your documents" sounds simple. Then you build it, and you discover a good RAG system is really eight systems wearing a trench coat.

I recently finished myRAG β€” a fully self-hosted RAG stack: FastAPI backend, React frontend, and three storage engines (Qdrant, PostgreSQL, Neo4j), all orchestrated with Docker Compose. This post walks through every stage of the pipeline and the concept behind it, with real code from the project.

For a link to the codebase, scroll to bottom!

Document ─► Docling ─► Chunker ─┬─► Dense embed (OpenRouter) ──┐
                                β”œβ”€β–Ί Sparse BM25 (fastembed) ───┼─► Qdrant (named vectors)
                                └─► LLM triple extraction ─────┼─► Neo4j (knowledge graph)
                                                               └─► PostgreSQL (metadata, doc_uuid)

Question ─► hybrid search (RRF) ─► rerank ─► + graph facts ─► + memory ─► token budget ─► LLM ─► SSE stream

Six containers: the app, the React/nginx frontend, docling-serve

, Qdrant, Postgres, and Neo4j.

Everything starts with converting PDFs/DOCX/HTML into text an LLM can use. I run Docling as a separate service that returns clean Markdown, preserving headings and tables.

Why Markdown? Because structure survives. A table flattened into a character soup is unsearchable no matter how good your embedding model is. Garbage in, garbage out β€” this stage silently determines your ceiling.

Chunks are the retrieval unit. Too large and the embedding becomes a blurry average of many topics; too small and the chunk loses the context needed to be understood alone.

chunking:
  strategy: recursive
  chunk_size: 512
  chunk_overlap: 64

Recursive splitting respects paragraph and sentence boundaries, and the overlap is essential: without it, answers that straddle a chunk boundary fall into the gap and are never retrieved whole.

Most tutorials embed once and call it done. I index every chunk two ways:

fastembed

, no API cost) β€” lexical similarity. Exact part numbers, names, acronyms β€” the things embedding models fumble.Qdrant stores both on the same point as named vectors, with BM25's IDF computed server-side:

self.client.create_collection(
    collection_name=...,
    vectors_config={"dense": VectorParams(size=4096, distance=Distance.COSINE)},
    sparse_vectors_config={"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)

One subtlety: BM25 weights documents and queries differently, so ingestion uses embed()

(term weighting) while queries use query_embed()

(term presence).

At query time, both searches run and Qdrant fuses them with RRF β€” which merges ranked lists instead of trying to normalize incomparable score scales:

result = self.client.query_points(
    collection_name=...,
    prefetch=[
        models.Prefetch(query=dense_vector, using="dense", limit=20),
        models.Prefetch(query=sparse_query, using="bm25", limit=20),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=10,
)

RRF scores each document by Ξ£ 1/(k + rank)

across lists. A chunk ranked highly by either method surfaces; one ranked highly by both wins. No score calibration, no tuning β€” and it works disturbingly well.

Vector search compares a query against chunks that were embedded without knowing the question. A cross-encoder reranker (Cohere rerank, via OpenRouter) reads query and chunk together and produces a much sharper relevance score.

The pattern is a funnel: hybrid-retrieve 10 candidates cheaply, rerank down to the best 5. It's the same two-stage architecture search engines have used for decades β€” recall first, precision second.

Pure vector RAG struggles with relational questions β€” "Who reports to the person who founded X?" spans facts that live in different chunks.

So during ingestion, an LLM extracts (subject, relation, object) triples from every chunk into Neo4j:

You are a knowledge-graph extraction engine. From the text below, extract factual
relationships as a JSON array of triples... Only extract relationships explicitly
stated in the text. Return ONLY the JSON array, no prose.

At query time the flow is: extract entities from the question β†’ match them against graph nodes via Neo4j's fulltext index β†’ pull their 1-hop neighborhood β†’ inject the triples into the prompt as structured facts:

Knowledge graph facts:
- Acme Corp --acquired--> Widget Inc
- Widget Inc --founded_by--> Jane Doe

Call it GraphRAG-lite: the vector store answers "what's relevant," the graph answers "how things relate." Every relationship is tagged with doc_uuid

, so deleting a document prunes its facts (and any orphaned entities) cleanly.

Multi-turn chat needs history, but the context window is finite. Two mechanisms:

Rolling summarization β€” after N turns, older exchanges are compressed into a running summary by a small LLM. Long conversations cost a paragraph, not pages.

Token budgeting β€” before generation, the prompt is assembled against a hard cap with explicit priorities:

while history_msgs and fixed + est(history_msgs) > budget:
    history_msgs.pop(0)          # oldest history goes first

for chunk in chunks:             # rerank order: best first
    if used + est(chunk) > avail and len(kept) >= min_chunks:
        break
    kept.append(chunk)

The mental model: every token of history you keep is a token of evidence you can't include. Making that trade-off explicit β€” instead of letting the API truncate arbitrarily β€” noticeably improves long conversations.

Ingestion is embarrassingly parallel β€” embedding API calls, local BM25 encoding, and per-chunk graph extraction are all independent I/O. A single shared thread pool handles the fan-out:

def map_parallel(fn, items):
    items = list(items)
    if len(items) <= 1:
        return [fn(i) for i in items]
    return list(get_pool().map(fn, items))

Three applications:

batches = [texts[i:i+32] for i in range(0, len(texts), 32)]
vectors = [v for batch in map_parallel(self._embed_request, batches) for v in batch]

sparse_future = get_pool().submit(self.sparse_embedder.embed_batch, texts)
vectors = self.embedder.embed_batch(texts)
sparse_vectors = sparse_future.result()

futures = [get_pool().submit(self.graph_extractor.extract, t) for t in texts]
for i, future in enumerate(as_completed(futures)):
    ...  # upsert triples + yield progress events as each completes

At query time, the graph fact lookup is submitted before retrieval starts, so it overlaps with retrieval + reranking instead of running after them. Graph extraction was the slowest ingestion phase by far β€” parallelizing it is the difference between 90 seconds and 15 for a mid-sized document. No async rewrite needed; threads are plenty for I/O-bound work.

doc_uuid

ties each document across Postgres (primary key), Qdrant (point payload), and Neo4j (relationship property). Deletes cascade through all three stores.parsing β†’ chunking β†’ embedding β†’ storing β†’ graph β†’ done

live. One gotcha: EventSource

can't send headers, so the client streams via fetch

  • ReadableStream

to pass the auth token./api/*

endpoint requires a bearer token, checked with secrets.compare_digest

(constant-time). The React build bakes the key in at build time from an env var β€” no login screen; docker-compose sources frontend and backend from the same .env

so they can't drift.config.yaml

for every tunable, Pydantic Settings overlays secrets from .env

, zero hardcoded values.RAG isn't one technique. It's a pipeline of small, well-understood ideas:

Parse cleanly β†’ chunk thoughtfully β†’ index twice β†’ fuse ranks β†’ rerank deeply β†’ add structure with a graph β†’ budget your tokens β†’ parallelize the waits.

None of these steps is hard alone. The engineering is in making them agree with each other β€” sharing one UUID, one config, one thread pool, and one honest token budget.

Access the full codebase here: https://github.com/sumannath/myRAG

Questions about any layer? The hybrid search and knowledge-graph stages delivered the biggest quality jumps for me, and I'm happy to go deeper on either in the comments.

── more in #artificial-intelligence 4 stories Β· sorted by recency
── more on @myrag 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/anatomy-of-a-full-ra…] indexed:0 read:6min 2026-07-12 Β· β€”