Connect Your API to a Knowledge Graph

Add Neo4j to your FastAPI stack and build a dual-database knowledge graph.

Introduction

30 Second Summary

Think about how you discover related information. One document mentions a topic, and that topic leads you to three other documents you never knew existed. That kind of connection-hopping is trivial for a human brain but brutally hard for a flat database table.

In this project, you will add a graph database to your existing API, transforming it from a document store into a Knowledge Graph platform that discovers connections between documents through shared topics.

What You'll Build

You type one command and a three-service stack launches. You create a document through the API and it appears both as a row in PostgreSQL and as a node in Neo4j Browser. You assign topics, relationships form, and then you hit a graph endpoint that instantly returns every document connected to yours.

By the end of this project, you'll have:

  • A three-service Docker Compose stack (FastAPI + PostgreSQL + Neo4j) that starts with a single command and keeps your data persistent across restarts.
  • Documents stored as graph nodes with properties, visible and explorable in Neo4j Browser where you can click through connections visually.
  • Topic nodes linked to documents via DISCUSSES relationships, forming a navigable knowledge graph you can traverse with a single Cypher query.
  • Graph traversal API endpoints that find related documents through shared topics, turning complex recursive JOINs into one-line graph queries.
  • Secret Mission: Add uniqueness constraints, a full-text search index, and a graph statistics endpoint to production-harden your Knowledge Graph.

Are there any prerequisites?

This project builds on your existing Enterprise Knowledge Assistant from Phase 0 (Build a Production-Structured API) and Phase 1 (Dockerize Your Knowledge Assistant). You need a working Docker Compose stack with FastAPI and PostgreSQL before starting.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. You're about to add a graph database to your existing platform, transforming it from a document CRUD API into a genuine Knowledge Graph system.

Add Neo4j to Your Docker Compose Stack

Your API stores documents as rows in PostgreSQL. That works well for listing and filtering, but gives you no way to model how documents connect to each other.

In this step, you'll add Neo4j as a third container in your Docker Compose stack. Neo4j is a graph database purpose-built for storing and traversing relationships between data.

In this step, get ready to:
  • Add a Neo4j service to your Docker Compose file.
  • Install the Neo4j Python driver and add connection config.
  • Verify Neo4j Browser loads at localhost:7474.
Add the Neo4j service to Docker Compose

Your compose.yaml currently runs two services: the FastAPI app and PostgreSQL. You'll add Neo4j as a third service so the graph database launches alongside everything else.

  • Open compose.yaml in VS Code.
  • Add the following neo4j service below the db service (still inside the services: block):
  neo4j:
    image: neo4j:2026.06.0
    restart: always
    environment:
      - NEO4J_AUTH=neo4j/knowledgegraph
    ports:
      - "7474:7474"
      - "7687:7687"
    volumes:
      - neo4j-data:/data
    healthcheck:
      test: ["CMD", "cypher-shell", "-u", "neo4j", "-p", "knowledgegraph", "RETURN 1"]
      interval: 10s
      timeout: 5s
      retries: 5

What does this service do?

  • neo4j:2026.06.0 pulls the Neo4j Community Edition image pinned to a specific version for reproducibility.
  • Port 7474 exposes Neo4j Browser (the web UI). Port 7687 exposes the Bolt protocol your Python driver uses to connect.
  • NEO4J_AUTH sets the initial username and password. The format is username/password.
  • The healthcheck runs cypher-shell to verify Neo4j is actually accepting queries, not just that the port is open.
  • Now add neo4j-data to the existing volumes: section at the bottom of the file. Find this line:
volumes:
  db-data:
  • Add neo4j-data: below db-data: so the section looks like this:
volumes:
  db-data:
  neo4j-data:
  • Next, update the app service to pass Neo4j connection details. Find the environment section of the app service and add these three lines below - DEBUG=true:
      - NEO4J_URI=bolt://neo4j:7687
      - NEO4J_USER=neo4j
      - NEO4J_PASSWORD=knowledgegraph
  • Finally, update the depends_on section of the app service. Find this block:
    depends_on:
      db:
        condition: service_healthy
  • Add the neo4j dependency below db so the block looks like this:
    depends_on:
      db:
        condition: service_healthy
      neo4j:
        condition: service_healthy

Why does the app depend on Neo4j?

