All articles

Setting Up a Knowledge Graph for Your Codebase: A Step-by-Step Guide

Most codebases rot because nobody knows what depends on what. A knowledge graph fixes that — here's how to build one without overengineering it.

D

DocPilot Team

AI documentation research and engineering from Onzira Labs.

6 min read

Why Your Codebase Deserves a Knowledge Graph

Your codebase is a graph. Functions call functions. Services call services. Modules import modules. The problem is that your tooling treats it like a folder hierarchy and a list of files, which is why "where is this used?" still takes 15 minutes and three Slack messages to answer.

A knowledge graph makes those relationships queryable. Instead of grepping for a symbol and scrolling through 200 results, you can ask: show me every HTTP endpoint that transitively depends on this database table or which services will be impacted if I deprecate this field?. The answers come back in milliseconds because the relationships are stored as edges, not reconstructed every time.

This guide walks through building one for your own codebase — architecture, schema, ingestion pipeline, and queries — using tools you probably already have access to. No graph database vendor pitch, no ML hype. Just a working system you can ship in a weekend.

Knowledge Graph Architecture for Code

Before touching a single file, you need a mental model. A code knowledge graph has three layers:

  • Entities — files, functions, classes, modules, services, endpoints, database tables, environment variables, configuration keys
  • Relationships — imports, calls, inherits, implements, reads, writes, deploys_to, owned_by
  • Metadata — language, last modified, commit history, ownership, documentation status

The architecture looks roughly like this:

[ Source Code ] --> [ Parser ] --> [ Extractor ] --> [ Graph DB ]
                                       |                  |
                                       v                  v
                                  [ Cache ]         [ Query API ]
                                                       |
                                                       v
                                                  [ Docs / UI ]

The parser turns code into an AST. The extractor walks the AST and emits entities and relationships. The graph database stores them. The query API exposes them. Docs and tooling consume the API.

Choosing a Storage Backend

You have three realistic options, each with tradeoffs:

BackendBest forQuery languageHosting
Neo4jDeep graph queries, large teamsCypherSelf-hosted or Aura
MemgraphReal-time ingestion, lower opsCypherSelf-hosted or Cloud
Postgres + pgvector + recursive CTEsAlready-Postgres shops, smaller graphsSQLYou already have it

If you're starting fresh and your graph will exceed ~100k nodes, pick Neo4j or Memgraph. If you're under 100k nodes and your team already lives in SQL, save the operational overhead and use Postgres. Recursive CTEs handle 3-5 hops comfortably.

A minimal Postgres schema looks like this:

CREATE TABLE nodes (
  id           TEXT PRIMARY KEY,
  kind         TEXT NOT NULL,  -- 'function', 'class', 'module', 'endpoint', 'table'
  name         TEXT NOT NULL,
  file_path    TEXT,
  language     TEXT,
  metadata     JSONB DEFAULT '{}',
  updated_at   TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE edges (
  id           BIGSERIAL PRIMARY KEY,
  source_id    TEXT NOT NULL REFERENCES nodes(id),
  target_id    TEXT NOT NULL REFERENCES nodes(id),
  relation     TEXT NOT NULL,  -- 'calls', 'imports', 'reads', 'writes'
  weight       INT DEFAULT 1,
  metadata     JSONB DEFAULT '{}'
);

CREATE INDEX ON edges (source_id);
CREATE INDEX ON edges (target_id);
CREATE INDEX ON edges (relation);

Step 1: Extract Entities and Relationships from Code

The extraction step is where most projects stall. Pick a parser per language. Don't write your own — ASTs are harder than they look.

For JavaScript and TypeScript, use ts-morph or @babel/parser. For Python, use tree-sitter-python or the built-in ast module. For Go, use go/parser. For Java/Kotlin, use tree-sitter.

Here's a working TypeScript extractor in 40 lines:

import { Project, SyntaxKind, Node } from 'ts-morph';

interface ExtractedNode {
  id: string;
  kind: string;
  name: string;
  file_path: string;
}
interface ExtractedEdge {
  source_id: string;
  target_id: string;
  relation: string;
}

export function extract(path: string): { nodes: ExtractedNode[]; edges: ExtractedEdge[] } {
  const project = new Project({ tsConfigFilePath: `${path}/tsconfig.json` });
  const nodes: ExtractedNode[] = [];
  const edges: ExtractedEdge[] = [];

  for (const sourceFile of project.getSourceFiles()) {
    const filePath = sourceFile.getFilePath();

    // Extract functions and classes
    sourceFile.forEachDescendant(node => {
      if (Node.isFunctionDeclaration(node) && node.getName()) {
        nodes.push({
          id: `${filePath}:${node.getName()}`,
          kind: 'function',
          name: node.getName()!,
          file_path: filePath,
        });
      }
      if (Node.isClassDeclaration(node) && node.getName()) {
        nodes.push({
          id: `${filePath}:${node.getName()}`,
          kind: 'class',
          name: node.getName()!,
          file_path: filePath,
        });
      }
    });

    // Extract imports as edges
    for (const imp of sourceFile.getImportDeclarations()) {
      const modulePath = imp.getModuleSpecifierValue();
      edges.push({
        source_id: filePath,
        target_id: modulePath,
        relation: 'imports',
      });
    }
  }

  return { nodes, edges };
}

Run this on your repo and you'll have a populated nodes and edges array ready to load.

Step 2: Load the Graph

Batch-load into your chosen backend. For Postgres, use a single transaction:

BEGIN;

INSERT INTO nodes (id, kind, name, file_path, language)
VALUES %s
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name, updated_at = now();

INSERT INTO edges (source_id, target_id, relation)
VALUES %s
ON CONFLICT DO NOTHING;

COMMIT;

For Neo4j, the equivalent is MERGE:

MERGE (n:Function {id: $id})
SET n.name = $name, n.file_path = $file_path
WITH n
MATCH (m:Module {path: $target})
MERGE (n)-[:IMPORTS]->(m);

The trick is making ingestion idempotent. You will re-run this on every commit, so node and edge identity must be stable across runs. That means deriving IDs from file paths and symbol names, not from random UUIDs.

Step 3: Query the Graph

This is where the payoff lands. Three queries you'll use constantly:

What does this function call?

SELECT target.name, target.kind, target.file_path
FROM edges e
JOIN nodes target ON target.id = e.target_id
WHERE e.source_id = 'src/billing/charge.ts:processPayment'
  AND e.relation = 'calls';

What depends on this table? (impact analysis)

WITH RECURSIVE dependents AS (
  SELECT target_id AS id, 1 AS depth
  FROM edges
  WHERE source_id = 'db:tables:users' AND relation IN ('reads', 'writes')
  UNION
  SELECT e.source_id, d.depth + 1
  FROM edges e
  JOIN dependents d ON e.target_id = d.id
  WHERE d.depth < 5
)
SELECT DISTINCT n.id, n.name, n.kind, n.file_path
FROM dependents d
JOIN nodes n ON n.id = d.id;

Find circular dependencies:

WITH RECURSIVE cycle(id, path, depth) AS (
  SELECT source_id, ARRAY[source_id, target_id], 1
  FROM edges WHERE relation = 'imports'
  UNION ALL
  SELECT e.source_id, c.path || e.target_id, c.depth + 1
  FROM edges e JOIN cycle c ON e.target_id = c.id
  WHERE c.depth < 10 AND e.target_id = ANY(c.path)
)
SELECT DISTINCT path FROM cycle WHERE depth >= 2;

That last query has saved me from shipping more than one architectural disaster.

Step 4: Keep It Fresh

A stale graph is worse than no graph because people act on it. Wire ingestion into your CI pipeline:

# .github/workflows/graph-sync.yml
name: Sync Knowledge Graph
on:
  push:
    branches: [main]
on_run: ubuntu-latest
run: |
  git checkout ${{ github.event.pull_request.head.sha || github.sha }}
  npm run extract -- ./src | psql $GRAPH_DB_URL -f load.sql
  npm run extract-tests -- ./tests | psql $GRAPH_DB_URL -f load.sql

Run extraction on every merge to main. For monorepos with thousands of files, cache unchanged files by hashing them and skipping ingestion when the hash matches the stored one.

Common Pitfalls

  • Don't try to model everything. Start with imports, calls, and one or two domain-specific relations. You can add more later.
  • Stable IDs are non-negotiable. If your IDs change between runs, your graph is useless.
  • Index early. Both source_id and target_id need indexes for any meaningful performance.
  • Version your schema. Adding a new relation type shouldn't require a migration of every existing edge.

Where This Goes Next

Once the core graph is stable, you can layer things on top: ownership data from CODEOWNERS, test coverage from coverage reports, documentation status from your docs repo. Each addition multiplies the value because it adds new query shapes, not just more data.

The real win isn't the graph itself — it's that onboarding a new engineer goes from "ask three people and read four docs" to "query the graph and read one summary page."

If you're spending more than a day setting up the extraction pipeline and wiring it into CI, an automated alternative is worth a look. DocPilot connects to your GitHub repo and generates a structured map of your codebase — functions, services, dependencies, and relationships — and keeps it current via webhooks. You skip the parser plumbing entirely and get a queryable knowledge graph alongside your auto-generated docs. The free tier covers small-to-medium repos, and it's a fast way to validate the value before committing to building the pipeline yourself.

Ship documentation that keeps up

Try DocPilot on your repository — free for one repo.

Connect your first repository in about a minute. No credit card. DocPilot writes and maintains the documentation from your code.

Read next