GraphRAG vs hybrid RAG: testing Microsoft Research's claim on 13,000 private notes

Microsoft Research's GraphRAG work argues that an LLM-built knowledge graph beats plain vector retrieval. A local Neo4j graph of a 13,284-note Obsidian vault, a 47-question retrieval eval and two fair comparisons show where that holds, where it does not, and what it costs.

GraphRAG vs hybrid RAG: testing Microsoft Research's claim on 13,000 private notes
Role
Graph design, extraction pipeline, retrieval engineering and evaluation
Timeline
One day, October 2026
Published
October 2026
Read
25 min
40,903Entities resolved across 13 folders and 31,047 chunks
52,866Dated claims, each tagged fact, plan or AI suggestion
47Hand-labelled eval questions across 9 types
~$28Total Haiku 5.5 spend, one-time; every query runs locally

The claim being tested

Microsoft Research's 2024 GraphRAG paper (Edge et al., From Local to Global: A Graph RAG Approach to Query-Focused Summarization) argues that when a language model first turns a corpus into a graph of entities, relationships and claims, the answers to questions about the whole corpus become more comprehensive than with conventional vector retrieval.

This case study tests that claim on a real, private corpus: an Obsidian vault of 13,284 Markdown notes (14 million words) that I use as a personal and business knowledge base. It is not a client deployment. The test started with one folder, the 623 notes behind Mindreader, the sales-psychology software company where I am CTO, because that folder holds five years of decisions, pivots, metrics and contradictions.

It follows an earlier piece, a private search engine over the same vault, which built the hybrid search (keyword plus semantic, fused by rank) that GraphRAG had to beat.

That hybrid baseline is already a step beyond what most AI chat tools do. When ChatGPT, Claude or a similar assistant searches company documents through a connector, it mostly relies on sparse (keyword) retrieval, because the connected systems hold no embeddings of the documents. Sparse retrieval finds the words in a question, not its meaning. Dense retrieval, which matches on embeddings, is the missing piece that improves search quality most, and it no longer needs a hosted service: the embedding model used here, bge-m3, is a 1.2 GB local model that runs on a laptop.

Diagram and results. How a graph query runs: question, then hybrid search (BM25 plus bge-m3, top 20 notes), then the graph layer (entities named in the question plus 8 seeds from the top notes, typed links 1 hop that never pass through a hub, dated claims marked fact, plan or AI suggestion with superseded values marked, and a local bge-m3 relevance trim), then an 8,000-token context of 40% graph lines and 60% chunks. The graph was built once with Claude Haiku 5.5: 5,673 entities, 5,304 links and 8,005 claims for about $7, and every query runs locally and free. How it was tested: 47 hand-labelled questions across 9 types; round 1, the graph alone; round 2, the graph on top of hybrid search. Results, round 1 over all 44 scored questions: gold notes in the top 10, hybrid 0.53 vs graph 0.39 (hybrid wins, p = 0.04); key facts reaching the answerer within 8,000 tokens, hybrid 0.47 vs graph 0.37 (p = 0.19). Round 2, hybrid vs hybrid plus graph: temporal questions, gold notes within budget 0.40 to 0.53; modality traps 0.60 to 0.47; all 44 questions, key facts 0.47 to 0.48, a tie with 3 better, 3 worse and 38 the same.
The design that was tested, how it was tested, and what changed. Grey is hybrid search, blue the graph method, green where the graph improved the result.

The constraints

Four constraints shaped every decision.

  1. Private by default. The notes include customer calls, finances and journals. The graph had to live on a laptop (Neo4j Desktop, local), embeddings had to be computed locally, and the only text leaving the machine was the one-time extraction call to the Anthropic API, folder by folder, with personal folders opt-in.
  2. Notes contradict each other over time. Paying-customer counts, positioning and pricing appear in dozens of notes from different years, and the numbers differ. A useful system must say "was X in 2025, later Y", not average them.
  3. Plans are not facts. Many notes are strategy drafts or saved ChatGPT conversations. "We will close 10 enterprise clients" is a plan, and "ChatGPT suggested this headline" is not a decision. Vector search cannot tell those apart from a record of what happened.
  4. Most files were online-only. 82% of the vault's Markdown files were Google Drive placeholders. Reading them meant fetching text on demand (99 MB of Markdown out of an 11.4 GB vault) without pulling down attachments.

