Automate Entity Extraction with an LLM
Use a local LLM to auto-populate a Knowledge Graph with typed entities.
Introduction
30 Second Summary
Every document you create contains hidden connections. People, technologies, and organizations are buried in plain text, invisible to any system that only stores raw content.
In this project, you will build an automated entity extraction pipeline that uses a local LLM to pull structured entities from document text and populate a Knowledge Graph with zero manual input.
What You'll Build
You paste a paragraph about cloud architecture into your API, and within seconds, entities like "Kubernetes" and "AWS" appear as typed nodes in your Neo4j graph, connected to the document that mentioned them through MENTIONS relationships.
By the end of this project, you'll have:
- An API that automatically extracts and classifies entities (Technology, Organization, Concept, Person) from any document text you send it, with no manual tagging required.
- A self-populating Knowledge Graph where typed Entity nodes and MENTIONS relationships appear every time you create a document through the API.
- A working Docker-to-host Ollama integration where your containerized app calls a GPU-accelerated LLM running natively on your machine, returning structured JSON every time.
- Secret Mission: A batch re-extraction endpoint that reprocesses all existing documents and a confidence threshold filter that keeps only high-quality entities in your graph.
Are there any prerequisites?
This project is Phase 3 of a series. You need a working three-service Docker Compose stack (FastAPI + PostgreSQL + Neo4j) from Phase 2, along with Ollama installed on your machine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Your Knowledge Graph currently relies on manual topic assignment. In this project, you'll wire up a local LLM to extract entities automatically every time a document is created.
Set Up the Ollama Integration
Your API and graph stack are up and running, but right now every piece of knowledge in your graph had to be tagged by hand. To automate entity extraction, you need a local LLM that your Python code can call.
Ollama is already installed on your machine, but your project does not know it exists yet. In this step, you will wire up the connection so your Python code can send prompts and get responses from the LLM.
In this step, get ready to:
- Add the ollama Python package to your project dependencies.
- Pull the llama3.1:8b model so it is ready to serve requests.
- Create a reusable LLM client module and confirm the model responds.
Add the ollama dependency and pull the model
Your project manages dependencies with uv. Adding a package updates your pyproject.toml and installs the library into your project environment in one command.
- Add the ollama package to your project by running this command in your terminal:
uv add ollama
What does this command do?
The uv add command adds ollama to your pyproject.toml and resolves the package into your lockfile and virtual environment. This gives your Python code access to the ollama library for talking to the local LLM server.
You should see output confirming the package was resolved and installed.
Seeing a resolution error?
- Make sure you are running the command from your project root (where pyproject.toml lives).
- Check that your virtual environment is active and uv is installed.
- help me fix the uv add ollama error
Next, you need the actual model weights downloaded and ready on your machine. Ollama serves models from a local registry. The llama3.1:8b model fits in your GPU's VRAM and works well for entity extraction.
- Pull the model by running this command:
ollama pull llama3.1:8b
What happens during the pull?
Ollama downloads the model weights from its registry to your local machine. For llama3.1:8b, this is roughly 4.7 GB at Q4_K_M quantization. The download time depends on your internet speed.
If you already pulled this model before, the command finishes almost instantly because the weights are already cached.
- Confirm the model is available by running:
ollama ls
You should see llama3.1:8b listed in the output with its size and modification date.
Don't see the model listed?
- Make sure Ollama is running. On Windows, check the system tray for the Ollama icon.
- If you see a connection error, Ollama's server may not be started. Open it from the Start menu. It runs as a background service after first launch.
- help me troubleshoot why ollama ls is not showing my model
Add Ollama config to Settings and create the LLM client module
Your app uses a centralized Settings class for configuration. Adding Ollama's URL and model name here keeps them configurable through environment variables later when you run inside Docker.
- Open app/core/config.py in VS Code.
- Find the line that says neo4j_password: str = "knowledgegraph".
- Add the following two lines directly below it:
ollama_url: str = "http://localhost:11434"
ollama_model: str = "llama3.1:8b"
What do these settings do?
- ollama_url points at the Ollama server's default address. Port 11434 is where Ollama listens.
- ollama_model specifies which model to use for extraction. Because it is in Settings, you can override it with an environment variable later.
- Also find the app_version line and change its value to "0.3.0" to reflect this new phase.
- Save app/core/config.py.
Now you need a module that creates an Ollama client from these settings.
- Create a new file called llm.py inside the app/core/ folder.
- Add the following code to app/core/llm.py:
from ollama import Client
from app.core.config import settings
# Cache the client so it is created only once
_client = None
def get_llm_client() -> Client:
"""Return a reusable Ollama Client instance."""
global _client
if _client is None:
_client = Client(host=settings.ollama_url)
return _client
What does this code do?
- get_llm_client() returns a reusable Ollama Client instance that talks to the URL from your settings.
- The _client module-level variable caches the Client so it is created only once. Every part of your app that needs the LLM calls this function.
- Client(host=settings.ollama_url) connects to Ollama at the configured address.
- Save app/core/llm.py.
- Verify the import chain works by running:
uv run python -c "from app.core.llm import get_llm_client; print(get_llm_client())"
You should see something like <ollama._client.Client object at 0x...> printed. This confirms the module loads without import errors.
Seeing an ImportError?
- Check that uv add ollama completed successfully earlier. Run uv pip list and confirm ollama appears.
- Make sure you are running the command from your project root (where pyproject.toml lives).
- help me fix my ollama import error
Verify local LLM connectivity
The client object exists, but you have not proven that it can actually reach Ollama and get a response. A quick connectivity test sends a request to the running server and confirms the model is loaded.
- Before you run this, do you think the client will successfully reach Ollama on localhost from your terminal? Why or why not?
- Test that your project can talk to Ollama by running:
uv run python -c "from ollama import Client; c = Client(); print(c.list())"
You should see your pulled models listed in the output, including llama3.1:8b.
Why does this prove connectivity?
The c.list() call hits Ollama's API and returns all locally available models. If this succeeds, you know your Python environment can reach the Ollama server and make requests.
Getting a connection refused error?
- Ollama must be running in the background. Check the system tray for the Ollama icon and make sure it shows as active.
- If Ollama was recently installed but never started, open it from the Start menu. It runs as a background service after first launch.
- Confirm Ollama's port is reachable by opening http://localhost:11434 in your browser. You should see a message confirming Ollama is running.
- help me connect to Ollama from my Python project
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/core/config.py and app/core/llm.py.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point.
app/core/config.py:
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.3.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"
ollama_url: str = "http://localhost:11434"
ollama_model: str = "llama3.1:8b"
settings = Settings()
app/core/llm.py:
from ollama import Client
from app.core.config import settings
_client = None
def get_llm_client() -> Client:
global _client
if _client is None:
_client = Client(host=settings.ollama_url)
return _client
Your LLM integration is wired up and responding. Next up, you will build an extraction service that sends document text to this model and gets back structured entities.
Build the Entity Extraction Service
Your LLM client can reach Ollama and get responses. But right now those responses come back as free-form text.
Free-form text is impossible to parse reliably into graph nodes. You need the LLM to return structured, predictable JSON so your code can automatically create typed entities in the Knowledge Graph. In this step, you build an extraction service that constrains the LLM's output to a strict schema.
In this step, get ready to:
- Define a JSON schema that constrains LLM output to a list of typed entities.
- Write a system prompt that instructs the model to extract entities from document text.
- Test the extraction function against sample documents and confirm well-formed results.
Define the extraction schema
Ollama's structured outputs feature lets you pass a JSON schema to the format parameter. The model is then forced to produce output that conforms to your schema exactly. This eliminates the guesswork of parsing free-form text.
- Create a new file called app/services/nlp_service.py in your project.
- Add the imports and extraction schema by pasting this code:
import json
from uuid import UUID
from app.core.config import settings
from app.core.llm import get_llm_client
# Define the JSON schema that constrains LLM output
EXTRACTION_SCHEMA = {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"type": {
"type": "string",
"enum": ["Technology", "Organization", "Concept", "Person"],
},
"description": {"type": "string"},
},
"required": ["name", "type", "description"],
},
}
},
"required": ["entities"],
}
What does this schema do?
The schema tells Ollama exactly what shape the response must take. Every entity in the output must have three fields:
- name is the entity's display name (e.g. "Kubernetes").
- type is restricted to exactly one of four categories: Technology, Organization, Concept, or Person.
- description is a one-sentence explanation of what the entity is.
Because the schema uses an enum for type, the LLM cannot invent new categories. Your downstream code can rely on these four labels being the only possibilities.
Write the system prompt
The schema constrains the shape of the output, but the system prompt tells the model what to extract and how to behave. A clear prompt reduces hallucinated entities and improves classification accuracy.
- Add the system prompt below your EXTRACTION_SCHEMA in nlp_service.py:
# Instructions that tell the LLM how to extract entities
SYSTEM_PROMPT = (
"You are a Named Entity Recognition system for a Knowledge Graph. "
"Extract all notable entities from the provided text. "
"Classify each entity as exactly one of: Technology, Organization, Concept, or Person. "
"Provide a brief one-sentence description for each entity. "
"Only extract entities that are explicitly mentioned in the text. "
"Do not invent entities that are not present."
)
Why these specific instructions?
The prompt includes two guardrails. "Only extract entities that are explicitly mentioned" prevents the model from hallucinating entities that sound plausible but are not in the text. "Do not invent entities" reinforces this boundary.
The classification instruction ("exactly one of") works together with the schema's enum constraint to ensure each entity gets a single, valid type.
- Save nlp_service.py.
Implement the extract_entities function
Now you connect the schema and prompt to the LLM client. The function sends document text to Ollama, receives structured JSON, and parses it into a Python list of entity dictionaries.
- Add the extract_entities function below your SYSTEM_PROMPT in nlp_service.py:
def extract_entities(text: str) -> list[dict]:
# Get the configured Ollama client
client = get_llm_client()
# Send the text to the LLM with the schema constraint
response = client.chat(
model=settings.ollama_model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Extract entities from this text:\n\n{text}"},
],
format=EXTRACTION_SCHEMA,
)
# Parse the JSON string into a Python dict
result = json.loads(response.message.content)
return result.get("entities", [])
What does this function do?
- get_llm_client() retrieves the cached Ollama client you built in the previous step.
- format=EXTRACTION_SCHEMA passes your JSON schema to Ollama. The model is forced to produce output matching this exact structure.
- response.message.content is a JSON string. You parse it with json.loads() and extract the entities list.
- Save nlp_service.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/services/nlp_service.py with all three sections: the imports and schema, the system prompt, and the function.
ⓧ I'd like to double check the full code
Your app/services/nlp_service.py should contain these three sections in order. Scroll up and confirm each block is present:
- Imports (json, UUID, settings, get_llm_client) and EXTRACTION_SCHEMA dictionary.
- SYSTEM_PROMPT string with six instruction sentences.
- extract_entities(text) function calling client.chat() with format=EXTRACTION_SCHEMA and returning the parsed entity list.
Test extraction locally with sample documents
The service is built. Before wiring it into your API, you test it locally to confirm the LLM returns well-formed entity lists with correct types.
Before you run this, what do you expect to see? Think about what entities exist in the sentence and what types they might get.
- Test the extraction function by running this command from your project root:
uv run python -c "from app.services.nlp_service import extract_entities; print(extract_entities('Kubernetes is an open-source container orchestration platform created by Google.'))"
You should see a JSON list of entities with names and types. Each entity has a name, type, and description field. "Kubernetes" should be classified as Technology and "Google" as Organization.
- Test with a second sample to confirm the LLM handles different content:
uv run python -c "from app.services.nlp_service import extract_entities; print(extract_entities('Elon Musk founded SpaceX to advance rocket technology and make humanity a multiplanetary species.'))"
You should see entities like "Elon Musk" (Person), "SpaceX" (Organization), and concepts like "rocket technology" (Technology) or "multiplanetary species" (Concept).
Seeing a connection error or timeout?
- Make sure Ollama is still running. Open a new terminal and run ollama list to confirm it responds.
- If the command hangs for a long time, the model may be loading into VRAM. The first call after Ollama starts can take 10-30 seconds while the model loads.
- If you see an import error, confirm you are running the command from your project root folder where pyproject.toml lives.
Help me debug my entity extraction function
Your extraction service works. The LLM reliably returns structured JSON conforming to your schema. Next up, you connect this to Docker so your API can call Ollama from inside a container.
Connect Docker to Your Local LLM
In the last step you tested entity extraction from your terminal and it worked perfectly. Your API runs inside a Docker container though, and inside that container "localhost" means the container itself. It does not mean your Windows machine running Ollama.
This step exposes the networking gap, fixes it, and then wires extraction into the document creation pipeline so entities flow into the graph automatically.
In this step, get ready to:
- Experience the Docker-to-host connection failure first-hand.
- Fix the networking with host.docker.internal.
- Wire extraction into the create endpoint so entities auto-populate the graph.
Experience the connection failure
Your Docker stack is currently running without any Ollama URL configured. The app container's default points at localhost:11434, which inside the container resolves to the container's own loopback. Nothing is listening there.
- Start your Docker stack in the background by running this command:
docker compose up --build -d
Before you run the next command, what do you think will happen when the container tries to reach Ollama at localhost:11434?
- Test Ollama connectivity from inside the running container by running:
docker compose exec app python -c "from app.core.llm import get_llm_client; c = get_llm_client(); print(c.list())"
You see a connection error. The container cannot reach anything on its own localhost port 11434 because Ollama is running on your host machine, not inside the container.
Why does localhost fail inside Docker?
Each Docker container has its own isolated network namespace. When code inside the container connects to localhost, it reaches the container's own loopback interface. Your Windows host (where Ollama is listening on port 11434) is a completely different network from the container's perspective.
Docker Desktop provides a special DNS name, host.docker.internal, that resolves to your host machine's IP. This is the bridge you need.
Fix with host.docker.internal
The fix is passing an environment variable in compose.yaml that overrides the default ollama_url setting inside the container.
- In compose.yaml, find the environment list under the app service. Locate this line:
- NEO4J_PASSWORD=knowledgegraph
- Add these two lines directly below it:
- OLLAMA_URL=http://host.docker.internal:11434
- OLLAMA_MODEL=llama3.1:8b
What does this do?
Pydantic Settings automatically reads environment variables matching your field names. When the container starts with OLLAMA_URL set, it overrides the default http://localhost:11434 in your Settings class. The host.docker.internal hostname resolves to your Windows host where Ollama is actually running.
- Save compose.yaml.
- Rebuild and restart the stack by running:
docker compose up --build -d
- Test the connection again from inside the container by running:
docker compose exec app python -c "from app.core.llm import get_llm_client; c = get_llm_client(); print(c.list())"
This time you see your pulled models listed, including llama3.1:8b. The container can now reach Ollama on your host.
Still seeing a connection error?
Make sure Ollama is running on your host machine. Open a separate terminal and run ollama list to confirm it responds.
Check that you typed host.docker.internal exactly (no typos, no https). The protocol must be http.
help me debug why my Docker container cannot connect to Ollama on the host
Wire extraction into the create endpoint
Now that Docker can talk to Ollama, you need two pieces: a graph function that writes entities to Neo4j, and an update to the documents router that calls extraction after saving each document.
- In app/services/graph_service.py, add the following function below your existing add_topic_to_document function:
def add_entity_to_document(doc_id: UUID, entity_name: str, entity_type: str, description: str) -> None:
driver = get_driver()
query = (
"MATCH (d:Document {id: $doc_id}) "
"MERGE (e:Entity {name: $entity_name}) "
"ON CREATE SET e.type = $entity_type, e.description = $description "
"MERGE (d)-[:MENTIONS]->(e) "
"WITH e, $entity_type AS etype "
"CALL apoc.create.addLabels(e, [etype]) YIELD node "
"RETURN node"
)
try:
driver.execute_query(
query,
doc_id=str(doc_id),
entity_name=entity_name,
entity_type=entity_type,
description=description,
database_="neo4j",
)
except Exception:
driver.execute_query(
"MATCH (d:Document {id: $doc_id}) "
"MERGE (e:Entity {name: $entity_name}) "
"ON CREATE SET e.type = $entity_type, e.description = $description "
"MERGE (d)-[:MENTIONS]->(e)",
doc_id=str(doc_id),
entity_name=entity_name,
entity_type=entity_type,
description=description,
database_="neo4j",
)
What does this code do?
The function uses MERGE to create an Entity node if it does not already exist, then creates a MENTIONS relationship from the Document to the Entity. It tries to add a dynamic label (like :Technology) using APOC.
Neo4j Community Edition may not include APOC, so the try/except falls back to creating the node with just a type property. Either way, the entity ends up in the graph.
- Still in app/services/graph_service.py, find the setup_constraints_and_indexes function and add this constraint at the end of the function body:
driver.execute_query(
"CREATE CONSTRAINT entity_name_unique IF NOT EXISTS "
"FOR (e:Entity) REQUIRE e.name IS UNIQUE",
database_="neo4j",
)
- Save app/services/graph_service.py.
Now update the documents router to call extraction after every document creation.
- In app/routers/documents.py, find the existing import line:
from app.services.graph_service import create_document_node
- Replace it with this expanded import:
from app.services.graph_service import add_entity_to_document, create_document_node
from app.services.nlp_service import extract_entities
- In the same file, find the create_document function. Locate this line inside it:
create_document_node(db_doc.id, db_doc.title, db_doc.source)
- Add this extraction block directly below that line:
try:
entities = extract_entities(db_doc.content)
for entity in entities:
add_entity_to_document(
db_doc.id, entity["name"], entity["type"], entity["description"]
)
except Exception:
pass
Why wrap extraction in try/except?
The try/except ensures your API never fails a document creation just because Ollama is temporarily unavailable. PostgreSQL remains the source of truth. The graph is an enrichment layer that gracefully degrades.
- Save app/routers/documents.py.
- Rebuild and restart the stack by running:
docker compose up --build -d
Before you test, what do you expect to happen when you create a document now? Will the 201 response be slower than before?
- Create a document to trigger extraction by running:
curl -X POST http://localhost:8000/api/v1/documents/ -H "Content-Type: application/json" -d "{\"title\": \"LLM Test\", \"content\": \"Google created TensorFlow for machine learning.\", \"source\": \"test\"}"
You see a 201 response with the document data. The response takes a few seconds longer because the LLM is extracting entities behind the scenes.
- Confirm no connectivity errors appeared by checking the Docker logs:
docker compose logs app --tail 20
You should see the POST request logged with a 201 status and no tracebacks mentioning connection errors to Ollama.
Seeing connection errors in the logs?
If the 201 returned but you see Ollama connection errors in the logs, the try/except swallowed the error gracefully. Double-check that your OLLAMA_URL environment variable in compose.yaml is exactly http://host.docker.internal:11434.
Also confirm Ollama is still running by opening a new terminal and running ollama list.
help me fix Ollama connectivity errors in my Docker Compose logs
Your containers can now talk to Ollama and extraction runs automatically on every new document. Next up, you will enrich the graph with typed entity nodes and build an endpoint to query them.
Enrich the Graph with Typed Entities
Your extraction pipeline stores entities in Neo4j, but every node carries the same generic :Entity label regardless of whether it represents a technology, an organization, or a person.
A production Knowledge Graph uses typed labels like :Entity:Technology so graph traversals can filter by category without scanning every node. In this step, you will upgrade the entity creation logic to add typed labels, expose an API endpoint for querying entities, and visualize the auto-populated graph.
In this step, get ready to:
- Upgrade entity creation to use APOC-powered typed labels with a graceful fallback.
- Add a GET endpoint that returns all extracted entities grouped by type.
- Create multiple documents and visualize the auto-populated Knowledge Graph in Neo4j Browser.
Update the graph service to create typed entity nodes
The add_entity_to_document function in graph_service.py currently creates nodes with a type property but no second label. You need it to call apoc.create.addLabels so entities gain labels like :Entity:Technology or :Entity:Organization.
- In VS Code, open app/services/graph_service.py.
- Find the existing add_entity_to_document function and replace it with the following code:
def add_entity_to_document(doc_id: UUID, entity_name: str, entity_type: str, description: str) -> None:
driver = get_driver()
query = (
"MATCH (d:Document {id: $doc_id}) "
"MERGE (e:Entity {name: $entity_name}) "
"ON CREATE SET e.type = $entity_type, e.description = $description "
"MERGE (d)-[:MENTIONS]->(e) "
"WITH e, $entity_type AS etype "
"CALL apoc.create.addLabels(e, [etype]) YIELD node "
"RETURN node"
)
try:
driver.execute_query(
query,
doc_id=str(doc_id),
entity_name=entity_name,
entity_type=entity_type,
description=description,
database_="neo4j",
)
except Exception:
driver.execute_query(
"MATCH (d:Document {id: $doc_id}) "
"MERGE (e:Entity {name: $entity_name}) "
"ON CREATE SET e.type = $entity_type, e.description = $description "
"MERGE (d)-[:MENTIONS]->(e)",
doc_id=str(doc_id),
entity_name=entity_name,
entity_type=entity_type,
description=description,
database_="neo4j",
)
What does this code do?
The primary query uses MERGE to create or match an Entity node, then calls apoc.create.addLabels to dynamically add the entity's type as a second label (e.g., Technology).
The try/except block provides a graceful fallback. Neo4j Community Edition does not include APOC by default, so the code falls back to creating entities with just the :Entity label and stores the type as a property instead.
Next, add the get_all_entities function that queries all Entity nodes with their connected document counts.
- Scroll to the bottom of graph_service.py (above setup_constraints_and_indexes) and add this function:
def get_all_entities() -> list[dict]:
driver = get_driver()
records, _, _ = driver.execute_query(
"MATCH (e:Entity)<-[:MENTIONS]-(d:Document) "
"RETURN e.name AS name, e.type AS type, e.description AS description, "
"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?
The Cypher pattern (e:Entity)<-[:MENTIONS]-(d:Document) traverses from Entity nodes backwards along MENTIONS relationships to find all connected Documents. The count(d) aggregation tells you how many documents reference each entity, ordered by popularity.
Finally, add a uniqueness constraint so duplicate Entity names cannot be created.
- Find the setup_constraints_and_indexes function at the bottom of the file.
- Add this constraint after the existing document_search index creation:
driver.execute_query(
"CREATE CONSTRAINT entity_name_unique IF NOT EXISTS "
"FOR (e:Entity) REQUIRE e.name IS UNIQUE",
database_="neo4j",
)
Why a uniqueness constraint?
Without this constraint, two documents mentioning "Kubernetes" would create two separate Entity nodes. The MERGE in your query already prevents duplicates at write time, but the constraint adds a database-level guarantee and speeds up lookups on the name property.
- Save app/services/graph_service.py.
You also need to update the get_graph_stats function to include the entity count so the stats endpoint reflects your new entities.
- Find the get_graph_stats function in graph_service.py.
- Add the following block at the end of the function, before the return stats line:
entity_records, _, _ = driver.execute_query(
"MATCH (e:Entity) RETURN count(e) AS entity_count",
database_="neo4j",
routing_="r",
)
stats["entity_count"] = entity_records[0].data()["entity_count"] if entity_records else 0
- Save app/services/graph_service.py.
Rebuild the Docker stack to pick up your changes by running this command in your terminal:
docker compose up --build -d
- Wait for the containers to report healthy. You can check with docker compose ps.
Seeing import errors in the logs?
Run docker compose logs app to check. If you see a NameError about get_all_entities, make sure the function is defined above setup_constraints_and_indexes in the file.
help me debug an import error in my graph_service.py after adding get_all_entities
Add a graph endpoint to list extracted entities
The entities exist in the graph, but there is no API route exposing them. You need a GET endpoint that returns all entities grouped by type with their document counts.
- Open app/routers/graph.py in VS Code.
- Replace the entire file with the following code:
from uuid import UUID
from fastapi import APIRouter
from app.services.graph_service import (
get_all_entities,
get_all_topics,
get_graph_stats,
get_related_documents,
search_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)}
@router.get("/entities")
def list_entities() -> dict:
entities = get_all_entities()
return {"entities": entities, "total": len(entities)}
@router.get("/search")
def search_graph(q: str) -> dict:
results = search_documents(q)
return {"query": q, "results": results, "total": len(results)}
@router.get("/stats")
def graph_statistics() -> dict:
return get_graph_stats()
What does the entities endpoint return?
The /api/v1/graph/entities endpoint calls get_all_entities() and returns a JSON object with an entities list containing each entity's name, type, description, and how many documents mention it.
- Save app/routers/graph.py.
- Rebuild the stack to deploy the new endpoint by running:
docker compose up --build -d
- Once healthy, verify the endpoint exists by running:
curl http://localhost:8000/api/v1/graph/entities
You should see a JSON response with an empty entities list and a total of 0. No documents with entities have been created through the new code path yet.
Getting a 404 on the entities endpoint?
Make sure the graph.py router file imports get_all_entities from graph_service. Also confirm app/main.py includes app.include_router(graph.router).
help me fix a 404 error on my /api/v1/graph/entities endpoint
Visualize the auto-populated Knowledge Graph
Time to feed your pipeline some real documents and watch the graph grow. You will create 3 documents about different topics, then verify the entities appear in Neo4j Browser.
- Create the first document about cloud architecture by running:
curl -X POST http://localhost:8000/api/v1/documents/ -H "Content-Type: application/json" -d "{\"title\": \"Cloud Architecture\", \"content\": \"Kubernetes is an open-source container orchestration platform created by Google. It runs on AWS and Azure to manage microservices at scale.\", \"source\": \"manual\"}"
You should see a 201 response with the document's ID and title.
- Create a second document about machine learning by running:
curl -X POST http://localhost:8000/api/v1/documents/ -H "Content-Type: application/json" -d "{\"title\": \"ML Frameworks\", \"content\": \"Google created TensorFlow for machine learning research. PyTorch by Meta is a popular alternative used in natural language processing.\", \"source\": \"manual\"}"
- Create a third document about DevOps by running:
curl -X POST http://localhost:8000/api/v1/documents/ -H "Content-Type: application/json" -d "{\"title\": \"DevOps Practices\", \"content\": \"Docker containers simplify deployment pipelines. Jenkins and GitHub Actions automate CI/CD workflows for engineering teams.\", \"source\": \"manual\"}"
What is happening behind the scenes?
Each POST request saves the document to PostgreSQL, creates a Document node in Neo4j, then sends the content to Ollama for entity extraction. The LLM identifies entities like "Kubernetes" (Technology), "Google" (Organization), and "microservices" (Concept), and your add_entity_to_document function writes them to the graph with typed labels.
- Verify the entities were extracted by hitting the new endpoint:
curl http://localhost:8000/api/v1/graph/entities
You should see a list of entities with their names, types (Technology, Organization, Concept, Person), descriptions, and document counts.
Now open Neo4j Browser to see the graph visually.
- Open your browser and navigate to http://localhost:7474.
- Sign in with username neo4j and password knowledgegraph.
- Before you run the query, predict what the graph will look like. How many Document nodes do you expect? How will they connect to Entity nodes?
- Run this Cypher query to see the full entity graph:
MATCH (d:Document)-[:MENTIONS]->(e) RETURN d.title, labels(e), e.name
You should see each of your three documents connected to multiple typed entity nodes. The labels(e) column shows labels like ["Entity", "Technology"] (if APOC is available) or ["Entity"] with the type stored as a property (fallback).
See the visual graph view
Click the **Graph** tab above the results table in Neo4j Browser to see an interactive visualization. Document nodes connect to Entity nodes through MENTIONS relationships, forming a web of knowledge.
No entities showing up in the graph?
Check the Docker logs for Ollama connectivity issues by running docker compose logs app. The try/except around extraction in the documents router silently swallows errors, so a connection timeout would produce documents without entities.
help me debug why my documents have no entities in the Knowledge Graph
✔️ Awesome, I've got everything!
Great. Double check you have saved both app/services/graph_service.py and app/routers/graph.py.
ⓧ I'd like to double check the full code
Here is the full app/routers/graph.py file. Confirm yours matches:
from uuid import UUID
from fastapi import APIRouter
from app.services.graph_service import (
get_all_entities,
get_all_topics,
get_graph_stats,
get_related_documents,
search_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)}
@router.get("/entities")
def list_entities() -> dict:
entities = get_all_entities()
return {"entities": entities, "total": len(entities)}
@router.get("/search")
def search_graph(q: str) -> dict:
results = search_documents(q)
return {"query": q, "results": results, "total": len(results)}
@router.get("/stats")
def graph_statistics() -> dict:
return get_graph_stats()
Scroll up and confirm each chunk of app/services/graph_service.py is present in order: add_entity_to_document with APOC and fallback, get_all_entities, the entity count block in get_graph_stats, and the entity_name_unique constraint in setup_constraints_and_indexes.
Your Knowledge Graph now self-populates with typed entities every time a document flows in. In the Secret Mission, you will add batch re-extraction for older documents and confidence scoring to filter out low-quality extractions.
Secret mission
Batch Re-Extract and Confidence Filtering
Documents created before the extraction pipeline was added have no entities in the graph. Can you build a batch endpoint that reprocesses them all, and add a confidence threshold so only high-quality extractions make it into the Knowledge Graph?
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, so there are no ongoing costs. The only impact is memory and VRAM usage while containers and Ollama are active.
Resources you used:
- Docker containers (app, db, neo4j) running locally. Free, uses memory.
- Docker volumes (db-data, neo4j-data) persisting data on disk. Free.
- Ollama running on your GPU. Free, uses VRAM only when processing requests.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the next phase soon.
- Your Docker containers continue running and serving requests on port 8000.
- Ollama keeps the model loaded in VRAM for 5 minutes by default, then unloads automatically.
- Neo4j Browser remains accessible at http://localhost:7474.
Pause - I'll come back to this later
Shut down running processes to free up memory and VRAM. Your data is preserved in Docker volumes.
- Stop the Docker containers by running this command from your project folder:
docker compose down
- Ollama continues running in the background but uses no VRAM when idle.
- Your PostgreSQL and Neo4j data remain safe in the db-data and neo4j-data volumes.
- To resume, run docker compose up --build from your project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes all graph data, documents, and entity extractions permanently.
- Remove containers and volumes (deletes all database and graph data) by running:
docker compose down -v
- Free disk space from the LLM model by running:
ollama rm llama3.1:8b
- Delete your project folder. On macOS or Linux:
cd ~/Desktop
rm -rf enterprise-knowledge-assistant
- On Windows:
cd ~/Desktop
rmdir /s /q enterprise-knowledge-assistant
- You can re-pull the model anytime with ollama pull llama3.1:8b if you need it for a future project.
Nice Work!
Nice Work!
Nice work. You have built an automated entity extraction pipeline that turns plain document text into a structured, self-populating Knowledge Graph.
You've learned how to:
- Build a configurable LLM client module that connects Docker containers to a local Ollama instance via host.docker.internal, bridging containerized apps to GPU-accelerated models.
- Create an NLP extraction service that uses structured outputs (JSON schema) to force the LLM to return typed entities reliably from any document text with a single call.
- Wire automatic extraction into the document creation pipeline so the Knowledge Graph self-populates with typed Entity nodes (Technology, Organization, Concept, Person) and MENTIONS relationships every time a document is created.
- Secret Mission: Added batch re-extraction for existing documents and confidence-scored relationships with threshold filtering.
Ready to quiz yourself?