Your RAG system retrieved six chunks about temperature monitoring. All semantically relevant. None of them mentioned that the temperature sensor feeds into the LIMS, which triggers batch release decisions, which means changing the sensor invalidates three qualification protocols. The vector search found similar text. It did not find the relationship.

This is the failure mode that separates toy RAG from production RAG. Semantic similarity is not relational truth. A vector database can tell you what sounds like your question. It cannot tell you what depends on the thing you are about to change.

The fix is not a better embedding model. It is a different architecture.

The three-database principle

A production retrieval system answers three fundamentally different types of questions:

Question Type Example Best Engine
“What does the text say?” “What does our SOP say about alarm testing?” Vector search
“How are things connected?” “What systems depend on this application?” Graph traversal
“What is the current state?” “Is this system validated? What version?” Relational query

No single database handles all three well. PostgreSQL with pgvector can do moderate-scale vector search, but it becomes a bottleneck above 10 million vectors. A graph database can store documents, but it is the wrong tool for full-text search and transactional metadata. A vector database can store payloads, but it does not do GROUP BY, aggregations, or audit trails.

The architecture that works is a triad:

                         USER QUERY                       


                    ┌─────────────────┐                   
                    │  Query Planner  │                   
                    │  Intent → Route │                   
                    └────────┬────────┘                   

          ┌──────────────────┼──────────────────┐         
          ▼                  ▼                  ▼         
    ┌───────────┐    ┌──────────────┐    ┌──────────────┐ 
    │ PostgreSQL │    │    Qdrant    │    │   Memgraph   │
    │           │    │              │    │              │ 
    │ Truth     │    │ Similarity   │    │ Relationships│ 
    │ Metadata  │    │ Embeddings   │    │ Traversals   │ 
    │ Audit     │    │ Hybrid search│    │ Communities  │ 
    └─────┬─────┘    └──────┬───────┘    └──────┬───────┘ 
          │                 │                   │         
          └────────┬────────┴───────────────────┘         

          ┌─────────────────┐                             
          │  Result Fusion  │                             
          │  RRF + Reranking│                             
          └────────┬────────┘                             

          ┌─────────────────┐                             
          │   LLM Generate  │                             
          └─────────────────┘                             

Each database is the authoritative source for one type of truth:

  • PostgreSQL — declarative truth. What is the current approved state? Who owns it? When was it last validated?
  • Qdrant — evidentiary truth. What content discusses or supports this claim?
  • Memgraph — structural truth. What is connected to what, and through what path?

This separation is not optional complexity. It is the difference between a system that hallucinates relationships and one that can show its work.

PostgreSQL: the system of record

PostgreSQL is the only store whose schema is permanent. Everything else is a derived index.

What belongs in PostgreSQL:

documents           — raw text, source metadata, timestamps, versions
chunks              — segmented text with offsets, hashes, token counts
entities            — canonical entity registry with UUIDs
chunk_entities      — bridge table linking chunks to entities
provenance          — extraction method, confidence, source document
audit_events       — who did what, when, with what justification
permissions        — who can see what (ACLs, tenant isolation)

PostgreSQL also handles full-text search via tsvector and pg_trgm. This matters more than people realize. Life-science data is full of exact identifiers — part numbers, protocol IDs, regulatory citations — where BM25 keyword matching dramatically outperforms dense vector search. When someone searches for URS-EMS-003, you want an exact match, not the ten chunks that are semantically similar to the concept of user requirement specifications.

CREATE TABLE chunks (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    document_id UUID REFERENCES documents(id),
    content     TEXT NOT NULL,
    embedding   vector(1536),
    tsv         tsvector GENERATED ALWAYS AS
                (to_tsvector('english', content)) STORED
);

CREATE INDEX idx_chunks_tsv ON chunks USING GIN(tsv);
CREATE INDEX idx_chunks_embedding ON chunks
    USING hnsw (embedding vector_cosine_ops);

The embedding column in PostgreSQL is a secondary index, not the primary vector store. Use it for queries where co-located metadata filtering matters more than raw ANN speed — for example, “find semantically similar chunks but only from approved documents in the last 90 days.”