The model: what goes into the graph and why

Three layers, one rule about evidence

The graph has three layers, and every fact in the top layer points back to a passage in the middle one.

LayerNodesBuilt byCost
StructureNote, Tag, Folder, with LINKS_TO, TAGGED, IN_FOLDERParsing the Markdown (wikilinks, front matter, folders)Free, ~2 minutes for 13,284 notes
EvidenceChunk, with a 1,024-dimension embedding and NEXT_CHUNKLocal chunker + local embedding modelFree, ~36 minutes for 4,779 chunks
KnowledgeEntity, Claim, and 29 typed relationshipsClaude Haiku 5.5, once per chunk, cached~$3.20–3.60 per full pass of the folder

The structure layer alone already holds 45,000 note-to-note links, and it showed 1,571 links pointing at notes that do not exist. The vault's own wiki notes (wiki/entities, wiki/concepts) became canonical records that extracted entities link to with DESCRIBED_BY.

Neo4j Desktop showing the local obsidian database: 142,423 nodes with labels Chunk, Claim, Concept, Decision, Document, Entity, Event, Folder, HubEntity, Lesson, Location, Note, Organization, Person, Product, Project and Tag; 366,575 relationships including ABOUT, AUTHORED, BUILT, CLIENT_OF, CONFLICTS_WITH, DECIDED, EVIDENCED_BY, FOUNDED, INTRODUCED, LEARNED_FROM and LED_TO. On the right, a Cypher query returns the company entity and 60 of its direct neighbours, drawn as a radial graph that completed in 149 ms.
The local database in Neo4j Desktop after all 13 folders were loaded: 142,423 nodes across the three layers and 366,575 relationships. On the right, one hub entity's direct neighbours, the kind of node the search is never allowed to pass through.

Chunking: one-time, local, and sized for the extractor

The first attempt used the embeddings from Obsidian's Smart Connections plugin. Both it and Neo4j run locally, but Smart Connections keeps its vectors in its own index, apart from the links, dates and keyword search, which made them hard to traverse. Neo4j stores the vectors next to the notes, links and claims, so retrieval and traversal happen in one query. The plugin's blocks also had a median of 49 characters, too small to carry a decision and its reason. The chunker was rebuilt with one rule: a note of up to 1,500 tokens stays whole; a longer note is split at its headings into sections of at most 1,000 tokens (minimum 100), with 200 characters of overlap, and every chunk carries a breadcrumb (Note title > Heading > Subheading) and the note's date. The Mindreader folder produced 4,779 chunks with a median of 837 tokens.

The embedding model changed too, from Smart Connections' 384-dimension bge-micro-v2 (66 MB, English only) to bge-m3 through Ollama: 1,024 dimensions, an 8,000-token window, and multilingual, which matters because some notes are in Chinese. Each chunk stores a hash of its breadcrumb and text, so a rebuild re-embeds only what changed.

Competency questions decided the schema

The schema was not designed from a generic ontology. It started from the questions the graph should answer: receipts and proof, timelines, people and introductions, decisions and their reasons, lessons, clients and outcomes, changes and conflicts, and connections across projects. Each entity type and relationship had to serve at least one of them.

  • Ten entity types: Person, Organization, Product, Project, Concept, Event, Location, Document, Decision and Lesson. Decision and Lesson are first-class nodes, so "why did we decide X and what did it lead to" is a path (DECIDED, LED_TO, LEARNED_FROM), not a text search.
  • 29 relationship types, from CLIENT_OF, INVESTED_IN and INTRODUCED to EVIDENCED_BY and POSITIONED_AGAINST, plus a vague RELATED_TO fallback.
  • Every link stores its evidence: how many chunks observed it, up to five evidence phrases, the source chunk ids, first and last seen dates, and the modalities it was stated in.

Claims are nodes, with two clocks and a modality

The most important modelling decision was to make claims nodes rather than properties. A claim such as "paying customers: 100+" is a Claim node with the attribute, the value, the exact quote, and three time-related fields:

(:Chunk)-[:SUPPORTS]->(:Claim {attribute, value, quote, modality,
                               valid_from, valid_to, note_date, is_current})-[:ABOUT]->(:Entity)