The service_healthy condition tells Docker Compose to wait until Neo4j's healthcheck passes before starting the app container. This prevents your app from crashing on startup because the database isn't ready yet.

  • Save compose.yaml.
  • Validate your Compose file by running this in your terminal:
docker compose config --quiet

If the command returns with no output and no errors, your YAML is valid.

Seeing a YAML error?

  • Check that the neo4j service is indented at the same level as app and db (two spaces).
  • Make sure neo4j-data: is indented under volumes: at the same level as db-data:.
  • Confirm there are no tabs in the file. YAML requires spaces only.

Help me fix my compose.yaml YAML syntax error for the neo4j service.

✔️ Awesome, I've got everything!

Great. Make sure you've saved compose.yaml before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your compose.yaml includes all of the following, in order:

  • The app service with NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD in its environment.
  • The app service's depends_on listing both db and neo4j with service_healthy conditions.
  • The db service (unchanged from before).
  • The neo4j service with image neo4j:2026.06.0, ports 7474 and 7687, the cypher-shell healthcheck, and neo4j-data volume.
  • The volumes: section declaring both db-data and neo4j-data.
Install the Neo4j Python driver and update config

Your app needs a Python library to communicate with Neo4j over the Bolt protocol. The official driver handles connections, retries, and transaction management.

  • Install the Neo4j Python driver by running this in your terminal:
uv add neo4j

You should see output confirming the neo4j package was added to your project dependencies.

Install failed?

  • Make sure you're running the command from your project's root directory (enterprise-knowledge-assistant).
  • Confirm uv is installed and on your PATH by running uv --version.

Help me troubleshoot my uv add neo4j install failure.

  • Now open app/core/config.py in VS Code.
  • Find the database_url field inside the Settings class. Add these three new fields below it:
    neo4j_uri: str = "bolt://localhost:7687"
    neo4j_user: str = "neo4j"
    neo4j_password: str = "knowledgegraph"

What do these config fields do?

  • neo4j_uri is the Bolt protocol address. In Docker, the hostname neo4j resolves to the container. Locally it falls back to localhost.
  • neo4j_user and neo4j_password match the credentials you set in NEO4J_AUTH in your compose file.
  • Because you're using pydantic_settings, these defaults get overridden by the NEO4J_URI, NEO4J_USER, and NEO4J_PASSWORD environment variables in Docker.
  • Save app/core/config.py.

✔️ Awesome, I've got everything!

Great. Make sure you've saved app/core/config.py before moving on.

ⓧ I'd like to double check the full code

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    app_name: str = "Enterprise Knowledge Assistant"
    app_version: str = "0.2.0"
    debug: bool = False
    database_url: str = "postgresql+psycopg://postgres:changeme@localhost:5432/knowledge_assistant"
    neo4j_uri: str = "bolt://localhost:7687"
    neo4j_user: str = "neo4j"
    neo4j_password: str = "knowledgegraph"


settings = Settings()
Start the stack and verify Neo4j Browser

Everything is configured. Time to bring up all three services and confirm Neo4j is accepting queries.

  • Start the full stack by running this command:
docker compose up --build

Docker will pull the Neo4j image (this may take a minute the first time), build your app container, and start all three services.

What should I see in the logs?

Watch for lines from the neo4j container mentioning "Bolt enabled" and "Remote interface available". Once you see those, the database is ready.

The app container will wait for both db and neo4j healthchecks to pass before starting. This can take 10-30 seconds.

Before you open the browser, what do you expect to see at http://localhost:7474? Think about what Neo4j Browser is and what state your graph database is in right now.

  • Open your web browser and navigate to http://localhost:7474.

You should see the Neo4j Browser interface. It's a web-based tool for running Cypher queries and visualizing your graph.

  • In the query bar at the top of Neo4j Browser, type this Cypher query and press the play button (or hit Enter):
RETURN 1 AS connected

You should see a table with one row showing the value 1 under a column called connected. This confirms Neo4j is running and accepting Cypher queries.

Can't reach localhost:7474?

  • Check that the neo4j container is running by checking the Docker Compose logs for errors.
  • If you see authentication errors in the logs, confirm NEO4J_AUTH=neo4j/knowledgegraph is set correctly in your compose file. The format is username/password with a forward slash separator.
  • If the container keeps restarting, the healthcheck may be failing. Run docker compose logs neo4j to see what's happening inside the container.

Help me debug why my Neo4j container won't start in Docker Compose.

