Skip to main content
Back to Blog
Graph RAGLangChainPostgreSQLpgvectorOllamaNext.jsExpress.jsDockerNginxAWSCI/CD

Architecting an Enterprise Graph RAG Pipeline: Semantic Knowledge Discovery with LangChain, PostgreSQL, and Next.js

Build a sovereign, entity-aware Graph RAG pipeline with LangChain, Ollama, PostgreSQL and pgvector, an Express.js API and a Next.js + Shadcn UI frontend, then ship it with Docker, Nginx and GitHub Actions on AWS.

October 12, 202626 min readNiraj Kumar

Introduction

If you have shipped a retrieval-augmented generation (RAG) system in the last couple of years, you probably know the feeling. The demo is magical. You ask a question, the system finds a handful of similar paragraphs, and the model writes a confident answer. Then a real user asks something like, "Which of our services will break if we retire the payments v1 API, and who owns them?" and the magic disappears.

The answer to that question is not sitting in one paragraph. It is scattered across a migration doc, a service catalog, an on-call rota and three incident reports. Vector search can find paragraphs that sound like your question, but it has no idea how those paragraphs relate to each other.

That gap is exactly what Graph RAG closes. Instead of treating your knowledge base as a bag of text chunks, you also model it as a graph of entities (services, teams, people, systems) and relationships (OWNS, DEPENDS_ON, REPLACED_BY). At query time, you combine semantic similarity with graph traversal, and the model gets context that is both relevant and connected.

In this guide, I will walk you through building the whole thing end to end, the way I would build it for a real client:

  • A sovereign LLM layer using LangChain and Ollama, so no data leaves your infrastructure
  • A PostgreSQL knowledge graph with pgvector for embeddings and recursive CTEs for traversal
  • A Node.js and Express.js REST API with validation, rate limiting and an ingestion endpoint
  • A Next.js, TypeScript, Tailwind CSS and Shadcn UI frontend that shows answers, sources and the entities behind them
  • Docker, Nginx and GitHub Actions to ship it all to an AWS Linux server

You do not need prior graph database experience. If you are comfortable with TypeScript and SQL, you are ready.

Why Basic Vector Search Is Not Enough

Vector search is brilliant at one thing: finding text that is semantically close to a query. It struggles when the answer depends on structure.

Here are the failure modes I see most often in production:

  • Multi-hop questions. "Who manages the team that owns the billing database?" requires following two or three links. No single chunk contains the full chain.
  • Entity ambiguity. "Atlas" might be a project, a database cluster and a person's nickname. Embeddings blur them together.
  • Fragmented context. Relevant facts are spread across many documents, and top-k similarity only returns the loudest few.
  • Missing explainability. When a stakeholder asks "why did the model say that?", a list of similar chunks is a weak answer.

Graph RAG addresses each of these by keeping explicit, queryable relationships alongside your embeddings.

CapabilityVector RAGGraph RAG
Finds semantically similar textExcellentExcellent
Answers multi-hop questionsPoorStrong
Disambiguates entitiesWeakStrong
Explains retrieval pathLimitedClear (entities and edges)
Setup complexityLowMedium

The extra complexity is real, but it is manageable, and PostgreSQL keeps it much smaller than you might expect.

Core Concepts You Need Before Writing Code

Let me define the moving parts so the rest of the tutorial reads smoothly.

Entities and relationships

An entity is a named thing: a service, a team, a person, a vendor, a document. A relationship is a typed, directed edge between two entities, such as payments-api DEPENDS_ON ledger-db. During ingestion, an LLM reads each chunk and extracts these as structured data.

Hybrid retrieval

Our retrieval step has two phases:

  1. Seed retrieval. Embed the question and find the top-k most similar chunks with pgvector.
  2. Graph expansion. Look up the entities mentioned in those seed chunks, walk a couple of hops across the relationship table, and pull in the neighbouring entities, edges and supporting chunks.

The final prompt contains passages, entities and relationships, so the model reasons over connected facts rather than isolated text.

Why PostgreSQL

I am a big believer in boring technology for enterprise systems. PostgreSQL gives you ACID transactions, mature backup tooling, row-level security and a huge hiring pool. With the pgvector extension it handles embeddings, and with recursive common table expressions (CTEs) it handles graph traversal. One database, one backup strategy, one thing to monitor.

Why a local LLM

