Discover how to turn unstructured text into a visual, searchable knowledge graph using GPU-powered Kubernetes. Before we dive into code, it’s important to understand why knowledge graphs matter and why companies are investing in building them.
Over 80% of most modern information is unstructured: emails, documents, reports, support tickets, research papers, logs, transcripts, you name it. Hidden inside that text are entities (people, places, companies, technologies) and the relationships between them. Knowledge graphs are the simplest way to reveal this structure. They turn messy text into a connected map that’s easy to search, analyze, and visualize.
This is exactly why large-scale tech platforms and data-driven organizations rely on knowledge graphs for search, recommendations, fraud detection, cybersecurity, and regulatory compliance. They make relationships computable. They turn ambiguity into clarity. They help people and systems answer questions faster.
The problem is: building them usually requires complex pipelines, heavyweight NLP frameworks, or expensive enterprise tools.
By the end, you’ll have a practical, working example of how AI can transform raw text into structured insight, a foundational pattern for search engines, analytics tools, recommendation systems, research assistants, and more.
Prerequisites
- Civo account with GPU access
- Docker installed locally
- Python installed
- relaxAI API key from the relaxAI dashboard
- kubectl installed
Project overview
What this project builds
In this tutorial, you’ll create a simple system that turns text into a visual knowledge graph. It’s designed to be easy to understand, even if you’ve never worked with AI pipelines before.
Pipeline flow
Here’s how the process works from start to finish:
- The user enters text in the frontend (this could be a paragraph, a short article, or any piece of writing)
- The backend sends the text to relaxAI (the model analyzes the content and identifies important entities and how they relate)
- relaxAI returns a clean JSON structure describing the graph, for example:
{
"nodes": [
{ "id": "Alice", "type": "Person" },
{ "id": "ACME Corp", "type": "Organization" }
],
"edges": [
{ "source": "Alice", "target": "ACME Corp", "relation": "works_for" }
]
}
- The frontend visualizes this graph using a lightweight library (tools like D3.js or Cytoscape.js display the nodes and connections in an interactive layout)
- The user explores the graph visually (instead of reading dense text, they can quickly see relationships and key information at a glance)
Components you’ll deploy
To make this work, you’ll deploy a small, easy-to-understand setup on Civo:
- Backend API Pod: Processes text, calls relaxAI, and returns graph-ready JSON.
- Frontend Pod: Renders the interactive graph in the browser.
- Unified dockerization step: You’ll build and package both components into container images.
- Unified deployment step: Deploy both containers to your Civo Kubernetes cluster.
This keeps the whole project lightweight, approachable, and perfect for beginners exploring AI on Kubernetes.
Project structure
Civo-knowledge-graph-generator/
├── backend/
│ ├── main.py
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
│ ├── index.html
│ └── Dockerfile
└── kubernetes/
├── backend-deployment.yaml
└── frontend-deployment.yaml
Step 1: Create a GPU-enabled Kubernetes cluster
- Log in to the Civo Dashboard
- Create a new Kubernetes cluster
- Add a GPU node pool (one node is sufficient for this demo)
- Download your kubeconfig
- Connect to the cluster by running
set KUBECONFIG=path-to-kubeconfig-filein your terminal (useexportinstead ofseton Linux or macOS).
Verify connectivity:
kubectl get nodes

Even though this tutorial uses small inputs, the cluster is GPU-enabled, so the same setup scales naturally to heavier inference workloads without architectural changes.
Step 2: Build the backend (entity/relationship extractor)
The backend is a small FastAPI service that runs on your Civo cluster and does one job: take raw text, send it to relaxAI, and return a clean JSON representation of the entities and relationships it finds. Instead of dealing with messy model output, your frontend gets a predictable structure with nodes and edges that is ready to render as a graph.
At a high level, the backend exposes a single /graphify endpoint. When the user submits text from the UI, this endpoint builds a carefully structured prompt and sends it to relaxAI’s chat completions API running on Civo. The model responds with a description of the entities (like people, organizations, locations) and how they connect. The backend always returns data in the same JSON structure described earlier (a list of nodes and a list of edges).
Here’s a simple visual representation of that schema:

Behind the scenes, the code adds a few important safety rails. The relaxAI prompt strongly enforces “JSON-only” output, so the model does not mix explanations with data. The backend then cleans up common formatting issues (like markdown code fences) and tries to parse the response as strict JSON.
If the model still returns something slightly off, the code applies a small “repair” step to handle minor issues such as missing brackets or trailing commas, then validates the result into Pydantic models for Node and Edge.
The service also includes:
- A
/healthendpoint so Kubernetes can run liveness/readiness probes - CORS configuration so the browser-based frontend can call the API
- Environment-variable configuration for your relaxAI API key and endpoint, so nothing sensitive is hardcoded
With those pieces in place, the full backend comes together as a single FastAPI application. It wires up the models, the relaxAI call, the JSON-repair logic, and the supporting endpoints into one self-contained file.
Below is the complete main.py implementation that you’ll deploy on your Civo cluster.
main.py
Import necessary libraries:
import os
import json
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
Environment variables:
RELAXAI_API_KEY = os.getenv("RELAXAI_API_KEY")
RELAXAI_API_URL = os.getenv(
"RELAXAI_API_URL",
"https://api.relax.ai/v1/chat/completions"
)
if not RELAXAI_API_KEY:
raise RuntimeError("RELAXAI_API_KEY is missing.")
FastAPI setup:
app = FastAPI(title="Knowledge Graph Backend (JSON-Safe)")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
Models setup:
class GraphifyRequest(BaseModel):
text: str
class Node(BaseModel):
id: str
type: str
class Edge(BaseModel):
source: str
target: str
relation: str
class GraphifyResponse(BaseModel):
nodes: list[Node]
edges: list[Edge]
Health check:
@app.get("/health")
def health():
return {"status": "ok"}
Hard JSON-only prompt builder:
def build_prompt(text: str) -> str:
return f"""
You are an information extraction engine.
STRICT REQUIREMENTS:
- OUTPUT ONLY JSON.
- NO explanation.
- NO natural language.
- NO markdown.
- NO commentary.
- NO code fences.
- If unsure, output an empty list for nodes/edges.
Extract entities (as nodes) and relationships (as edges) from the text.
VALID JSON FORMAT (follow EXACTLY):
{{
"nodes": [
{{"id": "Alice", "type": "Person"}},
{{"id": "CompanyX", "type": "Organization"}}
],
"edges": [
{{"source": "Alice", "target": "CompanyX", "relation": "works_at"}}
]
}}
Text:
{text}
"""
This helper function builds the exact prompt sent to relaxAI. It forces the model into a strict “JSON-only” mode by explicitly forbidding explanations, markdown, or natural language. The prompt also provides a concrete example of the expected structure so the model knows exactly how to format its response.
By injecting the user’s text at the bottom, the backend reliably guides relaxAI to return a clean set of nodes and edges every time.
JSON repair helpers:
def clean_model_output(s: str) -> str:
"""Remove backticks, code fences, leading junk."""
s = s.strip()
if s.startswith("```json"):
s = s[len("```json"):].strip()
if s.startswith("```"):
s = s[3:].strip()
if s.endswith("```"):
s = s[:-3].strip()
return s
def try_parse_json_strict(s: str):
"""Strict JSON parsing."""
return json.loads(s)
def try_parse_json_fallback(s: str):
"""
RelaxAI may add trailing commas or missing quotes.
We attempt limited cleanup.
"""
# Remove trailing commas before closing braces/brackets
s = s.replace(",}", "}").replace(",]", "]")
# Replace weird quote styles if any (very rare)
s = s.replace("“", "\"").replace("”", "\"")
return json.loads(s)
These helper functions make the backend far more resilient to imperfect model output. cleanmodeloutput strips away markdown fences or stray formatting that relaxAI might prepend or append. tryparsejsonstrict attempts a normal JSON parse first, while tryparsejsonfallback performs small, targeted repairs, like removing trailing commas or fixing curly quotes, before trying again. Together, they significantly boost the chances of turning the model’s response into valid, machine-readable JSON without breaking the safety or structure of the graph.
Main endpoint:
@app.post("/graphify", response_model=GraphifyResponse)
async def graphify(payload: GraphifyRequest):
if not payload.text.strip():
raise HTTPException(status_code=400, detail="Text input cannot be empty.")
prompt = build_prompt(payload.text)
body = {
"model": "Llama-4-Maverick-17B-128E",
"messages": [
{"role": "system", "content": "Return ONLY JSON following the given structure."},
{"role": "user", "content": prompt}
],
"temperature": 0,
"max_tokens": 300
}
Call relaxAI:
try:
async with httpx.AsyncClient(timeout=40.0) as client:
response = await client.post(
RELAXAI_API_URL,
json=body,
headers={"Authorization": f"Bearer {RELAXAI_API_KEY}"}
)
except httpx.RequestError as e:
raise HTTPException(status_code=503, detail=f"relaxAI unreachable: {str(e)}")
if response.status_code != 200:
raise HTTPException(status_code=response.status_code, detail=response.text)
# Extract model content
try:
content = response.json()["choices"][0]["message"]["content"]
content = clean_model_output(content)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Bad relaxAI response structure: {str(e)}")
# JSON parsing attempts
data = None
# 1) TRY STRICT JSON
try:
data = try_parse_json_strict(content)
except Exception:
pass
# 2) TRY FALLBACK JSON
if data is None:
try:
data = try_parse_json_fallback(content)
except Exception:
raise HTTPException(
status_code=500,
detail=f"Model returned invalid JSON even after cleanup: {content}"
)
# Guaranteed safe output
nodes = [Node(**n) for n in data.get("nodes", []) if "id" in n and "type" in n]
edges = [Edge(**e) for e in data.get("edges", []) if "source" in e and "target" in e]
return GraphifyResponse(nodes=nodes, edges=edges)
Once this backend is in place and containerized, you have a reliable API that turns arbitrary text into a graph-ready JSON payload. In the next step, the frontend will use this endpoint to fetch nodes and edges from Civo and draw them as an interactive knowledge graph in the browser.
Step 3: Build the frontend (graph visualizer)
With the backend doing the heavy lifting, the frontend’s job is simple: take the model’s extracted entities and relationships and turn them into an interactive graph. This is where everything becomes visual. Instead of staring at raw JSON, users can actually see how concepts connect.
The frontend is kept intentionally lightweight. No frameworks, no bundlers, no build process, just a single index.html using Cytoscape.js, a tiny helper script, and a hard-coded API endpoint. That’s enough to render a smooth, responsive knowledge graph inside the browser.
Here’s the basic flow:
- The user types text into a textbox.
- The frontend sends that text to the backend’s
/graphifyendpoint. - The backend returns JSON containing
nodesandedges. - Cytoscape.js renders that into a full graph layout.
- Users can pan, zoom, hover, and explore the structure as a real network.
Even though the UI is minimal, Cytoscape gives the frontend a surprising amount of polish. You get physics-based layout, smooth animations, styled nodes, and edges that automatically reposition themselves as the graph grows. The result feels far bigger than the tiny amount of code powering it.
If you want to improve things later, custom node icons, color-coded relationships, graph filtering, dark mode, etc., all of that can be added on top of this foundation. But the core here is deliberately small for demonstration purposes.
Index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Knowledge Graph Generator</title>
<!-- Cytoscape.js for graph visualization -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e, #16213e);
min-height: 100vh;
color: #e0e0e0;
}
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header { text-align: center; padding: 30px 0; }
h1 {
font-size: 2.5rem;
background: linear-gradient(90deg, #00d4ff, #7b2cbf);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 10px;
}
.subtitle { color: #888; font-size: 1.1rem; }
.main-content {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 20px;
margin-top: 20px;
}
.input-panel,
.graph-panel {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 24px;
}
textarea {
width: 100%;
height: 300px;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
padding: 16px;
color: #e0e0e0;
font-size: 1rem;
resize: vertical;
}
button {
width: 100%;
padding: 14px 28px;
margin-top: 16px;
background: linear-gradient(90deg,#00d4ff,#7b2cbf);
border: none;
border-radius: 8px;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
#graph-container {
flex: 1;
min-height: 500px;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
}
.legend { display: flex; gap: 20px; margin-top: 16px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 8px; }
.legend-dot { width: 12px; height: 12px; border-radius: 50%; }
.person { background: #00d4ff; }
.organization { background: #7b2cbf; }
.location { background: #00c853; }
.event { background: #ff9100; }
.status-message { margin-top: 16px; padding: 12px; border-radius: 8px; font-size: 0.9rem; }
.error { background: rgba(255,82,82,0.2); color: #ff8a80; border: 1px solid rgba(255,82,82,0.4); }
.success { background: rgba(0,200,83,0.2); color: #69f0ae; border: 1px solid rgba(0,200,83,0.4); }
.loading { background: rgba(0,212,255,0.2); color: #80d8ff; border: 1px solid rgba(0,212,255,0.4); }
@media (max-width: 900px) {
.main-content { grid-template-columns: 1fr; }
#graph-container { min-height: 400px; }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Knowledge Graph Generator</h1>
<p class="subtitle">Transform text into interactive knowledge graphs using AI</p>
</header>
<div class="main-content">
<div class="input-panel">
<h2>Input Text</h2>
<textarea id="text-input" placeholder="Enter text to analyze..."></textarea>
<button id="generate-btn">Generate Knowledge Graph</button>
<div id="status-container"></div>
</div>
<div class="graph-panel">
<h2>Knowledge Graph</h2>
<div id="graph-container"></div>
<div class="legend">
<div class="legend-item"><div class="legend-dot person"></div>Person</div>
<div class="legend-item"><div class="legend-dot organization"></div>Organization</div>
<div class="legend-item"><div class="legend-dot location"></div>Location</div>
<div class="legend-item"><div class="legend-dot event"></div>Event</div>
</div>
<div id="stats" class="stats"></div>
</div>
</div>
</div>
<script>
// ============================================================================
// CONFIGURATION
// ============================================================================
const API_URL = window.API_URL || "http://<BACKEND-EXTERNAL-IP>:8000";
let cy;
// ============================================================================
// GRAPH INITIALIZATION
// ============================================================================
function initGraph() {
cy = cytoscape({
container: document.getElementById("graph-container"),
style: [
{
selector: "node",
style: {
"label": "data(id)",
"width": 50,
"height": 50,
"font-size": "12px",
"color": "#e0e0e0",
"border-width": 3,
"border-color": "#fff",
"text-valign": "bottom",
"text-margin-y": 8
}
},
{ selector: 'node[type="Person"]', style: { "background-color": "#00d4ff" } },
{ selector: 'node[type="Organization"]', style: { "background-color": "#7b2cbf" } },
{ selector: 'node[type="Location"]', style: { "background-color": "#00c853" } },
{ selector: 'node[type="Event"]', style: { "background-color": "#ff9100" } },
{
selector: "edge",
style: {
"line-color": "#666",
"target-arrow-color": "#666",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
"label": "data(relation)",
"font-size": "10px",
"color": "#999"
}
}
],
layout: { name: "grid" }
});
}
// ============================================================================
// STATUS & STATS HELPERS
// ============================================================================
function showStatus(msg, type) {
document.getElementById("status-container").innerHTML =
`<div class="status-message ${type}">${msg}</div>`;
}
function clearStatus() {
document.getElementById("status-container").innerHTML = "";
}
function updateStats(nodes, edges) {
document.getElementById("stats").innerHTML =
`<div><strong>${nodes}</strong> entities</div>
<div><strong>${edges}</strong> relationships</div>`;
}
// ============================================================================
// MAIN FUNCTION
// ============================================================================
async function generateGraph() {
const text = document.getElementById("text-input").value.trim();
const btn = document.getElementById("generate-btn");
if (!text) {
showStatus("Please enter some text first.", "error");
return;
}
btn.disabled = true;
btn.textContent = "Generating...";
showStatus("Analyzing text...", "loading");
try {
const response = await fetch(`${API_URL}/graphify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text })
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Server error");
}
const data = await response.json();
if (!data.nodes?.length) {
showStatus("No entities found. Try a longer text sample.", "error");
updateStats(0, 0);
return;
}
cy.elements().remove();
data.nodes.forEach(n =>
cy.add({ group: "nodes", data: { id: n.id, type: n.type } })
);
data.edges.forEach(e =>
cy.add({
group: "edges",
data: {
source: e.source,
target: e.target,
relation: e.relation.replace(/_/g, " ")
}
})
);
cy.layout({
name: "cose",
animate: true,
idealEdgeLength: 100,
nodeRepulsion: 8000
}).run();
cy.fit(50);
showStatus(`Extracted ${data.nodes.length} entities and ${data.edges.length} relationships.`, "success");
updateStats(data.nodes.length, data.edges.length);
} catch (err) {
showStatus(`Error: ${err.message}`, "error");
} finally {
btn.disabled = false;
btn.textContent = "Generate Knowledge Graph";
}
}
// ============================================================================
// INITIALIZATION
// ============================================================================
document.addEventListener("DOMContentLoaded", () => {
initGraph();
updateStats(0, 0);
});
document.getElementById("text-input").addEventListener("keydown", e => {
if (e.ctrlKey && e.key === "Enter") generateGraph();
});
document.getElementById("generate-btn").onclick = generateGraph;
</script>
</body>
</html>
Here’s how the frontend should look:

const API_URL = window.API_URL || "http://<BACKEND-EXTERNAL-IP>:8000"; is baked into the Docker image at build time. That means you should deploy the backend first, wait for its LoadBalancer IP to become available, and then build the frontend image using that IP.
At this point, the frontend and backend are fully connected. You type text, the backend extracts meaning, and the browser builds a real-time knowledge graph. Next, we’ll package everything into Docker and deploy it on Civo so your graph visualizer runs as a fully hosted application.
Step 4: Dockerization
Create backend/Dockerfile:
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy code
COPY main.py .
# Run as non-root
RUN useradd -u 1001 appuser
USER 1001
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Create backend/requirements.txt:
fastapi==0.109.0
uvicorn[standard]==0.27.0
httpx==0.26.0
pydantic==2.5.3
python-dotenv==1.0.1
Create frontend/Dockerfile:
FROM nginx:alpine
Copy static frontend to nginx public directory
COPY index.html /usr/share/nginx/html/
Expose port 80 for Civo LoadBalancer
EXPOSE 80
Simple healthcheck
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
Build and push images:
# Build backend image
docker build -t YOUR_REGISTRY/knowledge-graph-backend:latest .
# Push backend image
docker push YOUR_REGISTRY/knowledge-graph-backend:latest
# Build frontend image
docker build -t YOUR_REGISTRY/knowledge-graph-frontend:latest .
# Push frontend image
docker push YOUR_REGISTRY/knowledge-graph-frontend:latest
Step 5: Deploy on Civo Kubernetes
Create kubernetes/backend-deployment.yaml:
# ---------------------------------------------------------
# Namespace
# ---------------------------------------------------------
apiVersion: v1
kind: Namespace
metadata:
name: knowledge-graph
---
# ---------------------------------------------------------
# RelaxAI Secret (user replaces the placeholder)
# ---------------------------------------------------------
apiVersion: v1
kind: Secret
metadata:
name: relaxai-secret
namespace: knowledge-graph
type: Opaque
stringData:
api-key: "YOUR_API_KEY_HERE"
---
# ---------------------------------------------------------
# Backend Deployment
# ---------------------------------------------------------
apiVersion: apps/v1
kind: Deployment
metadata:
name: knowledge-backend
namespace: knowledge-graph
spec:
replicas: 2
selector:
matchLabels:
app: knowledge-backend
template:
metadata:
labels:
app: knowledge-backend
spec:
containers:
- name: backend
image: alimohamed782/knowledge-graph-backend:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
env:
- name: RELAXAI_API_KEY
valueFrom:
secretKeyRef:
name: relaxai-secret
key: api-key
- name: RELAXAI_API_URL
value: "https://api.relax.ai/v1/chat/completions"
# Keep resource limits modest for Civo clusters
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
# Health checks → very important for FastAPI
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 3
periodSeconds: 10
# Reasonable security context for a simple API pod
securityContext:
runAsNonRoot: true
runAsUser: 1001
allowPrivilegeEscalation: false
securityContext:
fsGroup: 1000
---
# ---------------------------------------------------------
# Backend Service (LoadBalancer - public access)
# ---------------------------------------------------------
apiVersion: v1
kind: Service
metadata:
name: knowledge-backend
namespace: knowledge-graph
spec:
type: LoadBalancer
selector:
app: knowledge-backend
ports:
- port: 8000
targetPort: 8000
protocol: TCP
name: http
Apply it:
kubectl apply -f kubernetes/backend-deployment.yaml
Create kubernetes/frontend-deployment.yaml:
# ---------------------------------------------------------
# Frontend Deployment (nginx serving static files)
# ---------------------------------------------------------
apiVersion: apps/v1
kind: Deployment
metadata:
name: knowledge-frontend
namespace: knowledge-graph
spec:
replicas: 2
selector:
matchLabels:
app: knowledge-frontend
template:
metadata:
labels:
app: knowledge-frontend
spec:
containers:
- name: frontend
image: alimohamed782/knowledge-graph-frontend:latest
imagePullPolicy: Always
ports:
- containerPort: 80
# Basic resource limits (static HTML + JS requires almost nothing)
resources:
requests:
cpu: "20m"
memory: "32Mi"
limits:
cpu: "100m"
memory: "64Mi"
# Health checks for nginx
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 20
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 3
periodSeconds: 10
securityContext:
allowPrivilegeEscalation: false
# Pod-level security context
securityContext:
fsGroup: 101
---
# ---------------------------------------------------------
# Frontend Service (public LoadBalancer)
# ---------------------------------------------------------
apiVersion: v1
kind: Service
metadata:
name: knowledge-frontend
namespace: knowledge-graph
spec:
type: LoadBalancer
selector:
app: knowledge-frontend
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
Apply it:
kubectl apply -f kubernetes/frontend-deployment.yaml
Verify deployments:
kubectl get pods -n knowledge-graph
kubectl get svc -n knowledge-graph
Get external IPs:
# Backend LoadBalancer IP
kubectl get svc knowledge-backend -n knowledge-graph
# Frontend LoadBalancer IP
kubectl get svc knowledge-frontend -n knowledge-graph
Step 6: Test and visualize
Now that both the backend and frontend are deployed, you can verify everything is working by sending real text through the system and checking whether the generated knowledge graph appears in the browser. The snippet below is long enough to produce an interesting graph, with multiple entities and cross-organization relationships, making it ideal for a first test.
Use this text in the frontend input box:
If everything is configured correctly, your backend should extract entities, infer relationships, normalize them into the required JSON format, and the frontend should render them into an interactive graph as shown below:

The graph above is the final output of the full pipeline. Each circle represents an entity the model detected in the text (people, organizations, locations, or products), and each arrow represents a relationship inferred between them.
The system read a multi-sentence paragraph and automatically mapped out partnerships, meetings, collaborations, and technology dependencies. Instead of a wall of text, you now have a structured visual summary: OpenAI’s partnership with Microsoft and Azure, Sam Altman’s meeting with the European Commission in Brussels, Google DeepMind’s collaboration with the University of Oxford, NVIDIA’s role in large-scale training, and Anthropic’s joint initiative with AWS.
This is exactly the type of transformation the project is designed to deliver, turning unstructured language into a clear, navigable knowledge map.
This tutorial is intentionally small and focused, but it is designed as a foundation rather than a toy example. While the inputs used here are modest, the same pipeline can quickly grow in complexity as you process longer documents, batch multiple texts, or integrate the system into real applications. At that point, GPU acceleration becomes essential for keeping inference fast and responsive. Because the cluster is already GPU-enabled, no architectural changes are required to scale from simple experiments to more demanding, production-style workloads.
Key takeaways
Building a knowledge-graph extractor doesn’t require a massive data stack. A lightweight FastAPI service plus a small JS frontend is enough to turn unstructured text into a visual, explorable graph.
Civo’s GPU infrastructure makes this especially smooth. Provisioning is fast, costs stay predictable, and you avoid the usual cloud clutter that slows small AI projects down.
Once you have this pipeline running end-to-end, you’re not just drawing pretty graphs. You’ve built the core of a data-centric AI system: a repeatable way to turn messy language into structured relationships that other tools, agents, or downstream workflows can build on.
Use this as a foundation and expand as needed, more models, better extraction logic, bigger datasets. The architecture scales with you.