Your three-service stack is running. Next up, you'll create document nodes in Neo4j so your data lives in both databases simultaneously.

Create Document Nodes in the Graph

Your three-service stack is running and Neo4j Browser is live. But if you open it right now, the graph is completely empty. Documents only exist as rows in PostgreSQL.

In this step, you'll build a graph service layer that creates Document nodes in Neo4j every time you create a document through the API. This is your first step toward a real Knowledge Graph.

In this step, get ready to:
  • Create a graph driver module that manages the Neo4j connection.
  • Build a graph service function that writes document nodes using Cypher.
  • Wire a dual-write into your existing create endpoint so documents land in both databases.
Create the graph driver module

Your app needs a way to open and manage a connection to Neo4j. The Neo4j Python Driver uses a driver object to handle connection pooling and session management. You'll build a singleton module that creates one driver and reuses it across requests.

  • Create a new file app/core/graph.py and add the following code:
from neo4j import GraphDatabase

from app.core.config import settings

_driver = None


def get_driver():
    global _driver
    if _driver is None:
        _driver = GraphDatabase.driver(
            settings.neo4j_uri,
            auth=(settings.neo4j_user, settings.neo4j_password),
        )
    return _driver


def close_driver():
    global _driver
    if _driver is not None:
        _driver.close()
        _driver = None

What does this code do?

  • `_driver = None` holds the singleton driver instance at module level.
  • `get_driver()` creates the driver once using your config values (neo4j_uri, neo4j_user, neo4j_password), then returns the same instance on every subsequent call.
  • `close_driver()` cleanly shuts down the connection pool when the app stops.

Now wire the driver into your app's startup and shutdown lifecycle.

  • Open app/main.py and replace its contents with this updated version:
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.core.config import settings
from app.core.database import create_db_and_tables
from app.core.graph import close_driver, get_driver
from app.routers import documents


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    get_driver()
    yield
    close_driver()


app = FastAPI(
    title=settings.app_name,
    version=settings.app_version,
    debug=settings.debug,
    lifespan=lifespan,
)

app.include_router(documents.router)


@app.get("/health", tags=["health"])
def health_check() -> dict:
    return {
        "status": "healthy",
        "app": settings.app_name,
        "version": settings.app_version,
    }

What changed in main.py?

  • The new import from app.core.graph import close_driver, get_driver brings in both lifecycle functions.
  • `get_driver()` runs at startup, opening the Neo4j connection pool before any request arrives.
  • `close_driver()` runs after yield, closing the connection pool cleanly when the app shuts down.
  • Save both app/core/graph.py and app/main.py.
  • Rebuild and start the stack by running:
docker compose up --build
  • Confirm the app still starts successfully by running this in a new terminal:
curl http://localhost:8000/health

You should see {"status":"healthy",...} confirming the app boots with the Neo4j driver initialized.

App failing to start?

  • Check that neo4j appears as healthy in your docker compose ps output. The app depends on Neo4j being healthy before it starts.
  • Verify the import path is exactly from app.core.graph import close_driver, get_driver. A typo here causes an ImportError at startup.

Help me debug why my app won't start after adding the graph driver module.

Build the graph service function

The driver handles the connection. Now you need a function that actually writes data to Neo4j. This function will use a Cypher MERGE statement to create a Document node with properties.

Why MERGE instead of CREATE?

MERGE is idempotent. If your app retries or restarts, MERGE finds the existing node and updates it instead of creating a duplicate. CREATE would add a new node every time, even for the same document. Production Knowledge Graphs always use MERGE for ingestion.

  • Create a new file app/services/graph_service.py (VS Code will create the services folder for you when you save):
from uuid import UUID

from app.core.graph import get_driver


def create_document_node(doc_id: UUID, title: str, source: str | None) -> None:
    driver = get_driver()
    driver.execute_query(
        "MERGE (d:Document {id: $id}) "
        "SET d.title = $title, d.source = $source",
        id=str(doc_id),
        title=title,
        source=source,
        database_="neo4j",
    )

What does this code do?

  • `driver.execute_query()` handles sessions, transactions, and retries automatically. It is the recommended API for simple queries in the Neo4j Python Driver 6.x.
  • The Cypher statement MERGE (d:Document {id: $id}) finds or creates a node with the Document label matching that ID.
  • `SET d.title = $title, d.source = $source` writes the document's metadata as node properties.
  • `database_="neo4j"` targets the default database explicitly.
  • Save app/services/graph_service.py.