For many organisations, especially in finance, healthcare and government, sending internal documents to a third-party API is a non-starter. Ollama lets you run open-weight models on your own hardware and exposes a simple HTTP API that LangChain already supports. That is what I mean by sovereign: your data, your models, your network boundary.

Architecture Overview

Here is the full picture. Requests enter through Nginx, which routes the UI to Next.js and /api traffic to Express. Express orchestrates LangChain, which talks to Ollama for generation and embeddings and to PostgreSQL for storage and retrieval.

High-level architecture of the Graph RAG pipeline showing Nginx, Next.js, Express, LangChain, Ollama and PostgreSQL

And here is the project layout we will build:

graph-rag/
├── api/
│   ├── src/
│   │   ├── lib/        # db.ts, llm.ts
│   │   ├── services/   # ingest.ts, retrieve.ts, answer.ts
│   │   └── server.ts
│   ├── Dockerfile
│   └── package.json
├── web/
│   ├── app/            # Next.js App Router
│   ├── components/ui/  # Shadcn components
│   ├── Dockerfile
│   └── package.json
├── db/init.sql
├── nginx/default.conf
├── docker-compose.yml
└── .github/workflows/deploy.yml

Step 1: Spin Up PostgreSQL and Ollama

Start with the infrastructure. The pgvector/pgvector image ships PostgreSQL with the extension preinstalled, which saves you from compiling anything.

# docker-compose.dev.yml
services:
  db:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: graphrag
      POSTGRES_PASSWORD: change-me
      POSTGRES_DB: graphrag
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql

  ollama:
    image: ollama/ollama:latest
    ports:
      - "11434:11434"
    volumes:
      - ollama:/root/.ollama

volumes:
  pgdata:
  ollama:

Then pull the two models we need: a chat model for extraction and answers, and an embedding model.

docker compose -f docker-compose.dev.yml up -d
docker compose -f docker-compose.dev.yml exec ollama ollama pull llama3.1:8b
docker compose -f docker-compose.dev.yml exec ollama ollama pull nomic-embed-text

nomic-embed-text produces 768-dimensional vectors, which matters in the next step.

Step 2: Design the Knowledge Graph Schema

This is the heart of the system, so take your time here. We store documents and chunks, then entities, then the edges between them.

-- db/init.sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title      TEXT NOT NULL,
  source     TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE chunks (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  position    INT  NOT NULL,
  content     TEXT NOT NULL,
  embedding   vector(768) NOT NULL
);

CREATE INDEX chunks_embedding_idx
  ON chunks USING hnsw (embedding vector_cosine_ops);

CREATE TABLE entities (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name           TEXT NOT NULL,
  canonical_name TEXT NOT NULL,
  type           TEXT NOT NULL,
  description    TEXT,
  UNIQUE (canonical_name, type)
);

CREATE TABLE relationships (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source_id  UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
  target_id  UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
  predicate  TEXT NOT NULL,
  weight     INT  NOT NULL DEFAULT 1,
  UNIQUE (source_id, target_id, predicate)
);

CREATE INDEX relationships_source_idx ON relationships (source_id);
CREATE INDEX relationships_target_idx ON relationships (target_id);

CREATE TABLE chunk_entities (
  chunk_id  UUID NOT NULL REFERENCES chunks(id) ON DELETE CASCADE,
  entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
  PRIMARY KEY (chunk_id, entity_id)
);

CREATE INDEX chunk_entities_entity_idx ON chunk_entities (entity_id);

A few design decisions worth explaining:

  • canonical_name is the lower-cased, trimmed version of the name. It is how we merge "Payments API" and "payments api" into one node.
  • weight counts how many times we saw the same edge. Frequently repeated relationships are usually more trustworthy, and we use that for ranking later.
  • The HNSW index gives fast approximate nearest-neighbour search. It is the sensible default for most workloads in 2026, and it does not need a training step like IVFFlat does.
  • Two indexes on relationships keep traversal fast in both directions.

Step 3: Connect LangChain to Ollama

Now the API. Initialise a TypeScript project inside api/ and install the dependencies:

npm init -y
npm i express pg zod helmet cors express-rate-limit \
  @langchain/core @langchain/ollama @langchain/textsplitters
npm i -D typescript tsx @types/node @types/express @types/pg @types/cors

Keep the model wiring in one small file so you can swap models without touching business logic.

// api/src/lib/llm.ts
import { ChatOllama, OllamaEmbeddings } from "@langchain/ollama";

