BeClaude
Guide2026-04-22

Building Knowledge Graphs with Claude: From Unstructured Text to Structured Insights

Learn how to use Claude AI to extract entities and relations from unstructured text, resolve duplicates, and build queryable knowledge graphs for multi-hop reasoning.

Quick Answer

This guide shows you how to use Claude to extract typed entities and relations from unstructured text, resolve duplicate mentions, and build an in-memory knowledge graph for multi-hop question answering — all without training data or complex infrastructure.

Knowledge GraphsEntity ExtractionStructured OutputsClaude APIMulti-Hop Reasoning

Building Knowledge Graphs with Claude: From Unstructured Text to Structured Insights

You have a pile of unstructured documents and need to answer questions that span them — "who works with people who worked on project X", "which vendors are connected to this incident". No single document contains the answer. RAG retrieval won't chain the facts for you. You need a knowledge graph: entities as nodes, typed relations as edges, so that multi-hop reasoning becomes graph traversal.

Building one used to mean training a named-entity recognizer on your domain, training a relation classifier, writing entity-resolution heuristics, and maintaining all three as your data shifted. With Claude, each of those stages becomes a prompt.

What You'll Learn

By the end of this guide you will be able to:

  • Use structured outputs to extract typed entities and subject–predicate–object triples from arbitrary text with no training data
  • Apply Claude-driven entity resolution to collapse surface-form variants into canonical nodes, replacing brittle string-similarity heuristics
  • Assemble and query an in-memory graph, and run multi-hop questions by serializing subgraphs back to Claude
  • Measure extraction quality with precision/recall against a gold set and reason about the cost/quality tradeoff between Haiku and Sonnet
Everything runs in memory with no database. The techniques transfer directly to Neo4j, Neptune, or a Postgres adjacency table when you need to scale.

Prerequisites

  • Python 3.11+
  • Anthropic API key (get one here)
  • Basic familiarity with graphs (nodes, edges, traversal)

Setup

We use two models. Haiku handles the high-volume, schema-constrained extraction work where speed and cost matter more than nuance. Sonnet handles entity resolution and summarization, where the model needs to weigh conflicting evidence across documents.

import anthropic
from pydantic import BaseModel, Field
from typing import List, Optional
import networkx as nx

client = anthropic.Anthropic()

Define your extraction schema

class Entity(BaseModel): name: str = Field(description="The canonical name of the entity") type: str = Field(description="Entity type: PERSON, ORG, LOC, EVENT, etc.") description: str = Field(description="One-line description for disambiguation")

class Relation(BaseModel): subject: str = Field(description="Subject entity name") predicate: str = Field(description="Relation type in present tense") object: str = Field(description="Object entity name")

class Extraction(BaseModel): entities: List[Entity] relations: List[Relation]

Building a Corpus

We need a handful of documents that talk about overlapping entities, so that entity resolution has real work to do. The Apollo program is a good test bed: six short Wikipedia summaries that all mention NASA, the Moon, several astronauts, and a launch vehicle — but each article names them slightly differently.

We fetch summaries from the Wikipedia REST API rather than full articles to keep token costs low. For a production pipeline you would chunk full documents; the extraction logic is identical.

import requests

def fetch_wikipedia_summary(title): url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{title}" response = requests.get(url) response.raise_for_status() return response.json()["extract"]

documents = { "Apollo 11": fetch_wikipedia_summary("Apollo_11"), "Neil Armstrong": fetch_wikipedia_summary("Neil_Armstrong"), "Buzz Aldrin": fetch_wikipedia_summary("Buzz_Aldrin"), "Saturn V": fetch_wikipedia_summary("Saturn_V"), "NASA": fetch_wikipedia_summary("NASA"), "Moon landing": fetch_wikipedia_summary("Moon_landing") }

Entity and Relation Extraction

Classical NER tags spans of text with labels (PERSON, ORG, LOC). Classical relation extraction then classifies pairs of spans into relation types. Both traditionally require labeled training data per domain.

