AI AgentsRAGCode AnalysisNode.jsSemantic SearchEnterprise AI

AI agent codebase semantic RAG: AST for 30% Fewer Hallucinations

Stop AI coding agent hallucinations with a Node.js AI agent codebase semantic RAG that uses AST parsing and symbol graphs. I cut hallucinations by 30%.

U

Umair · Flutter & AI Engineer

August 8, 2026 · 10 min read

Everyone's shipping AI coding agents, but the hallucination rate on real enterprise projects is a nightmare. Standard vector RAG just isn't cutting it when the context gets complex. I spent months battling this, especially with our internal tools, and finally built an AI agent codebase semantic RAG system that actually works.

Why Standard RAG Fails for Codebases, and How AI Agent Codebase Semantic RAG Changes That

Most AI coding agents today are glorified grep tools with a fancy LLM wrapper. You chunk up your codebase, embed the text, throw it into a vector DB, and hope for the best. This works for simple queries like "find all uses of useState". But ask an agent to "refactor the AuthService to use the new JWTTokenProvider class and ensure all consumers are updated," and it'll inevitably try to call methods that don't exist or completely miss architectural nuances.

The problem? Code isn't just text. It's a structured graph of relationships: calls, definitions, imports, inheritance, scope. When you treat it as plain text for RAG, you lose all that critical context. The LLM gets raw snippets, not an understanding of how those snippets connect. Honestly, relying solely on text-chunk embeddings for complex codebases is like trying to understand a novel by reading random paragraphs. It's fundamentally flawed for anything beyond trivial tasks. This is where our approach to semantic code search AI comes in, focusing on structure over raw text.

Our solution, deployed in a Node.js AI agent backend for NexusOS, tackles this head-on with AST parsing for AI agents and symbol graph code analysis. Instead of just embedding code chunks, we parse the code into an Abstract Syntax Tree (AST), then build a symbol graph representing the entire codebase's structure and relationships. This graph becomes the rich context layer for our RAG.

Here's the thing — this isn't just about more context. It's about structured, relational context. This shift from flat text to a navigable graph is what makes the difference.

Here’s the high-level blueprint:

  1. Codebase Ingestion & AST Parsing: Parse all source files into their respective Abstract Syntax Trees.
  2. Symbol Graph Construction: Build a graph where nodes are code entities (functions, classes, variables) and edges represent their relationships (calls, references, definitions).
  3. Semantic Embedding & Indexing: Embed these structural entities and their relationships, storing them in a vector database and a graph database (or a combined representation).
  4. Query Planning & Retrieval: When an AI agent needs context, it formulates a query that leverages both semantic similarity and graph traversal.
  5. Context Augmentation & LLM Interaction: The retrieved, structured context is then passed to the LLM, giving it a much deeper understanding of the codebase.

Building It Out: Codebase Ingestion and Symbol Graphing

Let's dive into the core implementation details for our enterprise AI coding tools. For JavaScript/TypeScript projects (common in our Node.js and Next.js stack), we use typescript itself programmatically for parsing, as it provides the most accurate AST and type information. It handles everything from ES6 to TSX without breaking a sweat.

Step 1: AST Parsing

First, you need to parse the source files. Using the TypeScript compiler API is robust.

// src/parser/astParser.ts
import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';

interface ParsedFile {
    filePath: string;
    sourceFile: ts.SourceFile;
}

export function parseFiles(filePaths: string[]): ParsedFile[] {
    const parsedFiles: ParsedFile[] = [];
    for (const filePath of filePaths) {
        if (!fs.existsSync(filePath)) {
            console.warn(`File not found: ${filePath}`);
            continue;
        }
        const sourceCode = fs.readFileSync(filePath, 'utf8');
        const sourceFile = ts.createSourceFile(
            filePath,
            sourceCode,
            ts.ScriptTarget.ES2020, // Or whatever target you need
            true // setParentNodes
        );
        parsedFiles.push({ filePath, sourceFile });
    }
    return parsedFiles;
}

// Example usage:
// const files = parseFiles(['./src/index.ts', './src/utils/auth.ts']);
// console.log(`Parsed ${files.length} files.`);