Wire up the dual-write to both databases

The graph service function exists but nothing calls it yet. You need to update the create endpoint so every new document is written to PostgreSQL first, then mirrored as a node in Neo4j.

  • Open app/routers/documents.py and find the imports at the top of the file.
  • Add this import below the existing ones:
from app.services.graph_service import create_document_node
  • Now scroll down to the create_document function and find this line:
    session.refresh(db_doc)
    return db_doc
  • Add the create_document_node call between those two lines, so it looks like this:
    session.refresh(db_doc)
    create_document_node(db_doc.id, db_doc.title, db_doc.source)
    return db_doc

Why write to PostgreSQL first?

The PostgreSQL commit happens before the Neo4j write. If the graph write fails, you still have the document safely stored in your relational database. This order makes the system more resilient. The graph can always be rebuilt from PostgreSQL as the source of truth.

  • Save app/routers/documents.py.
  • Rebuild and start the stack by running:
docker compose up --build

Before you test, think about this: what do you expect to see in Neo4j Browser after you create a document through the API?

  • Create a document by running this curl command in a new terminal:
curl -X POST http://localhost:8000/api/v1/documents/ \
  -H "Content-Type: application/json" \
  -d '{"title": "Graph Database Fundamentals", "content": "An introduction to graph databases and their use cases.", "source": "internal"}'

You should see a JSON response with the new document's id, title, and created_at fields.

  • Open Neo4j Browser at http://localhost:7474 in your browser.
  • Run this Cypher query in the query bar at the top:
MATCH (d:Document) RETURN d

You should see a single Document node appear in the graph visualization with your document's title and source as properties. It sits there as an isolated dot with no connections.

Why is the node isolated?

Right now you're only creating nodes. A graph database storing disconnected nodes is just a worse version of PostgreSQL. The power comes from relationships, which you'll add in the next step.

No nodes showing up in Neo4j Browser?

  • Check the app container logs with docker compose logs app for any import errors or connection failures.
  • Make sure you added the create_document_node call after session.refresh(db_doc), not before the commit. The document needs to exist in PostgreSQL first.
  • Verify you're connected as neo4j in the Browser. If prompted, use password knowledgegraph.

Help me figure out why my document nodes aren't appearing in Neo4j Browser.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files: app/core/graph.py, app/services/graph_service.py, and app/main.py.

ⓧ I'd like to double check the full code

Here are the complete files you created or modified in this step. Confirm yours match exactly.

app/core/graph.py:

from neo4j import GraphDatabase

from app.core.config import settings

_driver = None


def get_driver():
    global _driver
    if _driver is None:
        _driver = GraphDatabase.driver(
            settings.neo4j_uri,
            auth=(settings.neo4j_user, settings.neo4j_password),
        )
    return _driver


def close_driver():
    global _driver
    if _driver is not None:
        _driver.close()
        _driver = None

app/services/graph_service.py:

from uuid import UUID

from app.core.graph import get_driver


def create_document_node(doc_id: UUID, title: str, source: str | None) -> None:
    driver = get_driver()
    driver.execute_query(
        "MERGE (d:Document {id: $id}) "
        "SET d.title = $title, d.source = $source",
        id=str(doc_id),
        title=title,
        source=source,
        database_="neo4j",
    )

app/main.py:

from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.core.config import settings
from app.core.database import create_db_and_tables
from app.core.graph import close_driver, get_driver
from app.routers import documents


@asynccontextmanager
async def lifespan(app: FastAPI):
    create_db_and_tables()
    get_driver()
    yield
    close_driver()


app = FastAPI(
    title=settings.app_name,
    version=settings.app_version,
    debug=settings.debug,
    lifespan=lifespan,
)

app.include_router(documents.router)


@app.get("/health", tags=["health"])
def health_check() -> dict:
    return {
        "status": "healthy",
        "app": settings.app_name,
        "version": settings.app_version,
    }

The updated import and dual-write call in app/routers/documents.py. Scroll up and confirm you have the from app.services.graph_service import create_document_node import and the create_document_node(db_doc.id, db_doc.title, db_doc.source) call between session.refresh(db_doc) and return db_doc.

Your documents now live in two places: PostgreSQL for relational queries and Neo4j for graph traversals. Next up, you'll connect these isolated dots by adding topics and relationships to build a real Knowledge Graph.

Add Topics and Relationships