We collapse both stages into a single Claude call per document. The key is structured outputs: we define the output shape as a Pydantic model and pass it to client.messages.parse(). Claude's response is guaranteed to validate against that schema and comes back as a typed Python object — no regex parsing, no JSON decode errors, no defensive isinstance checks.

def extract_from_text(text: str) -> Extraction:
    response = client.messages.parse(
        model="claude-3-haiku-20240307",
        max_tokens=4096,
        system="You are a precise entity and relation extractor. Extract all named entities and their relationships from the text.",
        messages=[{
            "role": "user",
            "content": f"Extract entities and relations from this text:\n\n{text}"
        }],
        response_model=Extraction
    )
    return response.content

Extract from all documents

all_extractions = {} for title, text in documents.items(): all_extractions[title] = extract_from_text(text)

Let's look at what was extracted. Notice how the same real-world entity appears under different surface forms across documents — this is the entity resolution problem we solve next.

Entity Resolution

The raw extraction gives us overlapping mentions: "NASA" and "National Aeronautics and Space Administration", "Neil Armstrong" and "Armstrong", possibly "the Moon" and "Moon". If we build a graph directly from this, we get a fractured mess where the same concept is split across disconnected nodes.

Traditional approaches use string similarity (edit distance, Jaccard on tokens) plus blocking rules. That works for typos but fails on "Edwin Aldrin" vs "Buzz Aldrin" — two names with zero character overlap that refer to the same person.

We instead ask Claude to cluster entities of each type, using the one-line descriptions from extraction as disambiguation context. The descriptions matter: "Armstrong — first person to walk on the Moon" and "Armstrong — jazz trumpeter" have the same name but should not merge.

def resolve_entities(extractions: dict) -> dict:
    # Collect all unique entity names with their descriptions
    entity_registry = {}
    for doc_title, extraction in extractions.items():
        for entity in extraction.entities:
            if entity.name not in entity_registry:
                entity_registry[entity.name] = {
                    "type": entity.type,
                    "descriptions": []
                }
            entity_registry[entity.name]["descriptions"].append(entity.description)
    
    # Group by type for resolution
    from collections import defaultdict
    by_type = defaultdict(list)
    for name, info in entity_registry.items():
        by_type[info["type"]].append({"name": name, "descriptions": info["descriptions"]})
    
    # Use Sonnet for resolution
    alias_to_canonical = {}
    for entity_type, entities in by_type.items():
        entity_list = "\n".join([
            f"{e['name']}: {'; '.join(e['descriptions'])}" for e in entities
        ])
        
        response = client.messages.parse(
            model="claude-3-sonnet-20240229",
            max_tokens=4096,
            system="You are an entity resolution expert. Group identical real-world entities together.",
            messages=[{
                "role": "user",
                "content": f"Group these {entity_type} entities that refer to the same real-world thing:\n\n{entity_list}"
            }],
            response_model=List[List[str]]  # List of clusters
        )
        
        for cluster in response.content:
            canonical = cluster[0]
            for alias in cluster:
                alias_to_canonical[alias] = canonical
    
    return alias_to_canonical

Two failure modes to watch for. First, any raw name Claude leaves out of every cluster silently disappears from the graph — a production resolver should fall back to a single-element cluster for unmatched names so nothing is lost. Second, the resolver can over-merge: a specific mission like "Gemini 12" may get folded into the broader "Project Gemini" because the descriptions overlap. The first loses nodes, the second loses precision. Both are worth spot-checking in the output below.

Assembling the Graph

With a clean alias map, we rewrite every relation endpoint to its canonical form and load the result into NetworkX. We use a MultiDiGraph because two entities can be connected by several distinct predicates ("launched from" and "operated by"), and direction matters ("Armstrong commanded Apollo 11" is not the same edge as "Apollo 11 commanded Armstrong").

Each node carries its type, the source document, and the original surface form for traceability.