Qdrant: the semantic memory

Qdrant handles high-performance dense retrieval. Its killer feature for hybrid RAG is native support for dense and sparse vectors in the same collection, fused via Reciprocal Rank Fusion in a single query.

This matters because enterprise text contains both semantic language and exact identifiers. Dense vectors handle “temperature monitoring system upgrade validation impact.” Sparse vectors handle “Vaisala viewLinc URS-EMS-004 21 CFR Part 11.” You need both.

results = client.query_points(
    collection_name="chunks",
    prefetch=[
        Prefetch(using="dense", query=dense_embedding, limit=50),
        Prefetch(using="sparse", query=sparse_embedding, limit=50),
    ],
    query=FusionQuery(fusion=Fusion.RRF),
    limit=20,
    query_filter=Filter(must=[
        FieldCondition(key="tenant_id",
                       match=MatchValue(value="tenant_12"))
    ])
)

Qdrant’s payload filtering is applied during the ANN search (pre-filtering), not after. At scale, this is the difference between 3ms and 300ms. Payloads carry the bridge identifiers — chunk_id, document_id, entity_ids — that link back to PostgreSQL and forward to Memgraph.

The payload schema is the contract between the three stores:

{
    "chunk_id": "c1a2b3c4-...",
    "document_id": "doc_9910",
    "entity_ids": ["ent_alpha", "ent_beta"],
    "tenant_id": "tenant_12",
    "doc_type": "validation_protocol",
    "status": "approved"
}

Memgraph: the relationship engine

Memgraph is an in-memory graph database, Cypher-compatible, built in C++. It stores entities as nodes and relationships as edges, with properties on both.

This is where the architecture goes from “find similar text” to “understand how things connect.”

Consider a change request: upgrading the temperature monitoring system. Vector search finds documents about temperature monitoring. Memgraph finds the blast radius:

EMS-001 (Temperature Monitoring)                              

    ├── implements ──── REQ-123 (Temperature Range Monitoring)
    ├── implements ──── REQ-124 (Alarm Response Protocol)     
    ├── has_risk ────── RISK-21 (Data Integrity Risk)         
    ├── validated_by ── OQ-44 (Operational Qualification)     
    ├── produces_data_for ── QMS-01 (Batch Release)           
    ├── integrates_with ─── LIMS-01 (LabVantage)              
    └── documented_by ───── SOP-100 (Environmental Monitoring)

A vector search for “temperature monitoring upgrade” would never discover that changing this system invalidates three qualification protocols and affects batch release decisions. The graph makes that connection explicit and traversable.

The Cypher query to extract this neighborhood:

MATCH (seed:Entity {id: $system_id})
MATCH path = (seed)-[r:RELATION*1..2]-(neighbor:Entity)
MATCH (neighbor)<-[:MENTIONS]-(chunk:Chunk)
RETURN path, collect(DISTINCT chunk.id) AS expanded_chunks

Memgraph also supports community detection via the MAGE algorithm library. Run Louvain clustering over the entity graph, generate LLM summaries for each community, and store those summaries as embeddings in Qdrant. Now broad queries like “what are the overarching risks across the architecture?” can match against community summaries first, then drill down to constituent chunks.

The retrieval patterns

The real power is not in any single store. It is in the retrieval patterns that combine them.

Pattern 1: Vector-seed graph expansion

The most common pattern. Start with semantic similarity, expand through relationships.

Question: "What validation evidence supports this system?"

Step 1: Embed question → Qdrant ANN → top-K chunks
Step 2: Extract entity_ids from Qdrant payloads
Step 3: Memgraph: traverse 1-2 hops from seed entities
Step 4: Collect connected chunk IDs from graph
Step 5: PostgreSQL: hydrate full text for all chunks

The vector search finds the entry point. The graph expansion finds the context that vector similarity alone would miss. This is the pattern that catches the “temperature sensor → LIMS → batch release” chain.

Start with structural constraints, then search semantically within the boundary.

Question: "Find similar deviations involving the same systems"

Step 1: Memgraph: identify systems connected to the query entity
Step 2: Collect all chunk IDs linked to those systems
Step 3: Qdrant: vector search with MatchAny filter on chunk_id
Step 4: Results are semantically similar AND structurally related