(:Claim)-[:SUPERSEDED_BY]->(:Claim)      a later note gives a different value
(:Claim)-[:CONFLICTS_WITH]-(:Claim)      two notes of the same date disagree

This is a bitemporal model: valid_from is when the fact became true in the world, note_date is when it was written down. Outdated facts are never deleted, only marked superseded, so "what did the notes say in February?" still works.

Each claim also carries a modality: fact, reported, plan, opinion, hypothetical or ai_suggestion. That one field is what lets an answer say "this was planned" or "ChatGPT suggested this" instead of reporting it as done. The extraction prompt also forbids conversation mechanics ("Tony asked…", "ChatGPT generated…") as relationships, which cut that noise by 84% between the first and second prompt versions.

Hubs are labelled so traversal can avoid them

A few entities are mentioned everywhere: the company itself (2,866 chunks) and the note author. Any traversal that passes through them reaches half the graph. Entities mentioned in more than 300 chunks get a :HubEntity label, and the search never passes through one. A hub can be where a search starts, never the middle of a path.

Hover or tap a node to see what it links to. Drag nodes to rearrange.

  • Hub (300+ mentions)
  • Company, product or technology
  • Method or framework
  • Client, backer or press
A public slice of the graph, exported from Neo4j: 30 entities from the Mindreader folder that are already public (clients, backers, press, the HIS temperament framework and the products), with the 45 links between them. Each link was observed in at least two passages, or is an approved public fact. Line weight is the number of observations; quotes and note paths are left out. The three dark nodes are hubs, which the search may start from but never pass through.

Extraction once, inference locally

The split between paid and local work was deliberate.

StepWhere it runsHow oftenWhy there
Entity, relationship and claim extractionClaude Haiku 5.5 (API)Once per chunk; cached by a hash of model, prompt version and textNeeds real language understanding; at $0.10 / $0.50 per million tokens a full folder costs a few dollars
Chunk and question embeddingsbge-m3 in Ollama, on the laptopChunks once; one question embedding per queryPrivate, free, fast enough
Graph traversal, keyword search, rankingNeo4j, on the laptopEvery queryDeterministic, free, inspectable
Relevance trimming of graph contextbge-m3, on the laptopEvery query (~5 s for ~100 lines)Free, no data leaves the machine

Extraction used structured outputs (a JSON schema enforced by the API) with low effort, and up to 64 parallel requests, well under the key's 10-million-input-tokens-per-minute limit. Prompt caching was evaluated and switched off: the system prompt was below the minimum cacheable size and every passage was unique, so caching added write cost without reads. A query costs nothing; the graph's running cost is the one-time extraction.

Schema evolution without re-extracting

The first full extraction filed 35% of links under RELATED_TO. Grouping those links by their evidence phrases showed recurring real relationships: positioned against, example of, party to an agreement, covers, affects, promotes. Re-extracting every chunk with six new types would have cost about $3.60. Instead, Haiku re-typed only the vague links in batches of 40, reading just the two endpoints and the evidence phrase. That cost $0.11 and brought RELATED_TO down to 16%.

Entity resolution: rules first, then two LLM passes

The extractor produced 15,345 entity mentions. Rule-based resolution (case, punctuation, .ai, company suffixes, one shared namespace for organisations, products, projects and concepts) brought that to 5,882. Two failure modes appeared on the way:

  • Alias chaining. One chunk calling A an alias of B, and another calling B an alias of C, merged unrelated entities: the company node briefly absorbed 466 aliases. The fix was that an alias only merges when nobody uses it as an entity name in its own right, and only when it points to a single target.
  • Near-duplicates. "MindReader AI" and "Mindreader" survive rules. Local bge-m3 similarity plus name containment proposed 1,161 candidate pairs; Haiku accepted 175. A second, stricter pass with explicit rejection rules confirmed 158, because the first pass alone had merged a company with its product and even two different tech CEOs. Five more were vetoed by hand in a tracked overrides file.

A separate audit checked every link seen only once against its own evidence phrase and dropped 613 of 5,394 that the phrase did not support ("discussed alongside" is not "affects"). The loaded graph: 5,673 entities, 5,304 typed relationships and 8,005 claims, with 112 supersessions and 27 conflicts.

The structure: how the system runs

Three skills and a read-only MCP server

