Skip to main content
The storage layer is the bridge between the SDK’s Python objects and FalkorDB’s graph database. Two classes handle everything: GraphStore manages node and relationship writes, and VectorStore manages embeddings, indexes, and search. A third class, EntityDeduplicator, handles post-ingestion entity merging. This document explains how each one works, what Cypher queries they generate, and how to tune them.

The Big Picture


GraphStore — Writing Nodes and Relationships

GraphStore is the single write path for all graph data. It uses parameterized Cypher to prevent injection and batched UNWIND for performance.

Batched UNWIND MERGE

Nodes and relationships are written in batches of 500 items per query. The pattern:
Why MERGE, not CREATE? MERGE is idempotent — if a node or relationship already exists, it updates the properties rather than creating a duplicate. This makes re-ingestion safe.

Label Hints

Relationship MATCH queries use label hints to speed up endpoint lookups. Instead of searching all nodes, FalkorDB only looks at nodes with the specified label: Unknown edge types default to (__Entity__, __Entity__).

Per-Item Fallback

If a batch upsert fails (e.g., a single malformed property causes the whole batch to error), GraphStore falls back to per-item upserts. The behavior differs slightly: for nodes, the first per-item failure raises a DatabaseError (remaining items in that batch are not attempted); for relationships, failures are logged as warnings and processing continues through the batch.

None-ID Guard

Before writing, nodes with None or empty IDs are filtered out. These come from bad LLM extraction (the LLM sometimes returns entities without proper names). The guard prevents phantom nodes from polluting the graph.

Property Cleaning

All properties go through _clean_properties() before writing:

Cypher Safety

Node labels and relationship types are sanitized via sanitize_cypher_label() to prevent Cypher injection. Properties are applied via parameter maps (e.g., SET n += item.properties), so property keys and values are never interpolated into the Cypher string.
VectorStore handles everything related to vector and fulltext operations.

Index Creation

Vector indexes use FalkorDB’s native vector index syntax:
Fulltext indexes use the fulltext index DDL, one field per statement:
FalkorDB 6.0.x rejects the older db.idx.fulltext.createNodeIndex(label, field, ...) procedure. Relationship vector indexes use the edge index syntax:
All index creation is idempotent — if the index already exists, the error is silently caught and logged at debug level.

Chunk Indexing (index_chunks)

When chunks are ingested, their text is embedded and stored:
  1. Batch embed: All chunk texts are passed to the embedder via a single aembed_documents call. The underlying provider controls how these are internally batched (e.g., via a configurable batch_size) and may split them across multiple API requests.
  2. Batch write: Vectors are written to Chunk nodes using UNWIND (500 per batch):
  3. Fallback: If batch embedding fails, chunks are embedded one at a time. If batch writing fails, items are written individually.

Entity Embedding Backfill (backfill_entity_embeddings)

Embeds entity nodes that have no name vector. finalize() no longer calls it: dedup embeds every entity, listing it as pending first. Called directly it is not a dedup step — an entity with a vector and no pending record counts as checked — so run finalize(dedup_all=True) once afterwards. It runs these steps:
  1. Query: Find entities missing embeddings: WHERE e.embedding IS NULL
  2. Embed: Batch-embed entity names via aembed_documents
  3. Write: Store vectors using UNWIND:
  4. Loop: Repeat until no more entities with NULL embeddings remain. Each batch naturally returns the next un-embedded set.

Relationship Embedding (embed_relationships)

RELATES edges with a fact property but no embedding are batch-embedded:
  1. Query: Find edges with r.embedding IS NULL AND r.fact IS NOT NULL
  2. Embed: Batch-embed the fact text
  3. Write: Store vectors on each edge individually (using internal FalkorDB edge IDs):

Search Methods

