Building a knowledge graph from unstructured text with an LLM

By Mei-Ling Zhou · 16 July 20260 views

Knowledge graphs encode what your data knows about the world: entities, their attributes, and the relationships between them. Building one from structured data (databases, APIs) is straightforward. Building one from unstructured text — articles, support tickets, meeting notes, research papers — requires extracting structure that was never made explicit.

LLMs are particularly good at this task. They understand context, resolve coreference ("Apple released the iPhone... the Cupertino company..."), infer implicit relationships, and handle the kind of ambiguity that makes rule-based NLP brittle. This article covers the complete pipeline from raw text to a queryable graph, with Neo4j as the storage backend.

Designing the entity and relationship schema

Before extraction, define what you want to extract. An open-ended extraction ("find all entities") produces an unusable graph. A constrained extraction ("find companies, people, products, and their relationships from these five relationship types") produces something you can query.

For a technology news knowledge graph, the schema might be:

// Entity types
type EntityType =
  | "Company"
  | "Person"
  | "Product"
  | "Technology"
  | "Location"
  | "Event"
  | "Funding";

// Relationship types (subject → predicate → object)
type RelationshipType =
  | "FOUNDED_BY"        // Company ← Person
  | "WORKS_AT"          // Person → Company
  | "ACQUIRED"          // Company → Company
  | "DEVELOPED"         // Company → Product | Technology
  | "INVESTED_IN"       // Company | Person → Company
  | "LOCATED_IN"        // Company | Person → Location
  | "PARTICIPATED_IN"   // Company | Person → Event
  | "USES"              // Company | Product → Technology
  | "RAISED"            // Company → Funding
  | "COMPETED_WITH";    // Company → Company

interface ExtractedEntity {
  name: string;
  type: EntityType;
  aliases: string[]; // alternative names, abbreviations
  attributes: Record<string, string>; // type-specific attributes
  confidence: number; // 0-1
  sourceSpan?: string; // the text that mentioned this entity
}

interface ExtractedRelationship {
  subjectName: string;
  subjectType: EntityType;
  predicate: RelationshipType;
  objectName: string;
  objectType: EntityType;
  attributes: Record<string, string>; // e.g., { "date": "2024-Q3", "amount": "$500M" }
  confidence: number;
  sourceSpan?: string;
}

interface ExtractionResult {
  entities: ExtractedEntity[];
  relationships: ExtractedRelationship[];
  documentId: string;
}

Define this schema in your extraction prompt. The more constrained the schema, the more consistent (and queryable) the output.

Extracting entities and relationships

The extraction prompt is the most important design decision in the pipeline:

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const EXTRACTION_SYSTEM_PROMPT = `You are a knowledge graph extraction engine for technology news.

## Entity types to extract
- Company: organisations, corporations, startups, VC firms
- Person: individuals (CEO, founder, investor, researcher)
- Product: software products, hardware devices, platforms
- Technology: languages, frameworks, protocols, infrastructure
- Location: cities, countries, regions
- Event: conferences, product launches, funding rounds (as events)
- Funding: specific funding rounds with amounts

## Relationship types (subject → predicate → object)
- FOUNDED_BY: Company → Person
- WORKS_AT: Person → Company (current role)
- ACQUIRED: Company → Company
- DEVELOPED: Company → Product or Technology
- INVESTED_IN: Company or Person → Company
- LOCATED_IN: Company or Person → Location
- PARTICIPATED_IN: Company or Person → Event
- USES: Company or Product → Technology
- RAISED: Company → Funding
- COMPETED_WITH: Company → Company

## Rules
- Extract only entities explicitly mentioned or strongly implied by the text
- Use canonical names (e.g., "Alphabet" not "Google's parent company")
- Record aliases for the same entity (e.g., Meta and Facebook)
- Assign confidence 0.9-1.0 for explicit mentions, 0.6-0.85 for inferences
- Include the verbatim text span that supports each extraction
- Do not hallucinate relationships not supported by the text
- Extract attributes relevant to the entity type:
  - Person: title/role if mentioned
  - Company: industry, size if mentioned
  - Funding: amount, stage (Series A/B/C etc), date if mentioned

Output a single JSON object with "entities" and "relationships" arrays.`;