const baseUrl = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434";
const chatModelName = process.env.CHAT_MODEL ?? "llama3.1:8b";

export const chatModel = new ChatOllama({
  baseUrl,
  model: chatModelName,
  temperature: 0.1,
});

// Extraction should be as deterministic as possible.
export const extractorModel = new ChatOllama({
  baseUrl,
  model: chatModelName,
  temperature: 0,
});

export const embeddings = new OllamaEmbeddings({
  baseUrl,
  model: process.env.EMBED_MODEL ?? "nomic-embed-text",
});
// api/src/lib/db.ts
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
});

Step 4: Build the Ingestion Pipeline

Ingestion has four jobs: split the text, embed each chunk, extract entities and relationships with the LLM, and write everything to PostgreSQL.

The trick with local models is structured output. Free-form text extraction is fragile, so we describe the shape we want with Zod and let LangChain enforce it.

// api/src/services/ingest.ts
import { z } from "zod";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { pool } from "../lib/db.js";
import { embeddings, extractorModel } from "../lib/llm.js";

const GraphSchema = z.object({
  entities: z.array(
    z.object({
      name: z.string().min(1),
      type: z.enum(["SERVICE", "TEAM", "PERSON", "SYSTEM", "DOCUMENT", "OTHER"]),
      description: z.string().default(""),
    })
  ),
  relationships: z.array(
    z.object({
      source: z.string().min(1),
      target: z.string().min(1),
      predicate: z.string().min(1),
    })
  ),
});

type GraphData = z.infer<typeof GraphSchema>;

const extractionPrompt = ChatPromptTemplate.fromMessages([
  [
    "system",
    "You extract knowledge graphs from enterprise documentation. " +
      "Only include entities that are explicitly named in the text. " +
      "Use short canonical names. Write predicates in UPPER_SNAKE_CASE, " +
      "for example OWNS, DEPENDS_ON, REPLACED_BY or MANAGES. " +
      "If the text contains no clear entities, return empty arrays.",
  ],
  ["human", "{text}"],
]);

const extractor = extractionPrompt.pipe(
  extractorModel.withStructuredOutput(GraphSchema, { name: "graph_extraction" })
);

async function extractGraph(text: string): Promise<GraphData> {
  try {
    return await extractor.invoke({ text });
  } catch (err) {
    // A single bad extraction should never kill the whole ingestion run.
    console.warn("Extraction failed for a chunk, skipping graph data", err);
    return { entities: [], relationships: [] };
  }
}

const canonical = (name: string) => name.trim().toLowerCase().replace(/\s+/g, " ");
const toVector = (v: number[]) => `[${v.join(",")}]`;

