A pharmaceutical quality team investigates a failed performance qualification test. The PQ protocol for electronic batch record token expiration handling failed — the client-side modal submitted a signature using a cached stale JWT. The deviation is logged. A CAPA is initiated. A change control is opened to patch the EBR system.
Six months later, a new CSV engineer asks: “What’s the current status of the token expiration fix, and which systems are affected?”
In a flat RAG system, this question fragments across vector similarity matches that may surface the original deviation, the CAPA, or the change control — but rarely all three in the correct causal sequence. The relationships between these records are invisible to embedding-based search.
This is the problem Hybrid GraphRAG solves.
The Architecture: Three Stores, Three Truths
The core design principle is separation of concerns across three specialized databases, each serving as the authoritative source for one type of truth:
| Store | Role | Question It Answers | Query Pattern |
|---|---|---|---|
| PostgreSQL 16 | System of Record | “What is the current approved state?” | ACID transactions, tsvector full-text, GROUP BY |
| Qdrant | Semantic Retrieval | “What content is semantically similar?” | Dense + sparse hybrid search with RRF fusion |
| Memgraph | Knowledge Graph | “How are things connected?” | Cypher multi-hop BFS, atomic GraphRAG |
PostgreSQL is the canonical source. Qdrant and Memgraph are derived indexes that rebuild from PostgreSQL — never the other way around. This is the architectural invariant that makes the system auditable under 21 CFR Part 11.
┌────────────────────────────────────────────────────────┐
│ USER QUERY / AGENT │
└──────────────────────────┬─────────────────────────────┘
│
┌────────────▼────────────┐
│ Intent Classifier │
│ & Query Rewriter │
└──────┬──────┬─────┬─────┘
│ │ │
┌──────────────────────────────────┘ │ └──────────────────────────────────┐
│ (Semantic Search) │ (Multi-Hop / Traceability) │ (Relational / Audit)
▼ ▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐ ┌───────────────────────────┐
│ QDRANT │ │ MEMGRAPH │ │ POSTGRESQL │
│ - Dense + Sparse vectors │ │ - In-Memory C++ Graph │ │ - System of Record │
│ - Prefetch + RRF Fusion │ │ - Atomic GraphRAG query │ │ - ACID Audit Logs │
│ - Tenant / GxP Filtering │ │ - Traceability BFS / WSP │ │ - tsvector Full-Text │
│ - Sub-5ms ANN Latency │ │ - MAGE Community Clusters│ │ - Relational Aggregates │
└─────────────┬─────────────┘ └─────────────┬─────────────┘ └─────────────┬─────────────┘
│ │ │
└──────────────────┬──────────────────────┘ │
│ │
▼ │
┌─────────────────────────────┐ │
│ Hybrid Fusion & Rerank │ │
│ (RRF + Cross-Encoder) │ │
└──────────────┬──────────────┘ │
│ │
▼ │
┌─────────────────────────────┐ │
│ EvidenceGate │◄───────────────────────────────────────────────┘
│ (Zero-Trust Live ACL & │
│ Supersession Revalidation) │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ Context Assembly & LLM │
│ (Grounded Citations + Path)│
└─────────────────────────────┘
The Intent Router: Not All Queries Are Equal
Before retrieval begins, the system classifies the query intent. This determines how aggressively to weight vector search versus graph traversal:
| Intent | Trigger Keywords | Primary Store | Vector Weight | Graph Weight |
|---|---|---|---|---|
| TRACEABILITY_AUDIT | “traceability”, “URS-”, “test failed”, “V-model” | Memgraph + Postgres | 0.3 | 0.7 |
| IMPACT_ANALYSIS | “deviation”, “CAPA”, “root cause”, “ripple effect” | Memgraph | 0.4 | 0.6 |
| GLOBAL_GOVERNANCE | “all systems”, “GAMP category”, “overview” | Postgres + Memgraph | 0.5 | 0.5 |
| SEMANTIC_SEARCH | (default) | Qdrant + Memgraph | 0.7 | 0.3 |
A query like “Show the full traceability chain for URS-EBR-001” routes heavily toward Memgraph (70% graph weight). A query like “What does ALCOA+ require for data integrity?” routes toward Qdrant semantic search (70% vector weight).
This is not a hard switch — it is a weight adjustment in the Reciprocal Rank Fusion scoring.
Qdrant: Dense + Sparse Hybrid Search
The Qdrant collection stores both dense embeddings (384-dimensional vectors from all-MiniLM-L6-v2 via FastEmbed) and sparse token-weight vectors (BM25-style term frequency hashing). The hybrid search executes as parallel prefetch queries fused with Reciprocal Rank Fusion:
# Parallel prefetch: dense + sparse
prefetch_queries = [
models.Prefetch(query=dense_vector, using="dense", filter=query_filter, limit=limit * 3),
models.Prefetch(
query=models.SparseVector(indices=sparse_indices, values=sparse_values),
using="sparse", filter=query_filter, limit=limit * 3
)
]
# RRF fusion
search_result = client.query_points(
collection_name=collection_name,
prefetch=prefetch_queries,
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=limit
)
Payload pre-filtering ensures that superseded documents (is_superseded == False) and system-specific queries (system_name == 'EBR') are filtered at the index level, not post-retrieval. This is critical for GxP — you cannot surface an obsolete SOP alongside an active one and expect the LLM to distinguish them.
The Qdrant dashboard shows the collection with its payload indexes on doc_code, doc_type, system_name, gxp_category, lifecycle_status, and is_superseded:

Memgraph: Multi-Hop Knowledge Graph
Memgraph stores the structural relationships that vector search cannot capture. The graph model encodes the GAMP 5 V-Model lifecycle:
(Requirement)-[:IMPLEMENTS_URS]->(FunctionalSpec)
(FunctionalSpec)-[:SPECIFIES_FS]->(DesignSpec)
(DesignSpec)-[:VERIFIES_DS]->(TestScript)
(TestScript)-[:TRIGGERED_DEVIATION]->(Deviation)
(Deviation)-[:RESOLVED_BY_CAPA]->(CAPA)
(CAPA)-[:REQUIRES_CHANGE_CONTROL]->(ChangeControl)
(ChangeControl)-[:MODIFIES_SYSTEM]->(System)
The multi-hop traceability query traverses this chain in a single Cypher statement:
MATCH (u:Requirement)
WHERE toLower(u.code) CONTAINS toLower($keyword)
OPTIONAL MATCH (f:FunctionalSpec)-[:IMPLEMENTS_URS]->(u)
OPTIONAL MATCH (ds:DesignSpec)-[:SPECIFIES_FS]->(f)
OPTIONAL MATCH (t:TestScript)-[:VERIFIES_DS]->(ds)
OPTIONAL MATCH (t)-[:TRIGGERED_DEVIATION]->(dev:Deviation)
OPTIONAL MATCH (dev)-[:RESOLVED_BY_CAPA]->(capa:CAPA)
RETURN u.code, f.code, ds.code, t.code, t.status,
dev.code, dev.severity, capa.code
Memgraph also supports Atomic GraphRAG — vector search entry points directly within Cypher using vector_search.search(). This lets the system find semantically similar chunks and immediately expand into the surrounding graph neighborhood:
CALL vector_search.search("chunk_vec", $limit, $query_embedding)
YIELD node AS start_chunk, similarity
OPTIONAL MATCH path = (start_chunk)-[*1..2]-(connected)
RETURN start_chunk.code, similarity,
[n IN nodes(path) | {label: labels(n)[0], code: n.code}] AS context_entities
The Memgraph Lab visualization shows the full knowledge graph — requirement nodes, test scripts, deviations, CAPAs, and change controls connected by typed relationships:

EvidenceGate: Zero-Trust Retrieval
This is the component that makes the system safe for regulated environments. Every candidate chunk retrieved from Qdrant or Memgraph is intercepted before LLM prompt assembly and validated against the live PostgreSQL state:
Retrieved Candidates (Qdrant & Memgraph)
│
▼
┌────────────────────┐
│ EvidenceGate │ ─── Queries live PostgreSQL System of Record
└──────────┬─────────┘
│
┌──────────┴──────────┐
▼ ▼
[APPROVED & EFFECTIVE] [SUPERSEDED / OBSOLETE]
│ │
▼ ▼
Prompt Assembly Rejected & Logged
(Citations injected) (Audit reason recorded)
The validation checks three conditions against PostgreSQL:
- Existence — the chunk code must exist in the canonical
documents+chunkstables - Lifecycle status — must be
EFFECTIVEorAPPROVED(notDRAFT,OBSOLETE, orQUARANTINED) - Supersession —
is_supersededmust beFALSE
If a chunk fails any check, it is rejected with a reason code (e.g., DOCUMENT_IS_SUPERSEDED, INVALID_LIFECYCLE_STATUS) and logged to the audit trail. The LLM never sees it.
This solves the stale index problem: if an SOP is placed on QA hold after a deviation, the vector embeddings in Qdrant may still return it as a top hit. EvidenceGate catches this at query time by checking the live PostgreSQL state.
The 5-Step Closed-Loop Workflow
The Pydantic AI agent orchestrates a five-step workflow that closes the knowledge loop:
┌───────────────────────────────┐
│ 1. Query Intake │
└───────────────┬───────────────┘
│
▼
┌───────────────────────────────┐
┌───►│ 2. Vector/Graph Retrieval │
│ └───────────────┬───────────────┘
│ │
│ ▼
Knowledge │ ┌───────────────────────────────┐
Feedback │ │ 3. Agent Response & Feedback │
Loop │ │ (Pydantic AI) │
│ └───────────────┬───────────────┘
│ │
│ ▼
│ ┌───────────────────────────────┐
│ │ 4. Ticket Escalation/Resolut. │
│ └───────────────┬───────────────┘
│ │
│ ▼
│ ┌───────────────────────────────┐
└────┤ 5. Auto-Ingestion & Indexing │
└───────────────────────────────┘
Step 1 (Query Intake): Accepts the query, user role, query type, and human-in-the-loop toggle. Assigns a tracking ID (TICK-XXXXXX) for 21 CFR Part 11 provenance.
Step 2 (Retrieval): Executes parallel retrieval across Qdrant (dense + sparse hybrid), Memgraph (multi-hop traversal), and PostgreSQL (EvidenceGate verification).
Step 3 (Agent Response): The Pydantic AI agent — powered by DeepSeek-v4-flash via OpenAI-compatible API — synthesizes a structured HelpdeskResolution with confidence score, GxP risk rating, verified citations, and graph traversal paths.
Step 4 (Escalation): Routes based on risk. Critical deviations escalate to the QA Lead. Standard inquiries with high confidence auto-resolve. Human-in-the-loop mode forces PENDING_HUMAN_IN_LOOP for electronic signature approval.
Step 5 (Knowledge Feedback Loop): This is the self-learning step. The resolution is auto-ingested as a new canonical document into PostgreSQL, projected as vector embeddings into Qdrant, and linked as entities in Memgraph. The next query on a similar topic immediately retrieves this learned experience. Resolution accuracy increments by ~1.8% per cycle.
The structured output from the Pydantic AI agent enforces type safety:
class HelpdeskResolution(BaseModel):
resolution_summary: str
confidence_score: float = Field(ge=0.0, le=1.0)
gxp_risk_level: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"]
citations: List[CitationItem]
graph_paths: List[str]
action_verdict: Literal["AUTO_RESOLVED", "ESCALATED_TO_QA", "PENDING_HUMAN_IN_LOOP"]
knowledge_feedback_generated: bool
feedback_details: Optional[KnowledgeUpdatePayload]
The Seed Data: Realistic GxP Scenarios
The system ships with a seed dataset that mirrors real pharmaceutical quality management:
Regulatory Governance: FDA 21 CFR Part 11 (electronic records and signatures), EU Annex 11 (computerised systems), ISPE GAMP 5 (V-Model lifecycle), ALCOA+ data integrity principles.
Target Systems: Electronic Batch Record (EBR), Chromatography Data System (CDS — Empower), LIMS, Manufacturing Execution System (MES — DeltaV), Veeva Vault QMS.
Traceability Chains:
URS-EBR-001→FS-EBR-014→DS-EBR-089→OQ-EBR-101(PASSED)URS-EBR-001→FS-EBR-014→DS-EBR-089→PQ-EBR-202(FAILED) →DEV-2026-091→CAPA-2026-022→CC-2026-068
Quality Events: Two complete deviation-CAPA-change control chains covering a token expiration race condition in EBR signatures and a chromatography audit trail gap under high concurrency.
The Web UI
The system includes an interactive web UI at localhost:8090 with an animated 5-step workflow simulator, query type carousel (Standard, Multi-Hop Traceability, Deviation Impact, Complex Incident), human-in-the-loop toggle, live metrics bar, and a Cytoscape.js subgraph visualizer for inspecting knowledge graph nodes and edges:

Docker Compose Deployment
The entire stack runs in Docker Compose with five containers:

| Container | Image | Port | Role |
|---|---|---|---|
hybrid-rag-postgres |
pgvector/pgvector:pg16 | 5434 | System of record |
hybrid-rag-qdrant |
qdrant/qdrant:v1.13.4 | 6335 | Vector search |
hybrid-rag-memgraph |
memgraph/memgraph-mage:latest | 7688 | Knowledge graph |
hybrid-rag-memgraph-lab |
memgraph/lab:latest | 3006 | Graph visualization |
hybrid-rag-api |
Custom (Python 3.12) | 8090 | FastAPI + Web UI |
Launch with a single command:
./run.sh
# Or manually:
docker compose up -d --build
docker compose exec api python -m src.seed.seed_all
The LLM Fallback Chain
The synthesizer implements a four-tier fallback for LLM generation:
- DeepSeek-v4-flash (primary) — via OpenAI-compatible API
- OpenAI GPT-4o-mini — fallback if DeepSeek key is missing
- Ollama local models — for air-gapped environments
- Built-in regulatory rule engine — zero external dependency, intent-specific synthesis using retrieved evidence only
The fourth tier is important: the system works without any LLM. The rule engine produces structured regulatory assessments by formatting verified chunks and graph paths into intent-specific templates. This is the deterministic fallback that keeps the system functional during LLM outages.
The Bottom Line
Flat RAG works for simple Q&A over unstructured documents. It breaks the moment you need to trace a requirement through a functional specification, into a design specification, through a test protocol, into a deviation, through a CAPA, and into a change control — and verify that every node in that chain is still active and approved.
Hybrid GraphRAG solves this by giving each database a single job: PostgreSQL owns the truth, Qdrant finds the similarity, Memgraph maps the relationships. EvidenceGate ensures the LLM only sees verified, current, approved evidence. And the knowledge feedback loop means the system gets smarter with every query it resolves.
The codebase is open source under MIT. The seed data encodes realistic pharmaceutical quality scenarios. The Docker Compose stack runs on any machine with 8GB of RAM.
If you are building AI systems for regulated environments — where a hallucinated citation can trigger an FDA 483 observation — this is the architecture pattern to study.
Source code: hybrid-graphrag on GitHub • Architecture: PostgreSQL 16 + Qdrant v1.13.4 + Memgraph MAGE + Pydantic AI • LLM: DeepSeek-v4-flash
Saram Consulting