async function extractFromText(
  text: string,
  documentId: string
): Promise<ExtractionResult> {
  const response = await client.messages.create({
    model: "claude-sonnet-4-5",
    max_tokens: 4096,
    system: EXTRACTION_SYSTEM_PROMPT,
    messages: [
      {
        role: "user",
        content: `Extract entities and relationships from the following text:\n\n${text}`,
      },
    ],
  });

  const responseText = response.content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text)
    .join("");

  const jsonMatch = responseText.match(/\{[\s\S]*\}/);
  if (!jsonMatch) {
    throw new Error(`Extraction failed: no JSON in response for document ${documentId}`);
  }

  const parsed = JSON.parse(jsonMatch[0]);
  return {
    entities: parsed.entities ?? [],
    relationships: parsed.relationships ?? [],
    documentId,
  };
}

For longer documents, chunk the text with overlap and merge results:

function chunkText(text: string, chunkSize = 2000, overlap = 200): string[] {
  const chunks: string[] = [];
  let start = 0;

  while (start < text.length) {
    const end = Math.min(start + chunkSize, text.length);

    // Try to break at a sentence boundary
    let breakPoint = end;
    if (end < text.length) {
      const sentenceEnd = text.lastIndexOf(". ", end);
      if (sentenceEnd > start + chunkSize * 0.7) {
        breakPoint = sentenceEnd + 2;
      }
    }

    chunks.push(text.slice(start, breakPoint));
    start = breakPoint - overlap;
  }

  return chunks;
}

async function extractFromLongDocument(
  text: string,
  documentId: string
): Promise<ExtractionResult> {
  const chunks = chunkText(text);
  const chunkResults = await Promise.all(
    chunks.map((chunk, i) => extractFromText(chunk, `${documentId}-chunk-${i}`))
  );

  // Merge entities and relationships from all chunks
  return mergeExtractionResults(chunkResults, documentId);
}

Entity resolution and deduplication

The same entity appears in different forms across documents: "OpenAI", "Open AI", "OpenAI Inc.", "the AI lab" in one paragraph referring to the same company. Without deduplication, the graph grows with phantom duplicates.

interface EntityCandidate {
  name: string;
  type: EntityType;
  aliases: string[];
  occurrenceCount: number;
}

async function resolveEntity(
  candidate: EntityCandidate,
  existingEntities: EntityCandidate[]
): Promise<{ isNew: boolean; canonicalName: string; matchedEntity?: EntityCandidate }> {
  if (existingEntities.length === 0) {
    return { isNew: true, canonicalName: candidate.name };
  }

  // Fast path: exact name match
  const exactMatch = existingEntities.find(
    (e) => e.name.toLowerCase() === candidate.name.toLowerCase() && e.type === candidate.type
  );
  if (exactMatch) {
    return { isNew: false, canonicalName: exactMatch.name, matchedEntity: exactMatch };
  }

  // Fast path: alias match
  const aliasMatch = existingEntities.find(
    (e) =>
      e.type === candidate.type &&
      e.aliases.some((a) => a.toLowerCase() === candidate.name.toLowerCase())
  );
  if (aliasMatch) {
    return { isNew: false, canonicalName: aliasMatch.name, matchedEntity: aliasMatch };
  }

  // LLM resolution for ambiguous cases (only when string similarity is high)
  const candidates = existingEntities
    .filter((e) => e.type === candidate.type)
    .filter((e) => levenshteinSimilarity(e.name, candidate.name) > 0.6)
    .slice(0, 5); // limit to top 5 similar entities

  if (candidates.length === 0) {
    return { isNew: true, canonicalName: candidate.name };
  }

  const response = await client.messages.create({
    model: "claude-haiku-3-5", // Fast model for simple matching
    max_tokens: 256,
    messages: [
      {
        role: "user",
        content: `Is "${candidate.name}" (type: ${candidate.type}, aliases: ${candidate.aliases.join(", ")}) 
the same entity as any of these?

${candidates.map((c, i) => `${i + 1}. "${c.name}" (aliases: ${c.aliases.join(", ")})`).join("\n")}

Respond with JSON: {"match": number|null, "reasoning": "one sentence"}
Use the number (1-${candidates.length}) for a match, null if it's a new entity.`,
      },
    ],
  });

  const text = response.content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text)
    .join("");

  const result = JSON.parse(text.match(/\{[\s\S]*\}/)![0]);

  if (result.match !== null) {
    const matched = candidates[result.match - 1];
    return { isNew: false, canonicalName: matched.name, matchedEntity: matched };
  }

  return { isNew: true, canonicalName: candidate.name };
}