Vector search on Chunk nodes:
Vector search on Entity nodes:
Equally close nodes are cut by id, not by the order the engine lists them (FalkorDB 4.x and 6.0.x differ). To see every tie at the cut, the query asks the index for $fetch_k = 2×top_k nodes, and asks again for twice as many (up to 1024) while the score at the cut still equals the score of the last node fetched, since the tie may continue past it. Vector search on RELATES edges (with fallback):
The scan runs when the server rejects the primary query (FalkorDB 4.x does when there is no RELATES vector index, or while it is still being built), or when it returns no rows and db.indexes() shows no built vector index on RELATES.embedding (FalkorDB 6.0.x answers a missing index with no rows). With the index in place, an empty answer is returned as is. If FalkorDB is unreachable, or the primary query runs past its timeout, the search returns no results without scanning: the scan would fail the same way or take longer still. The scan reads every embedded edge, so the SDK logs a warning the first time a store falls back to it. Fulltext search:
Entity fulltext search ranks by name before score. The index covers name and description alike, so for a common term every hit can score the same, and an entity named by the query would rank no higher than the ones whose descriptions merely mention it. Results come back in this order: an exact name match (case-insensitive, ignoring surrounding spaces), then names containing the query, then by score, then by id. Special characters in fulltext queries are escaped for RediSearch compatibility (commas, brackets, operators, etc.).

Stored Embedding Optimization

During retrieval, the rerank_chunks() function uses stored embeddings when possible. Instead of re-embedding all candidate chunks (which would require an expensive API call), it fetches the vectors already stored on Chunk nodes and computes cosine similarity locally. This makes reranking instant when stored embedding coverage is >= 90%.

ensure_indices()

Creates all 5 standard indexes in one call. Tracks state internally (_indices_ensured) to avoid redundant creation. Called automatically after each ingest() call, and re-run during finalize() (which resets the flag). FalkorDB builds an index over existing data in the background after CREATE ... INDEX returns, and a vector search on it finds nothing until the build ends. ensure_indices() therefore waits until db.indexes() reports every index built before it returns (up to 10 minutes, logging progress every 10 s), so a search right after ingest() sees the data. On a large first ingest this wait is most of the time ingest() spends after extraction.

EntityDeduplicator — Merging Duplicate Entities

After ingesting multiple documents, the same real-world entity might exist as multiple nodes (e.g., “Alice” from doc 1 and “Alice Smith” from doc 5). The deduplicator merges them. It is incremental. An entity is pending when it has no name vector, or when a pending system record lists it (see SDK system nodes). Ingest writes entities without a vector and marks nothing on them, so a new entity is pending as written. A dedup run lists every entity without a vector in a pending record before it embeds it, so no crash can leave an unchecked entity looking checked. It then reaches the rest of the graph through indexes — the __Entity__.embedding vector index for nearest names, __Entity__.id for reads and merges — never by reading every entity. A run clears an entity’s record once its check completes, so a second run with nothing pending does no work. An entity ingested while a run works is found by the next one. An entity whose check did not complete stays pending and is retried: a failed lookup or judge call, an answer that was not a clean partition, a group held for one, or a merge an update() changed. After 3 judged runs without a usable verdict it becomes unresolved: it is reported in FinalizeResult.dedup_unresolved, left out of every candidate set and never merged automatically, until dedup_all=True retries it. A table re-sync that renames a row drops its vector, so the row is checked under its new name. deduplicate_entities(dedup_all=True) / finalize(dedup_all=True) checks every entity — once, after vectors were written outside finalize(). Every merge is decided before any is written. A run takes the graph’s dedup lock (a second concurrent finalize() raises DedupRunActiveError), decides the merges of both phases below, writes them as a merge plan, seals it, and only then applies it. Each merge is one atomic query that moves every relationship the duplicate has (every type, both directions), folds its properties into the survivor, deletes it, and ticks itself off in the plan. A crash at any point is picked up by the next run: before sealing, nothing was merged and the work is judged again; after sealing, the next run finishes the plan from its ticks, and nothing is merged twice or half. A run that stalls past the lock’s 10-minute heartbeat is taken over, and every write it makes afterwards checks — in the same query — that it still holds the lock, so it changes nothing.