This dramatically reduces irrelevant retrieval. Instead of searching the entire corpus for similar deviations, you search only within the subgraph of related systems.

Pattern 3: Global community retrieval

For broad, thematic questions.

Question: "What are the main themes across all deviations this quarter?"

Step 1: Memgraph: retrieve community summaries (pre-computed)
Step 2: Qdrant: match question embedding against community embeddings
Step 3: Return top community summaries as high-level context
Step 4: Optionally drill down to constituent chunks

This is the Microsoft GraphRAG “global search” pattern, adapted for a multi-store architecture.

Pattern 4: Parallel hybrid with fusion

For complex queries where you do not know which store will be most useful.

Question ──┬──► Qdrant (semantic)  ──┐                                   
           ├──► Memgraph (graph)   ──┼──► RRF Fusion ──► Reranker ──► LLM
           └──► PostgreSQL (BM25)  ──┘                                   

All three stores queried in parallel. Results combined via Reciprocal Rank Fusion:

RRF_Score(d) = Σ  w_m / (k + r_m(d))

where:
  M = {Vector Rank, Graph Rank, BM25 Rank}
  k = 60 (smoothing constant)
  w_m = weights based on query intent

For multi-hop relationship queries, increase the graph weight. For pure semantic questions, increase the vector weight. The query planner classifies intent and adjusts weights accordingly.

Score fusion is not optional

Do not just concatenate results from three stores and hope the LLM sorts it out. The fusion step is where the architecture earns its complexity.

A three-stage pipeline works best:

Stage 1: Broad retrieval
    Qdrant dense+sparse → 100 candidates
    Memgraph traversal  → 50 candidates
    PostgreSQL BM25     → 50 candidates

Stage 2: Fusion + graph filtering
    RRF across all candidates → 30 ranked results
    Graph relevance boosting for entity-connected chunks

Stage 3: Expensive reranking
    Cross-encoder (ms-marco-MiniLM) → 10 final chunks
    Context assembly with citations

The cross-encoder reranking step is expensive — 100-300ms — but it is the difference between “relevant chunks” and “the right chunks.” For production systems, it is worth the latency.

The canonical ID is the glue

The three stores must share a common identity layer. PostgreSQL assigns canonical IDs. Everything references them.

PostgreSQL:  entity_id = AST-000183

Memgraph:    (:Entity {id: "AST-000183"})

Qdrant:      payload: {"entity_id": "AST-000183"}

This means you never trust the vector database or graph database as the authoritative identity system. If either downstream store gets corrupted or needs a schema migration, you rebuild from PostgreSQL. The graph and vector stores are projections, not sources of truth.

Synchronization: the outbox pattern

Writing to three databases introduces consistency challenges. The solution is the transactional outbox pattern with PostgreSQL as the coordinator.

CREATE TABLE outbox (
    id            BIGSERIAL PRIMARY KEY,
    aggregate     TEXT NOT NULL,
    aggregate_id  UUID NOT NULL,
    operation     TEXT NOT NULL,
    payload       JSONB NOT NULL,
    target        TEXT NOT NULL,
    processed_at  TIMESTAMPTZ
);

When a chunk is ingested, PostgreSQL writes the chunk and the outbox entry in the same transaction. A separate worker processes the outbox, propagating to Qdrant and Memgraph asynchronously.

PostgreSQL (ACID write)                                          

    ├── outbox entry → Qdrant worker → upsert vectors            
    └── outbox entry → Memgraph worker → upsert graph nodes/edges

This gives you transactional consistency on the write path and eventual consistency on the read path. For RAG use cases, a few seconds of staleness in the graph does not matter for retrieval quality.

The critical rule: Qdrant and Memgraph are derived indexes that rebuild from PostgreSQL. Never the other way around.

The stale edge problem

Graph data is interdependent in a way that vector data is not. If Supplier A stops supplying Factory Y but the edge remains in Memgraph, your RAG system will confidently hallucinate a dead relationship.