Your documents now exist as nodes in Neo4j, but open Neo4j Browser and look at what you have. Isolated dots floating in empty space, with no lines connecting them.

A graph database storing disconnected nodes is actually worse than PostgreSQL. The entire value of a graph comes from relationships. In this step, you will connect documents to topics so you can traverse from one document to related ones through shared subjects.

In this step, get ready to:
  • Add a graph service function that creates Topic nodes and DISCUSSES relationships.
  • Build a topics endpoint that lets you assign topics to documents.
  • Verify that documents connect through shared topics in Neo4j Browser.
Add the topic relationship function

Before you can assign topics through the API, you need a function that writes the relationship to Neo4j. This function will use Cypher MERGE statements to create Topic nodes and link them to existing Document nodes.

  • In VS Code, open app/services/graph_service.py.
  • Add this function below the existing create_document_node() function:
def add_topic_to_document(doc_id: UUID, topic_name: str) -> None:
    driver = get_driver()
    driver.execute_query(
        "MATCH (d:Document {id: $doc_id}) "
        "MERGE (t:Topic {name: $topic_name}) "
        "MERGE (d)-[:DISCUSSES]->(t)",
        doc_id=str(doc_id),
        topic_name=topic_name,
        database_="neo4j",
    )

What does this code do?

  • MATCH (d:Document {id: $doc_id}) finds the existing Document node by its ID.
  • MERGE (t:Topic {name: $topic_name}) finds or creates a Topic node. MERGE is idempotent, so assigning the same topic twice won't create duplicates.
  • MERGE (d)-[:DISCUSSES]->(t) creates a DISCUSSES relationship from the document to the topic, only if one doesn't already exist.

Why MERGE instead of CREATE?

CREATE always makes a new node or relationship, even if an identical one exists. MERGE checks first and only creates if nothing matches. This prevents duplicate Topic nodes from appearing when multiple documents discuss the same subject.

  • Save app/services/graph_service.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

from uuid import UUID

from app.core.graph import get_driver


def create_document_node(doc_id: UUID, title: str, source: str | None) -> None:
    driver = get_driver()
    driver.execute_query(
        "MERGE (d:Document {id: $id}) "
        "SET d.title = $title, d.source = $source",
        id=str(doc_id),
        title=title,
        source=source,
        database_="neo4j",
    )


def add_topic_to_document(doc_id: UUID, topic_name: str) -> None:
    driver = get_driver()
    driver.execute_query(
        "MATCH (d:Document {id: $doc_id}) "
        "MERGE (t:Topic {name: $topic_name}) "
        "MERGE (d)-[:DISCUSSES]->(t)",
        doc_id=str(doc_id),
        topic_name=topic_name,
        database_="neo4j",
    )
Create the topics endpoint

Now you need an API endpoint that accepts a topic name and calls your new function. This requires a Pydantic schema for the request body and a new router file.

  • Create a new file app/schemas/topics.py and add the following schema:
from pydantic import BaseModel, Field


class TopicCreate(BaseModel):
    # Accepts a topic name between 1 and 100 characters
    name: str = Field(min_length=1, max_length=100)


class TopicResponse(BaseModel):
    name: str
    document_count: int

What does this schema define?

TopicCreate validates incoming requests. It requires a non-empty topic name with a 100-character limit. TopicResponse will be used later when you list topics with their document counts.

  • Save app/schemas/topics.py.
  • Create a new file app/routers/topics.py and add the following endpoint:
from uuid import UUID

from fastapi import APIRouter, HTTPException

from app.schemas.topics import TopicCreate
from app.services.graph_service import add_topic_to_document

router = APIRouter(prefix="/api/v1/documents", tags=["topics"])


@router.post("/{document_id}/topics", status_code=201)
def assign_topic(document_id: UUID, topic: TopicCreate) -> dict:
    # Link a topic to a document in the knowledge graph
    add_topic_to_document(document_id, topic.name)
    return {"document_id": str(document_id), "topic": topic.name, "status": "linked"}

What does this endpoint do?

  • The route POST /api/v1/documents/{document_id}/topics nests topics under a specific document, making the relationship clear in the URL.
  • It calls add_topic_to_document() to create the graph relationship, then returns a confirmation with the linked document ID and topic name.
  • Save app/routers/topics.py.

Now register the new router so FastAPI knows about it.

  • Open app/main.py.
  • Find this import line:
from app.routers import documents
  • Replace it with:
from app.routers import documents, topics
  • Find the line app.include_router(documents.router) and add this line directly below it:
app.include_router(topics.router)
  • Save app/main.py.
  • Rebuild and restart your stack by running:
docker compose up --build
  • Once the stack is healthy, test the new endpoint by running this in a new terminal:
curl -X POST http://localhost:8000/api/v1/documents/ \
  -H "Content-Type: application/json" \
  -d '{"title": "GraphQL Best Practices", "content": "A guide to schema design.", "source": "internal-wiki"}'

Copy the id from the response. Then assign a topic to it:

curl -X POST http://localhost:8000/api/v1/documents/PASTE_DOCUMENT_ID_HERE/topics \
  -H "Content-Type: application/json" \
  -d '{"name": "API Design"}'

You should see a JSON response confirming the link:

{"document_id": "...", "topic": "API Design", "status": "linked"}

Seeing a 422 or connection error?

  • Make sure you replaced PASTE_DOCUMENT_ID_HERE with the actual UUID from the first curl response.
  • Check that all three containers are healthy in your Docker Compose output. The neo4j service must show healthy before the app can connect.
  • Confirm you saved all three files (graph_service.py, topics.py schema, topics.py router) before rebuilding.

Help me debug my topics endpoint

Verify documents connect through shared topics

One linked document is good, but the real power appears when multiple documents share the same topic. Let's create a second document and assign it the same topic.

  • Create a second document by running:
curl -X POST http://localhost:8000/api/v1/documents/ \
  -H "Content-Type: application/json" \
  -d '{"title": "REST API Versioning", "content": "Strategies for API version management.", "source": "tech-blog"}'
  • Copy the id from the response and assign the same topic:
curl -X POST http://localhost:8000/api/v1/documents/PASTE_SECOND_DOCUMENT_ID_HERE/topics \
  -H "Content-Type: application/json" \
  -d '{"name": "API Design"}'

Both documents now discuss the same topic. Before you check the graph, think about what you expect to see. What shape should the graph take when two documents share a topic?

  • Open Neo4j Browser at http://localhost:7474 in your browser.
  • Run this Cypher query to see documents and their topics:
MATCH (d:Document)-[:DISCUSSES]->(t:Topic) RETURN d, t

You should see two Document nodes both pointing to a single Topic node labeled "API Design". The graph fans inward.

  • Now run the full traversal query that finds documents connected through shared topics:
MATCH (d:Document)-[:DISCUSSES]->(t:Topic)<-[:DISCUSSES]-(other:Document) RETURN d, t, other

You should see both documents linked through the shared "API Design" topic. This is the core of a Knowledge Graph. Two documents that were unrelated rows in PostgreSQL are now visibly connected through a shared concept.

Not seeing any nodes?

  • Confirm the curl commands returned {"status": "linked"} responses earlier. If they returned errors, the nodes were never created.
  • Check that you are connected to the neo4j database in Neo4j Browser (shown in the top-left dropdown).
  • Try running MATCH (n) RETURN n to see all nodes. If nothing appears, the driver connection may not be reaching your container.

Help me debug my Knowledge Graph nodes

Your Knowledge Graph now has real structure. Documents connect to topics, and topics connect documents to each other. Next up, you will expose these graph traversals as API endpoints so any frontend or service can discover related documents programmatically.

Build Graph Traversal Endpoints

Your Knowledge Graph has documents connected through shared topics. You can see these connections in Neo4j Browser by writing Cypher queries manually.

The problem is that no frontend, service, or integration can access those graph traversals. They are locked behind the Neo4j Browser interface. In this step, you will expose graph queries as API endpoints so any client can discover related documents programmatically.

In this step, get ready to:
  • Add Cypher traversal queries to the graph service layer.
  • Create a graph router with endpoints for related documents and topic listings.
  • Rebuild the stack and test the new endpoints through curl.
Add traversal queries to the graph service

The graph service currently knows how to create nodes and relationships. It needs two new functions: one that finds documents related through shared topics, and one that lists all topics with document counts.

  • In app/services/graph_service.py, add the following function below add_topic_to_document():
