
This blog is written by AI for SEO
Entity Resolution for Knowledge Graphs Built by an LLM
Entity resolution for knowledge graphs means deciding which nodes an LLM extracted are the same real-world thing, then linking or merging them without losing the evidence. The pipeline in this guide is block, generate candidates, score on shared edges, link or merge, and keep provenance. You can build every step on any stack. The last two steps show how it runs in HelixDB, a graph-vector database built in Rust, where candidate search and the merge commit live in one engine.
The problem shows up fast. Run an extraction pass over a few thousand documents and "OpenAI", "Open AI Inc." and "the lab" arrive as three nodes, each with a slice of the edges. Multi-hop retrieval then misses connected context, because the path you want runs through a node that was never joined to its twin. Prerequisites: a graph of extracted entities with edges to the documents or chunks that mentioned them, and an embedding for each entity. This post covers resolution across a whole document corpus. Per-agent dedupe is a different problem, covered in deduplicating entities in an agent memory graph.
Step 1: Know what LLM extraction breaks (aliases, type drift, and why this is not classic record linkage)
LLM extraction breaks identity in three ways: aliases, type drift and context-free mentions. Aliases are the obvious one: "OpenAI", "Open AI Inc." and "the lab" are one entity. Type drift is subtler. The same mention is tagged Organization in one document and Product in another, so a hard type filter silently splits it. Context-free mentions are the worst: "Mercury" is a planet, an element or a company depending on the passage.
Classic record linkage assumes structured records with comparable fields: name, date of birth, address. LLM-extracted nodes have a name, a loose type, a description the model wrote and a neighbourhood of edges. The strongest signal you have is not a field at all. It is the graph around the node.
That shapes the whole design. Names and embeddings find candidates. They don't prove identity. Entities that are similar or share neighbours may be related but distinct, such as a project and one specific mission inside it. Treat similarity as a reason to look, and decide with evidence.
Step 2: Normalize and block so you never compare every pair
Block first, because comparing every pair of n entities is O(n^2) and a million-node graph makes that impossible. Blocking cuts the comparison set to pairs that share something cheap to check.
Normalize names before anything else: lowercase, strip punctuation, drop corporate suffixes (Inc., Ltd., LLC), fold unicode, expand obvious abbreviations. Then build several blocking keys per entity, because one key always misses something:
The normalized name and its first token.
A phonetic or character n-gram key for typos.
The coarse type, used as a soft signal rather than a hard wall, given type drift.
Exact identifiers when they exist: email, domain, ticker, URL.
Two entities are candidates if they share any block. Keep blocks small. Cap any block above a few hundred members and split it with a second key, or a common name like "John Smith" will swamp the pipeline. Blocking is not a match decision. It only decides who gets compared.
Step 3: Generate candidates with BM25 on names plus embeddings of name and context
Generate candidates from two directions. Lexical search on names catches spelling variants, and embeddings catch the cases where the surface form differs entirely. Use both, because each misses what the other finds.
For the lexical side, index entity names and aliases with BM25 (a keyword ranking function) and query with each entity's name. "Open AI Inc." finds "OpenAI" this way even when an embedding would be noisy on short strings.
For the semantic side, embed the name together with its context, not the name alone. A bare name embeds to almost nothing useful and produces false matches. Concatenate the name, the type, the description the LLM wrote and a few neighbour names, then embed that string. "the lab" with neighbours like "GPT", "Altman" and "San Francisco" lands next to "OpenAI" with the same neighbours.
Take the top k from each source, union them, and drop anything outside the blocks from Step 2. If you have a third candidate source, add it: entities that share neighbours in the graph. The output of this step is a list of (a, b) pairs and nothing more.
Step 4: Score each pair with shared-edge neighbourhood overlap and hard-conflict checks
Score each pair on several signals and let shared edges carry the most weight, since they are the evidence an LLM can't fake with a fluent description. Never decide on vector similarity alone.
A workable scoring function combines:
Name score: normalized string similarity plus BM25 rank.
Embedding similarity: cosine over the name-plus-context vectors.
Neighbourhood overlap: the Jaccard overlap of the two entities' neighbours, computed over typed edges. Two nodes that both have
FOUNDED_BY -> Sam AltmanandHEADQUARTERED_IN -> San Franciscoare very likely the same. Weight by relation: sharing a rare relation target counts for much more than sharing a hub like "United States".Type compatibility: a soft score, because of drift.
Then run hard-conflict checks that veto the pair regardless of score: different email addresses or domains, different ticker symbols, incompatible dates (founded 1998 versus 2015), or an explicit DIFFERENT_FROM edge from an earlier review. One conflict overrides a high similarity.
Output a score and a list of the signals that produced it. You'll need that list in Step 6.
Step 5: Decide link or merge: SAME_AS edges for uncertain pairs, merges only above a high bar
Merge only when the evidence is strong, and write a SAME_AS edge for everything uncertain. A false merge destroys information and is costly to undo. A false link costs you one extra hop.
Use three bands:
Above the high bar (for example, a matching identifier, or a high combined score with strong shared-edge overlap and no conflicts): merge.
Middle band: keep both nodes and add a
SAME_ASedge carrying the score, the signals and a timestamp. Queries can traverseSAME_ASwhen they want the union and ignore it when they want precision.Below the low bar: do nothing, or record
DIFFERENT_FROMif a reviewer said so.
A merge itself is a fixed procedure: pick a canonical node, fold the other's names into an aliases property, merge properties with a stated conflict rule, rewire every incident edge to the canonical node, and drop duplicate edges that now point the same way. Run the middle band through a review queue and promote links to merges as more documents add evidence. Conservative resolution gets better with time. Aggressive merging only gets harder to reverse.
Step 6: Keep provenance and evidence so every resolution can be audited and undone
Store provenance for every mention and every decision, or you can't debug a bad merge. Resolution algorithms make mistakes, and the only fix is knowing exactly what was folded in.
Model it in the graph:
A
Mentionnode per extracted occurrence, with the surface string, the source document and chunk, and the extraction run id.A
MENTIONSedge from each mention to the entity it currently resolves to.On every
SAME_ASedge and every merge, an evidence record: the signals and scores from Step 4, the rule version, and who or what decided.On merged canonical nodes, the ids of the nodes that were absorbed.
Mentions point at the canonical node rather than being copied into it. So undoing a merge means re-pointing the mentions that belonged to the absorbed node and restoring its edges from the evidence record. Without the record, reversal is guesswork. Log the merge as one operation with one id so it can be reverted as one operation.
Step 7: Run it in HelixDB: pre-filtered vector search, BM25 and an atomic merge in one engine
HelixDB is an open-source graph-vector database built in Rust that holds graph, vector and BM25 full-text search in one engine, which collapses Steps 3 to 5 into queries against a single store. Without it, candidate search runs in a vector DB, neighbourhood overlap runs in a graph DB, and the merge has to write to both and hope neither fails halfway.
Three features matter here:
Pre-filtered vector search. Vector search can be scoped to things related to each other, so similarity runs over a graph-filtered candidate set instead of the whole index. For resolution, that means searching only inside a block or an entity's neighbourhood, which cuts both cost and false matches.
BM25 in the same engine. Name lookup and embedding lookup are two queries against one database, so candidate generation needs no second system.
Atomic writes. Graph and vector writes commit or roll back together, so a merge that rewires edges and updates the canonical node's embedding can't leave half the work behind.
Setup is helix init local then helix start dev (needs Docker or Podman, serves on port 6969), or open it embedded. Here is the shape of a candidate query with the TypeScript SDK, following the hybrid search pattern from the docs:
import { g, VectorDistanceMetric } from "@helix-db/helix-db";
// Candidates by name (BM25)
const byName = g()
.nodes("Entity")
.search("name", "Open AI Inc.")
.limit(20);
// Candidates by name+context embedding
const byEmbedding = g()
.nodes("Entity")
.vectorSearch("embedding", contextVector, {
metric: VectorDistanceMetric.Cosine,
limit: 20,
});
const [nameHits, vectorHits] = await Promise.all([
client.query(byName.toQueryRequest()).send(),
client.query(byEmbedding.toQueryRequest()).send(),
]);Create a text index on Entity.name and a vector index on Entity.embedding first, and make sure the query vector matches the index dimension. Union the two hit lists, score them with Step 4 in your application code, then do the merge as one write batch built with writeBatch(): update the canonical node, add the alias, rewire the edges, write the evidence record, and send it once so it commits or rolls back as a unit. Note the documented limits: an unrestricted vector search caps at 800 effective results, so keep candidate scopes tight. The query reference is in the HelixDB docs, and the code is at github.com/HelixDB/helix-db. For retrieval over the cleaned graph afterwards, see knowledge graph RAG.
Step 8: Measure it with labelled pairs, and what to do when resolution goes wrong
Measure resolution with labelled pairs, not by eyeballing the graph. Sample a few hundred candidate pairs across the score range, have a person label each as same, different or unsure, and compute precision and recall at your merge threshold. Oversample the middle band, since that is where decisions are hard.
Track precision on merges separately from recall on links. Merge precision should be very high, because errors are expensive. Link recall can be looser. When you change a rule or threshold, rerun against the same labelled set before it touches the graph.
When resolution goes wrong, work in this order:
A false merge: use the evidence record to revert that one merge operation, add a
DIFFERENT_FROMedge so it isn't proposed again, and add the missed hard-conflict check.Duplicates that persist: the missed pair usually shares no block. Add a blocking key, then check whether name-plus-context embeddings include enough neighbours.
Hub entities merging everything: down-weight overlap on high-degree neighbours.
FAQ notes: RDF vs LPG and tooling
RDF stores facts as subject-predicate-object triples with global URIs. A labelled property graph (LPG) stores nodes and edges that carry properties. Neo4j is an LPG. LLM extraction pipelines mostly produce LPG-shaped output, and everything above works on either model. Entity resolution libraries such as Splink, Zingg, dedupe and the commercial Senzing resolve records before graph load. They don't replace the graph-aware scoring in Step 4, which needs the neighbourhood.
Start with SAME_AS links, merge only when the evidence earns it
The reliable pattern is conservative: block, generate candidates two ways, score on shared edges, link what is uncertain, merge what is proven, and keep every mention so any call can be undone. Build the pipeline on whatever stack you have, then try the candidate search and atomic merge in one engine. Run helix init local and helix start dev, load a slice of your extracted graph, and star HelixDB on GitHub if it saves you the second database.