Working prototype (containerised, not yet multi-tenant)

GraphRAG Document Intelligence Workspace

Upload documents, ask questions, get streamed answers grounded in cited passages and an explorable knowledge graph.

Role: Full-stack / AI engineer (design, backend, frontend, infrastructure)

Overview

A GraphRAG system that combines vector search (Qdrant) with a knowledge graph (Neo4j) so answers are grounded in source text and backed by traceable entity relationships. Documents are ingested asynchronously, retrieved with a LangGraph pipeline, and answered over Server-Sent Events with citations the user can inspect.

Tech Stack

backend

Python 3.12FastAPISQLAlchemy (async)AlembicPydantic SettingsCeleryLangGraphstructlogPrometheus

data

PostgreSQLRedisQdrantNeo4j

ai

OpenAI Responses APIOpenAI structured outputFastEmbed / Hugging Face (BAAI/bge-small-en-v1.5)

frontend

Next.js 16React 19TypeScript 5.9Tailwind CSS v4Cytoscape.jsReact MarkdownLucide

devops

DockerDocker ComposeMulti-stage buildsNon-root runtime image

testing

Pytest unit tests (parsing, extraction, chunking, streaming contracts)Vitest

Key Numbers

4 (PDF, DOCX, TXT, MD)

Supported formats

~200 / ~1,000 tokens

Child / parent chunk size

2 (default)

Graph hops

10

Relationship types

4 + worker

Data services orchestrated

6

SSE event types

The Problem

Plain vector RAG retrieves isolated snippets. It misses relationships that span passages (who acquired whom, who works for which company) and gives users little way to verify where an answer came from.

  • Long-running document parsing, embedding and entity extraction must not block the API
  • Small chunks retrieve precisely but lack context; large chunks retrieve poorly
  • LLM answers need verifiable citations and must resist instructions hidden in documents
  • Re-running a failed ingestion must not duplicate vectors or graph data
  • Users need to see why an answer was produced, not just the text

The Solution

  1. 1Asynchronous ingestion: the API validates and stores the upload, returns 202 Accepted, and a Celery worker does the heavy work
  2. 2Hierarchical chunking: ~200-token child chunks are searched, ~1,000-token parent chunks are returned as context
  3. 3Hybrid retrieval: Qdrant finds child chunks, then Neo4j relationships are traversed from entities in those chunks, bounded by a hop limit
  4. 4Grounded generation: the model is told to answer only from supplied evidence, treat document text as untrusted, and cite exact IDs (P1, P2...)
  5. 5Streaming UI: tokens, citations and graph data arrive as typed SSE events and render incrementally

Document ingestion

PDF, DOCX, TXT and Markdown with MIME-type, extension, size and empty-file validation, filename sanitising, UUID storage paths and SHA-256 duplicate detection.

Live ingestion progress

Per-job stage, progress, attempts and errors, polled by the UI every 1.2 s; failed jobs can be retried.

Parent/child retrieval

Child hits are grouped by parent, the best child per parent is kept, parents are ranked, and the full parent text is used as answer context.

Knowledge graph extraction

OpenAI structured output into Pydantic models; typed relationships (ACQUIRED, FOUNDED, WORKS_FOR, LOCATED_IN, PARTNERS_WITH, PRODUCES, OWNS, SUPPLIES, INVESTED_IN, RELATED_TO) with evidence, confidence and source provenance.

Bounded multi-hop graph retrieval

Defaults: 8 child results, 4 parent citations, 2 graph hops, 60 graph records, so latency and prompt size stay predictable.

Streaming grounded answers

SSE events: retrieval, graph, token, citations, done, error. Only citation IDs actually used in the answer are persisted.

Token budget management

Evidence context is built within a configured token budget before calling the model.

Interactive graph inspector

Cytoscape.js view of retrieved entities and relationships, with colour-coded entity types, fit-to-view, and edge selection showing relationship type and extraction evidence.

Citation cards

Expandable source cards showing filename, page/section, similarity score, matched passage and full parent context.

Retrieval scoping and cancellation

Users choose which ready documents to search and can abort generation mid-stream.

Persistence and audit trail

Conversations, messages and RetrievalRuns (question, vector hits, citations, graph data, latency) are stored in PostgreSQL.

Observability and health

Request IDs, structured logs (structlog), Prometheus metrics, /health/live and /health/ready (checks PostgreSQL, Redis, Qdrant, Neo4j).

Optional API-key auth