Phase 1: Exact Name Match (Always Runs)

  1. Fetch the pending entities with their primary label (the non-__Entity__ label), and their 10 nearest names from the vector index — searched from the vector stored on each entity — that could share a group with them (same canonical name or an acronym pair); when all 10 do, the lookup widens tenfold for that entity (up to 1,000), so twenty mentions of one name are all found
  2. Group by (canonical_name, label) — the name is case-folded with accents, punctuation, inner dots (A.I. = AI) and a leading English article removed, Surname, Given un-inverted, common abbreviations expanded and one trailing legal form (Ltd, Inc) dropped; word order and the letters of non-Latin scripts are kept. Grouping by label prevents cross-type merging (Person “Paris” and Location “Paris” stay separate)
  3. Fold acronyms: a 3–6 letter name joins the group of the single same-label entity whose initials it spells (AIHS / Ashford Island Historical Society). An acronym with several candidate expansions is left alone
  4. For each group with duplicates:
    • Who decides: when a group holds a node a declared table wrote, it merges here by rule, with no LLM. A group of extracted mentions only is handed to the judge (Phase 2) instead — the same spelling is evidence, not proof (Paris, France and Paris, Texas) — and so is a group holding an entity whose nearest-name search did not finish: a second row of the name may lie past what it found
    • Survivor: A node a declared table wrote (it carries the row’s key, so the table’s next re-sync still finds it), then a real node over a placeholder, then the long form over an acronym, then the best-connected node (most RELATES and MENTIONED_IN edges), then the longest description, then the longest name. Two nodes written from declared keys are never merged into each other, whatever they are called
    • The merge (applied from the sealed plan): one generated query per duplicate. The SDK reads which relationship types the duplicate has, and generates a block per type and direction that moves those edges to the survivor with the write path’s own keys — RELATES keyed on rel_type with source_chunk_ids unioned rather than overwritten, every other type on its endpoints with SET r += properties, except that a MENTIONED_IN edge to a chunk the survivor also mentions keeps both edges’ spans and descriptions (each once, each whole); an edge between the duplicate and the survivor is dropped rather than turned into a self-loop. The survivor is bound before every MERGE (a path-MERGE with an unbound (s {id}) creates an empty ghost __Entity__ node), and every pattern expands from the bound duplicate (anchored on a labelled far end, the planner scans the whole label). In the same query the duplicate’s source_chunk_ids (on entities written by an earlier version) are unioned into the survivor’s, its description is joined onto the survivor’s with " | " (each member kept once, in descriptions), its name is added to the survivor’s aliases when it is not a spelling variant, and every other property the survivor lacks — a typed column a table signed, is_stub — is copied over. A property both hold with different values is a collision, and neither value is lost: the one the judge chose stays (its KEEP, on a judged merge), otherwise the survivor’s, and the other is appended to <property>__dropped on the survivor — a list that later merges add to (two list values are unioned instead); FinalizeResult.dedup_collisions names each. Then DETACH DELETE. The query re-checks that nothing it was generated from has changed — a relationship type it has no block for, a property of the duplicate added, removed or changed, a new value on the survivor for one of those keys, or a label added to or removed from either node — and writes nothing if so; the merge is read and generated again. An entity deleted, re-keyed or renamed since it was judged makes the merge changed: skipped, and its group decided again next run

Phase 2: Judged Cross-Document Dedup