function levenshteinSimilarity(a: string, b: string): number {
  const al = a.toLowerCase();
  const bl = b.toLowerCase();
  const maxLen = Math.max(al.length, bl.length);
  if (maxLen === 0) return 1;
  // Simple character overlap as fast approximation
  const common = [...al].filter((c) => bl.includes(c)).length;
  return common / maxLen;
}

Storing in Neo4j

Neo4j's Cypher query language is well-suited for knowledge graph storage and retrieval. Use MERGE rather than CREATE to handle deduplication at the database level:

import neo4j, { Driver, Session } from "neo4j-driver";

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

async function upsertEntity(
  session: Session,
  entity: ExtractedEntity,
  documentId: string
): Promise<void> {
  // MERGE ensures we don't create duplicates on re-processing
  await session.run(
    `
    MERGE (e:Entity {name: $name})
    SET e.type = $type,
        e.confidence = CASE WHEN e.confidence IS NULL OR $confidence > e.confidence 
                       THEN $confidence ELSE e.confidence END,
        e.aliases = CASE WHEN e.aliases IS NULL THEN $aliases 
                    ELSE [x IN e.aliases WHERE NOT x IN $aliases] + $aliases END,
        e.lastSeenIn = $documentId,
        e.updatedAt = datetime()
    WITH e
    // Add type label dynamically
    CALL apoc.create.addLabels(e, [$type]) YIELD node
    RETURN node
    `,
    {
      name: entity.name,
      type: entity.type,
      confidence: entity.confidence,
      aliases: entity.aliases,
      documentId,
    }
  );
}

async function upsertRelationship(
  session: Session,
  rel: ExtractedRelationship,
  documentId: string
): Promise<void> {
  // Relationships are keyed by subject+predicate+object — same relationship mentioned 
  // in multiple docs increases confidence rather than creating duplicates
  const query = `
    MATCH (subject:Entity {name: $subjectName})
    MATCH (object:Entity {name: $objectName})
    MERGE (subject)-[r:${rel.predicate}]->(object)
    SET r.confidence = CASE WHEN r.confidence IS NULL OR $confidence > r.confidence
                       THEN $confidence ELSE r.confidence END,
        r.mentions = coalesce(r.mentions, 0) + 1,
        r.lastSeenIn = $documentId,
        r.attributes = CASE WHEN r.attributes IS NULL THEN $attributes
                       ELSE apoc.map.merge(r.attributes, $attributes) END
    RETURN r
  `;

  await session.run(query, {
    subjectName: rel.subjectName,
    objectName: rel.objectName,
    confidence: rel.confidence,
    documentId,
    attributes: rel.attributes ?? {},
  });
}

async function ingestExtractionResult(
  result: ExtractionResult
): Promise<{ entitiesWritten: number; relationshipsWritten: number }> {
  const session = driver.session();
  let entitiesWritten = 0;
  let relationshipsWritten = 0;

  try {
    // Write entities first (relationships reference them)
    for (const entity of result.entities) {
      await upsertEntity(session, entity, result.documentId);
      entitiesWritten++;
    }

    // Write relationships
    for (const rel of result.relationships) {
      try {
        await upsertRelationship(session, rel, result.documentId);
        relationshipsWritten++;
      } catch (err) {
        // Log but continue — missing entity reference should not abort ingestion
        console.warn(`Skipped relationship ${rel.subjectName}${rel.objectName}: ${err}`);
      }
    }
  } finally {
    await session.close();
  }

  return { entitiesWritten, relationshipsWritten };
}