def get_related_documents(doc_id: UUID) -> list[dict]:
    driver = get_driver()
    records, _, _ = driver.execute_query(
        "MATCH (d:Document {id: $doc_id})-[:DISCUSSES]->(t:Topic)"
        "<-[:DISCUSSES]-(other:Document) "
        "WHERE other.id <> $doc_id "
        "RETURN DISTINCT other.id AS id, other.title AS title, "
        "collect(DISTINCT t.name) AS shared_topics",
        doc_id=str(doc_id),
        database_="neo4j",
        routing_="r",
    )
    return [record.data() for record in records]

What does this Cypher query do?

The MATCH pattern starts at your target document, follows DISCUSSES relationships outward to topics, then follows those same relationships back inward to find other documents sharing those topics.

The WHERE other.id <> $doc_id clause excludes the original document from its own results. The collect(DISTINCT t.name) groups the shared topic names into a list for each related document.

  • Below get_related_documents(), add the topics listing function:
def get_all_topics() -> list[dict]:
    driver = get_driver()
    records, _, _ = driver.execute_query(
        "MATCH (t:Topic)<-[:DISCUSSES]-(d:Document) "
        "RETURN t.name AS name, count(d) AS document_count "
        "ORDER BY document_count DESC",
        database_="neo4j",
        routing_="r",
    )
    return [record.data() for record in records]

What does this query do?

This matches every Topic node and counts how many Document nodes point to it via DISCUSSES relationships. The ORDER BY document_count DESC sorts the most-connected topics first.

The routing_="r" flag tells the driver this is a read-only query. It communicates intent clearly even in a standalone (non-clustered) setup.

  • Save app/services/graph_service.py.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

Your app/services/graph_service.py should now have these functions in order:

  • create_document_node() (from the previous step).
  • add_topic_to_document() (from the previous step).
  • get_related_documents() (added just now).
  • get_all_topics() (added just now).

Scroll up and confirm each function is present in that order.

Create the graph router and wire it up

The traversal logic is ready in the service layer. Now you need HTTP endpoints that call those functions and return JSON responses.

  • Create a new file app/routers/graph.py and add the following code:
from uuid import UUID

from fastapi import APIRouter

from app.services.graph_service import get_all_topics, get_related_documents

# Router for graph traversal endpoints
router = APIRouter(prefix="/api/v1/graph", tags=["graph"])


@router.get("/documents/{document_id}/related")
def find_related_documents(document_id: UUID) -> dict:
    # Traverse the graph to find documents sharing topics
    related = get_related_documents(document_id)
    return {"document_id": str(document_id), "related": related, "total": len(related)}


@router.get("/topics")
def list_topics() -> dict:
    # Return all topics with their document counts
    topics = get_all_topics()
    return {"topics": topics, "total": len(topics)}

What does this router do?

The /documents/{document_id}/related endpoint takes a document UUID and returns all documents connected through shared topics. The response includes the list of shared topic names for each match.

The /topics endpoint returns every topic in the graph ranked by how many documents discuss it. This gives a quick overview of your Knowledge Graph's coverage.

  • Save app/routers/graph.py.

✔️ Awesome, I've got everything!

Great. Confirm you saved the file before continuing.

ⓧ I'd like to double check the full code

from uuid import UUID

from fastapi import APIRouter

from app.services.graph_service import get_all_topics, get_related_documents

router = APIRouter(prefix="/api/v1/graph", tags=["graph"])


@router.get("/documents/{document_id}/related")
def find_related_documents(document_id: UUID) -> dict:
    related = get_related_documents(document_id)
    return {"document_id": str(document_id), "related": related, "total": len(related)}


@router.get("/topics")
def list_topics() -> dict:
    topics = get_all_topics()
    return {"topics": topics, "total": len(topics)}

The router exists but FastAPI does not know about it yet. You need to register it in the app.

  • In app/main.py, find this import line:
from app.routers import documents, topics
  • Add graph to the import so it looks like this:
from app.routers import documents, graph, topics
  • Then find the router registrations:
app.include_router(documents.router)
app.include_router(topics.router)
  • Add the graph router below them:
app.include_router(documents.router)
app.include_router(topics.router)
app.include_router(graph.router)
  • Save app/main.py.
  • Rebuild and restart your stack by running:
docker compose up --build

Wait for all three services to report healthy. You should see the FastAPI startup log showing your new graph routes registered.

Seeing an import error?

  • Check that app/routers/graph.py exists and is saved. A missing file causes an ImportError.
  • Verify the import in app/main.py reads from app.routers import documents, graph, topics with commas between each name.
  • Confirm graph.py imports get_all_topics and get_related_documents from app.services.graph_service.