export async function ingestDocument(title: string, source: string, text: string) {
  const splitter = new RecursiveCharacterTextSplitter({
    chunkSize: 1000,
    chunkOverlap: 150,
  });
  const pieces = await splitter.splitText(text);
  const vectors = await embeddings.embedDocuments(pieces);

  // Do the slow LLM work BEFORE opening a transaction.
  const graphs: GraphData[] = [];
  for (const piece of pieces) graphs.push(await extractGraph(piece));

  const client = await pool.connect();
  try {
    await client.query("BEGIN");

    const doc = await client.query<{ id: string }>(
      "INSERT INTO documents (title, source) VALUES ($1, $2) RETURNING id",
      [title, source]
    );
    const documentId = doc.rows[0].id;

    for (let i = 0; i < pieces.length; i++) {
      const chunk = await client.query<{ id: string }>(
        `INSERT INTO chunks (document_id, position, content, embedding)
         VALUES ($1, $2, $3, $4::vector) RETURNING id`,
        [documentId, i, pieces[i], toVector(vectors[i])]
      );
      const chunkId = chunk.rows[0].id;

      const idByName = new Map<string, string>();

      for (const e of graphs[i].entities) {
        const row = await client.query<{ id: string }>(
          `INSERT INTO entities (name, canonical_name, type, description)
           VALUES ($1, $2, $3, $4)
           ON CONFLICT (canonical_name, type)
           DO UPDATE SET description = COALESCE(NULLIF(entities.description, ''), EXCLUDED.description)
           RETURNING id`,
          [e.name, canonical(e.name), e.type, e.description]
        );
        idByName.set(canonical(e.name), row.rows[0].id);
        await client.query(
          `INSERT INTO chunk_entities (chunk_id, entity_id)
           VALUES ($1, $2) ON CONFLICT DO NOTHING`,
          [chunkId, row.rows[0].id]
        );
      }

      for (const r of graphs[i].relationships) {
        const s = idByName.get(canonical(r.source));
        const t = idByName.get(canonical(r.target));
        if (!s || !t || s === t) continue; // drop dangling edges and self-loops
        await client.query(
          `INSERT INTO relationships (source_id, target_id, predicate)
           VALUES ($1, $2, $3)
           ON CONFLICT (source_id, target_id, predicate)
           DO UPDATE SET weight = relationships.weight + 1`,
          [s, t, r.predicate.trim().toUpperCase()]
        );
      }
    }

    await client.query("COMMIT");
    return { documentId, chunks: pieces.length };
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

Notice two small but important choices. We run extraction before opening the transaction, because a local model can take seconds per chunk and you do not want a database connection sitting idle that long. And we silently drop edges whose endpoints were not extracted as entities in the same chunk, which filters out a lot of hallucinated links.

Step 5: Hybrid Retrieval With Recursive CTEs

This is where Graph RAG earns its name. First, find the seed chunks with vector similarity. Then expand outwards through the graph.

// api/src/services/retrieve.ts
import { pool } from "../lib/db.js";
import { embeddings } from "../lib/llm.js";

const WALK_SQL = `
WITH RECURSIVE seed AS (
  SELECT DISTINCT entity_id FROM chunk_entities WHERE chunk_id = ANY($1::uuid[])
),
walk(id, depth, path) AS (
  SELECT entity_id, 0, ARRAY[entity_id] FROM seed
  UNION ALL
  SELECT n.next_id, w.depth + 1, w.path || n.next_id
  FROM walk w
  JOIN relationships r ON w.id IN (r.source_id, r.target_id)
  CROSS JOIN LATERAL (
    SELECT CASE WHEN r.source_id = w.id THEN r.target_id ELSE r.source_id END AS next_id
  ) n
  WHERE w.depth < $2 AND n.next_id <> ALL(w.path)
)
SELECT e.id, e.name, e.type, e.description, MIN(w.depth) AS depth
FROM walk w
JOIN entities e ON e.id = w.id
GROUP BY e.id
ORDER BY MIN(w.depth), e.name
LIMIT 40;
`;

export interface RetrievalResult {
  passages: { id: string; title: string; content: string; score: number }[];
  entities: { id: string; name: string; type: string; depth: number }[];
  relationships: { source: string; predicate: string; target: string }[];
}

export async function retrieve(question: string, k = 5, depth = 2): Promise<RetrievalResult> {
  const queryVector = `[${(await embeddings.embedQuery(question)).join(",")}]`;

  // 1. Seed retrieval with pgvector (cosine distance).
  const seeds = await pool.query(
    `SELECT c.id, d.title, c.content, 1 - (c.embedding <=> $1::vector) AS score
     FROM chunks c JOIN documents d ON d.id = c.document_id
     ORDER BY c.embedding <=> $1::vector
     LIMIT $2`,
    [queryVector, k]
  );
  const seedIds: string[] = seeds.rows.map((r) => r.id);
  if (seedIds.length === 0) return { passages: [], entities: [], relationships: [] };

  // 2. Graph expansion with a recursive CTE.
  const walked = await pool.query(WALK_SQL, [seedIds, depth]);
  const entityIds: string[] = walked.rows.map((r) => r.id);
  const neighbourIds: string[] = walked.rows.filter((r) => r.depth >= 1).map((r) => r.id);

  // 3. Edges between the entities we found.
  const edges = await pool.query(
    `SELECT s.name AS source, r.predicate, t.name AS target
     FROM relationships r
     JOIN entities s ON s.id = r.source_id
     JOIN entities t ON t.id = r.target_id
     WHERE r.source_id = ANY($1::uuid[]) AND r.target_id = ANY($1::uuid[])
     ORDER BY r.weight DESC
     LIMIT 50`,
    [entityIds]
  );

  // 4. Extra supporting passages that mention neighbouring entities.
  const extra = neighbourIds.length
    ? await pool.query(
        `SELECT DISTINCT c.id, d.title, c.content, 0.0 AS score
         FROM chunk_entities ce
         JOIN chunks c ON c.id = ce.chunk_id
         JOIN documents d ON d.id = c.document_id
         WHERE ce.entity_id = ANY($1::uuid[]) AND c.id <> ALL($2::uuid[])
         LIMIT 4`,
        [neighbourIds, seedIds]
      )
    : { rows: [] };

  return {
    passages: [...seeds.rows, ...extra.rows],
    entities: walked.rows,
    relationships: edges.rows,
  };
}

Let me unpack the recursive query, because it is the part people find intimidating. The seed CTE collects every entity mentioned in our top-k chunks. The walk CTE starts from those entities at depth 0, then repeatedly follows edges in either direction, increasing the depth each time. The path array tracks where we have already been so we never loop, and w.depth < $2 caps the number of hops. The final SELECT deduplicates entities and keeps the shallowest depth for each.

For a question about retiring the payments v1 API, the seed chunks mention payments-api. One hop out we find checkout-service and billing-worker. Two hops out we find the Growth Platform team that owns them. That chain never appeared in a single paragraph, but the graph assembled it for us.

Step 6: Generate Grounded Answers

With retrieval in place, answer generation is mostly about a disciplined prompt. We label each kind of context and tell the model to stay inside it.

// api/src/services/answer.ts
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { chatModel } from "../lib/llm.js";
import { retrieve } from "./retrieve.js";

const prompt = ChatPromptTemplate.fromMessages([
  [
    "system",
    "You are an enterprise knowledge assistant. Answer ONLY using the context provided. " +
      "Use the relationships to connect facts across passages. " +
      "If the context does not contain the answer, say you do not know. " +
      "Cite passages by their number, like [1] or [2].",
  ],
  [
    "human",
    "Passages:\n{passages}\n\nEntities:\n{entities}\n\nRelationships:\n{relationships}\n\nQuestion: {question}",
  ],
]);

const chain = prompt.pipe(chatModel).pipe(new StringOutputParser());

export async function answerQuestion(question: string) {
  const ctx = await retrieve(question);

  const answer = await chain.invoke({
    question,
    passages: ctx.passages.map((p, i) => `[${i + 1}] (${p.title}) ${p.content}`).join("\n\n"),
    entities: ctx.entities.map((e) => `${e.name} (${e.type})`).join(", ") || "none",
    relationships:
      ctx.relationships.map((r) => `${r.source} -${r.predicate}-> ${r.target}`).join("\n") || "none",
  });

  return { answer, ...ctx };
}

Step 7: Expose Everything Through an Express REST API

The API layer should be thin: validate input, call the service, return JSON. I also add Helmet, rate limiting and a simple API key on the ingestion route, because you do not want strangers writing to your knowledge base.

// api/src/server.ts
import express from "express";
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { z } from "zod";
import { answerQuestion } from "./services/answer.js";
import { ingestDocument } from "./services/ingest.js";
import { pool } from "./lib/db.js";

const app = express();
app.set("trust proxy", 1); // we sit behind Nginx
app.use(helmet());
app.use(cors({ origin: process.env.CORS_ORIGIN?.split(",") ?? false }));
app.use(express.json({ limit: "2mb" }));
app.use("/api/", rateLimit({ windowMs: 60_000, limit: 30, standardHeaders: true }));

app.get("/api/health", async (_req, res) => {
  await pool.query("SELECT 1");
  res.json({ status: "ok" });
});

const QuerySchema = z.object({ question: z.string().min(3).max(500) });

app.post("/api/query", async (req, res, next) => {
  try {
    const { question } = QuerySchema.parse(req.body);
    res.json(await answerQuestion(question));
  } catch (err) {
    next(err);
  }
});

const IngestSchema = z.object({
  title: z.string().min(1),
  source: z.string().default("manual"),
  text: z.string().min(20),
});

app.post("/api/ingest", async (req, res, next) => {
  try {
    if (req.header("x-api-key") !== process.env.INGEST_API_KEY) {
      return res.status(401).json({ error: "Unauthorized" });
    }
    const body = IngestSchema.parse(req.body);
    res.status(201).json(await ingestDocument(body.title, body.source, body.text));
  } catch (err) {
    next(err);
  }
});

app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  if (err instanceof z.ZodError) {
    return res.status(400).json({ error: "Invalid request", details: err.issues });
  }
  console.error(err);
  res.status(500).json({ error: "Something went wrong" });
});

