FalkorDB
Solutions
Use cases
Why FalkorDB
Developers

Keeping GraphRAG Fresh: Incremental Knowledge Graph Indexing Without Full Rebuilds

Title card: Keeping GraphRAG Fresh, incremental updates without full rebuilds

A GraphRAG pipeline is only as good as its knowledge graph, and a knowledge graph is only as good as its freshness. The moment your source documents change and your graph does not, every retrieval built on top of it drifts toward stale context. For small corpora, a nightly full rebuild is annoying but survivable. At production scale, it becomes a reliability problem you cannot schedule your way out of.

This post walks through the patterns that replace full rebuilds with safe, incremental updates: the tradeoffs between indexing strategies, how to handle the hard cases (document edits, deletes, entity merges, changed relationships), and a concrete reference implementation using FalkorDB and CocoIndex.

The core principle: every write to your knowledge graph should be idempotent, traceable to a source, and scoped to exactly what changed.

Why Full Re-Indexing Breaks at Scale

Full rebuilds feel safe. You wipe the graph, re-extract all entities and relationships from scratch, and reload. The graph is consistent by construction. The problem is the cost curve.

Consider what a full rebuild actually requires:

  • Re-embedding every document, even the documents that did not change
  • Re-running entity extraction across the entire corpus, which is LLM-call-heavy and expensive
  • Dropping and recreating indexes, which can block or degrade reads depending on your database configuration
  • Reconciling the new graph with any live application state that held references to the previous node IDs

At a few hundred documents, this takes minutes. At tens of thousands of documents with relationship-dense graphs, it takes hours and introduces a freshness gap that grows with corpus size. You end up in a situation where the graph is always slightly behind, because the rebuild window is longer than the update cadence.

There is also a correctness risk that often goes unmentioned: if the rebuild fails halfway through, you are left with a partial graph or a rollback to the previous state. Neither is safe for a system where downstream agents are actively querying.

Past a certain corpus size, the rebuild window grows longer than your update cadence and the cost of the rebuild starts to exceed the cost of staying stale. Incremental strategies become the practical path forward.

Three Indexing Strategies and When to Use Each

There are three distinct strategies for keeping a knowledge graph current. They are not interchangeable.

Strategy How it works Best for Risk
Full rebuild Drop and recreate the entire graph Initial load, major schema migrations Long downtime, high LLM cost
Append-only Only add new nodes and edges, never modify or delete Immutable event logs, append-only data sources Graph accumulates stale and contradictory data over time
Incremental (true) Detect what changed, apply targeted upserts and deletes, preserve provenance Production corpora with edits, deletions, and evolving entities Requires content hashing and source tracking

Most teams start with append-only because it is easy to implement: process new documents, write new nodes, done. The problem surfaces when a document is updated or deleted. An append-only pipeline has no mechanism to retract what it previously wrote. Entities extracted from the old version of a document persist indefinitely, polluting retrieval with outdated facts.

True incremental indexing is more complex to build but it is the only strategy that handles the full document lifecycle correctly. It requires two things that append-only pipelines lack:

  1. A content hash or version fingerprint for every source document
  2. Source provenance on every node and edge in the graph, linking graph elements back to the document that produced them

With those two primitives in place, you can detect changes, scope your updates, and retract stale data without touching anything else in the graph.

Handling the Hard Cases

The simple case is a new document arriving in the pipeline. The hard cases are what separate a production-grade implementation from a prototype.

Document Edits

When a document changes, the naive approach is to delete all graph elements tied to that document and re-extract from the new version. This works but is wasteful: entities and relationships that did not change get deleted and recreated, which can break references held by other parts of the graph.

A better approach is diff-based reconciliation:

  1. Hash the new document version and compare against the stored hash
  2. Re-extract entities and relationships from the updated document
  3. Compute the delta: what was added, what was removed, what is unchanged
  4. Apply only the delta to the graph using upserts for new or changed elements and targeted deletes for removed ones

Document Deletes

Deleting a document requires retracting every graph element whose provenance traces exclusively to that document. The key word is “exclusively.” If an entity like Acme Corp was extracted from five documents and one is deleted, the entity node should persist. Only the edges and properties sourced exclusively from the deleted document should be removed.

