Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cb24885
feat(models): add ChunkEntityRow and ChunkRelationshipRow
Naseem77 Jul 28, 2026
5cfd34e
feat(storage): add chunk-level cache read accessors to GraphStore
Naseem77 Jul 28, 2026
dc29560
feat(ingestion): add CachedChunkExtraction strategy
Naseem77 Jul 28, 2026
1395945
feat(api): wire cache_unchanged_chunks into update/apply_changes
Naseem77 Jul 28, 2026
a90d1ac
test: unit + FalkorDB integration coverage for chunk cache
Naseem77 Jul 28, 2026
7cd0c85
docs: export CachedChunkExtraction and add CHANGELOG entry
Naseem77 Jul 28, 2026
07eae99
style: apply ruff format to graph_store cache accessors
Naseem77 Jul 28, 2026
d6ddcf4
fix(storage): guard cache accessors against scalar list properties
Naseem77 Jul 29, 2026
ba00308
fix: compare bare model names in graph config validation
Naseem77 Aug 5, 2026
d8e46b8
test: cover provider-prefix normalization in config validation
Naseem77 Aug 5, 2026
160fd8f
test: add chunk-cache edge coverage
Naseem77 Aug 5, 2026
9c251fb
fix: never treat a chunk as its own cache entry
Naseem77 Aug 6, 2026
42a9784
refactor: match embedding models by route segment
Naseem77 Aug 6, 2026
19c9aeb
test: cover route-segment embedding model matching
Naseem77 Aug 6, 2026
582a2d4
fix: treat a qualified name on both sides as two models
Naseem77 Aug 6, 2026
f6ca63e
test: pin owner-qualified names as distinct models
Naseem77 Aug 6, 2026
ddd1111
fix: fail fast when the database is unreachable
Naseem77 Aug 6, 2026
3667777
test: pin database failures as DatabaseError, not LLM calls
Naseem77 Aug 6, 2026
7b1088f
docs: correct the fail-open claim and note the breaking change
Naseem77 Aug 6, 2026
60f04af
fix: fall back to extraction when the cache cannot rebuild a chunk
Naseem77 Aug 9, 2026
6d9ca88
Merge branch 'main' into feat/chunk-level-update-cache
galshubeli Aug 10, 2026
8b057b7
fix: union cached and freshly extracted relationships on merge
Naseem77 Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

#### Chunk-level extraction cache for `update()` (`cache_unchanged_chunks`)

- **`GraphRAG.update(..., cache_unchanged_chunks=True)`** — opt-in
chunk-level extraction cache. New chunks whose text is byte-identical
to an existing chunk of the same document skip LLM extraction
entirely: their entities, relationships, and mentions are rebuilt
from the live graph and remapped onto the new chunk uids. Only
genuinely new/changed chunks are sent to the extractor. Editing one
paragraph of a 50-chunk document now costs ~1 extraction instead
of 50. Cache effectiveness is reported in
`UpdateResult.metadata["cache_stats"]`
(`cached_chunks` / `extracted_chunks`). Also available on
`update_sync()`, `apply_changes()` (applies to the `modified` list),
and `apply_changes_sync()`. Default `False` — existing behavior is
unchanged.
- **`CachedChunkExtraction(inner, graph_store, document_id)`** — the
underlying decorator `ExtractionStrategy`, exported at top level for
advanced pipelines. Fail-open by construction: any cache lookup or
rebuild failure falls back to full extraction (worst case is paying
for skippable LLM calls, never data loss). Caveats: the graph is the
cache, so manually deleted entities are not resurrected from
unchanged chunks, and ontology/prompt/model changes do not
re-extract unchanged chunks — pass `cache_unchanged_chunks=False`
(the default) to force a full rebuild.
- **`GraphStore.get_document_chunk_texts()`**,
**`get_entities_mentioned_in_chunks()`**,
**`get_relationships_for_chunks()`** — new schema-owning read
accessors backing the cache (with `ChunkEntityRow` /
`ChunkRelationshipRow` typed rows in `core.models`).

## [1.3.0] - 2026-06-04