app.listen(Number(process.env.PORT ?? 4000), () => console.log("API ready"));

You can test it right away:

curl -X POST http://localhost:4000/api/query \
  -H "Content-Type: application/json" \
  -d '{"question":"Which services depend on the payments v1 API?"}'

Step 8: Build the Next.js Frontend With Shadcn UI

On the front end, the goal is simple: a clean search box, a readable answer, and an honest view of why the system answered that way. Showing entities and relationships builds trust with business users far more than a wall of text.

Create the app and add the components we need:

npx create-next-app@latest web --typescript --tailwind --app
cd web
npx shadcn@latest init
npx shadcn@latest add button input card badge skeleton

Enable standalone output (for slim Docker images) and a dev-time proxy to the API:

// web/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
  async rewrites() {
    return process.env.NODE_ENV === "development"
      ? [{ source: "/api/:path*", destination: "http://localhost:4000/api/:path*" }]
      : [];
  },
};

export default nextConfig;

Now the main page:

// web/app/page.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

type QueryResponse = {
  answer: string;
  passages: { id: string; title: string; content: string }[];
  entities: { id: string; name: string; type: string; depth: number }[];
  relationships: { source: string; predicate: string; target: string }[];
};

export default function Home() {
  const [question, setQuestion] = useState("");
  const [data, setData] = useState<QueryResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function ask(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    setError(null);
    try {
      const res = await fetch("/api/query", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ question }),
      });
      if (!res.ok) throw new Error("The request failed. Please try again.");
      setData(await res.json());
    } catch (err) {
      setError(err instanceof Error ? err.message : "Unexpected error");
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="mx-auto max-w-3xl space-y-6 px-4 py-12">
      <h1 className="text-3xl font-bold tracking-tight">Enterprise Knowledge Discovery</h1>

      <form onSubmit={ask} className="flex gap-2">
        <Input
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          placeholder="Which services break if we retire the payments v1 API?"
        />
        <Button type="submit" disabled={loading || question.length < 3}>
          {loading ? "Thinking..." : "Ask"}
        </Button>
      </form>

      {error && <p className="text-sm text-red-600">{error}</p>}
      {loading && <Skeleton className="h-40 w-full" />}

      {data && !loading && (
        <div className="space-y-4">
          <Card>
            <CardHeader><CardTitle>Answer</CardTitle></CardHeader>
            <CardContent className="whitespace-pre-wrap">{data.answer}</CardContent>
          </Card>

          <Card>
            <CardHeader><CardTitle>Entities in context</CardTitle></CardHeader>
            <CardContent className="flex flex-wrap gap-2">
              {data.entities.map((e) => (
                <Badge key={e.id} variant={e.depth === 0 ? "default" : "secondary"}>
                  {e.name} · {e.type}
                </Badge>
              ))}
            </CardContent>
          </Card>

          <Card>
            <CardHeader><CardTitle>Relationships</CardTitle></CardHeader>
            <CardContent className="space-y-1 font-mono text-sm">
              {data.relationships.map((r, i) => (
                <div key={i}>{r.source} → {r.predicate} → {r.target}</div>
              ))}
            </CardContent>
          </Card>
        </div>
      )}
    </main>
  );
}