The pipeline is packaged as three agent skills, so Claude Code and Codex follow the same rules:

  • /neo4j-obsidian-kb-admin refreshes the knowledge base: it checks what changed in the vault, re-chunks only edited notes, re-extracts only new chunks after a cost check (anything over $0.50 needs approval), proposes new relationship types from the vague links, and rebuilds the entity layer in about ten seconds.
  • /obsidian-hybrid-retrieval handles everyday lookups: keyword (sparse) and embedding (dense) search fused by rank, then a reranker, then a check against the source notes.
  • /obsidian-graphrag-neo4j-search handles questions where relationships and multi-step hops matter (who introduced whom, what a decision led to, how a figure changed over time), with dated citations to note paths.

A read-only Neo4j MCP server lets the agent run ad-hoc Cypher for counts and filters, capped at 8,000 tokens and 30 seconds per response so a careless query cannot flood the conversation.

The search rules live in code, not in the prompt

Every limit is enforced by the search script, so an agent cannot talk itself past it:

question ─► BM25 + bge-m3 over chunks, fused by rank (k = 60) ─► top 20 notes
          ─► entry entities: named in the question + 8 seeds from those notes
          ─► 1 hop (2 at most), never through a hub, 25 links per named entity
          ─► dated claims matching the question, with their superseded neighbours
          ─► lines scored against the question with bge-m3; anything 0.15 below the best is trimmed
          ─► claim and link lines (40% of the budget) + ranked chunks (60%)

Questions that name a relationship ("who introduced…", "which clients…") are mapped to the relationship types they imply, and those links are fetched first and never trimmed. Older claims are returned and marked superseded, never hidden.

The evidence: the eval and its results

The eval set

The retrieval eval has 47 questions in nine categories, each written to stress a different property: single facts (8, the control group), multi-hop (8), entity roll-ups (7), temporal (6), modality traps (5), broad themes (5), paraphrase and alias (4), disambiguation (1) and unanswerable (3). They were drafted from the notes, not from the graph (45 of 47), then reviewed and approved, and split into a dev set (17) and a test set (30).

Each question has gold notes graded 2 (essential) or 1 (supporting, or a deliberate distractor such as an outdated figure), and key facts with an exact quote from a gold note. Generation quality is deliberately out of scope: this set measures retrieval only, and answer quality belongs in a separate eval.

The scoring

MetricWhat it answers
recall@10, recall@20, all_gold@20, MRR, nDCG@10Did the right notes rank, and how early?
fact_recall@8kDid each key fact's exact quote reach the first 8,000 tokens the answerer would read?
note_recall@8k, precision@8kHow many gold notes made the budget, and how much of the budget went on them?
current_firstOn temporal and modality questions, did the current note rank above the outdated distractor?
Sign testOver per-question wins and losses, is the gap more than chance?

Three fairness fixes were needed before the numbers meant anything. The hybrid search printed only a 420-character excerpt per note, which would have penalised it on text-based metrics, so the eval looks up the full chunk it chose. The graph returns chunks rather than notes, so it was asked for 60 chunks to compete at the same 20-note depth. And the 8,000-token budget uses the same 40/60 split between graph lines and chunks that the search script uses.

Round 1: graph search on its own

Question typenFacts reaching the answerer (8k), hybrid / graphGold notes in top 10, hybrid / graph
Single fact80.88 / 0.881.00 / 0.88
Multi-hop80.26 / 0.220.39 / 0.30
Entity roll-up70.55 / 0.350.29 / 0.24
Temporal60.28 / 0.140.40 / 0.19
Modality trap50.40 / 0.400.60 / 0.50
Broad theme50.27 / 0.230.43 / 0.22
All 44 scored440.47 / 0.370.53 / 0.39

Hybrid search won on every type, including the types where the graph was expected to win. On gold notes in the top 10 it won 15 questions, lost 5 and tied 24 (sign test p = 0.04).

The failure analysis showed three causes, none of them the graph's data. The graph's chunk ranking had no keyword search, so exact-term matches were lost. Chunks reached through claims and links were fused with the same weight as direct matches, pushing gold notes from ranks 1–3 down to 12–17. And on temporal questions, deliberately fetching each claim's earlier versions promoted exactly the outdated notes the gold labels marked as distractors (current note first: 0 of 6, against 0.40 for hybrid).

Round 2: the graph layered on hybrid search