Querying the knowledge graph

The value of a knowledge graph is in the queries it enables. Cypher supports multi-hop relationship traversal that would be expensive or impossible in SQL:

// Who are all the investors (direct and indirect) in a company?
async function getInvestmentNetwork(companyName: string, maxDepth = 3): Promise<unknown[]> {
  const session = driver.session();
  try {
    const result = await session.run(
      `
      MATCH path = (investor)-[:INVESTED_IN*1..${maxDepth}]->(company:Entity {name: $name})
      WHERE investor.type IN ['Company', 'Person']
      RETURN 
        [node IN nodes(path) | node.name] AS chain,
        length(path) AS depth,
        [rel IN relationships(path) | rel.attributes] AS details
      ORDER BY depth
      `,
      { name: companyName }
    );
    return result.records.map((r) => r.toObject());
  } finally {
    await session.close();
  }
}

// Find companies that compete with a given company through shared investors
async function findCompetitorsViaInvestors(companyName: string): Promise<unknown[]> {
  const session = driver.session();
  try {
    const result = await session.run(
      `
      MATCH (target:Company {name: $name})<-[:INVESTED_IN]-(investor)
      MATCH (investor)-[:INVESTED_IN]->(competitor:Company)
      WHERE competitor.name <> $name
      WITH competitor, collect(investor.name) AS sharedInvestors, count(*) AS overlap
      ORDER BY overlap DESC
      RETURN competitor.name AS company, sharedInvestors, overlap
      LIMIT 20
      `,
      { name: companyName }
    );
    return result.records.map((r) => r.toObject());
  } finally {
    await session.close();
  }
}

// Find the shortest path between two entities
async function findConnectionPath(nameA: string, nameB: string): Promise<unknown> {
  const session = driver.session();
  try {
    const result = await session.run(
      `
      MATCH (a:Entity {name: $nameA}), (b:Entity {name: $nameB})
      MATCH path = shortestPath((a)-[*..6]-(b))
      RETURN 
        [n IN nodes(path) | n.name] AS entities,
        [r IN relationships(path) | type(r)] AS relationships,
        length(path) AS hops
      `,
      { nameA, nameB }
    );
    return result.records[0]?.toObject() ?? null;
  } finally {
    await session.close();
  }
}

Natural-language graph queries

Combine the knowledge graph with an LLM to answer natural-language questions that would require complex Cypher:

async function answerGraphQuestion(question: string): Promise<string> {
  // Step 1: Convert question to Cypher
  const cypherResponse = await client.messages.create({
    model: "claude-sonnet-4-5",
    max_tokens: 512,
    system: `Convert natural language questions about a technology knowledge graph to Cypher queries.

Graph schema:
- Nodes: Entity with labels Company, Person, Product, Technology, Location, Event, Funding
- Node properties: name, type, aliases, confidence, attributes (map)
- Relationships: FOUNDED_BY, WORKS_AT, ACQUIRED, DEVELOPED, INVESTED_IN, LOCATED_IN, 
  PARTICIPATED_IN, USES, RAISED, COMPETED_WITH
- Relationship properties: confidence, mentions, attributes (map)

Output only the Cypher query. No explanation. Use LIMIT 20 unless a specific count is asked.`,
    messages: [
      { role: "user", content: `Convert to Cypher: ${question}` },
    ],
  });

  const cypherQuery = cypherResponse.content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text)
    .join("")
    .trim();

  // Step 2: Execute the query
  const session = driver.session();
  let queryResults: unknown[];
  try {
    const result = await session.run(cypherQuery);
    queryResults = result.records.map((r) => r.toObject());
  } catch (err) {
    return `Query error: ${err}. Generated Cypher: ${cypherQuery}`;
  } finally {
    await session.close();
  }

  if (queryResults.length === 0) {
    return "No results found in the knowledge graph for that query.";
  }

  // Step 3: Synthesise natural-language answer
  const answerResponse = await client.messages.create({
    model: "claude-haiku-3-5",
    max_tokens: 512,
    messages: [
      {
        role: "user",
        content: `Question: ${question}

Graph query results:
${JSON.stringify(queryResults, null, 2)}

Answer the question concisely based on these results.`,
      },
    ],
  });

  return answerResponse.content
    .filter((b): b is Anthropic.TextBlock => b.type === "text")
    .map((b) => b.text)
    .join("")
    .trim();
}