Ontology discovery (#271): bootstrap an ontology straight from a
Expand Down
4 changes: 4 additions & 0 deletions graphrag_sdk/src/graphrag_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@
SentenceTokenCapChunking,
)
from graphrag_sdk.ingestion.extraction_strategies.base import ExtractionStrategy
from graphrag_sdk.ingestion.extraction_strategies.cached_chunk_extraction import (
CachedChunkExtraction,
)
from graphrag_sdk.ingestion.extraction_strategies.coref_resolvers import (
CorefResolver,
FastCorefResolver,
Expand Down Expand Up @@ -185,6 +188,7 @@
"FixedSizeChunking",
"SentenceTokenCapChunking",
"ExtractionStrategy",
"CachedChunkExtraction",
"GraphExtraction",
"EntityExtractor",
"GLiNERExtractor",
Expand Down
57 changes: 55 additions & 2 deletions graphrag_sdk/src/graphrag_sdk/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
SentenceTokenCapChunking,
)
from graphrag_sdk.ingestion.extraction_strategies.base import ExtractionStrategy
from graphrag_sdk.ingestion.extraction_strategies.cached_chunk_extraction import (
CachedChunkExtraction,
)
from graphrag_sdk.ingestion.extraction_strategies.entity_extractors import (
DEFAULT_ENTITY_TYPES,
_strip_markdown_fences,
Expand Down Expand Up @@ -1923,6 +1926,7 @@ async def update(
chunker: ChunkingStrategy | None = None,
extractor: ExtractionStrategy | None = None,
resolver: ResolutionStrategy | None = None,
cache_unchanged_chunks: bool = False,
if_missing: Literal["error", "ingest"] = "error",
ctx: Context | None = None,
) -> UpdateResult:
Expand Down Expand Up @@ -1984,6 +1988,23 @@ async def update(
with no extra plumbing. Required in text mode.
loader / chunker / extractor / resolver: Per-call strategy
overrides, identical to ``ingest()``.
cache_unchanged_chunks: When ``True``, wrap the extractor in
:class:`~graphrag_sdk.ingestion.extraction_strategies.cached_chunk_extraction.CachedChunkExtraction`:
new chunks whose text is byte-identical to an existing
chunk of this document skip LLM extraction — their
entities/relations/mentions are rebuilt from the live
graph and remapped onto the new chunk uids. Only changed
chunks pay for extraction. Cache effectiveness is
reported in ``UpdateResult.metadata["cache_stats"]``
(``cached_chunks`` / ``extracted_chunks``). Two semantic
caveats: (1) the graph is the cache, so manually deleted
entities are NOT resurrected from unchanged chunks; (2)
if the ontology, prompts, or model changed since ingest,
unchanged chunks keep their previously extracted data.
Leave ``False`` (the default) to force full re-extraction
in those situations. No effect on the
``if_missing="ingest"`` fresh-ingest fallthrough (a new
document has no chunks to reuse).
if_missing: ``"error"`` (default) raises ``DocumentNotFoundError``
when the id is unknown. ``"ingest"`` falls through to
``ingest()`` for upsert semantics.
Expand Down Expand Up @@ -2109,10 +2130,24 @@ async def update(
# Contradictions surface here rather than mid-extraction.
await self._ensure_ontology_initialized()

# Chunk-level extraction cache (opt-in). Wraps the effective
# extractor so byte-identical chunks are rebuilt from the live
# graph instead of re-extracted. Must read the OLD chunks, which
# still exist until the Phase 5 cutover — safe by construction.
active_extractor = extractor or self._default_extractor()
cache_wrapper: CachedChunkExtraction | None = None
if cache_unchanged_chunks:
cache_wrapper = CachedChunkExtraction(
inner=active_extractor,
graph_store=self._graph_store,
document_id=resolved_id,
)
active_extractor = cache_wrapper

pipeline = IngestionPipeline(
loader=loader or TextLoader(), # unused (text is provided below)
chunker=chunker or SentenceTokenCapChunking(),
extractor=extractor or self._default_extractor(),
extractor=active_extractor,
resolver=resolver or ExactMatchResolution(),
graph_store=self._graph_store,
vector_store=self._vector_store,
Expand Down Expand Up @@ -2190,12 +2225,19 @@ async def update(
f"wrote {pipeline_result.chunks_indexed} new chunks"
)

result_metadata = dict(pipeline_result.metadata)
if cache_wrapper is not None:
result_metadata["cache_stats"] = {
"cached_chunks": cache_wrapper.cached_chunk_count,
"extracted_chunks": cache_wrapper.extracted_chunk_count,
}

return UpdateResult(
document_info=DocumentInfo(uid=resolved_id, path=doc_path, metadata=loaded_metadata),
nodes_created=pipeline_result.nodes_created,
relationships_created=pipeline_result.relationships_created,
chunks_indexed=pipeline_result.chunks_indexed,
metadata=pipeline_result.metadata,
metadata=result_metadata,
chunks_deleted=chunks_deleted,
entities_deleted=entities_deleted,
replaced_existing=True,
Expand Down Expand Up @@ -2324,6 +2366,7 @@ async def apply_changes(
chunker: ChunkingStrategy | None = None,
extractor: ExtractionStrategy | None = None,
resolver: ResolutionStrategy | None = None,
cache_unchanged_chunks: bool = False,
max_concurrency: int = 3,
update_concurrency: int = 1,
ctx: Context | None = None,
Expand Down Expand Up @@ -2377,6 +2420,11 @@ async def apply_changes(
``added``/``modified``. ``deleted`` ignores this.
resolver: Override the resolution strategy for ``added``/
``modified``. ``deleted`` ignores this.
cache_unchanged_chunks: Forwarded to ``update()`` for the
``modified`` list — unchanged chunks skip LLM extraction
and are rebuilt from the live graph. See
:meth:`update` for semantics and caveats. ``added`` and
``deleted`` ignore this.
max_concurrency: Parallelism cap for ``ingest()`` of the
``added`` list. Matches ``ingest()``'s own knob and the
``add`` step is pure ingestion with no orphan-cleanup
Expand Down Expand Up @@ -2469,6 +2517,7 @@ async def _update_one(path: str) -> BatchEntry[UpdateResult]:
chunker=chunker,
extractor=extractor,
resolver=resolver,
cache_unchanged_chunks=cache_unchanged_chunks,
if_missing="ingest",
ctx=ctx.child(),
)
Expand Down Expand Up @@ -3138,6 +3187,7 @@ def update_sync(
chunker: ChunkingStrategy | None = None,
extractor: ExtractionStrategy | None = None,
resolver: ResolutionStrategy | None = None,
cache_unchanged_chunks: bool = False,
if_missing: Literal["error", "ingest"] = "error",
ctx: Context | None = None,
) -> UpdateResult:
Expand All @@ -3161,6 +3211,7 @@ def update_sync(
chunker=chunker,
extractor=extractor,
resolver=resolver,
cache_unchanged_chunks=cache_unchanged_chunks,
if_missing=if_missing,
ctx=ctx,
)
Expand Down Expand Up @@ -3193,6 +3244,7 @@ def apply_changes_sync(
chunker: ChunkingStrategy | None = None,
extractor: ExtractionStrategy | None = None,
resolver: ResolutionStrategy | None = None,
cache_unchanged_chunks: bool = False,
max_concurrency: int = 3,
update_concurrency: int = 1,
ctx: Context | None = None,
Expand All @@ -3215,6 +3267,7 @@ def apply_changes_sync(
chunker=chunker,
extractor=extractor,
resolver=resolver,
cache_unchanged_chunks=cache_unchanged_chunks,
max_concurrency=max_concurrency,
update_concurrency=update_concurrency,
ctx=ctx,
Expand Down
38 changes: 38 additions & 0 deletions graphrag_sdk/src/graphrag_sdk/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,44 @@ class DocumentRecord(DataModel):
content_hash: str | None = None


class ChunkEntityRow(DataModel):
"""One entity mentioned by a specific chunk, as read back from the graph.

Returned by ``GraphStore.get_entities_mentioned_in_chunks()``. Consumed
by ``CachedChunkExtraction`` to rebuild ``GraphData`` for unchanged
chunks without re-running LLM extraction.

``label`` is the entity's concrete label (the non-``__Entity__`` one);
``None`` if the node somehow carries no usable label (tampered graph).
"""

chunk_id: str
entity_id: str
label: str | None = None
name: str | None = None
type: str | None = None
description: str | None = None
source_chunk_ids: list[str] = Field(default_factory=list)


class ChunkRelationshipRow(DataModel):
"""One RELATES edge whose provenance includes a specific chunk.

Returned by ``GraphStore.get_relationships_for_chunks()``. Consumed by
``CachedChunkExtraction`` to re-emit relationship facts for unchanged
chunks during a cached document update.
"""

chunk_id: str
start_entity_id: str
end_entity_id: str
rel_type: str | None = None
description: str | None = None
fact: str | None = None
src_name: str | None = None
tgt_name: str | None = None


# ── Schema Types ─────────────────────────────────────────────────


Expand Down
4 changes: 4 additions & 0 deletions graphrag_sdk/src/graphrag_sdk/ingestion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@

from graphrag_sdk.ingestion.chunking_strategies.base import ChunkingStrategy
from graphrag_sdk.ingestion.extraction_strategies.base import ExtractionStrategy
from graphrag_sdk.ingestion.extraction_strategies.cached_chunk_extraction import (
CachedChunkExtraction,
)
from graphrag_sdk.ingestion.extraction_strategies.graph_extraction import GraphExtraction
from graphrag_sdk.ingestion.loaders.base import LoaderStrategy
from graphrag_sdk.ingestion.pipeline import IngestionPipeline
from graphrag_sdk.ingestion.resolution_strategies.base import ResolutionStrategy

__all__ = [
"CachedChunkExtraction",
"ChunkingStrategy",
"ExtractionStrategy",
"GraphExtraction",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# GraphRAG SDK — Ingestion: Extraction Strategies

from graphrag_sdk.ingestion.extraction_strategies.base import ExtractionStrategy
from graphrag_sdk.ingestion.extraction_strategies.cached_chunk_extraction import (
CachedChunkExtraction,
)
from graphrag_sdk.ingestion.extraction_strategies.coref_resolvers import (
CorefResolver,
FastCorefResolver,
Expand All @@ -15,6 +18,7 @@
)

__all__ = [
"CachedChunkExtraction",
"ExtractionStrategy",
"GraphExtraction",
"EntityExtractor",
Expand Down
Loading