The second design keeps hybrid search's ranked chunks untouched and adds the graph's claim and link lines inside the same 8,000-token budget, so the question becomes: does the graph add anything on top?

Question typeFacts reaching the answerer (8k), hybrid / hybrid + graphGold notes within budget, hybrid / hybrid + graph
Temporal0.28 / 0.360.40 / 0.53
Modality trap0.40 / 0.400.60 / 0.47
Broad theme0.27 / 0.300.38 / 0.43
All 44 scored0.47 / 0.480.52 / 0.52

Overall it tied: 3 questions better, 3 worse, 38 the same. The dated claim lines brought current notes into the budget on temporal questions. On modality traps, the graph lines displaced chunks that held the gold note. Everywhere else they repeated what the chunks already said. With five to eight questions per type, no single category gap is significant; the temporal gain is a lead to test, not a proven result.

Round 3: the same questions on a graph seven times larger

After twelve more folders went in (31,047 chunks instead of 4,779), the 47 Mindreader questions were run again, this time with latency recorded per question and a third variant that switches off the relevance trim. All three share the same hybrid base, so the comparison stays like for like.

Over the 44 scored questionsHybridHybrid + graphHybrid + graph, no trimHybrid + graph, after the lookup fix
Key facts within 8,000 tokens0.510.500.400.49
Gold notes within 8,000 tokens0.540.560.500.56
Gold notes in the top 100.550.550.550.55
Questions better / worse than hybrid on key facts–1 / 5 (p = 0.22)1 / 12 (p = 0.003)1 / 6 (p = 0.13)
Median latency0.8 s9.2 s0.5 s4.2 s
95th-percentile latency0.9 s13.2 s0.6 s6.0 s

Three results held from Round 2, and one is new:

  • Time-based questions still gain. Gold notes within budget rose from 0.44 to 0.53 on temporal questions, and multi-hop questions now gain too (0.39 to 0.47, key facts 0.34 to 0.39).
  • Plans-that-never-happened questions still lose (0.70 to 0.63), and so do entity roll-ups and broad themes on key facts (0.55 to 0.48 and 0.30 to 0.23): the graph lines take budget from chunks that already held the answer.
  • Overall it is still a tie.
  • The trim is what keeps it a tie, and it is most of the cost. The graph lookup itself takes about half a second. Embedding every candidate claim and link line with the local model to cut the irrelevant ones takes the other eight, and the larger graph produces more candidate lines (median latency rose from 7.3 s to 9.2 s). Without the trim, the graph's lines flood the budget and key facts fall from 0.51 to 0.40, significantly worse than hybrid alone.

A routine refresh of the knowledge base afterwards found why the trim had so much to do. The entity lookup matched question words against every alias the extractor had recorded, and some of those aliases were ordinary words: "his" was an alias on a dozen people, "framework" and "map" on unrelated entities. A question about "his role" started its traversal from people it never mentioned. Ignoring pronouns and requiring one-word aliases to match with their capitals ("HIS" the framework, not "his") cut the median latency from 9.2 to 4.2 seconds and the 95th percentile from 13.2 to 6.0, with retrieval unchanged (key facts 0.49, gold notes 0.56).

The baseline moved between rounds as well (key facts 0.47 to 0.51 for hybrid alone) without any Mindreader note changing. The cause is an edge case worth knowing, described below.

What the evals did not measure

The Microsoft Research result is about global questions, summaries over a whole corpus. Most of these 47 questions are local: a fact, a timeline, a set of related notes. Only the five broad-theme questions resemble the paper's setting, and there the layered graph was slightly ahead (0.30 vs 0.27 of key facts), too few questions to call. So this eval does not contradict the paper; it shows the claim does not carry over to everyday lookups on its own.

The key-fact metric checks for an exact quote, and the graph's claim lines carry the extractor's quotes, which are sometimes shorter than the gold quote, a bias against the graph. More importantly, the graph's main extras are labels a retrieval metric cannot see: "superseded on 2026-02-01 by…", "this was a plan", "ChatGPT suggested this". Whether those stop a model from reporting a stale or planned-only figure as current is a generation question, and it is where the next eval goes.

Decisions and trade-offs