Ingest resolves per document, so Airbus in one file and Airbus SE in another never meet until here. Every run has a judge: a DedupJudge, LLMJudge with the GraphRAG LLM unless finalize(judge=...) is given another. The run (storage/judge_dedup.py) nominates the candidates and checks every answer; the judge decides identity only — it is given sets and returns verdicts, and never writes a merge. It works over the pending entities, with the exact phase’s decisions applied in memory (an entity it absorbs is represented by its survivor):
  1. Nominate candidate pairs without an LLM: the 5 nearest names of each pending entity from the __Entity__ vector index at cosine ≥ 0.5 (searched from the stored vectors, 250 entities per query, one result row each), plus the same-spelling pairs of extracted mentions and the ambiguous acronym/long-form pairs Phase 1 handed over. Descriptions are not embedded
  2. Group candidates into dense sets of at most 8: more than half of a set’s pairs must be nominated edges, so a chain A–B–C–D where only neighbours are similar (exactly half) cannot form one set (agreements in separate sets still chain, step 4); an edge left between two sets is its own 2-member set. Whole sets are packed into prompts of ≤ 3,000 tokens; the entity text is rendered one line per entity — name, labels, up to 5 existing aliases, description, and the values it holds for the attributes your ontology declares for its type(s) (born: 1960; role: "CEO") — flattened and quoted, and the prompt tells the model it is untrusted document data. Each prompt opens with the types its members carry and their declared attributes (only those types, not the whole ontology); a node already merged under two types shows the attributes of both
  3. Judge — the judge partitions each set into same-referent groups (no similarity scores shown) and gives each group of two or more its best name, copied from the members’ names and aliases. Two different values of a fact that does not change (a birth or founding date, a birthplace, an identifier) mean different entities; a value only one side holds is not a contradiction; a value that changes over time (an age, a title) is weak evidence. Where a group’s members disagree on an attribute, the judge also says whose value the merged entity keeps (| KEEP: born=1): a member number, never a value it writes itself. Measured on a small labelled set (gpt-4.1, 3 runs each): namesakes told apart only by a birth year or birthplace were merged every time without the values shown, and kept apart every time with them; the same person with a changed age or title still merged. The answer lists only groups of two or more (SET 1 GROUP: 1, 4 | NAME: …), or SET 2 NONE for a set where nothing merges; a member no line lists is a different entity from the rest of its set. Leaving out the one-member lines cut answer tokens by about two thirds (measured on the 6-document experiment: 569 → 194 tokens, 4 → 2 calls), and the model, freed of them, stopped the slip that had kept Tim Cook and Timothy Cook apart. The answer ends with the line END: since unlisted members count as different, an answer cut short after a group line would otherwise turn the groups it never reached into “different” verdicts. Without END, the set of a last group line is unanswered (a last NONE line closed its set, so a forgotten END there costs nothing). LLMJudge usually reuses the GraphRAG LLM, so an output limit set on it for short answers (max_tokens) is raised to 4,096 for judge calls (LLMJudge(min_output_tokens=) sets the floor; None keeps the LLM’s own limit, for a server with a small context window); it never sets a limit itself, since a reasoning model spends output tokens on thought. (This step describes LLMJudge; a judge of your own gets the same sets as JudgeSets and answers with SetVerdicts.) Whatever the judge, an answer counts only when it is clean — no member the set lacks, no member in two groups, no merged name or KEEP on a one-member group; a set without one — a failed call, a malformed answer, or one cut before END — is asked again (retry=True; LLMJudge then asks it on its own), up to 2 more times. Every set is judged before anything is merged
  4. Resolve over the whole run: agreements are unioned across sets — “AAPL” = “Apple Inc.” in one set and “Apple Inc.” = “Apple” in another become one group — but never into a group holding a pair the judge kept apart in a set, or a protected pair (two keyed rows, a mention two rows could own; neither is ever put to the judge). A group that holds a member of a set that never got a usable answer is held: that answer could still change it, so nothing in it merges and its pending members are checked again (one held only because of a neighbour’s failed set gets its judged attempt back)
  5. Same → merge, by the Phase 1 rules and the same single query: survivor by the Phase 1 rank; every member’s description kept; other names kept in aliases; provenance and missing properties carried; every edge moved. The survivor gains every member’s label, as Cypher labels and on record in merged_labels, and takes the judge’s name for the group when it is one of the members’ own names or aliases (its old name moves to aliases, and its name vector is replaced; the id does not change; a table row keeps its declared name). The rename is the group’s last operation in the plan
  6. Different → nothing is written to the entities: the pending entity’s check is complete, and the pair is put to the judge again only if a later pending entity brings it into a set
