Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,75 @@ 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: a cache lookup or rebuild failure
falls back to extraction (worst case is paying for skippable
LLM calls, never data loss). Fallback is scoped per chunk — a single
chunk the cache cannot rebuild is re-extracted on its own, and any
chunk transitively depending on it, rather than voiding the whole
document's cache. An unreachable graph
(`DatabaseUnavailableError`) and an exhausted latency budget are the
exceptions and propagate — extraction only needs the LLM, so it would
bill in full and then fail at the write phase regardless. A query the
server *rejects* (`DatabaseError`) still falls open, because the
server answered and the write phase will work. 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`).

### Changed

- **`FalkorDBConnection.query()` now raises `DatabaseError` for every
driver-level failure** — unreachable server, open circuit breaker,
exhausted retries, or a permanent error. Previously the raw
`redis.exceptions.*` exception escaped, which callers could only
catch as a generic `Exception`; telling an infrastructure failure
apart from an application error is what lets the chunk cache stop
instead of paying for an LLM run it could not store. The originating
exception is preserved as `__cause__` and its text is kept in the
message, so message-based handling (such as tolerating "index
already exists") is unaffected. Catch `DatabaseError` instead of
`redis.exceptions.ConnectionError` if you were relying on the
driver's own types.
- **`FalkorDBConnection.ping()` returns `False` when the server is
unreachable** instead of raising. Reporting "not alive" is the point
of the call. A missing `falkordb` package still raises `ImportError`
— a broken install is a packaging bug, not a down server, and
reporting it as one sends operators to the wrong place.
- **New `DatabaseUnavailableError(DatabaseError)`** distinguishes "the
server never answered" (unreachable, open circuit breaker, exhausted
retries) from "the server answered and rejected the query" (plain
`DatabaseError`). Existing `except DatabaseError` handlers are
unaffected. The chunk cache uses the split to decide whether falling
open is safe. `query()`'s permanent-error short-circuit now also
recognizes syntax errors, invalid input, unknown functions, type
mismatches, and missing procedures, so a rejected query fails fast
instead of burning the full retry budget first.

## [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
99 changes: 96 additions & 3 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 @@ -144,6 +147,46 @@ def _neutralize_context_close_tag(text: str) -> str:
return _CONTEXT_CLOSE_RE.sub("</ context>", text)


def _same_embedding_model(stored: str, current: str) -> bool:
"""Whether two model identifiers name the same embedding model.

A leading segment can mean two different things, and the string alone
doesn't say which:

- a *route* — ``"azure/text-embedding-3-large"`` is the same model as
``"text-embedding-3-large"``, just reached through a different endpoint;
- an *owner* — ``"BAAI/bge-m3"`` and ``"ollama/bge-m3"`` are a fp32 release
and a quantized build, same dimensions but different vectors.

A bare name is the tell: it carries no route, so a segment present on only
one side is one. That case is ignored. When both sides carry a segment they
name owners and are compared in full, which keeps the pairs above distinct.

This matters because the dimension check can't separate them — every
realistic collision here has identical dimensions — and the resulting
failure is silent: retrieval still returns results, ranked against vectors
from another model.

Comparison is case-insensitive and ignores surrounding whitespace. Only a
whole ``/`` segment is ever ignored, never a substring, so
``"text-embedding-3-large"`` and ``"text-embedding-3-large-v2"`` stay
distinct.
"""
a = stored.strip().lower()
b = current.strip().lower()
if not a or not b:
return a == b
if a == b:
return True
_, a_sep, a_tail = a.partition("/")
_, b_sep, b_tail = b.partition("/")
if a_sep and a_tail and not b_sep:
return a_tail == b
if b_sep and b_tail and not a_sep:
return b_tail == a
return False
Comment on lines +170 to +187


def _strip_and_load_json(text: str) -> Any:
"""Parse LLM-emitted JSON, tolerating optional markdown fences.

Expand Down Expand Up @@ -1923,6 +1966,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 +2028,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 +2170,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 +2265,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 +2406,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 +2460,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 +2557,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 @@ -2829,7 +2918,7 @@ async def _validate_graph_config(self, *, ctx: Context | None = None) -> None:
stored_dim = result.result_set[0][1]
current_model = self.embedder.model_name

if stored_model and stored_model != current_model:
if stored_model and not _same_embedding_model(stored_model, current_model):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth stepping back on this comparison generally.

Inside the SDK both sides are already symmetric — _write_graph_config writes self.embedder.model_name and this reads it back to compare against self.embedder.model_name. A graph written by this SDK and read by this SDK cannot hit the prefix mismatch. The asymmetry is entirely the server's EmbedderConfig.to_embedder() storing a bare name and later constructing a prefixed one.

So a global invariant is being loosened for every SDK user to compensate for a caller bug in a different repo. And because _write_graph_config uses MERGE ... SET, affected graphs rewrite the stored name on their next successful ingest — only the bootstrap needs a one-shot fix.

The original incident (a ConfigError that then broke every subsequent update) is a legitimate complaint, but it's a complaint about an un-overridable, inscrutable error, not about the error existing. That's fixable without widening the check:

if raw names match                     -> pass
if probe vectors are similar           -> pass, rewrite stored name
if basenames match and dims match      -> ConfigError naming the override flag
otherwise                              -> ConfigError

The third branch is where this PR's users land, and the message carries its own fix: "same base model via a different provider — pass embedding_model_policy='allow_provider_change' if these vectors are comparable." One deliberate line during a rare, supervised migration, and it self-heals on the next write. That's a much better shape than permanently relaxing the check for everyone.

Preference order: fix EmbedderConfig upstream > probe-vector fingerprint > explicit override flag > the current string widening.

raise ConfigError(
f"Embedding model mismatch: graph was built with "
f"'{stored_model}' but current embedder is "
Expand Down Expand Up @@ -3138,6 +3227,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 +3251,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 +3284,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 +3307,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
Loading
Loading