DecisionChosenRejectedWhy
Where the graph livesNeo4j Desktop, local (1 GB heap)Neo4j Aura (cloud)Private notes stay on the machine; the graph fits comfortably in a 1 GB heap
Embeddingsbge-m3 locally (1,024 dims, multilingual)Hosted embedding API; keeping 384-dim bge-microPrivacy, zero per-query cost, Chinese notes
ExtractorClaude Haiku 5.5, structured outputsLarger modelsA few dollars per folder; schema-checked JSON; quality checked on samples first
FactsClaims as nodes with modality and two datesProperties on entitiesContradictions and plans become queryable instead of overwritten
New relationship typesRe-type vague links ($0.11)Re-extract the folder (~$3.60)Same result for 3% of the cost
Merging entitiesRules, then two LLM passes, then hand overridesOne LLM passOne pass merged unrelated entities; precision matters more than recall for identity
Search limitsEnforced in the search scriptWritten as prompt instructionsAn agent can ignore a prompt; it cannot ignore code
Graph as a productAn add-on layer on hybrid searchA replacement for itRound 1: alone it lost; Round 2: on top it tied and helped time-based questions
Relevance trimLocal bge-m3 scoring of every graph line (~4 s per query at 13 folders)No trim (~0.5 s)Without it, key facts fell from 0.51 to 0.40 (p = 0.003); the trim is what makes the graph safe to add, and it is acceptable for research questions, not for autocomplete
Speed budgetGraph layer only for questions that need itGraph on every queryHybrid answers in 0.8 s and ties overall; the graph's ~4 s pays off only on time-based and multi-hop questions

Some problems only surfaced because the results were checked by hand: timelines first sorted by valid_from instead of the note date, which made a 2025 note saying "launched in 2022" look older than a 2023 note; search results changed between runs because Python's randomised set ordering broke ties between seed entities; and the first eval run would have understated hybrid search because of its excerpt length.

When the graph is worth the wait

The eval turns into a routing rule. Hybrid search is the default: it answers in under a second and ties the graph overall. The graph layer earns its extra three to five seconds only when the answer has to be assembled across dates or entities, or when the answer depends on whether a statement is still true or was ever more than a plan.

Question shapeExampleUseEvidence from the eval
A single fact"What did the pilot cost?"HybridSame score (0.88 of key facts), about 5× faster
Something planned that may not have happened"Did the dating MVP launch?"HybridGold notes 0.70 vs 0.63: graph lines displaced the chunk with the answer
A roll-up or broad theme"Everything about one client", "why the pivot"HybridKey facts 0.55 vs 0.48 and 0.30 vs 0.23
A paraphrase or alias"Who handled the money side?"HybridTie (0.50 each)
How something changed over time"How did paying-customer numbers change?"GraphGold notes 0.44 → 0.53; outdated values marked superseded, same-date disagreements flagged
A multi-step link"Who introduced X, and what came of it?"GraphGold notes 0.39 → 0.47, key facts 0.34 → 0.39
Why a decision was made and what it led to"Why move away from SaaS, and what followed?"GraphFollows DECIDED, LED_TO and LEARNED_FROM paths that text search has to guess
Whether something was a fact, a plan or an AI suggestion"Was 10 enterprise clients a result or a target?"GraphEvery claim carries a modality; a retrieval score cannot see it, the answer needs it
Counts and filters"How many client relationships started in 2026?"Graph (Cypher)A query over typed links, not a search
How two things connect"How is the framework linked to the AIA rollout?"Graph (path)Shortest path of up to four hops, never through a hub

When in doubt, start with hybrid search, and switch to the graph when it returns passages that disagree or might be out of date.

Edge cases that only appeared at scale