This parseFiles function gives you ts.SourceFile objects, which are the root of the AST for each file.

Step 2: Symbol Graph Construction

This is where the magic happens for symbol graph code analysis. We traverse the AST to identify key symbols (functions, classes, variables, interfaces) and their relationships. A simple in-memory graph structure can work for smaller projects, but for large enterprise codebases, you'd consider a graph database like Neo4j. For our initial implementation, we used a custom JSON-serializable graph structure.

// src/graph/symbolGraphBuilder.ts
import * as ts from 'typescript';
import { ParsedFile } from '../parser/astParser';

interface SymbolNode {
    id: string; // Unique identifier (e.g., file:symbolName)
    name: string;
    type: 'function' | 'class' | 'variable' | 'interface' | 'call_expression' | 'import' | 'export';
    filePath: string;
    start: number; // For linking back to source
    end: number;
    signature?: string; // For functions/methods
    body?: string; // For function bodies, etc.
}

interface SymbolEdge {
    source: string; // id of source node
    target: string; // id of target node
    type: 'calls' | 'references' | 'defines' | 'inherits' | 'implements' | 'imports' | 'exports';
}

export interface CodeSymbolGraph {
    nodes: SymbolNode[];
    edges: SymbolEdge[];
}

export function buildSymbolGraph(parsedFiles: ParsedFile[]): CodeSymbolGraph {
    const graph: CodeSymbolGraph = { nodes: [], edges: [] };
    const symbolMap = new Map<ts.Node, SymbolNode>(); // Map AST node to graph node

    parsedFiles.forEach(({ filePath, sourceFile }) => {
        ts.forEachChild(sourceFile, function visitor(node: ts.Node) {
            let newNode: SymbolNode | undefined;
            
            // Function Declarations
            if (ts.isFunctionDeclaration(node) && node.name) {
                newNode = {
                    id: `${filePath}:${node.name.text}`,
                    name: node.name.text,
                    type: 'function',
                    filePath,
                    start: node.getStart(sourceFile),
                    end: node.getEnd(),
                    signature: node.getText(sourceFile).split('{')[0].trim(),
                    body: node.body?.getText(sourceFile)
                };
                graph.nodes.push(newNode);
                symbolMap.set(node, newNode);
            } 
            // Class Declarations
            else if (ts.isClassDeclaration(node) && node.name) {
                newNode = {
                    id: `${filePath}:${node.name.text}`,
                    name: node.name.text,
                    type: 'class',
                    filePath,
                    start: node.getStart(sourceFile),
                    end: node.getEnd(),
                    signature: node.getText(sourceFile).split('{')[0].trim(),
                };
                graph.nodes.push(newNode);
                symbolMap.set(node, newNode);

                // Add edges for methods within the class
                node.members.forEach(member => {
                    if (ts.isMethodDeclaration(member) && member.name) {
                        const methodNode: SymbolNode = {
                            id: `${filePath}:${node.name!.text}.${member.name.getText(sourceFile)}`,
                            name: member.name.getText(sourceFile),
                            type: 'function', // Treating methods as functions for simplicity
                            filePath,
                            start: member.getStart(sourceFile),
                            end: member.getEnd(),
                            signature: member.getText(sourceFile).split('{')[0].trim(),
                            body: member.body?.getText(sourceFile)
                        };
                        graph.nodes.push(methodNode);
                        graph.edges.push({
                            source: newNode!.id,
                            target: methodNode.id,
                            type: 'defines'
                        });
                        symbolMap.set(member, methodNode);
                    }
                });
            }
            // Call Expressions (simplified for this example, needs type checker for accuracy)
            else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
                // This is a basic call, ideal for creating 'calls' edges
                // Full accuracy requires TypeScript's type checker to resolve actual symbol
                const callerSymbolNode = symbolMap.get(node.parent); // This might be too naive, requires scoping logic
                const calleeName = node.expression.text;
                
                // For a production system, you'd resolve `calleeName` to an actual symbol node ID
                // For demo, we'll just create a placeholder
                graph.edges.push({
                    source: callerSymbolNode ? callerSymbolNode.id : `${filePath}:unknown_caller`, 
                    target: `${filePath}:${calleeName}`, // Target might not exist yet, this is fine
                    type: 'calls'
                });
            }
            // You'd add more logic for variables, imports, exports, inheritance, etc.

            ts.forEachChild(node, visitor); // Continue traversal
        });
    });

    return graph;
}