def build_knowledge_graph(extractions: dict, alias_map: dict) -> nx.MultiDiGraph:
    G = nx.MultiDiGraph()
    
    for doc_title, extraction in extractions.items():
        # Add entities as nodes
        for entity in extraction.entities:
            canonical = alias_map.get(entity.name, entity.name)
            G.add_node(
                canonical,
                type=entity.type,
                source=doc_title,
                surface_form=entity.name
            )
        
        # Add relations as edges
        for relation in extraction.relations:
            subj_canon = alias_map.get(relation.subject, relation.subject)
            obj_canon = alias_map.get(relation.object, relation.object)
            G.add_edge(
                subj_canon,
                obj_canon,
                predicate=relation.predicate,
                source=doc_title
            )
    
    return G

Build the graph

alias_map = resolve_entities(all_extractions) G = build_knowledge_graph(all_extractions, alias_map)

print(f"Graph has {G.number_of_nodes()} nodes and {G.number_of_edges()} edges")

Querying the Graph with Multi-Hop Reasoning

Now for the payoff: answering questions that require traversing multiple relationships. We serialize the relevant subgraph back to Claude for reasoning.

def query_graph(G: nx.MultiDiGraph, question: str) -> str:
    # Serialize graph to text
    edges_text = []
    for u, v, data in G.edges(data=True):
        edges_text.append(f"{u} --[{data['predicate']}]--> {v}")
    
    graph_text = "\n".join(edges_text)
    
    response = client.messages.create(
        model="claude-3-sonnet-20240229",
        max_tokens=4096,
        system="You are a knowledge graph query assistant. Answer questions by reasoning over the provided graph edges.",
        messages=[{
            "role": "user",
            "content": f"Given this knowledge graph:\n\n{graph_text}\n\nQuestion: {question}\n\nAnswer step by step, showing your reasoning."
        }]
    )
    
    return response.content[0].text

Example multi-hop question

question = "Which astronauts were involved in the Apollo program and what spacecraft did they use?" answer = query_graph(G, question) print(answer)

Measuring Quality

To trust your graph in production, you need to measure extraction quality. Create a gold standard set of entities and relations for a small sample of documents, then compare Claude's output against it.

def evaluate_extraction(gold_entities: set, gold_relations: set, 
                       predicted_entities: set, predicted_relations: set):
    # Entity precision and recall
    entity_true_positives = gold_entities & predicted_entities
    entity_precision = len(entity_true_positives) / len(predicted_entities) if predicted_entities else 0
    entity_recall = len(entity_true_positives) / len(gold_entities) if gold_entities else 0
    
    # Relation precision and recall
    relation_true_positives = gold_relations & predicted_relations
    relation_precision = len(relation_true_positives) / len(predicted_relations) if predicted_relations else 0
    relation_recall = len(relation_true_positives) / len(gold_relations) if gold_relations else 0
    
    return {
        "entity_precision": entity_precision,
        "entity_recall": entity_recall,
        "relation_precision": relation_precision,
        "relation_recall": relation_recall
    }

Cost/Quality Tradeoffs

ModelCost per 1K docsQuality (F1)Use Case
Haiku~$0.500.85-0.90High-volume extraction
Sonnet~$3.000.92-0.96Entity resolution, complex reasoning
For production, use Haiku for initial extraction and Sonnet for resolution and query answering. This balances cost and quality effectively.

Key Takeaways

  • No training data needed: Claude extracts entities and relations from unstructured text using structured outputs (Pydantic models), eliminating the need for domain-specific NER or relation classifiers
  • LLM-powered entity resolution outperforms heuristics: Claude can resolve aliases like "Buzz Aldrin" vs "Edwin Aldrin" that string similarity would miss, using semantic context from descriptions
  • Multi-hop reasoning becomes graph traversal: By serializing the knowledge graph back to Claude, you can answer complex questions that span multiple documents without RAG retrieval
  • Cost optimization matters: Use Haiku for high-volume extraction and Sonnet for resolution and reasoning — this hybrid approach balances quality and cost
  • Always measure and validate: Track precision/recall against a gold standard, and watch for over-merging or missing entities in your resolution step