Help me debug my graph router import error.

Test the graph traversal endpoints

Your graph endpoints are live. Time to prove they work by querying related documents through the API.

Before you run this, think: you assigned the same topic to two documents in the previous step. If you query one document's related endpoint, do you expect to see the other document in the response?

  • Open a new terminal and test the related documents endpoint by running:
curl -s http://localhost:8000/api/v1/graph/topics | python3 -m json.tool

You should see a JSON response listing the topics you created earlier, each with a document_count showing how many documents discuss that topic.

  • Now test the related documents endpoint. You need a document ID from earlier. List your documents first by running:
curl -s http://localhost:8000/api/v1/documents/ | python3 -m json.tool
  • Copy one of the document id values from the response, then query its related documents by running:
curl -s http://localhost:8000/api/v1/graph/documents/[[DOCID="your-document-id-here"]]/related | python3 -m json.tool

You should see a JSON response with a related array. Each entry contains the related document's id, title, and the shared_topics that connect them.

Why is this powerful?

In a relational database, finding documents that share topics with a target document would require a multi-join query across pivot tables. The deeper you traverse (documents related to related documents), the more JOINs pile up.

In Neo4j, this is a single Cypher MATCH pattern. Adding more hops (second-degree connections, third-degree) costs one extra line, not an exponentially complex query.

Getting an empty related array?

  • Make sure you assigned the same topic to at least two different documents in the previous step. A document with a unique topic has no related documents.
  • Verify you are using a valid document UUID from the /api/v1/documents/ listing. A non-existent ID returns an empty result rather than an error.
  • Open Neo4j Browser at http://localhost:7474 and run MATCH (d)-[:DISCUSSES]->(t) RETURN d, t to confirm relationships exist.

Help me debug why my related documents endpoint returns empty results.

Your Knowledge Graph now has a full API layer. Any frontend or service can discover document relationships without writing raw Cypher. Next up, you will production-harden this graph with constraints, indexes, and statistics.

Secret mission

Production-Harden Your Knowledge Graph

Your Knowledge Graph works, but it has no guardrails. Without constraints, duplicate Topic nodes can creep in through slight spelling variations. Without indexes, searching document titles requires scanning every node. Add uniqueness constraints, a full-text search index, and a graph statistics endpoint to make your Knowledge Graph production-ready.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker, so there are no ongoing costs.

Resources you used:

  • Docker containers: app (FastAPI), db (PostgreSQL), neo4j (Neo4j) running locally.
  • Docker volumes: db-data (PostgreSQL) and neo4j-data (Neo4j) persisting data on disk.
  • Project folder: enterprise-knowledge-assistant on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building on this project.

  • Your stack runs locally and costs nothing.
  • Run docker compose up from your project folder to resume at any time.

Pause - I'll come back to this later

Shut down running containers to free up memory while keeping your data safe for next time.

  • Stop all containers by running this command from your project folder:
docker compose down
  • Your named volumes (db-data and neo4j-data) keep your data safe on disk.
  • When you are ready to resume, run docker compose up --build to start everything again with your data intact.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes all data in your PostgreSQL and Neo4j databases.

  • Remove all containers and volumes by running this command from your project folder:
docker compose down -v
  • Clean up Docker images by running:
docker image prune
  • Type y when prompted to confirm the cleanup.
  • Delete the project folder from your Desktop by running:
cd ~/Desktop
rm -rf enterprise-knowledge-assistant

On Windows?

Use this command in PowerShell instead:

cd ~\Desktop
Remove-Item -Recurse -Force enterprise-knowledge-assistant

Nice Work!

Nice Work!

You just built a dual-database Knowledge Graph platform where documents live in PostgreSQL for relational queries and Neo4j for graph traversals. This is the exact architecture that Knowledge Graph Engineer roles demand.

What you learned:

  • Added Neo4j as a graph database alongside PostgreSQL, orchestrated by Docker Compose with health checks and persistent volumes.
  • Built a graph service layer that dual-writes documents to both PostgreSQL and Neo4j, with Document and Topic nodes connected by DISCUSSES relationships.
  • Created graph traversal API endpoints that find related documents through shared topics using Cypher queries.
  • Secret Mission: Added uniqueness constraints, a full-text search index, and a graph statistics endpoint for production-grade Knowledge Graph engineering.

Ready to quiz yourself?