Confidence scoring and graph quality

Not all extracted facts are equally reliable. Track confidence at the entity and relationship level and surface it in queries:

// Filter graph to high-confidence facts only
async function getHighConfidenceSubgraph(
  centerEntity: string,
  minConfidence = 0.8,
  maxHops = 2
): Promise<unknown[]> {
  const session = driver.session();
  try {
    const result = await session.run(
      `
      MATCH (center:Entity {name: $name})
      CALL apoc.path.subgraphAll(center, {
        maxLevel: $maxHops,
        relationshipFilter: ">"
      }) YIELD nodes, relationships
      WITH nodes, relationships
      WHERE ALL(r IN relationships WHERE r.confidence >= $minConfidence)
        AND ALL(n IN nodes WHERE n.confidence >= $minConfidence)
      RETURN nodes, relationships
      `,
      { name: centerEntity, maxHops, minConfidence }
    );
    return result.records.map((r) => r.toObject());
  } finally {
    await session.close();
  }
}

// Entities that appear in multiple documents have higher effective confidence
async function recalculateConfidence(session: Session): Promise<void> {
  await session.run(`
    MATCH ()-[r]->()
    SET r.adjustedConfidence = 
      CASE 
        WHEN r.mentions >= 5 THEN MIN(1.0, r.confidence + 0.15)
        WHEN r.mentions >= 3 THEN MIN(1.0, r.confidence + 0.08)
        ELSE r.confidence
      END
  `);
}

Incremental updates

Documents arrive continuously. The pipeline must handle incremental ingestion without reprocessing the entire corpus:

interface DocumentRecord {
  id: string;
  contentHash: string;
  extractedAt: string;
  entityCount: number;
  relationshipCount: number;
}

async function shouldReprocess(
  documentId: string,
  contentHash: string,
  db: Map<string, DocumentRecord>
): Promise<boolean> {
  const existing = db.get(documentId);
  if (!existing) return true; // New document
  if (existing.contentHash !== contentHash) return true; // Content changed
  return false; // Same content, same extraction = skip
}

async function processDocumentIncremental(
  documentId: string,
  text: string,
  processedDocs: Map<string, DocumentRecord>
): Promise<void> {
  const contentHash = require("crypto")
    .createHash("sha256")
    .update(text)
    .digest("hex");

  if (!(await shouldReprocess(documentId, contentHash, processedDocs))) {
    console.log(`Skipping ${documentId}: already processed`);
    return;
  }

  const result = await extractFromLongDocument(text, documentId);
  const { entitiesWritten, relationshipsWritten } = await ingestExtractionResult(result);

  processedDocs.set(documentId, {
    id: documentId,
    contentHash,
    extractedAt: new Date().toISOString(),
    entityCount: entitiesWritten,
    relationshipCount: relationshipsWritten,
  });

  console.log(
    `Processed ${documentId}: ${entitiesWritten} entities, ${relationshipsWritten} relationships`
  );
}

A knowledge graph built this way typically reaches useful density after 200–500 documents and becomes significantly richer at 2,000+. The graph enables queries that are impossible or prohibitively expensive against the raw text — finding indirect connections, computing centrality, discovering clusters, and answering multi-hop questions in milliseconds rather than re-querying the LLM each time.

The combination of LLM extraction with a proper graph database storage layer is one of the most powerful patterns available for making unstructured text queryable and discoverable at scale.

Comments

No comments yet. Be the first!

Sign in to leave a comment.