[SL_CORE]
< CD ..||6 min read

GraphRAG and Knowledge Graphs: Structured AI Reasoning

# GraphRAG replaces flat vector search with graph-structured knowledge, enabling multi-hop reasoning and relationship traversal. Learn when graph structure outperforms plain RAG and how to build it.

[graphrag][knowledge-graph][rag][architecture]

Plain RAG retrieves chunks — disconnected pieces of text. GraphRAG retrieves relationships — how entities connect, what implies what, which facts support which conclusions. For questions that require connecting multiple facts or following chains of reasoning, graph structure produces dramatically better answers.

Why Graph Structure Matters

Consider a knowledge base about a software system:

  • Document A: "Service X depends on Service Y"
  • Document B: "Service Y was deprecated in v2.0"
  • Document C: "Service Z replaced Service Y in v2.0"

Plain RAG retrieves these as separate chunks. GraphRAG knows: X depends on Y, Y is deprecated, Z replaced Y → therefore X needs to be updated to depend on Z.

This multi-hop reasoning is impossible with flat retrieval.

Knowledge Graph Structure

A knowledge graph stores entities (nodes) and relationships (edges):

interface KGEntity {
  id: string;
  type: string;         // "Service", "Person", "Concept", "Event"
  name: string;
  properties: Record<string, string | number | boolean>;
  embedding?: number[];  // for semantic search on entities
}

interface KGRelationship {
  id: string;
  fromId: string;
  toId: string;
  type: string;         // "DEPENDS_ON", "AUTHORED_BY", "CAUSES", "PART_OF"
  properties: Record<string, string | number | boolean>;
  weight?: number;       // relationship strength
}

interface KnowledgeGraph {
  entities: Map<string, KGEntity>;
  relationships: KGRelationship[];
  adjacencyList: Map<string, string[]>;  // entity → connected entity IDs
}

Building a Knowledge Graph from Documents

Extract entities and relationships using an LLM:

async function extractKGFromDocument(document: string): Promise<{
  entities: Omit<KGEntity, "embedding">[];
  relationships: Omit<KGRelationship, "id">[];
}> {
  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 2000,
    messages: [{
      role: "user",
      content: `Extract entities and relationships from this document as a knowledge graph.

Return JSON:
{
  "entities": [
    {"id": "unique_id", "type": "EntityType", "name": "Entity Name", "properties": {}}
  ],
  "relationships": [
    {"fromId": "id1", "toId": "id2", "type": "RELATIONSHIP_TYPE", "properties": {}}
  ]
}

Entity types: Service, API, Person, Organization, Concept, Technology, Event, Error
Relationship types: DEPENDS_ON, USES, AUTHORED_BY, CAUSES, PART_OF, REPLACED_BY, DOCUMENTED_IN

Document:
${document}`,
    }],
  });

  return JSON.parse(response.content[0].text);
}

// Process entire document corpus
async function buildKnowledgeGraph(documents: string[]): Promise<KnowledgeGraph> {
  const kg: KnowledgeGraph = {
    entities: new Map(),
    relationships: [],
    adjacencyList: new Map(),
  };

  for (const doc of documents) {
    const { entities, relationships } = await extractKGFromDocument(doc);

    for (const entity of entities) {
      // Add embedding for semantic search
      const [embedding] = await embedTexts([`${entity.type}: ${entity.name}`]);
      kg.entities.set(entity.id, { ...entity, embedding });

      if (!kg.adjacencyList.has(entity.id)) {
        kg.adjacencyList.set(entity.id, []);
      }
    }

    for (const rel of relationships) {
      const id = crypto.randomUUID();
      kg.relationships.push({ ...rel, id });

      // Update adjacency list
      const neighbors = kg.adjacencyList.get(rel.fromId) ?? [];
      if (!neighbors.includes(rel.toId)) {
        neighbors.push(rel.toId);
        kg.adjacencyList.set(rel.fromId, neighbors);
      }
    }
  }

  return kg;
}

Graph Traversal for Multi-Hop Queries

// BFS traversal to find connected entities
function traverseGraph(
  kg: KnowledgeGraph,
  startEntityId: string,
  maxHops = 3,
  relationshipFilter?: string[]
): KGEntity[] {
  const visited = new Set<string>([startEntityId]);
  const queue: Array<{ id: string; depth: number }> = [{ id: startEntityId, depth: 0 }];
  const results: KGEntity[] = [];

  while (queue.length > 0) {
    const { id, depth } = queue.shift()!;
    const entity = kg.entities.get(id);
    if (entity) results.push(entity);

    if (depth >= maxHops) continue;

    // Get relevant relationships
    const nextRels = kg.relationships.filter(
      (r) => r.fromId === id &&
      (!relationshipFilter || relationshipFilter.includes(r.type))
    );

    for (const rel of nextRels) {
      if (!visited.has(rel.toId)) {
        visited.add(rel.toId);
        queue.push({ id: rel.toId, depth: depth + 1 });
      }
    }
  }

  return results;
}