APP_AUTH_MODE=api_key enforces an X-API-Key header using constant-time comparison.

Architecture

Layered: API -> schemas -> database -> RAG/domain services -> infrastructure adapters

Three-panel workspace: documents, chat, graph
Three-panel workspace: documents, chat, graph
Citation card with parent context
Citation card with parent context
Graph inspector with relationship evidence
Graph inspector with relationship evidence
Ingestion flow
  1. 1Upload and validate
  2. 2Queue Celery job (HTTP 202)
  3. 3Parse into structural blocks
  4. 4Build parent/child chunks and persist
  5. 5Embed children and upsert to Qdrant
  6. 6Extract entities/relations from parents
  7. 7Write graph and provenance to Neo4j
  8. 8Mark document ready
Query flow
  1. 1Embed question
  2. 2Search Qdrant child chunks
  3. 3Group by parent and rank
  4. 4Traverse Neo4j from mentioned entities (bounded hops)
  5. 5Build token-budgeted evidence context
  6. 6Stream OpenAI answer over SSE
  7. 7Validate and persist citations and retrieval run

Components

Frontend

Next.js 16, React 19, TypeScript, Tailwind v4

Three-panel workspace: documents, chat, graph

API

FastAPI, async SQLAlchemy, Pydantic, Alembic

Ingest, documents, chat (SSE), graph endpoints; health and metrics

Worker

Celery + Redis

Parse, chunk, embed, extract and index documents

Relational store

PostgreSQL

Documents, jobs, parent/child chunks, conversations, messages, retrieval runs

Vector store

Qdrant

Child-chunk embeddings and semantic search

Graph store

Neo4j

Entities, relationships and provenance

Orchestration

LangGraph

Retrieval state machine: vector + parent retrieval -> graph traversal

Models

FastEmbed (BAAI/bge-small-en-v1.5) or OpenAI embeddings; OpenAI Responses API

Embeddings, entity extraction, answer generation

Key Decisions

Parent/child chunking

Why: Small chunks give precise matching; returning the larger parent gives the model enough context to answer well.

Deterministic IDs and idempotent ingestion

Why: Retries replace Qdrant vectors and rebuild Neo4j document data instead of duplicating them.

Bounded graph traversal

Why: Hop and record limits keep latency and prompt size predictable on densely connected graphs.

SSE over WebSockets

Why: Streaming is one-directional, works over plain HTTP, and is simple to parse and cancel with AbortController.

Local embeddings by default

Why: FastEmbed removes per-token embedding cost and an external dependency; OpenAI embeddings stay available as a switch.

Prompt-injection posture

Why: Document content is explicitly treated as untrusted data and answers must cite exact evidence IDs.

Local React state instead of a global store

Why: A single-page workflow with one stream and one graph did not justify the overhead.

Challenges & Lessons

Coordinating four data stores (PostgreSQL, Redis, Qdrant, Neo4j) during ingestion

Deterministic IDs plus replace-on-retry made partial failures recoverable without cleanup scripts.

Parsing a streamed response in the browser

Wrote a custom SSE reader that buffers partial frames and dispatches typed handlers, isolated behind an API client adapter.

Keeping answers verifiable

Citation IDs are scanned from the generated text and only valid, used ones are stored and shown.

Prompt size growth from parent chunks plus graph evidence

Added explicit token budgeting when assembling context.

Engineering Practices

  • Testing: Unit tests cover parsing, extraction, chunking and streaming contracts.
  • Deployment: Docker Compose orchestrates API, worker, PostgreSQL, Redis, Qdrant, Neo4j and frontend; the API container runs Alembic migrations before starting Uvicorn; frontend uses a three-stage standalone Next.js build.
  • Observability: Request IDs, structured logs, request-duration histograms, readiness checks across all four data services.

Known Limitations

  • Authorization is a single global API key: no users, tenants, or per-document/conversation ownership
  • Frontend does not send X-API-Key and has no auth flow
  • No runtime validation of API payloads on the frontend
  • Upload polling has no timeout; limited frontend test coverage
  • Initial migration is generated from SQLAlchemy metadata

Next Steps

  • Per-user/tenant ownership with JWT or OAuth, enforced on documents, conversations and retrievals
  • Server-side proxy for API credentials
  • Zod validation for API and SSE payloads
  • Reranking and retrieval evaluation (recall, faithfulness) on a labelled question set
  • Component, SSE-parser and Playwright end-to-end tests