The darker badges are the entities found directly in the seed chunks. The lighter ones were discovered through graph expansion. That small visual cue makes the benefit of Graph RAG instantly obvious to non-technical stakeholders.

Next.js and Shadcn UI interface showing a generated answer, entity badges and relationship list

Step 9: Containerise the Full Stack With Docker

Multi-stage builds keep images small and keep build tools out of production.

# api/Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 4000
CMD ["node", "dist/server.js"]
# web/Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine AS runtime
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
WORKDIR /app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Add a build script (tsc -p .) to the API's package.json and a tsconfig.json that outputs to dist. Then tie everything together for production:

# docker-compose.yml
services:
  db:
    image: pgvector/pgvector:pg16
    env_file: .env
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
      interval: 10s
      retries: 5
    restart: unless-stopped

  ollama:
    image: ollama/ollama:latest
    volumes:
      - ollama:/root/.ollama
    restart: unless-stopped

  api:
    image: ghcr.io/your-org/graphrag-api:latest
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  web:
    image: ghcr.io/your-org/graphrag-web:latest
    depends_on:
      - api
    restart: unless-stopped

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - web
      - api
    restart: unless-stopped

volumes:
  pgdata:
  ollama:

Notice that only Nginx publishes ports. PostgreSQL, Ollama and the API are reachable only on the internal Docker network, which is exactly what you want from a security standpoint.