// Find entities related to a query
async function semanticEntitySearch(
  kg: KnowledgeGraph,
  query: string,
  topK = 5
): Promise<KGEntity[]> {
  const [queryEmbedding] = await embedTexts([query]);

  const entitiesWithEmbeddings = [...kg.entities.values()].filter((e) => e.embedding);

  return entitiesWithEmbeddings
    .map((entity) => ({
      entity,
      score: cosineSimilarity(queryEmbedding, entity.embedding!),
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK)
    .map(({ entity }) => entity);
}

GraphRAG Query Pipeline

async function graphRAGQuery(
  kg: KnowledgeGraph,
  query: string
): Promise<string> {
  // 1. Find relevant seed entities via semantic search
  const seedEntities = await semanticEntitySearch(kg, query, 3);

  // 2. Traverse graph from seeds to get connected context
  const connectedEntities: KGEntity[] = [];
  for (const seed of seedEntities) {
    const neighbors = traverseGraph(kg, seed.id, 2);
    connectedEntities.push(...neighbors);
  }

  // 3. Get relevant relationships
  const relevantEntityIds = new Set(connectedEntities.map((e) => e.id));
  const relevantRelationships = kg.relationships.filter(
    (r) => relevantEntityIds.has(r.fromId) && relevantEntityIds.has(r.toId)
  );

  // 4. Build structured context
  const entityContext = connectedEntities
    .filter((e, i, arr) => arr.findIndex((x) => x.id === e.id) === i) // deduplicate
    .map((e) => `${e.type}: ${e.name} (${JSON.stringify(e.properties)})`)
    .join("\n");

  const relationshipContext = relevantRelationships
    .map((r) => {
      const from = kg.entities.get(r.fromId)?.name ?? r.fromId;
      const to = kg.entities.get(r.toId)?.name ?? r.toId;
      return `${from} --[${r.type}]--> ${to}`;
    })
    .join("\n");

  // 5. Generate answer with graph context
  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1500,
    system: "Answer questions using the knowledge graph context. Follow relationship chains to answer multi-hop questions.",
    messages: [{
      role: "user",
      content: `Knowledge Graph:

Entities:
${entityContext}

Relationships:
${relationshipContext}

Question: ${query}`,
    }],
  });

  return response.content[0].text;
}

Storing the Knowledge Graph

For production, use a graph database rather than in-memory structures:

// Neo4j integration
import neo4j from "neo4j-driver";

const driver = neo4j.driver(process.env.NEO4J_URI!, neo4j.auth.basic("neo4j", process.env.NEO4J_PASSWORD!));

async function storeEntityNeo4j(entity: KGEntity) {
  const session = driver.session();
  await session.run(
    `MERGE (e:${entity.type} {id: $id})
     SET e.name = $name, e.properties = $properties`,
    { id: entity.id, name: entity.name, properties: JSON.stringify(entity.properties) }
  );
  await session.close();
}

async function storeRelationshipNeo4j(rel: KGRelationship) {
  const session = driver.session();
  await session.run(
    `MATCH (a {id: $fromId}), (b {id: $toId})
     MERGE (a)-[r:${rel.type}]->(b)
     SET r.properties = $properties`,
    { fromId: rel.fromId, toId: rel.toId, properties: JSON.stringify(rel.properties) }
  );
  await session.close();
}

// Multi-hop query via Cypher
async function multiHopQuery(startEntity: string, targetEntityType: string, maxHops = 3): Promise<any[]> {
  const session = driver.session();
  const result = await session.run(
    `MATCH path = (start {name: $name})-[*1..${maxHops}]->(target:${targetEntityType})
     RETURN path, length(path) as hops
     ORDER BY hops LIMIT 10`,
    { name: startEntity }
  );
  await session.close();
  return result.records;
}

When GraphRAG > Plain RAG

| Query type | Plain RAG | GraphRAG | |-----------|-----------|---------| | "What is X?" | ✓ | Overkill | | "How do X and Y relate?" | ✗ | ✓ | | "What does X depend on?" | ✗ | ✓ | | "Why did X cause Y?" | ✗ | ✓ | | "Find all services affected by Z" | ✗ | ✓ |

Use plain RAG from Scratch for simple Q&A. Add GraphRAG when queries require traversing relationships.

FAQ

How long does KG extraction take for large document sets? ~1–3 seconds per document with Claude Sonnet. For 1,000 documents: ~30 minutes. Run extraction in batch jobs, cache the resulting graph.

How do I keep the knowledge graph up to date? Re-extract entities from changed documents and update the graph incrementally. Track document versions — only re-extract when a document changes.

Is Neo4j necessary or can I use a relational DB? For small graphs (<100K relationships), a relational DB with a self-referential table works. For complex traversals, property graphs (Neo4j, Amazon Neptune) are significantly faster.

How do I handle entity resolution (same entity, different names)? Embedding-based deduplication: if two entity embeddings have similarity >0.95, treat as the same entity. Or use an LLM step to merge duplicates during extraction.

What's the main limitation of GraphRAG? Extraction quality. If the LLM extracts wrong relationships, the graph misleads the retrieval. Validate a sample of extracted relationships manually, especially for high-stakes domains.