This is why provenance tracking is not optional. Without it, you cannot safely delete anything.

Entity Merges and Coreference

Entity extraction is imperfect. The same real-world entity often appears under multiple surface forms: ACME, Acme Corp, Acme Corporation. Over time, a graph without merge logic accumulates duplicate nodes that fragment retrieval.

The correct pattern is to assign a canonical identifier during extraction and use it as the merge key on upsert:

MERGE (e:Entity {canonical_id: $canonical_id})
ON CREATE SET e.name = $name, e.source_docs = [$doc_id]
ON MATCH SET e.source_docs =
  CASE WHEN $doc_id IN e.source_docs THEN e.source_docs
       ELSE e.source_docs + [$doc_id] END

The MERGE clause ensures that re-processing a document never creates duplicate nodes. The CASE guard on ON MATCH makes the write idempotent: running the same document through the pipeline twice leaves source_docs with one entry, not two. The canonical_id is the stable key; surface-form variations can be stored as aliases.

Changed Relationships

Relationships carry semantics that can change independently of the entities they connect. A REPORTS_TO edge that was accurate six months ago may be wrong today. Tracking relationship provenance with the same source_docs pattern lets you retract stale edges when their source document is updated or deleted, without touching the entity nodes themselves.

Change-Data-Capture Patterns for Graph Pipelines

Incremental indexing requires knowing what changed in your source data. Change-data-capture (CDC) is the class of patterns that solve this at the ingestion layer, before any graph write happens.

Hash-Based CDC

The simplest CDC pattern: store a SHA-256 hash of each document’s content alongside its ID. On each pipeline run, recompute the hash and compare. If the hash matches, skip the document entirely. If it differs, process only that document.

import hashlib

def content_hash(text: str) -> str:
    return hashlib.sha256(text.encode()).hexdigest()

def needs_reindex(doc_id: str, new_hash: str, hash_store: dict) -> bool:
    return hash_store.get(doc_id) != new_hash

This approach requires no changes to your source systems and works with any document store. The tradeoff is that it runs on a pull cadence: you detect changes only when you check.

Event-Driven CDC

For lower latency, subscribe to change events from your source system directly. Object storage buckets, document databases, and content management systems all emit events on create, update, and delete. Your pipeline consumes these events and processes only the affected documents.

The graph write pattern stays the same regardless of how you detect the change. The event just determines the trigger.

CocoIndex also has a live mode (cocoindex update -L) for sources it can watch continuously, so changes are picked up as they happen instead of on a schedule.

Idempotency as a Safety Net

Every graph write in an incremental pipeline must be idempotent. If the same update runs twice due to a retry or a duplicate event, the graph state should be identical to running it once.

In FalkorDB’s OpenCypher dialect, MERGE provides this guarantee for nodes. For relationships, combine MERGE with ON CREATE / ON MATCH clauses to control what gets set on first creation versus subsequent matches. Never use CREATE for incremental writes; it will produce duplicates.

Reference Implementation: FalkorDB and CocoIndex

CocoIndex is an open-source data indexing framework built around incremental processing. It tracks source state, computes deltas, and applies targeted upserts and deletes without you writing the diff logic. Combined with FalkorDB as the graph backend, it gives you a production-ready incremental GraphRAG pipeline without building the CDC and provenance layers from scratch.

Install with:

pip install cocoindex[falkordb]

The pipeline runs in two phases. Phase 1 (extract_file) is memoized: CocoIndex re-runs extraction for a file only when its content changes. Phase 2 (in app_main) takes the extractions from every current file and declares the complete set of nodes and relationships, with each entity declared once however many documents mention it. CocoIndex reconciles that declared state against FalkorDB, so deleting a file removes only what was sourced exclusively from it. Shared entities survive.

import pathlib
from collections.abc import AsyncIterator
from dataclasses import dataclass, field

import cocoindex as coco
from cocoindex.connectors import falkordb, localfs
from cocoindex.resources.file import FileLike, PatternFilePathMatcher

KG_DB = coco.ContextKey[falkordb.ConnectionFactory]("kg_db")