Scaling from one folder to thirteen surfaced a second set of problems, none of which the one-folder build had hit.

  • Filtered vector search changes when the index grows. The vector and keyword indexes rank all 31,047 chunks, and the folder filter applies afterwards to an eight-fold over-fetch. Adding twelve folders changed every one of the 47 hybrid rankings for Mindreader questions, although no Mindreader note changed, because both the candidate pool and the keyword weights (which depend on how rare a word is across the whole index) shifted. A production system that serves one team's documents should filter inside the index, or keep an index per permission scope.
  • Short aliases collide. The extractor recorded "HIS" (the name of a sales framework) as an alias of a dozen entities, because it is also an English pronoun. Matching by alias pulled in 12 unrelated people, both in the public graph export above and in live search, until lookups ignored pronouns and required one-word aliases to match with their capitals. In search, that fix alone halved latency.
  • Merge quality drops with breadth. Across the larger graph, the two LLM passes confirmed 1,145 of 10,900 candidate merges. A hand check of 30 confirmed merges found about four wrong, all the same pattern: a generic name absorbed into a specific one ("Distribution" into "Distribution Docs README"). The one-folder graph had far fewer generic names to confuse.
  • Long runs need to resume. The extraction hit the API account's monthly spend limit halfway through a folder, and the local embedding server dropped connections under sustained load. Because every extraction is cached per chunk and every embedding batch is saved before the next, both resumed where they stopped, with nothing paid twice.
  • Not every saved conversation belongs in a graph. Of 1,251 saved ChatGPT conversations, a $0.11 Haiku triage kept 153 with business decisions. Long chats were re-read at four points, because a chat's title only reflects its first message; that recovered 23 the opening alone had missed.
  • Namesakes merge into the author. Any person the notes call just "Tony" was resolved to the vault's author, which held for the one company folder. Once customer reviews from lead research went in, a renovation firm's branch manager and senior boss, both called Tony, became the author, along with their job titles. Mentions of a bare "Tony" now stay separate when the extractor describes someone else's role and gives another full name, and the relationships and claims from that passage move with them.

Entity resolution is maintenance, not a one-off

Every one of these problems came from adding nodes, not from changing code. Rules that were correct for 5,673 entities from one company's notes failed at 40,903 entities from thirteen folders, because new folders bring new namesakes, new generic words and new alias collisions. So a large addition is followed by the same refresh every time, as a step in the knowledge-base admin skill:

  1. Re-run the clean-up on the new candidates

    Near-duplicate detection, the two LLM merge passes and the audit of single-observation links run again. Decisions are cached, so only new candidate pairs are judged (about $0.86 for the twelve-folder addition).

  2. Rebuild the entity layer

    Resolution, merges and claim timelines are rebuilt from scratch in about 20 seconds, locally and for free, so a rule fix applies to every folder at once.

  3. Spot-check what the new folders changed

    Read the largest entities' aliases for names that belong to someone else, sample 30 confirmed merges, and run a handful of questions to see which entities they start from. Fixes go into code or a tracked overrides file, never into the database by hand.

  4. Re-run the eval

    The retrieval eval runs again for free. If the scores or latency moved, the change is explained before the graph is used. The first time this routine ran, its spot-check found the pronoun aliases, and the fix halved graph query time (median 9.2 to 4.2 seconds, 95th percentile 13.2 to 6.0) with retrieval unchanged.

From one vault to a company

This vault has one user and no access rules. In client deployments, the most common challenges are rarely retrieval quality alone. Three come up again and again:

  1. Permissions. Not every employee may see every document, and access changes after indexing: someone moves team, a folder is locked down. Access lists copied at index time go stale, so right before the answer is generated and returned, the system calls the source's permissions API to confirm the user is still cleared for each document in the context, and drops any they are not. Confidential information never reaches the answer for a user who cannot open the original.
  2. Updated, conflicting and new documents. A revised price list or procedure must replace the old one in answers, not sit beside it. The claim model above was built for this: a later value supersedes the earlier one, same-date disagreements are flagged as conflicts, and incremental refreshes bring new documents in without a full rebuild.
  3. The interface. Where employees reach the chat decides whether they use it, and it carries their identity through to the permissions check.

On the model side, the competency-question approach carries over: the graph's relationship types should match what users actually ask. If staff at a repair company keep asking which tools suit which appliance brands, the schema should enforce a direct relationship between tool and brand entities, rather than leaving the extractor to file it under a vague RELATED_TO.

What happens next

The graph now covers 13 folders: 31,047 chunks, 40,903 entities, 34,459 typed relationships and 52,866 claims, with 944 supersessions and 204 conflicts. Extending it from one folder cost about $21 of one-time extraction and clean-up, against an estimate of $20, for about $28 in total. The search now runs across every folder by default.

Three things come next. The eval set needs questions about the other projects, so the comparison is no longer one folder's. The merge confirmation needs a stricter rule for one-word generic names. And a separate generation eval needs to answer the open question this one could not: whether dated, modality-tagged claims make the final answer more truthful, even when they do not make retrieval better.

Show me the workflow. I’ll tell you honestly whether AI belongs in it.