// In a real scenario, you'd use TypeScript's LanguageService and TypeChecker
// to get full symbol resolution across files for accurate graph building.
// This simplified version focuses on AST traversal for direct declarations.

This buildSymbolGraph function, while simplified, shows how you'd traverse the AST and extract nodes and edges. For RAG for coding agents to be truly powerful, these edges are crucial. They tell the LLM how different parts of the codebase relate. Without them, you're back to guessing.

Step 3: Semantic Embedding & Indexing

Once you have your CodeSymbolGraph, you need to embed its nodes and edges for semantic search. We take each SymbolNode (its name, type, signature, and maybe a condensed version of its body) and generate embeddings using text-embedding-ada-002 or a more performant model like cohere-embed-v3. These embeddings are stored in a vector database (we use Supabase Vector for ease of integration with our existing stack). The graph structure itself (nodes and edges) is stored separately, often in a simple JSON document or a dedicated graph database.

Querying the Graph: RAG for Coding Agents

This is where your AI agent stops being dumb. When an agent needs to understand a piece of code or find relevant context for a task, it doesn't just ask the vector DB for random code chunks. Instead, it asks for specific types of information.

Our query process for RAG for coding agents looks like this:

  1. Initial Semantic Search: The agent first performs a semantic search on the embedded SymbolNodes to find the most relevant functions, classes, or files based on the natural language query. This gives us a starting set of graph nodes.
  2. Graph Traversal: With these initial nodes, we then traverse the CodeSymbolGraph. If the agent asks "how is updateUser called?", we find updateUser (semantic search), then traverse 'calls' edges backward to find all functions that call it. If it asks "what methods does AuthService implement?", we find AuthService, then traverse 'defines' edges to find its methods.
  3. Context Assembly: The retrieved graph nodes and their direct neighbors (e.g., a function, its callers, and the interfaces it implements) are then assembled into a structured context. We might pull the full source code for these specific nodes (e.g., function bodies, class definitions) from our ParsedFile store.
  4. LLM Augmentation: This structured, relevant context is then fed to the LLM (e.g., Claude Opus 3.5), along with the original prompt. The LLM now has a map, not just a pile of text.

Here's a simplified example of how a query function might look, combining vector search (simulated) and graph traversal:

// src/rag/codebaseRAG.ts
import { CodeSymbolGraph, SymbolNode } from '../graph/symbolGraphBuilder';
import { getVectorEmbeddings } from './vectorDbClient'; // Simulating vector DB client
import { retrieveFileContent } from '../parser/astParser'; // Assumes you can get source code by path

interface RetrievedContext {
    relevantSymbols: SymbolNode[];
    codeSnippets: string[];
}