Mitigations:

  • TTL on edges: (:Supplier)-[:SUPPLIES {valid_until: '2026-08-01'}]->(:Factory)
  • CDC from PostgreSQL: when a contracts table row is deleted, cascade to Memgraph
  • Chunk hash versioning: store consistent chunk hash versions across all three stores to prevent stale traversals referencing purged payloads
  • Periodic graph validation jobs: compare graph edges against PostgreSQL source tables

Do not let the LLM write Cypher

This is especially important for regulated environments. Do not give the LLM access to generate arbitrary graph queries. Instead, create typed retrieval operations:

get_system_dependencies(system_id)
get_validation_coverage(system_id)
get_change_impact(change_id)
get_requirement_traceability(requirement_id)
get_related_risks(system_id)
get_tests_for_requirement(requirement_id)

Each function executes controlled Cypher internally. The agent decides “I need validation coverage” rather than inventing a query. This is considerably easier to govern, audit, and validate.

Entity resolution is the hardest part

Without entity resolution, your graph will contain “Vaisala EMS”, “Vaisala system”, “Vaisala viewLinc”, “Environmental Monitoring”, “EMS”, and “EMS system” as six separate nodes. This is graph garbage.

The resolution cascade:

  1. Exact match on normalized names (lowercase, stripped)
  2. Fuzzy match using pg_trgm similarity in PostgreSQL (threshold ~0.7)
  3. Embedding match — find nearest entity in Qdrant, merge if cosine > 0.95
  4. LLM disambiguation for ambiguous cases (expensive, use sparingly)

Invest heavily here. Entity resolution quality determines graph quality, which determines retrieval quality, which determines answer quality. The chain is only as strong as its weakest link.

Provenance: every claim traces to evidence

Every graph edge should carry provenance:

EMS-001 ──implements──> REQ-123        
                                       
    provenance:                        
        source = URS-004               
        source_version = 3             
        evidence_location = section 4.2
        extraction_method = human      
        confidence = 1.0               

Now the AI can say: “EMS-001 implements REQ-123 based on URS-004 v3, Section 4.2.” That is dramatically more defensible than a bare assertion.

Every AI-generated answer should resolve through an evidence chain:

Claim → Evidence → Source Document → Approved Version → Audit Trail

This is the architecture that makes AI answers auditable.

Performance budget

The total retrieval latency for a hybrid query:

Stage Latency
Query embedding ~50ms
Qdrant dense + sparse search ~15-30ms
PostgreSQL BM25 ~20-50ms
Memgraph traversal (2-hop) ~5-50ms
Result fusion (RRF) ~5ms
Cross-encoder reranking ~100-300ms
Total retrieval ~150-500ms

Graph-enhanced retrieval adds 100-300ms over vector-only RAG. The tradeoff is worth it for any query where relationships matter. For simple factual questions, the query planner can skip the graph path entirely and stay under 100ms.

Semantic caching (cosine similarity > 0.85 against previous queries) eliminates redundant graph traversals for common questions.

When this is overkill

Be honest about complexity costs. Three databases means three deployment targets, three connection pools, three backup strategies, three failure modes.

This architecture is justified when:

  • You have more than 100,000 documents with rich entity relationships
  • Queries require multi-hop reasoning (“How does X influence Y through Z?”)
  • You need both precise keyword matching and semantic understanding
  • You are in a regulated industry where provenance and audit trails are mandatory

If you have fewer than 50,000 documents and simple question-answering needs, PostgreSQL with pgvector is sufficient. If relationships matter but scale is small, a single graph database with native vector search (Memgraph or Neo4j) may be enough.

The bottom line

The answer to “should I use one database or three?” is the same as the answer to “should I use one tool or three?” — it depends on whether you are doing one job or three.

Vector search finds what sounds like your question. Graph traversal finds what depends on the thing you are asking about. Relational queries tell you what is actually true. A production RAG system needs all three, and it needs them wired together through shared canonical IDs, a transactional outbox, and a fusion layer that knows when to trust which source.

The architecture is not complicated. But the entity resolution, the provenance tracking, and the discipline to keep PostgreSQL as the single source of truth — those are where most implementations fail.

Build the identity layer first. The rest follows.