Step 10: Configure the Nginx Reverse Proxy

Nginx gives you a single public entry point, TLS termination and sane timeouts for slow LLM responses.

# nginx/default.conf
upstream web_app { server web:3000; }
upstream api_app { server api:4000; }

server {
  listen 80;
  server_name graphrag.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl;
  http2 on;
  server_name graphrag.example.com;

  ssl_certificate     /etc/nginx/certs/fullchain.pem;
  ssl_certificate_key /etc/nginx/certs/privkey.pem;

  add_header X-Content-Type-Options nosniff always;
  add_header X-Frame-Options DENY always;
  add_header Referrer-Policy strict-origin-when-cross-origin always;

  gzip on;
  gzip_types application/json text/css application/javascript;

  location /api/ {
    proxy_pass http://api_app;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 180s; # local LLMs can be slow
  }

  location / {
    proxy_pass http://web_app;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
}

For certificates, Let's Encrypt with Certbot on the host is the simplest route. Mount the resulting files into the Nginx container and set up a renewal cron job.

Step 11: Automate CI/CD to AWS With GitHub Actions

The pipeline has two jobs. First, verify the code. Second, build images, push them to GitHub Container Registry and tell the EC2 host to pull and restart.

# .github/workflows/deploy.yml
name: ci-cd

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        project: [api, web]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: ${{ matrix.project }}/package-lock.json
      - run: npm ci
        working-directory: ${{ matrix.project }}
      - run: npm run lint --if-present && npm run build
        working-directory: ${{ matrix.project }}

  deploy:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: ./api
          push: true
          tags: ghcr.io/${{ github.repository_owner }}/graphrag-api:latest

      - uses: docker/build-push-action@v6
        with:
          context: ./web
          push: true
          tags: ghcr.io/${{ github.repository_owner }}/graphrag-web:latest

      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ubuntu
          key: ${{ secrets.EC2_SSH_KEY }}
          script: |
            cd /opt/graph-rag
            docker compose pull api web
            docker compose up -d
            docker image prune -f

CI/CD pipeline diagram from GitHub push through tests, image builds, GHCR and deployment to an AWS EC2 host

On the AWS side, launch an Ubuntu LTS instance, install Docker and the Compose plugin, open ports 80 and 443 in the security group (and 22 only from your own IP), and clone or copy your compose file and .env into /opt/graph-rag. Be realistic about sizing: an 8B model on CPU works but feels slow, so for interactive use consider a GPU instance, or run Ollama on a separate GPU host and point OLLAMA_BASE_URL at it.

Real-World Example: Incident Impact Analysis

Let me close the loop with a scenario I have seen in different forms at several companies.

An engineering manager uploads the service catalog, three architecture decision records and a year of post-incident reviews. Ingestion extracts roughly a few hundred entities and edges. Later, someone asks:

"If we retire the payments v1 API, which teams need to be notified?"

The vector search surfaces the ADR about the v1 deprecation. The graph expansion then follows DEPENDS_ON edges to checkout-service and billing-worker, and OWNS edges to their teams. The answer lists the teams, cites the passages, and the UI shows the exact path through the graph. Before Graph RAG, this was a half-day of Slack archaeology. Now it is a ten-second question.

Best Practices

  • Validate every LLM output. Treat model responses as untrusted input. Zod schemas are your seatbelt.
  • Normalise entity names. Canonical names, plus an alias table later on, prevent duplicate nodes from fragmenting your graph.
  • Cap traversal depth. Two hops is usually the sweet spot. Beyond that, noise grows faster than signal.
  • Keep chunks focused. Around 800 to 1,200 characters with some overlap gives extraction enough context without drowning the model.
  • Log retrieval, not just answers. Store which chunks and entities each answer used. It is invaluable for debugging and for audits.
  • Version your prompts and models. A model upgrade can change extraction behaviour. Re-run a small evaluation set before promoting it.
  • Secure the perimeter. Keep the database and model server off the public internet, rotate secrets, and add per-document access controls if your data is sensitive.

Common Mistakes to Avoid

  • Extracting everything. Asking the LLM for every noun in the text produces a noisy, unusable graph. Restrict entity types and demand explicit mentions.
  • Holding database transactions open during LLM calls. It exhausts your connection pool under load. Do the slow work first, then write.
  • Skipping the evaluation set. Without 20 to 50 real questions and expected answers, you cannot tell whether a change helped or hurt.
  • Forgetting indexes on edge tables. Recursive traversal without indexes on source_id and target_id degrades quickly.
  • Trusting small models blindly. Tiny models invent relationships. If precision matters, use a larger model for extraction and a smaller one for answering.
  • Exposing Ollama publicly. It has no authentication by default. Keep it on the internal network.

🚀 Pro Tips

  • Add a re-ranker. After combining seed and graph passages, score them against the question with a lightweight cross-encoder and keep only the best five.
  • Use weights as a signal. Sort edges by weight so repeatedly confirmed relationships appear first in the prompt.
  • Cache embeddings for repeated questions. A tiny in-memory or Redis cache removes a surprising amount of latency.
  • Stream the answer. Server-Sent Events from Express make the UI feel instant even when generation is slow.
  • Run extraction as a background job. For large corpora, push ingestion onto a queue so uploads return immediately.
  • Try Row-Level Security. PostgreSQL RLS lets you enforce per-team document visibility directly at the database layer.
  • Back up with pg_dump on a schedule. Your knowledge graph is now a business asset, so treat it like one.

📌 Key Takeaways

  • Graph RAG combines semantic similarity with explicit entity relationships, which makes multi-hop questions answerable.
  • PostgreSQL, pgvector and recursive CTEs are enough to build a production-grade knowledge graph without adding a new database.
  • Ollama and LangChain let you keep the entire AI stack sovereign and private.
  • Structured output with Zod makes local-model entity extraction reliable enough to trust.
  • Docker Compose, Nginx and GitHub Actions give you a repeatable path from laptop to AWS.
  • Showing entities and relationships in the UI builds user trust and makes retrieval explainable.

Conclusion

Graph RAG is not about chasing a trend. It is about acknowledging that enterprise knowledge is relational by nature. People, systems, decisions and incidents are connected, and an assistant that understands those connections gives answers people actually rely on.

The stack we built here is deliberately pragmatic. PostgreSQL does double duty as vector store and graph engine. LangChain and Ollama keep inference private. Express and Next.js give you a clean, typed boundary between the model and the people using it. And Docker, Nginx and GitHub Actions mean you can deploy it on a Tuesday afternoon without holding your breath.

From here, the natural next steps are an entity-resolution pass that merges aliases, community summaries for corpus-wide questions, and an evaluation harness that runs on every pull request. Start small, ingest one real document set, and let your users' questions tell you where the graph needs to grow.

If you build this, I would love to hear what you learn. The best Graph RAG systems I have seen were shaped less by clever algorithms and more by teams who listened closely to the questions their colleagues kept asking.

References

Frequently asked questions

What is the difference between Graph RAG and traditional vector RAG?

Traditional RAG retrieves text chunks that look semantically similar to the question. Graph RAG also extracts entities and relationships from those chunks and walks the connections between them, so it can pull in related context that is not textually similar but is structurally relevant. This makes it far better at multi-hop and relationship-heavy questions.

Do I need a dedicated graph database like Neo4j for Graph RAG?

Not necessarily. For most enterprise knowledge bases, PostgreSQL with pgvector and recursive CTEs is enough for one to three hops of traversal. A dedicated graph database becomes worthwhile when you need very deep traversals, graph algorithms such as community detection at scale, or billions of edges.

Can I run Graph RAG fully offline with local models?

Yes. With Ollama you can run both the chat model and the embedding model on your own hardware, so no document content ever leaves your network. The trade-off is that you need enough CPU, RAM or GPU capacity to keep latency acceptable.

Which Ollama models work well for entity extraction?

Instruction-tuned models in the 7B to 14B range, such as Llama 3.1 8B or Qwen 2.5 14B, handle structured JSON extraction reasonably well when paired with a strict schema. Smaller models tend to hallucinate entities, so always validate the output with a schema library like Zod.

How should I deploy this stack to AWS?

A single EC2 Linux instance running Docker Compose behind Nginx is a solid starting point. Use GitHub Actions to build and push images, then deploy over SSH. If you need LLM inference at reasonable speed, choose a GPU instance or move the model server to a dedicated host.

Discussion

All Articles
Graph RAGLangChainPostgreSQLpgvectorOllamaNext.jsExpress.jsDockerNginxAWSCI/CD

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.

Building this for real? LangChain Developer Services — Production LangChain & LangGraph systems for RAG, agents, and AI workflows.