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.

This tutorial will show you how to build a lightweight, end-to-end pipeline with no heavy infrastructure, no specialized machine learning background, just a small FastAPI backend, a simple JavaScript frontend, and a GPU node doing all the AI lifting.

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

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

  1. Log in to the Civo Dashboard
  2. Create a new Kubernetes cluster
  3. Add a GPU node pool (one node is sufficient for this demo)
  4. Download your kubeconfig
  5. Connect to the cluster by running set KUBECONFIG=path-to-kubeconfig-file in your terminal (use export instead of set on Linux or macOS).

Verify connectivity:

kubectl get nodes

Create a GPU-enabled Kubernetes cluster

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:

Build the backend (entity/relationship extractor)

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 /health endpoint 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:

  1. The user types text into a textbox.
  2. The frontend sends that text to the backend’s /graphify endpoint.
  3. The backend returns JSON containing nodes and edges.
  4. Cytoscape.js renders that into a full graph layout.
  5. 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:

Build the frontend (graph visualizer)

Important note: This value 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:

In 2024, OpenAI partnered with Microsoft to deploy an advanced AI safety system across Azure data centers. Sam Altman met with leaders at the European Commission in Brussels to discuss regulatory guidelines for frontier models. Meanwhile, Google DeepMind researchers collaborated with the University of Oxford to publish a study on reinforcement learning methods for autonomous robotics. The report highlighted that NVIDIA GPUs remained essential for large-scale training workloads. Later that year, Anthropic announced a joint initiative with AWS to improve secure model deployment across enterprise environments.

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:

Test and visualize

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.

Additional resources