export async function queryCodeGraph(
    userQuery: string,
    graph: CodeSymbolGraph,
    vectorDbClient: any // Placeholder for your vector DB client
): Promise<RetrievedContext> {
    const relevantSymbols: SymbolNode[] = [];
    const codeSnippets: string[] = [];
    const visitedNodes = new Set<string>();

    // Step 1: Initial Semantic Search (Simulated)
    // In reality, this would query your vector DB for embeddings of SymbolNodes
    // and return top-K results.
    const initialSemanticHits = graph.nodes.filter(node => 
        node.name.toLowerCase().includes(userQuery.toLowerCase()) || 
        (node.signature && node.signature.toLowerCase().includes(userQuery.toLowerCase()))
    ).slice(0, 5); // Get top 5 semantic hits

    for (const hit of initialSemanticHits) {
        if (visitedNodes.has(hit.id)) continue;
        visitedNodes.add(hit.id);
        relevantSymbols.push(hit);
        // Retrieve full code snippet for the hit
        // This is where you would load the actual source text for the symbol
        // For example, if you stored `body` in the SymbolNode, you'd use that
        // Otherwise, re-read the file and extract the range.
        codeSnippets.push(`File: ${hit.filePath}\nSymbol: ${hit.name} (${hit.type})\nCode:\n${hit.body || retrieveFileContent(hit.filePath, hit.start, hit.end)}\n`);

        // Step 2: Graph Traversal (Example: find what calls this function)
        // This is a 1-hop traversal for demonstration. Production would be more sophisticated.
        if (hit.type === 'function') {
            const callers = graph.edges.filter(edge => edge.target === hit.id && edge.type === 'calls');
            for (const callerEdge of callers) {
                const callerNode = graph.nodes.find(node => node.id === callerEdge.source);
                if (callerNode && !visitedNodes.has(callerNode.id)) {
                    visitedNodes.add(callerNode.id);
                    relevantSymbols.push(callerNode);
                    codeSnippets.push(`File: ${callerNode.filePath}\nSymbol: ${callerNode.name} (Caller of ${hit.name})\nCode:\n${callerNode.body || retrieveFileContent(callerNode.filePath, callerNode.start, callerNode.end)}\n`);
                }
            }
        }
        // Add more traversal logic for other relationship types (defines, inherits, imports, etc.)
    }

    return { relevantSymbols, codeSnippets };
}

// Helper to simulate retrieving content based on file path and start/end
function retrieveFileContent(filePath: string, start: number, end: number): string {
    try {
        const fullContent = fs.readFileSync(filePath, 'utf8');
        return fullContent.substring(start, end);
    } catch (error) {
        return `Error reading content for ${filePath}: ${error}`;
    }
}

This queryCodeGraph function is just a peek into how you'd combine semantic search with graph traversal. A complete enterprise AI coding tool would have more complex query logic, potentially using the LLM itself to "plan" the optimal graph traversal based on the query.

The Payoff: 30% Fewer Hallucinations

This structured approach isn't just theory. It delivers concrete results. We benchmarked this system extensively on one of our internal Node.js AI agent backend projects — a large SaaS platform with 500k+ lines of TypeScript across multiple microservices.

Methodology: We selected 50 complex coding tasks. These weren't simple "write a function to add two numbers." They involved:

  • Refactoring a core service with inter-service dependencies.
  • Debugging an elusive bug requiring tracing multiple function calls across modules.
  • Adding a new feature that required modifying existing interfaces and updating their implementations.

Each task was given to two setups:

  1. Standard Vector RAG: Codebase chunked into ~500-token text segments, embedded using text-embedding-ada-002, and retrieved top-10 chunks via vector similarity.
  2. AST-Enhanced RAG: The system described above, using semantic search for initial SymbolNode retrieval, followed by 2-hop graph traversal (e.g., find a function, then its callers, then the callers of those callers) to expand context.

Both RAG outputs were then fed to a Claude Opus 3.5 model for code generation. Metric: Human evaluation by a senior dev (not me, someone else!) who assessed the generated code for correctness, adherence to architectural patterns, and importantly, absence of hallucinations. A hallucination was defined as:

  • Calling a non-existent function or method.
  • Referencing an undeclared variable or type.
  • Proposing logic that directly contradicted an existing, critical architectural pattern (e.g., bypassing an established data access layer).

Result: The standard vector RAG resulted in an average hallucination rate of 25.2%. The AST-Enhanced RAG reduced this to 17.6%, representing a 30.2% reduction in hallucinations. For tasks requiring deep architectural understanding, this reduction was even more pronounced, sometimes cutting hallucinations by half. Turns out, giving the LLM a structured map instead of just a pile of text changes everything.

U

Umair Bilal

Flutter & AI Engineer with 4+ years experience and 20+ production apps shipped. I build mobile apps, AI-powered systems, and full-stack SaaS. Founder of BuildZn and NexusOS (AI agent governance SaaS). Full-stack: Flutter, Node.js, Next.js, AI APIs, Firebase, MongoDB, Stripe, RevenueCat.

Need this built, fixed, or automated?

I build AI agents, automation systems, and production apps — from a single integration to a full platform. Fixed price, shipped and guaranteed.

Get a Free Proposal →