@coco.lifespan
async def coco_lifespan(builder: coco.EnvironmentBuilder) -> AsyncIterator[None]:
    builder.settings.db_path = pathlib.Path("./cocoindex.db")  # CocoIndex's tracking state
    factory = falkordb.ConnectionFactory(uri="falkor://localhost:6379", graph="knowledge_graph")
    builder.provide(KG_DB, factory)
    yield
    await factory.close()


@dataclass
class Entity:
    canonical_id: str  # stable merge key
    name: str
    label: str


@dataclass
class Relation:
    id: str             # stable edge key
    relation_type: str
    doc_id: str         # provenance: which document produced this edge


@dataclass
class FileExtractions:
    entities: list[Entity] = field(default_factory=list)
    edges: list[tuple[str, str, Relation]] = field(default_factory=list)  # (from_id, to_id, relation)


async def extract_with_llm(text: str, doc_id: str) -> FileExtractions:
    # Replace with your LLM extraction logic. Return the entities found in `text`
    # and the relationships between them as (from_id, to_id, Relation),
    # with each Relation's doc_id set for provenance.
    return FileExtractions()


# Phase 1: per-file extraction, memoized. Re-runs only when the file changes.
@coco.fn(memo=True)
async def extract_file(file: FileLike) -> FileExtractions:
    doc_id = str(file.file_path.path)
    return await extract_with_llm(await file.read_text(), doc_id)


# Phase 2: declare the full graph from every current file.
@coco.fn
async def app_main(sourcedir: pathlib.Path) -> None:
    entity_table = await falkordb.mount_table_target(
        KG_DB, "Entity",
        await falkordb.TableSchema.from_class(Entity, primary_key="canonical_id"),
        primary_key="canonical_id",
    )
    relation_target = await falkordb.mount_relation_target(
        KG_DB, "RELATES_TO", entity_table, entity_table,
        await falkordb.TableSchema.from_class(Relation, primary_key="id"),
        primary_key="id",
    )

    files = localfs.walk_dir(
        sourcedir, recursive=True,
        path_matcher=PatternFilePathMatcher(included_patterns=["**/*.md"]),
    )
    extractions = await coco.map(extract_file, [f async for _, f in files.items()])

    # Declare each entity once, however many documents mention it.
    # It stays in the graph as long as at least one current document does.
    entities: dict[str, Entity] = {}
    for ex in extractions:
        for e in ex.entities:
            entities.setdefault(e.canonical_id, e)
        for from_id, to_id, rel in ex.edges:
            relation_target.declare_relation(from_id=from_id, to_id=to_id, record=rel)
    for e in entities.values():
        entity_table.declare_record(row=e)


app = coco.App(coco.AppConfig(name="GraphRAGPipeline"), app_main, sourcedir=pathlib.Path("./docs"))

Run a catch-up pass (scan all sources, apply changes, exit):

cocoindex update main.py

For sources CocoIndex can watch continuously, you can also run it in live mode with cocoindex update -L. See the CocoIndex docs for which sources support it.

When a file changes, CocoIndex re-runs extract_file only for that file, then app_main declares the graph again from all current extractions. CocoIndex compares that with what is in FalkorDB: unchanged entities and relationships are left alone, removed ones are deleted, and new ones are upserted. Because the graph is declared from all files together, an entity that appears in two documents survives when one of them is deleted.

For a deeper walkthrough of this integration, see our session with the CocoIndex team.

This pattern pairs well with the GraphRAG retrieval layer described in the FalkorDB GraphRAG SDK overview. The SDK handles query-time graph traversal and context assembly; CocoIndex handles the write path.

What This Means in Practice

For any GraphRAG system that serves live data, keeping the graph current is a correctness problem. The patterns here, content hashing, source provenance, idempotent upserts, and targeted retraction, are the minimum viable foundation for a graph that stays accurate as its source corpus evolves.

The implementation complexity is real, but it is bounded. You are solving it once, at the pipeline layer, so that every query on top of it can trust what it finds.

Start with provenance. If you add nothing else from this post, add source_docs tracking to every node and edge you write today. It is the primitive that makes everything else possible: safe deletes, accurate merges, and auditable lineage from graph element back to source document.