Sechno
Web Design

Build a Code Graph to Supercharge AI-assisted Coding and Fix LLM Debugging Blindspots

A practical guide for developers to build a lightweight code graph (AST + symbol edges + embeddings) to improve LLM-based code suggestions, debugging, and change-focused reasoning.

SSechno Team 1 min read 58 views
Build a Code Graph to Supercharge AI-assisted Coding and Fix LLM Debugging Blindspots

Why a code graph matters for LLM-assisted development

LLMs are powerful at generating and explaining code, but they struggle when the task requires precise, local reasoning about code changes, call flows, and symbol relationships. A code graph — a structured, queryable representation of your codebase that combines AST nodes, symbol edges, and semantic embeddings — gives an LLM the context it needs to reason accurately about what changed and where to look.

What you'll get from this post

  • Concrete architecture and components for a practical code graph.
  • Minimal implementation snippets (file parsing, symbol extraction, embeddings + index upsert).
  • How to use the graph to improve LLM debugging for a failing test or changed file.
  • Tradeoffs, incremental-update tips, and a short checklist for production.

High-level architecture

At a practical level, a code graph has three layers:

  1. Structural layer: AST nodes, symbol declarations (functions, classes, modules) and edges like "defines", "calls", "imports". Use tree-sitter or language servers to extract this.
  2. Semantic layer: Embeddings for nodes or logical groupings (function body, docstring + signature, file scope) so you can search semantically with a vector index (FAISS, Weaviate, Milvus, or a managed vector DB).
  3. Query & orchestration layer: Small API to answer queries like "Which functions are transitively affected by this file change?" or "Gather context for LLM: code + call graph slice + tests." This layer composes graph queries and LLM prompts.

Step 1 — Extract symbols and edges (practical example)

Use tree-sitter for robust multi-language parsing. Below is a minimal Node.js-style approach that walks files and extracts function and import relationships as nodes and edges. This example shows the concept — adapt it per language and project conventions.

const Parser = require('tree-sitter');
const JavaScript = require('tree-sitter-javascript');
const fs = require('fs');
const path = require('path');
 
function parseFile(filePath) {
  const src = fs.readFileSync(filePath, 'utf8');
  const parser = new Parser();
  parser.setLanguage(JavaScript);
  const tree = parser.parse(src);
 
  const nodes = [];
  const edges = [];
 
  function walk(node) {
    if (!node) return;
    // Example: find function declarations and export names
    if (node.type === 'function_declaration' || node.type === 'method_definition') {
      const nameNode = node.childForFieldName('name');
      const name = nameNode ? src.slice(nameNode.startIndex, nameNode.endIndex) : '

Keep the extractor small and deterministic — you'll run it in CI and incrementally on diffs.

Step 2 — Create semantic units and embeddings

Not everything needs an embedding. Good candidates: full function body + signature, class docstrings, public API surfaces, and test names/bodies. Create short, searchable documents with metadata: { id, file, symbol, type, text } and compute embeddings for each.

Example Python flow (embedding upload to a vector index). This pseudocode assumes you have an embedding function that returns a float array and a small key-value store for metadata. Replace with your preferred embedding provider + vector DB.

import json
import requests
 
def compute_embedding(text):
    # Replace with your provider call; return list[float]
    resp = requests.post('https://api.example.com/v1/embeddings', json={ 'input': text })
    return resp.json()['data'][0]['embedding']
 
def upsert_documents(docs, vector_index):
    for doc in docs:
        emb = compute_embedding(doc['text'])
        vector_index.upsert(id=doc['id'], vector=emb, metadata={ 'file': doc['file'], 'symbol': doc['symbol'], 'type': doc['type'] })
 
# Example doc
docs = [{ 'id': 'file.js::doThing', 'file': 'src/file.js', 'symbol': 'doThing', 'type': 'function', 'text': 'function doThing(a) { ... }' }]
# vector_index is a placeholder for FAISS/Weaviate/Milvus client
# upsert_documents(docs, vector_index)

Step 3 — Answer change-focused queries

When a test fails or a diff is introduced, you want to gather a compact, high-quality context for the LLM. Combine:

  • AST slice for directly changed files/lines (structural layer).
  • Transitive closure on the call graph for functions referenced by the changed symbols (graph traversal).
  • Top N semantically similar nodes from the vector index (embedding search) to cover indirect dependencies.

Example query flow:

  1. Input: git diff or failing test stack trace (file:line, symbol).
  2. Run graph traversal: find functions that call or are called by changed symbols, up to depth 2.
  3. Fetch node texts and metadata. Run vector search on the failing test text to pull semantically related helpers and mocks.
  4. Assemble a narrow prompt for the LLM: include failing snippet, relevant functions & test, call graph edges, and ask for root-cause candidates and targeted fix suggestions.

Prompt composition advice

  • Prefer many small, precise contexts rather than dumping full files.
  • Label each snippet with file, symbol, and range.
  • Include the exact failing error and stack trace up front.
  • Use the LLM to rank suspect symbols, not to blindly patch multiple files.

Tradeoffs and implementation tips

  • Storage vs recall: Embedding everything at token granularity increases recall but costs storage and indexing time. Start with function-level embeddings and add finer granularity for hotspots.
  • Staleness: Rebuild affected graph nodes incrementally using git diffs or CI hooks. Full rebuilds are rare; incremental updates keep latency low.
  • Language coverage: tree-sitter covers many languages, but language servers (LSP/LSIF) provide richer type and jump-to-def info for typed languages.
  • Privacy & licensing: If your embedding provider or vector DB is external, gate what code is sent and consider on-prem options for sensitive repos.

Minimal CI integration

  1. Run extract + upsert on PR commit (or on a per-file-change basis) to update only modified nodes.
  2. Expose an internal endpoint: POST /analysis with { diff, stack } that returns ranked suspects and context snippets for the LLM.
  3. Store the graph catalog in a small DB (SQLite/JSON + FAISS). Use a graph DB (Neo4j) only if you need complex multi-repo traversals.

Quick checklist before rollout

  • Start with a single language and instrument a small repo.
  • Measure usefulness: does the LLM's precision for root causes increase (manual evaluation)?
  • Implement rate limits and privacy guards on embedding calls.
  • Provide fallbacks for the LLM to ask for more context rather than guessing.

Concise conclusion

A lightweight code graph that combines structural AST data, explicit symbol edges, and targeted semantic embeddings drastically improves the signal you can give an LLM. Instead of relying on a model to rediscover relationships across a large codebase, the graph supplies precise, queryable context so LLMs can focus on reasoning and suggestion. Build incrementally: parse, index, embed, query, and integrate into CI. The result is faster, more accurate debugging and safer LLM-driven changes.

For more background reading, see the original discussion on code graphs and recent explainers about LLM blindspots when debugging code changes linked below.

Selected references:

Was this helpful?

Share this post

Comments (0)

Want to join the conversation?

Log in or sign up to leave a comment and share your thoughts.

Log in to Comment