gpt-4.1 made 9 wrong merges where gpt-4o-mini made 51 on the benchmark corpus, so give LLMJudge a gpt-4.1-class model: finalize(judge=LLMJudge(gpt41)). Statistics (entities, neighbours, candidates, sets, judge calls, failed calls, unanswered sets, agreed pairs, merges decided, and what the judge spent: LLMJudge’s prompts in llm_calls) are in EntityDeduplicator.last_judge_stats and FinalizeResult.judge_stats; what the run left — still pending, unresolved, merges skipped as changed — is in EntityDeduplicator.last_report and on FinalizeResult. There is no run without a judge — judge=False raises TypeError — so nothing waits for a judged run that may never come. A run with nothing pending asks the judge nothing.

Running alongside update() and deletes

update(), delete_document() and a table re-sync may run while finalize() deduplicates; they never wait. Every SDK query that deletes or re-keys an entity leaves a deleted record in the same query while a dedup run is in progress, and a planned merge that would touch such an entity is skipped as changed. FalkorDB runs one write at a time per graph, and each merge and each SDK delete is a single query, so every delete lands either before a merge — which sees the record — or after it, as an ordinary delete. Deletes you issue with your own Cypher are not covered.

SDK system nodes

The SDK keeps a small, fixed set of its own nodes in the data graph, under two labels, and never anything on your entities: They live beside the data, not in a graph of their own, because a dedup write must check that its run still owns the lock in the same query that changes entities, and a FalkorDB query cannot span two graphs. They have no edges and no vector, so no traversal or search reaches them, and get_statistics() does not count them. Range indexes on kind and eid keep every check one index lookup.

FalkorDB-Specific Notes

Version

GraphRAG SDK requires FalkorDB 4.20 or later. Before 4.20.0 every write to a node holding a vector left a stale copy in the vector index, and searches returned fewer neighbours than asked for (measured: 2–3 of 6 after one write) — and ingest, update() and dedup all write to such nodes. Each connection asks the server its version once, before its first query (and again after close()), and refuses an older one with FalkorDBVersionError (a DatabaseError), so nothing is written to it: ingest(), query(), finalize() and the rest fail at once, with the version found and how to upgrade. A server that does not report its version (MODULE LIST refused by an ACL, or disabled) is let through with a warning; an error reply that passes, such as TRYAGAIN, is retried like a query; one that cannot be asked, after the usual retries, fails that query with DatabaseUnavailableError — no query runs unchecked — and is asked again by the next.

Vector Storage

FalkorDB stores vectors as vecf32 — a native 32-bit float vector type. All vectors in the SDK are stored via:

Vector Search API

Node vector search uses 4 arguments:
Relationship vector search (FalkorDB >= 4.2):

Graph Deletion

For fast graph deletion, use GRAPH.DELETE (the Redis-level command):
This is much faster than MATCH (n) DETACH DELETE n on large graphs.

Retry Behavior

The connection layer makes up to retry_count attempts per query (default 3: the first try and two retries), with exponential backoff and jitter between them (retry_delay * 2^attempt * random(0.5, 1.5)), and employs a circuit breaker to short-circuit repeated failures. Everything that is not a server reply is retried (a lost connection or socket timeout, LOADING), and of the server’s replies only the temporary ones (a full query queue, BUSY, NOREPLICAS, MISCONF, a cluster redirect or failover, a read-only replica, out of memory); one that outlasts every attempt raises DatabaseUnavailableError. A query the server rejects, or a read it stops at its timeout, raises DatabaseError at once, without retrying or counting against the breaker. See Configuration.

File Reference