# Unified Knowledge Graph RAG on AWS — Technical Documentation
This is the **design reference for contributors and advanced users**: how the
library is built and why. Usage is in the guides of the [documentation hub](./index.md), every
configuration key in the [Configuration Reference](./reference/configuration.md), the
contribution workflow in [CONTRIBUTING.md](../CONTRIBUTING.md), and extension
recipes in [§15](#15-extension-guide).
## Table of Contents
1. [Overview and Design Philosophy](#1-overview-and-design-philosophy)
2. [Hexagonal Architecture (Ports & Adapters)](#2-hexagonal-architecture-ports--adapters)
3. [Domain Model](#3-domain-model)
4. [Ingestion Pipeline](#4-ingestion-pipeline)
5. [Incremental Indexing](#5-incremental-indexing)
6. [Retrieval: Two Methodologies](#6-retrieval-two-methodologies)
7. [Hybrid Scoring and Token Management](#7-hybrid-scoring-and-token-management)
8. [AWS Service Integration](#8-aws-service-integration)
9. [Evaluation Framework](#9-evaluation-framework)
10. [Visualization & Analytics](#10-visualization--analytics)
11. [Prompts and Prompt Tuning](#11-prompts-and-prompt-tuning)
12. [Configuration System](#12-configuration-system)
13. [Testing Strategy](#13-testing-strategy)
14. [CI/CD and Security](#14-cicd-and-security)
15. [Extension Guide](#15-extension-guide)
16. [Further Reading](#16-further-reading)
---
## 1. Overview and Design Philosophy
`unified-kg-rag-on-aws` reimplements the Microsoft GraphRAG and LightRAG
methodologies on an AWS-native stack (Bedrock, Neptune, OpenSearch, S3,
DynamoDB). Its design principles:
- **Two methodologies, one infrastructure.** GraphRAG (community summaries)
and LightRAG (dual-level keywords) share ingestion, indexing, caching,
multilingual processing and hybrid search; **only the retrieval algorithm
differs**.
- **Generalization first.** No hardcoded domain rules or regex heuristics:
semantic judgments go to the LLM or to authoritative data, token counts to
the model's tokenizer API where it has one (a script-aware estimate
otherwise), and thresholds and weights are configuration.
- **Hexagonal boundaries.** Domain and algorithm code depends on abstract
ports; the AWS (and local) adapters sit behind them.
- **Registry-based extension.** Search strategies, renderers, parsers and
providers register through registries, with no change to dispatch code;
evaluators map from `EvaluatorType` in one method (§2.4).
Where the framework extends the GraphRAG and LightRAG papers (triple-hybrid
retrieval, both methodologies on one index, incremental indexing, multilingual
ingestion, bring-your-own graphs and schemas, measured defaults) and where it
deliberately departs from them is summarized in
[Overview: Beyond the papers](./concepts/overview.md#beyond-the-papers); the
sections below give the design behind each.

---
## 2. Hexagonal Architecture (Ports & Adapters)
### 2.1 Dependency Rule and Layer Map
Imports point **inward**. The layers, outermost first; a module imports its
own layer and the layers after it, never one before it:
| Layer | Package | Role |
|---|---|---|
| Entry points | `application/cli/` | the console scripts: parse flags, load config, wire, run |
| Evaluation | `evaluation/` | drives the RAG chain over a dataset; of the application it imports only the chain (`application/retrieval/`) and the composition root (`application/bootstrap.py`) |
| Application | `application/` | orchestration: ingestion pipeline, indexing manager, RAG chain, prompt tuner, config loading, composition root |
| Adapters | `adapters/` | technology bindings and port implementations |
| Ports | `ports/` | the interfaces the application wires and the adapters implement |
| Domain | `domain/` | models, pure algorithms, prompts |
| Shared kernel | `shared/` | logging, errors, metrics, tracing hooks, write fence, pure helpers |
The rule holds for every import: at module level, inside a function, and
under `TYPE_CHECKING` (a type-only import is still a dependency). Inside the
package the root facade is imported only for `__version__`.
```mermaid
flowchart TB
cli["application/cli/
entry points"]
evaluation["evaluation/
evaluators, strategy comparison"]
application["application/
ingestion pipeline, indexing manager, RAG chain, bootstrap"]
adapters["adapters/
AWS and local bindings, search strategies, retrievers"]
ports["ports/
abstract interfaces"]
domain["domain/
models, pure algorithms, prompts"]
shared["shared/
logging, errors, metrics, pure helpers"]
cli --> evaluation --> application --> adapters --> ports --> domain --> shared
```
An arrow reads "may import"; a layer may also import every layer further
down (the application imports the domain directly, every layer the kernel).
- **Ports sit outside the domain.** A port is written in terms of the domain
models (`DocStatusPort.diff` returns a `DocumentDelta`), so the domain
cannot import it back. Domain code that reads a store declares the subset
it reads as its own `Protocol` (`domain.ingestion.delta_detector.
DocStatusReader`, the read half of `DocStatusPort`); every port
implementation satisfies it structurally, mypy checks each caller, and
`tests/unit/test_fake_port_signatures.py` pins the two shapes together.
The application hands the port in.
- **`shared/` is a leaf kernel.** It imports no other layer, so every layer,
the domain included, uses it without a cycle. Code that needs the domain
models lives in the layer it belongs to: config loading in
`application/config.py`, the stage cache and resume managers in
`application/ingestion/`, stage cache keys and document identity in
`domain/ingestion/`, store names in `domain/retrieval/`, the LangChain
helpers in `adapters/llm/langchain.py` and
`adapters/ingestion/document_converter.py`, the rich console helpers in
`application/cli/display.py`. `setup_logging` takes the configuration
structurally (`shared.logging.HasLoggingSettings`).
- **Evaluation reaches the stores through the chain** (its retrievers,
`GraphRAGChain.check_filter_values` / `check_filter_keys`) and imports no
store adapter.
- **Backends are injected.** Vendor bindings (`adapters/aws/`,
`adapters/local/`) and port implementations (the `storage/` indexers,
`retrievers/`, `doc_status/`, `guardrail/`) are built by the provider
builders and the composition roots: `application/bootstrap.py`
(`build_providers`, `build_query_guard`, `build_checkpoint_sync`) and the
CLIs. The orchestrators receive them (`Providers`, those builders, or a
constructor argument).
- **Plugins through their registry.** Outside its package, a renderer,
search strategy, evaluator, schema source or seed source is reached
through the package root only, or, for renderers and evaluators, also
their `base` module (§2.4); `tests/unit/test_layering.py` (`_PLUGINS`)
lists the importable modules. The search-strategy registry lives with the
strategy bases it stores (`adapters/retrieval/strategy_registry.py`), as
the provider and renderer registries live with what they register.
No `domain/` module may load LangChain, LangSmith, boto3/botocore, lxml,
opensearch-py or gremlin-python, even transitively, so `shared/utils/`
holds only dependency-light helpers; `tests/unit/test_domain_purity.py`
checks this in a clean interpreter.
```
unified_kg_rag/
├─ __init__.py # public API facade: lazy re-exports of the supported names
├─ domain/ # technology-agnostic core (no boto3/LangChain/backend imports)
│ ├─ models/ # Pydantic domain models
│ ├─ ingestion/ # pure algorithms: delta_detector, graph_builder/resolver/analyzer,
│ │ │ # claim_resolver, contributions, derivation, cache_keys,
│ │ │ # document_identity, seed_graph, visualization_data (+ layout helpers),
│ │ │ # community_clustering (Leiden hierarchy, content-named communities)
│ │ └─ merge/ # cross-run merger
│ ├─ retrieval/ # store_names, index_prefixes, hybrid_normalization,
│ │ # ppr (HippoRAG 2 Personalized PageRank, §6.4)
│ └─ prompts/ # version-controlled prompt templates
├─ ports/ # interfaces the application wires, adapters implement (DocStatusPort,
│ # BaseIndexer/GraphIndexer/VectorIndexer, ModelFactoryPort,
│ # ContentGuardPort — ports/__init__ is the port catalog)
├─ adapters/ # concrete technology bindings
│ ├─ aws/ # Bedrock model factories, Neptune/OpenSearch clients, S3 transfer and caches
│ ├─ local/ # Ollama/OpenAI-compatible models, cross-encoder, embedded SQLite graph/search stores
│ ├─ llm/ # provider-neutral chain assembly, transient retry, token estimate,
│ │ # LangChain batch/XML helpers (langchain.py)
│ ├─ observability/ # OpenTelemetry tracer, exporters, LangChain callback
│ ├─ storage/ # Neptune/OpenSearch/SQLite-graph indexers (write-side port implementations)
│ ├─ retrievers/ # Neptune/OpenSearch/SQLite-graph retrievers
│ ├─ doc_status/ # DynamoDB/SQLite document-status registries (DocStatusPort)
│ ├─ guardrail/ # Bedrock ApplyGuardrail (ContentGuardPort)
│ ├─ search_strategies/# simple/local/global/drift + lightrag(mix/hybrid/naive) + ppr
│ ├─ retrieval/ # abstract retriever/strategy bases, strategy_registry, hybrid scorer,
│ │ # token/memory managers, ppr_ranking (PageRank over the stores)
│ ├─ ingestion/ # LLM/IO coupled: chunker, *_extractor, loader, parser,
│ │ # translator, gleaner, community_detector, document_converter
│ ├─ renderers/ # graph visualization renderers
│ ├─ schema_sources/ # @register_schema_source: extraction-schema loaders (file, owl)
│ ├─ seed_sources/ # @register_seed_source: seed graph loaders (jsonl, csv, rdf)
│ ├─ source_files/ # shared readers of a source's files: local/S3 locations, RDF files, checks, parse
│ ├─ evaluators/ # evaluator bases + langchain/ragas evaluators
│ ├─ store_errors.py # is_fatal_retrieval_error: the fatal-vs-degraded policy (§8.1)
│ ├─ providers.py # Providers: the per-run bundle of model and store providers
│ └─ provider_registry.py # @register_provider: builders keyed by role and kind
├─ application/ # orchestration + entry points
│ ├─ cli/ # run-ingestion/rag/eval/visualization/prompt-tuning, prepare-benchmark,
│ │ # display (rich console helpers)
│ ├─ ingestion/ # DataIngestionPipeline + pipeline_stages, IncrementalIndexer,
│ │ # namespace lease, extraction-record store, stage cache
│ │ # (cache_manager), resume (pipeline_manager; the resume
│ │ # point and rewinds in resume) and run metrics
│ │ # (pipeline_metrics)
│ ├─ storage/ # IndexingManager (indexer fan-out) and its collaborators:
│ │ # cross_run_merge, removal, reannotation, stored_items
│ ├─ retrieval/ # rag_chain (GraphRAGChain, RAGInput/Output), its query
│ │ # processing (query_processing) and filter-key checks
│ │ # (filter_validation)
│ ├─ prompts/ # PromptTuner (LLM-based corpus profiling)
│ ├─ bootstrap.py # the composition root: build_providers, build_query_guard,
│ │ # build_checkpoint_sync, resolve_extraction_schema,
│ │ # load_seed_graphs
│ ├─ config.py # get_config / ConfigLoader: file + environment -> Config
│ └─ observability.py # configure_observability: installs the tracing backend
├─ shared/ # leaf kernel (logging, exceptions, metrics, observability hooks,
│ # write fence, utils/: hashing, ids, concurrency, scripts)
└─ evaluation/ # real logic package: evaluation_manager / graph_aware /
# retrieval / answer_match, strategy comparison + pairwise
# judge, benchmarks/ (MuSiQue)
```
- **Real logic package.** `evaluation/` (the manager and the LLM-free
evaluators) is a layer of its own (above). Everything else is imported
from its real location (`application.*`, `adapters.*`, `domain.*`).
- **Vendor and role packages.** Vendor packages (`adapters/aws/`,
`adapters/local/`) hold technology bindings (clients, embedded engines and
stores, model factories, token counters, caches); role packages
(`storage/`, `retrievers/`, `doc_status/`, `guardrail/`) hold the port
implementations the application wires. A role package imports a vendor
package, never the reverse: `SQLiteGraphIndexer` writes to
`SQLiteGraphStore` as `NeptuneIndexer` writes through `NeptuneClient`. Of
the adapters, a vendor package imports only the other vendor package and
the vendor-neutral kernel (`adapters/llm/`, `adapters/observability/`),
which imports no other adapter package.
### 2.2 Ports (Abstract Interfaces)
| Port | Location | Adapters | Notes |
|---|---|---|---|
| `DocStatusPort` | `ports/doc_status.py` | `adapters/doc_status/dynamodb.py`, `sqlite.py`; `FakeDocStatusStore` for tests | Document status and each document's text units, partitioned by index namespace; the domain's delta reads its `DocStatusReader` subset |
| `ContentGuardPort` | `ports/guardrail.py` (with `GuardVerdict`) | `adapters/guardrail/bedrock.py` (Bedrock `ApplyGuardrail`) | Screens the raw query and the final answer (§14) |
| `GraphIndexer` (write) | `ports/indexer.py` | `adapters/storage/neptune_indexer.py`; local `sqlite_graph_indexer.py` | One contract for full builds and deltas (`upsert_*` / `delete_by_id`) |
| `VectorIndexer` (write) | `ports/indexer.py` | `adapters/storage/opensearch_indexer.py` (on OpenSearch, or on the embedded engine through `SearchEngineClient`) | Same |
| `BaseGraphRAGRetriever` (read) | `adapters/retrieval/base.py` | `adapters/retrievers/{neptune,opensearch,sqlite_graph}_retriever.py` | Retrieval adapters |
| Model factories, token counter | `ports/model_factory.py` (`ModelFactoryPort`, `TokenCounterPort`, the `*ModelInfo` records) | `adapters/aws/bedrock.py`, `bedrock_models.py`, `token_counter.py`; local `adapters/local/{models,embeddings,rerank,token_counter}.py` | Carried by one `Providers` bundle (§15) |
The read-side bases (`BaseGraphRAGRetriever`, `BaseSearchStrategy`) and
`BaseGraphRAGEvaluator` are *adapter bases*: they build infrastructure
(scorer, token manager) in `__init__`, so they live in `adapters/` and
`ports/__init__` only names them.
### 2.3 Role-Based Retriever Injection
Strategies receive retrievers **by role, not by backend**:
`RetrieverRole.GRAPH` (graph expansion) and `RetrieverRole.DOCUMENT`
(vector and lexical lookup). They reach them only through
`self.graph_retriever` / `self.document_retriever`, and the chain's role →
builder map binds the implementation, so swapping the graph backend changes
no strategy code.
```python
# adapters/retrieval/strategy_registry.py
@register_strategy(SearchStrategy.LOCAL, required_roles=(RetrieverRole.DOCUMENT, RetrieverRole.GRAPH))
class LocalSearchStrategy(BaseSearchStrategy): ...
```
The write path has two fixed store roles: `IndexingManager` always writes a
graph store and a vector store, because the fan-out (entities to both stores,
entities before edges, the orphan-edge cascade) is domain knowledge; which
backend fills each role is open (`providers.graph.kind` /
`providers.vector.kind`, or an injected indexer).
### 2.4 Registries
- **Search strategies**: `@register_strategy(...)`
(`adapters/retrieval/strategy_registry.py`): a class, its required roles and
query inputs, keyed by `SearchStrategy`; a wrapper (`iterative`) names the
strategy it runs under a config (`wraps`, §6.3).
- **Providers**: `@register_provider(ProviderRole.X, "kind")`
(`adapters/provider_registry.py`): the builder `providers..kind`
selects, for every model, store and registry role.
- **Renderers**: `@register_renderer("name")` (`adapters/renderers/base.py`);
**parsers**: `ParserFactory.register_loader(".ext", Loader)`.
- **Schema sources**: `@register_schema_source("kind")`
(`adapters/schema_sources/registry.py`): the loader
`processing.graph_extraction.schema_source.kind` selects (§12).
- **Seed sources**: `@register_seed_source("kind")`
(`adapters/seed_sources/registry.py`): the loader each
`processing.seed_graphs[].kind` selects (§4.5).
- **Evaluators**: one lazy-import branch per `EvaluatorType` in
`EvaluationManager._resolve_evaluator_class`.
### 2.5 Dependency Rule Verification and Public API
`tests/unit/test_layering.py` enforces §2.1 on every import of every
module (lazy and `TYPE_CHECKING` ones included, relative ones resolved):
the layer order, evaluation's reach into the application, injected
backends (also inside the adapters: the search strategies, the retrieval
kernel and the ingestion adapters import none), store-free evaluation,
vendor packages as leaves, plugins through their registries,
and the root facade imported only for `__version__`. Importing `pkg.mod`
runs `pkg/__init__.py`, so the imports a package's `__init__` makes count
for every module that imports from the package; outside the facade and the
console-script launcher, a package module is never imported by a name
string (`importlib.import_module`, `__import__`, `pkgutil.resolve_name`,
`importlib.util.find_spec`, also under an alias, through `getattr` or with
`name=`), and a dynamic import of a name the scan cannot read (a variable,
a concatenation, a relative name), `exec` or
`importlib.util.spec_from_file_location` needs an entry saying what it
imports. Its list of allowed exceptions is empty, and each rule has
a synthetic case it must catch. A clean-interpreter check pins the runtime
side, which the scan of edges cannot see (an allowed adapter module passing
a backend on): importing any module of the application (the CLIs and the
composition root aside), evaluation, the search strategies, the retrieval
kernel or the ingestion adapters loads no vendor binding, port
implementation, gremlin-python or opensearch-py.
`tests/unit/test_domain_purity.py` checks the third-party side: no domain
module loads an infrastructure package, even transitively.
**Public API.** Callers outside the package import from `unified_kg_rag`,
whose `__all__` is the supported surface: the names for using the framework
(`Config`, `get_config`, `DataIngestionPipeline`, `GraphRAGChain`, `RAGInput`
/ `RAGOutput` / `SearchOutput`, `EvaluationManager`, ...) and for extending it
(the ports, `Providers` / `build_providers`, the registries). Other module
paths are internal; only a change to the exported names, configuration keys,
CLI flags and exit codes, or stored formats is breaking. Names resolve on
first access (`import unified_kg_rag` loads nothing), and
`tests/unit/test_public_api.py` pins the list.
---
## 3. Domain Model
`domain/models/` holds pure Pydantic models with no infrastructure
dependency:
- `Entity` (`name`, `description`, `type`, `text_unit_ids`, `community_ids`,
`rank`, `confidence`, embedding fields, the schema annotations
`in_schema` / `type_iri`, the stated `iri` and the derived
`source_kinds`), `Relationship` (`source_id` / `target_id`,
`description`, `weight`, `text_unit_ids`, `in_schema` / `predicate_iri`,
`source_kinds`; §4.2, §4.5),
`Community` / `CommunityReport`, `TextUnit`, `Covariate` (claim);
- `DocStatus` (a `PENDING` write-ahead record, then `PROCESSED` / `FAILED` /
`QUARANTINED`), `DocStatusRecord` (content hash, the document's text units,
suffix), `DocumentDelta` (new / changed / unchanged / deleted, plus failed,
quarantined, quarantined-unchanged, deferred and settings-changed ids),
`DocumentLineage` (the text units one document produced);
- `SearchQuery` / `SearchResult` / `RetrievalResult`, `SearchStrategy` /
`SearchType` / `RetrieverRole`;
- `SeedGraph` / `SeedNode` / `SeedEdge` (`seed_graph.py`): what a seed source
reads, before conversion (§4.5).
**Lineage is the core data.** Entities and relationships record the
`text_unit_ids` they were stated in. This lineage, not a token-overlap
heuristic, decides whether an entity relates to a text unit, in any
language, and incremental removal follows it (§5).
**Rank (MS GraphRAG parity).** An entity's `rank` is its degree, a
relationship's the sum of its endpoints' degrees (`domain/ingestion/graph_rank.py`).
A full build sets them; an incremental merge recomputes them for what it
writes over the stored graph's neighbours. Only document-backed edges
count: an edge every text unit of which is a seed graph's adds to no
degree (§4.5), so with no seed graph the rank is the plain degree.
**Index suffix.** Every OpenSearch index and Neptune label is named
`-[-]`: ingestion writes
`processing.document_parsing.index_value`, queries select `RAGInput.suffix`
(`run-rag` / `run-eval`: `--suffix`, else the config's `index_value`), all
`default` when unset; one suffix per tenant or corpus version.
**Display names and identity keys.**
- `Entity.name` keeps the display form the source used, only trimmed and
whitespace-collapsed (`clean_display_name`), so context and reports quote
"$1,000 penalty" or "C++" verbatim.
- Ids hash `entity_key(name)` (`shared/utils/common.py`): NFKC, casefold, `_`
and `-` as spaces, quote marks and commas dropped, whitespace collapsed,
trailing sentence punctuation stripped, **every other symbol and the
letters and digits of all scripts kept**. "ACME Corp." and "Acme Corp"
share an id; "C++" and "C#" do not; a non-empty name never yields an empty
key. Every exact-name match (extraction, gleaning, claim resolution,
incremental merges) and the fuzzy matcher's shingles use the same key.
- **Changing the key changes every id**, so it requires a full re-index.
---
## 4. Ingestion Pipeline
`DataIngestionPipeline` (`application/ingestion/pipeline.py`) runs 12
checkpointed stages, so a re-run resumes after the last valid one. Extraction
works per text unit and the graph is resolved from the merged per-unit
records, so a full build does not depend on batching and an incremental run
can re-derive what it touches (§5).

| # | Stage | Module | Notes |
|---|---|---|---|
| 1 | Document parsing | `parser.py` (`ParserFactory`) | PDF/TXT/CSV/JSON (+ MD/HTML with the `unstructured` extra) |
| 2 | Document loading | `loader.py` | optional MinHash deduplication; the incremental diff |
| 3 | Chunking | `chunker.py` | intelligent (LLM boundaries, default) or simple |
| 4 | Translation | `translator.py` | optional; skipped when source = target language |
| 5 | Graph extraction | `graph_extractor.py` | entities and relationships per text unit |
| 6 | Gleaning | `gleaner.py` | optional; re-asks a unit while its last answer added something |
| 7 | Graph resolution | `graph_resolver.py`, `description_summarizer.py` | merges name variants, re-summarizes long descriptions (calls the LLM) |
| 8-9 | Claim extraction and resolution | `claim_extractor.py`, `claim_resolver.py` | optional (off by default) |
| 10 | Graph analysis | `graph_analyzer.py` | graph build and statistics |
| 11 | Community detection | `community_detector.py`, `domain/ingestion/community_clustering.py` | hierarchical Leiden (the clustering is pure, in the domain) and structured reports |
| 12 | Indexing | `application/storage/indexing_manager.py` (+ `cross_run_merge.py`, `removal.py`, `reannotation.py`) | graph and vector stores; full build or delta |
The stage order lives in `DataIngestionPipeline.STAGE_CLASSES`; every stage
gets its models and stores from the run's `Providers` bundle.
### 4.1 Extraction records and the extraction store (stages 5-8)
- **One record per text unit.** Extraction and gleaning write one
`TextUnitExtraction` per unit (`domain/models/extraction.py`): the
entities and relationships that unit states, in its own words, plus the
entity keys rejected for it. Gleaning edits one record at a time: its
prompt lists the unit's own items, and a correction never reaches what
another unit says (MS GraphRAG and LightRAG likewise glean each chunk's own
extraction). It lists the entities with their types and gives the
extraction prompt's entity and relationship type blocks and its 1-10
confidence and strength scales, so a gleaned item is typed and scored as
an extracted one (a missing score takes extraction's default too).
- **Output format and gleaning (deliberate divergences).** Extraction asks
for XML records (`GraphExtractionPrompt`): each entity with its type,
description, a 1-10 `` and a verbatim `` span,
each relationship with a strength and its span, where MS GraphRAG and
LightRAG parse delimited tuples. The span is what entity grounding checks
an item against its chunk with, and the confidence what
`entity_confidence_threshold` filters on. Gleaning is a refinement prompt
of its own (`GraphRefinementPrompt`), sent without the extraction's
conversation: it lists the unit's text and current items and asks for
missing items and corrections. Upstream continues the extraction
conversation ("MANY entities were missed ...") with its history. A round
here costs one prompt of the unit's text and items rather than a growing
conversation, and it can correct an item (`ENTITY_CORRECTION`,
`RELATIONSHIP_CORRECTION`), not only add one.
- **Resolution starts from the merge of all records**
(`domain/ingestion/contributions.merge_contributions`: items merged by id,
weights kept per unit), so the merged graph does not depend on which units
shared a batch. Claim prompts list the unit's own entities, so a unit's
claims depend on the unit alone. A claim's subject and object resolve
only against the entities its own text units stated (`ClaimResolver`, in
a full build as incrementally): a claim whose subject its units' entity
extraction did not list is dropped, and an object not listed there is
kept as the literal name the claim states (`object_id` empty), though
another document states that entity. Each unit's statement is resolved
on its own, and resolution merges the statements that resolve to one
claim id.
- **The extraction store** keeps each record, keyed by
`:`, in the vector store's
extraction-records index. The fingerprint
(`cache_keys.unit_record_fingerprint`) covers the models, the extraction,
gleaning or claim settings, the target language and the prompt templates in
effect. A unit whose text and settings have a stored record takes it
instead of an LLM call, so an edited document re-extracts only its changed
chunks, and identical chunks elsewhere or in a rebuild reuse theirs.
`--force-rebuild` re-extracts and refreshes every record; an unreadable
store is logged and the run re-extracts. A record that cannot be written
fails its unit (`records_not_stored`), so its document is recorded `FAILED`
and extracted again: incremental runs derive from the records (§5.4).
- **A text unit names its stored records** in `attributes.extraction_records`,
computed from the run's records when the unit is indexed (not set on the
cached units), so a resumed run writes them too.
- **A record stays while a stored text unit names it.** A full build or
reset whose text units were all written keeps only the records they name
(`VectorIndexer.retain_extraction_records` copies them into a new index and
swaps the alias); an incremental run deletes the records only removed text
units named.
- **Chunk boundaries are records too.** The intelligent chunker stores each
pre-chunk's boundary answer in the same index, keyed by
`:` (the
every-call and chunking settings, the chunking model and the boundary
prompt), and the text units cut from it name the record (`chunks` in
`attributes.extraction_records`), so it is kept and removed like the
others. A pre-chunk with a stored answer takes it instead of a model call:
a reset of an unchanged corpus asks the boundary model nothing, and a
model that answers otherwise each time (no temperature control) cannot
move the boundaries, and with them every downstream cache key, of
unchanged text. The stage cache could not hold them: it is per pipeline
id and keyed by the whole corpus. Only an answer that cuts its pre-chunk
is stored: one it falls back from (lines that do not exist, a miss rate
over the limit) would split it by size on every later run, so it is asked
again, and a stored one of that kind is replaced. `--force-rebuild` asks
again.
- **A full build reuses the corpus-wide answers of unchanged input.** The
LLM response cache keys a call by its prompt and model, which are stable
(community ids are content ids), but it lives in the run's cache
directory: a reset under a new pipeline id starts it empty, so before
this every reset summarized every long description and reported every
community again. When the run's graph is the corpus's (no registry, a
reset, or no unchanged document), graph resolution reads the stored
graph copies of the items over the summarization threshold (one read
per suffix) and keeps a summary whose `summarized_from` is the hash of
the item's description and whose `summarized_with` is
`cache_keys.description_summary_fingerprint` (every-call settings, the
summary model, the summarization section, the prompt), so a reset after
a summarization setting change summarizes again; a copy the graph capped
is not reused. The report answers are extraction-store records keyed by
`:`
(the chain input as packed, with the community's name, which titles the
report), stored with their generation time and named by the report
(`attributes.report_record`), so the rebuild's retention keeps the ones
its reports name. A stored answer gives the same report a call would
(rank and lineage are recomputed), and its vectors are reused by digest.
An incremental run that leaves documents unchanged neither reads nor
writes them: its communities cluster the delta, which a full build
clusters otherwise, so the first reset after incremental runs asks for
those reports. `--force-rebuild` asks
again and refreshes the records.
- **Gleaning stops on observed output**: a unit is re-sent (up to
`max_rounds`) only while its previous answer added something new. An empty
answer is the "nothing missing" signal MS GraphRAG asks for with a separate
yes/no prompt, so no call is spent on it. `max_rounds` stays 3, against
MS GraphRAG's 1, for the measured graph recall (§9.1).
### 4.2 Graph identity and merging (stages 5-7)
- **Entity identity (deliberate divergence).** An entity id hashes
`entity:{entity_key(name)}`: the type is not part of it, so a name stated
with two types is one entity, typed by the most frequent of its statements
(document statements first, the smallest type on a tie;
`contributions.merge_entity_contributions`). MS GraphRAG groups extracted
entities by name and type; LightRAG keys them by name, as here.
- **Relationship identity (deliberate divergence).** A relationship id hashes
`relationship:{entity_key(source)}:{entity_key(target)}:{type}`, lowercased,
so the model-written `type` is part of the identity and the edge is
directed: two types between one pair are two edges here and one in MS
GraphRAG and LightRAG (the community graph still joins them, §4.3). The
parts are joined with `:` unescaped, so a `:` in a name or type can collide;
the format is kept because fixing it would re-key every stored relationship.
- **Endpoints cite their relationships' text units (deliberate
divergence).** An entity also cites the text units of each relationship it
is an endpoint of, and an endpoint no unit listed becomes a stub entity
citing them (`relationship_endpoints.cite_relationship_endpoints`). Upstream, an
entity's text units depend on which chunks were extracted together; here
they depend only on the chunks that name it, so an incremental run cites
what a full build cites and a removed document strips an entity to exactly
the surviving text units that name it. The cost: local search and
community lineage also reach chunks that mention an entity only through a
relationship. With entity grounding on, gleaning drops a relationship naming
an entity rejected as ungrounded for its chunk, so no stub brings it back.
- **Entity merging.** Two names merge when their `compact_name_key` is equal:
`entity_key` without a short, fixed list of legal-form designators (`(주)`,
`주식회사`, `株式会社`, `有限公司`, a trailing `Inc` / `Ltd` / `LLC` / `Co` /
`Corp`, ...) and without spaces next to Han, Hangul or Kana, so
"(주)가나다", "가나다 상사" / "가나다상사" and "Acme Inc" / "Acme" merge. A
merged group takes the name cited by the most text units (ties: the
smallest), so the result does not depend on input order.
- **Fuzzy matching is opt-in** (`processing.fuzzy_entity_resolution`), because
name similarity also joins different organizations that share most of their
name ("Northfield University" / "Northfield University Hospital"). When it is
on, names link above `processing.similarity_threshold` only when their
*discriminator tokens* (tokens with a digit, single letters, Roman numerals)
are identical, their types are compatible, and, for names ending in Han,
Hangul or Kana, their final (head) characters are equal ("가나다연구원" and
"가나다연구소" stay apart). A type-aware union-find never groups two
differently typed names. Without fuzzy matching, full and incremental
builds group the same names (both merge by compact name key). With it, the
incremental merge uses the same matcher but folds each delta entity into
its one best-matching stored entity and does not join stored entities with
each other: a delta name that links two stored names a full build chains
into one group ("Northfield Medical Centre" between "... Center" and
"... Centre Annex") merges with one of them only, until
`indexing.reset: true` regroups the corpus.
- **Schema annotations are written, not extracted.** `IndexingManager`
sets `in_schema` and `type_iri` on each entity and `in_schema` and
`predicate_iri` on each relationship it writes, on every write path (full
build, delta, the strip of a removal, the stored edges it rewrites for
their names or rank), from the item's stored type and the schema
(`domain/ingestion/schema_annotation.SchemaAnnotator`, matched by
`schema_key`, so the lowercased type vote still matches). A pure function
of the merged type, it gives a full build and an incremental run the same
values; pattern conformance is not stored, since it depends on endpoint
types other documents can change. Where the schema does not apply
(`SchemaConformance.applies`: `off`, `align` with entity types alone),
`in_schema` is `None`; where it applies, both kinds get it, and every item
of a kind with no declared type is in schema (nothing of it is off schema,
so `in_schema: true` never empties a kind); an IRI is written
whenever the stored type names a declared type with an `iri` and the mode
is not `off` (so `align` with entity types alone still links them), and is
`None` otherwise. `None` is never written: models drop it from their dumps
(`SchemaAnnotated`), extraction records never hold annotations, the
Neptune codec and the OpenSearch documents omit it. A default config (no
`iri` anywhere) therefore writes no annotation field on its items; only the index mappings gain
`in_schema` (boolean) and the IRI keyword fields, added to live indices
by the incremental preflight (`check_index_settings` →
`_add_missing_fields`). Ids are unchanged; the IRIs are the seam for
linking items to an external ontology. A schema edit that changes only
the annotations (the digest, §12 "Extraction settings") is applied by
`IndexingManager.reannotate`: each store lists its items' annotations
with their types (`read_schema_annotations`; a relationship's vector copy,
which holds no type, takes its edge's), and only those that differ are
rewritten in place (`write_schema_annotations`: Neptune and the SQLite
graph drop and set the two properties, OpenSearch a partial update with
null for an unset one), so the stores end as a full build leaves them.
Neptune lists the ids in one traversal and reads the annotations in
`batch_size` batches; OpenSearch pages by id. A store that inherits the
port's defaults (`rewrites_schema_annotations`, which compares the
functions its methods are bound to, so a forwarding wrapper answers for
the store it wraps) makes the pass read nothing; a store error ends the
pass with a warning, not the run.
- **Merged descriptions** are concatenated, so `DescriptionSummarizer`
re-summarizes those over a token budget with a cheap model (map-reduce when
one exceeds a call's input); a failed call keeps the concatenation. The
trigger is a cost variant of upstream, not parity: MS GraphRAG
`summarize_descriptions` summarizes every item with more than one
description and LightRAG also at `force_llm_summary_on_merge` (8)
fragments; here only the token budget triggers it.
### 4.3 Communities (stages 10-11)
- **Community graph.** `GraphBuilder` builds an undirected graph with one edge
per entity pair; relationships of other types or directions between the pair
share it, with the summed weight, so Leiden sees the pair's total strength.
- **Hierarchy (deliberate divergence).** Level 0 is one Leiden partition of the
whole entity graph; each coarser level partitions the graph of the previous
level's communities, up to `max_levels`. Because modularity's resolution
limit grows level-0 communities with the corpus beyond what one report can
describe, a community over `max_cluster_size` (default 20) members is
partitioned again on its own subgraph. Level-0
communities under `min_community_size` merge into their strongest neighbour.
A connected component smaller than `min_community_size` (an isolated
entity, a pair) gets no community or report; only these are skipped, unlike
MS GraphRAG's `use_lcc`, which drops every component but the largest. On an
incremental run, whose graph is only the delta, a small component is
clustered when it reaches `min_community_size` in the whole graph a full
build would cluster: walking the stored relationships that cite text units
of documents the run neither processes nor removes, at most
`min_community_size` batched reads. Stored entities without a community it
links to stay without one until a rebuild (`community_drift`
`unclustered_entities`). A
parent identical to its only child is never created, so each distinct
community is reported once. A Leiden failure fails the stage instead of
truncating the hierarchy.
- **Determinism.** Leiden's partition depends on insertion order and
`graspologic_native` returns labels in a per-process order, so the clustering
(`domain/ingestion/community_clustering.py`) sorts nodes and edges and
renumbers each level by its smallest member id:
the same graph and `random_state` always yield the same communities and ids.
- **Reports (MS GraphRAG parity, two deliberate deviations below).** A report's input takes the community's
entities by degree (document-backed, §4.5), capped at
`max_entities_per_report`, and packs them into `max_report_context_tokens`
edge by edge, as MS GraphRAG's `sort_context`: the relationships among them
by combined endpoint degree (then weight and id), each with its endpoints
and, with claim extraction on, the claims about them; one that does not fit
is left out and the next tried; the entities with no relationship among
them come last; the top entity always fits. Packing the entities first left
the relationships whatever budget remained (with 200-token descriptions, 2
of 40 edges). MS GraphRAG's 8000-token budget with `max_cluster_size` 10 is
the tuning point for larger reports: the defaults (4000, 20) are kept for
cost. Reports are generated bottom-up. The prompt returns a structured
report (summary, 0-10 rating with explanation, findings); `full_content`
for embedding and search is rendered from it, with no second call.
- **Sub-community roll-up (deviation).** When a parent's own context was
cut, its children's report summaries (biggest child first, each with its
rating) are added to that context under a second budget of
`max_report_context_tokens` (`enable_sub_community_rollup`), so the
prompt stays within about twice the raw budget. MS GraphRAG substitutes
sub-community reports for the raw context that does not fit; adding them
keeps the parent's best-connected entities and relationships verbatim, at
up to twice the input tokens of a parent that overflows.
- **Report rank (deviation).** A report's `rank` is the sum of its members'
document-backed degrees (`_compute_report_rank`, no model call; the member
count without a graph), where MS GraphRAG's is the model's rating. The
model's rating is kept as `rating` (in the report text, and a filter key
of the reports index); global search's map step rates each report against
the query instead. `rank` orders a search of the reports index without
query text.
- **Report lineage.** Each report carries `text_unit_ids` and `document_ids`
(the union over its members: provenance, not citation support) and its
place in the hierarchy, which global search selects by (§6.1.1).
### 4.4 Pipeline infrastructure
- **Resume.** Each stage's output is checkpointed under the run's pipeline id
(`application/ingestion/pipeline_manager.py`, `application/ingestion/cache_manager.py`); the stage cache
key is described in §12. Where a resumed run starts, and the rewinds that
move it back, are in `application/ingestion/resume.py`.
- **S3 sync** (`adapters/aws/s3_cache.py`) uploads the changed files after
every stage, data before the metadata that refers to it. On SIGTERM,
`run-ingestion` stops the running stage (`request_stop`: deferred while a
checkpoint is written, no I/O in the handler) and uploads its checkpoint.
- **Reuse across runs.** Within what a run writes, a text embedded before
with the same embedding provider, model and dimension keeps its stored
vector (each vector stores a digest of its text, §5.2), so a changed
document re-embeds only the chunks, descriptions and reports whose text
changed, and a reset of an unchanged corpus embeds again only what it
regenerates. The intelligent chunker's boundary answers are kept per
pre-chunk in the extraction store (§4.1), so a reset or an edit asks the
model only for pre-chunks whose text or chunking settings changed, and an
unchanged pre-chunk keeps its boundaries even with a model that answers
otherwise each time. Only an answer the chunker used is kept: a pre-chunk
split by size instead (the answer named lines that do not exist) asks the
model again on the next run. A full build (a reset, a first run, or a run
without the registry) does the same for the corpus-wide answers: a
description over the summarization threshold whose stored copy was
summarized from the same text with the same summarization settings keeps
that summary, and a community whose report input (its members,
descriptions, edges and claims as packed, its roll-up text and name) and
report settings are unchanged takes the stored answer (the extraction
store keeps it, and the report names it in `attributes.report_record`). A
community's name (`Level 0 Community 1a2b3c4d`) derives from its members,
so a document that adds a community leaves the others' names, and their
stored answers, as they were.
- **LLM response cache** (`adapters/llm/response_cache.py`): a LangChain
`BaseCache` on every ingestion model, keyed by the rendered messages and the
model and generation settings, stored with the stage cache. An earlier
run's entry is served at most once per run and truncated responses are not
kept, so a retry after a bad response asks the model again. A
`BatchProcessor` item that failed every attempt drops the responses its
calls were served or stored (`discard_if_item_fails`), so a re-run asks
again for that item only instead of replaying them; so does an item whose
answer the stage rejects afterwards (a graph or claims record that fails
to build), through the `discards` the batch hands back. A call abandoned on
its call timeout (`call_timeout_seconds`) is neither served nor stored: its late answer
is not its item's, which was retried or failed meanwhile.
- **Output fixing.** Structured outputs are parsed with an output-fixing
parser (the model of `models.roles.fixing`, always on). The XML parser unwraps one
element wrapping the expected sections and reads a section holding only
"None" (or "N/A", "null", "-") as empty; the extraction, claims and
gleaning parsers raise `OutputParserException` for any other answer that
parses but is not the prompt's shape (no section, a section holding
prose, nameless entities in a complete answer), so the fixer repairs it
as it repairs output that does not parse.
- **Resources.** The pipeline releases indexer and client resources in
`close()`, which `run-ingestion` calls in `finally` (§8.2).
### 4.5 Seed graphs as virtual documents
A seed graph (`processing.seed_graphs`,
[Seed graphs](./guides/seed-graphs.md))
is a knowledge graph the user brings. It is imported as virtual documents
of the run's own corpus rather than through a second write path, so
lineage, removal, derivation, the extraction store and the write-ahead
protocol apply unchanged, and an incremental run converges to a full build
of the documents plus the configured seeds. No model reads a seed.
- **Load and convert.** `DataIngestionPipeline.__init__` calls
`bootstrap.load_seed_graphs` right after `resolve_extraction_schema`,
before any model is built: each item's registered loader returns a
`SeedGraph`, and `convert_seed(graph, SeedConversionSettings, schema)`
(`domain/ingestion/seed_graph.py`, pure) turns it into shard documents.
It checks (duplicate ids, dangling edges, the caps, which fail instead
of truncating), maps types (a `type_iri` / `predicate_iri` a schema type
declares, the last-declared winning, else the label through
`SchemaConformance`), conforms the whole seed at once (both endpoint
types are known, so patterns always apply; under `qualify` each
identity of a shared name, its IRI else its id, is checked by its own
type, as the distinct entity it becomes), applies
`on_name_collision`, shards nodes by `int(sha256(id)[:8], 16) % shards`
(an edge goes with its source node), packs node cards (a node and its
outgoing edges, by `(entity_key(name), id)`) into units of at most
`max_items_per_unit` statements (a hub card is split; a shard over 2,000
units fails naming its largest card's node, keeping a shard's registry
record small) and renders
`seed_graphs//-of-.jsonl`: a header line (name,
`SEED_FORMAT_VERSION`, filters), then the units' canonical JSON lines,
a blank line before each unit. The content hash therefore covers the
name, the filters, the format, the schema's effect and every statement,
and the output does not depend on the input's order. Every check is
linear in the seed (an edge's endpoints are dict lookups). The models
refuse what a store cannot write: a lone surrogate in any text, a name
over 1,000 characters, an IRI over 4,000 or a type over 256, so each
stays far under an indexed keyword's 32,766 bytes.
- **Keying.** `processing.seed_graphs` is neutral in `CONFIG_KEYING`: the
conversions' digests are folded into the corpus fingerprint
(`cache_keys.seed_corpus_fingerprint`), which keys every stage, and
nothing enters `extraction_settings`, so where a seed is read from keys
nothing and a seed edit re-extracts no document. Without seeds the
fingerprint is the one it was.
- **Stages.** Loading appends the shards after deduplication
(`assign_document_identity` keeps their path), so the registry diffs
them like files. Chunking cuts them with `seed_text_units` into `sg-`
units (the chunker's stable id, prefixed: they sort after every hex
document unit id, and `is_seed_unit` is the one test anywhere).
Translation, extraction, gleaning and claims leave them out; a seed
unit's record is `parse_seed_unit(unit)`, a pure function of its text,
stored under `SEED_RECORD_FINGERPRINT` (the format only, never a model
setting), and its text unit names only that graph record. A failed
report never fails a seed unit. With seeds configured an empty corpus
is a valid run.
- **Document-first merge.** Every merge path (the records' merge, graph
resolution, the incremental merge, derivation) applies one rule keyed
on `is_seed_unit` (`document_first`): the type vote counts document
statements and falls back to seed votes only when none gives a type;
a resolved group's name is keyed `(-document units, -seed units,
name)`, and its type is the named member's unless that is unknown
(none, blank, `unknown`), then the known type the most units cite; confidence is the maximum over document statements when any
exist. Descriptions stay a union, document lines first because records
merge in unit-id order. `Entity.iri` is a stated field, kept in
records, merged as the smallest non-null value (groups with several
are counted). Seed properties live in `attributes.seed_properties`,
merged per key, never over an extracted attribute; the OpenSearch
entities index leaves them unmapped (`enabled: false`), so no key or
value fails a write and none is filterable, and an incremental strip
compares them (`_vector_content`), as a lineage update leaves stored
attributes as written. The rules hold per
name key: names that only resolve together (a legal-form variant, or
fuzzy matching) let seed units break a tie between document spellings,
and a document entity named only as a relationship endpoint (no type)
takes the seed's type and confidence, as a gap filled.
- **Source kinds.** `annotate_sources` sets `source_kinds` where indexing
sets the schema annotations: `None` for an item no seed unit cites,
else the sorted kinds (`["document", "seed_graph"]` or
`["seed_graph"]`). It is derived, left out of records, and written only
when set, so an item no seed states is stored byte for byte as before
and nothing is embedded again. Seed text units carry a `source_kind`
keyword (no filter key). Graph edges store the list as a sorted JSON
string (`encoded_source_kinds`); `SEED_ONLY_SOURCE_KINDS` is the one
encoding of a seed-only edge.
- **Ranks.** `with_graph_ranks(counts=is_document_backed)` counts an edge
unless every text unit it cites is a seed unit, in the full build,
derivation and both incremental rank paths, which read stored
neighbours through `GraphIndexer.read_neighbor_ids(document_backed=True)`
(Neptune `bothE().not(has("source_kinds", '["seed_graph"]')).otherV()`;
the SQLite graph and the fakes apply the same predicate; an edge
without the property is document-backed). `count_neighbors` keeps every
edge: it sizes the community walk, which clusters seeds too. The
community detector ranks and selects report entities by
document-backed degree, while Leiden sees every edge at its full
weight. A large catalog seed therefore enriches the corpus's entities
without turning its hubs into the top-ranked entities. Query-time
`entity_importance_source: degree` counts the live edges, seeds
included.
- **Communities.** A community whose text units are all seed units is
stored without a report and counts as neither generated nor failed
(`communities_seed_only`); a parent with one evidenced child is
evidenced, and a child without a report leaves the parent its raw
context. An incremental run's graph holds its delta only, so a
community seed units alone state there is checked against the stored
members (`_evidenced_by_stored_members`, one `read_entities` batch per
suffix): a member citing a document unit the run does not replace, or
stored without lineage and without seed-only `source_kinds`, evidences
it. A seed added beside unchanged documents thus reports their
communities as a full build does: the report reads each such member's
stored description followed by the run's lines, and a member citing a
unit the run replaces (an edited seed's earlier shard) has its stored
description derived again from the records of its other units first,
as the indexing's strip does (`stripped_descriptions`, one read of
those units' records). The report then reads what the run indexes:
that derived description, and the merge with the run's lines, are
each summarized when over the threshold (`needs_summary`), as the
strip and the merge summarize them, and the summaries go to the
indexing through `PipelineContext.member_summaries`, marked
`summarized_from`, so `reuse_summaries` takes them and the model is
asked once (a resumed indexing summarizes again; the context does not
persist them). A summary the indexing reuses, or an item already holds,
is not summarized again even when it is over the threshold itself, so
the run stores the description its report read. Without that, a hub's report entry was every line its
documents state and could crowd it out of its own report. A full build
summarizes the member's lines in one pass, so the two summaries are
equivalent, not equal; with summarization off both read the lines
whole (no cap: a cap would make the incremental input differ from the
full build's). A member the run states with neither a type nor a
description (the endpoint of a seed edge into a kept shard) reads its
stored copy's type and description; an unread member store counts the
communities in `reports_failed`, not `communities_seed_only`. The
relationship lines are the run's graph's
only (the delta's seed edges; a stored document edge between members
is not read): clustering the delta only, the community itself can
differ from a full build's. Global search answers from the
reports, so content only seeds state is in none, while a mixed
community's report describes its seed members too.
- **Scope (decided).** The shards belong to the source scope of the run
that configures them. A separate `seed:` scope per seed was
rejected: every run-level registry path (the diff, attempts,
write-ahead, commit, the per-scope extraction-settings record) assumes
one scope per run, a reset clears every scope of the namespace, and a
registry-off build has no scope at all. A seed-only scope is still a
run whose corpus is only seeds, with a fixed `source_scope`. The
trade-off: one seed configured on two scopes of a namespace is imported
twice (the same ids, seed edge weights counted twice).
- **Model settings never re-import a seed**
(`delta_detector.keep_seed_settings_unchanged`, in `detect_delta` and
`delta_still_applies`): a shard the diff lists only as
`settings_changed` is unchanged, since no model setting changes what it
states; re-importing would only delete and re-embed its items. Its
record keeps the earlier fingerprint, so later runs exempt it again. A
schema change that changes the converted statements changes the content
hash and re-imports the shard.
- **Reset.** A reset (or a registry-off full build) processes the whole
corpus, configured seeds included, so the sweep keeps them. Before any
write, `incremental._confirm_reset_of_seed_scopes` lists the namespace
once (the quarantine check reads the same listing), counts as the run's
own every record of a spelling of its source scope
(`normalize_source_scope`, as the incremental diff's `_stored_spelling`
does) and, when another scope holds seed paths (`is_seed_path`), or the
corpus is seeds alone, the scope holds seed records of its own and
another scope holds documents, asks the
pipeline's `confirm` (the CLI's terminal prompt, `--confirm-reset` /
`UKR_CONFIRM_RESET`, or `--yes`); a no, or no `confirm`, raises
`ConfirmationDeclinedError`, which the stage and the pipeline pass on
untouched (a cancellation, not a failed stage). Refusing every such
reset was rejected: it would make the common case, a seed on the
corpus, impossible to rebuild. The answer has its own input because
`--yes` also accepts a resume's integrity issues, which an ECS task
answering a reset must not accept blindly.
- **Empty corpus.** A corpus of seeds alone is a seed-only run only for a
scope the registry holds no document of
(`incremental._refuse_seeds_over_documents`, before any write, also
under a reset): otherwise an unmounted volume or an empty S3 sync would
delete the scope's documents with exit 0. An incremental run reads the
scope's records from its delta (every record of the scope a seeds-only
corpus does not match is `deleted` or `failed`), not the namespace. A
reset from seeds alone of a scope with no record at all, beside other
scopes' documents, fails too (`_refuse_seeds_over_other_scopes`): a
seed-only scope's earlier runs recorded its seeds, so this is a new,
moved or mistyped prefix, and the reset would rebuild the namespace
from the seeds. Keyed on the registry, not on a flag: a seed-only scope
needs no extra setting, and the mistake it guards against carries none.
A scope's first run warns when another scope already holds a seed of
the same name (its weights would count twice). A registry-off build
cannot tell a seed-only corpus from a missing one, so
`DocumentLoadingStage._confirm_seeds_alone` asks `confirm` before seeds
alone replace an index that holds items (`holds_any_item`, one bounded
read per store, and the vector store's `holds_any_text_unit`, so a
corpus that stated no item counts; unknown or unreadable counts as
holding). An index that
holds none builds unasked: a first seed-only build loses nothing, and
asking there would demand a flag for nothing.
- **Not in v1.** IRI-based identity and aliases, `owl:sameAs`, a
retrievable fact card (seed units as TEXT context), and federation
(§15, *New seed source*).
### 4.6 Methodology profiles
One ingestion builds what the selected methodologies read, and a query
fails fast when its strategy's methodology is not in the namespace, so a
LightRAG-only corpus never pays for GraphRAG's communities and reports.
- **Catalogue (domain, pure).** `domain/models/methodology.py` names the
methodologies (`Methodology`: `graphrag`, `lightrag`, `hipporag`,
`raptor`) and the index artifacts (`IndexArtifact`) each needs
(`METHODOLOGY_PROFILES`, required and optional):
| Methodology | Required | Optional (used when built) |
|---|---|---|
| `graphrag` | text units, entities, relationships, graph, communities, community reports | relationship vectors, claims |
| `lightrag` | text units, entities, relationships, relationship vectors, graph | — |
| `hipporag` | text units, entities, relationships, relationship vectors, graph | — |
| `raptor` | text units, summary tree | — (`tree` also reads the entities index where another methodology built it) |
`domain/ingestion/methodologies.py` maps each artifact to the stages that
build it and each stage to the stages it reads
(`ARTIFACT_STAGES`, `STAGE_DEPENDENCIES`): `stages_for(artifacts)` is
their closure. The summary tree is built by the `summary_tree` stage
after indexing (§6.5), from the stored text units, so `raptor` alone runs
no graph stage; `hipporag` needs nothing `lightrag` does not build, so
every default index holds it.
- **Selection.** `indexing.methodologies` (default `[graphrag, lightrag]`:
today's union, every stage) selects what a run builds. The effective
methodologies (`effective_methodologies`) leave out those whose required
artifacts a switch turns off: `graph.community_detection.enabled: false`
drops `graphrag`, `indexing.opensearch.build_relationship_vector_index:
false` drops `lightrag` and `hipporag`, so a pre-existing light config
reads as the profile it builds; none left is a config error. A stage
runs when `stages_enabled` and its config section allow it and it is in
`stages_for` the effective methodologies' artifacts, plus claims when
`processing.claim_extraction.enabled` (claims are per-document extraction
output, recorded in each document's extraction settings, so their switch
alone governs them; with no selected methodology reading them a WARNING
says so). For the default union every stage is selected, and
`indexing.methodologies` keys no stage cache and, at its default, no
evaluation manifest (`cache_keys._KEYED_ONCE_SET`), so default users see
no change. An explicit `--enabled-stages` list wins in both directions
(a listed stage runs, an unlisted one does not); a run that writes the
index (lists `indexing`) and leaves out a stage a selected methodology
needs logs a WARNING naming both (not for `OPTIONAL_STAGES`: parsing,
translation, gleaning).
- **Build record.** `IndexBuildRecord` gains `artifacts` (what the namespace
holds and the runs maintain) and `methodologies` (those whose required
artifacts it holds, for manifests and readers). A run's artifacts
(`built_artifacts`) are those whose producing stage completed in its
context (a phased run's earlier phases count) and whose switch is on
(relationship vectors, report generation). A full build records them; an
incremental record keeps those both it and the stored record hold
(`IndexBuildRecord.over`), so an artifact a run stopped maintaining leaves
the record. An incremental run that writes nothing writes no record:
what it left out is still current.
- **Adding a methodology** that needs an artifact the namespace does not
hold (communities and reports for `graphrag`, relationship vectors for
`lightrag`) needs the whole corpus, not a delta (the summary tree is the
exception: it is built from the stored text units, §6.5): a run with the registry
on that finds one (the stored record against the effective
methodologies, `missing_artifacts`) rebuilds the namespace for that run
(`indexing.reset`, WARNING), before any cache key is computed. A rebuild
from one corpus deletes the namespace's other source scopes, so when the
registry holds any (`incremental.other_source_scopes`) the run is refused
instead (`ConfigurationError`, exit 2, nothing written). The rebuild
reuses what the stores keep
(§4.1, §4.4): chunk boundaries, extraction records, description
summaries and vectors, so its new model calls are the added
methodology's (one report per community). Without the registry every run
is a full build and reuses the same. A record written before profiles
existed has no `artifacts`: it is read as the configured methodologies,
so an upgrade promotes no run.
- **Removing a methodology** stops maintaining its artifacts: the next run
that writes the namespace records them gone, and its strategies fail fast.
Nothing is deleted: a reset with the remaining methodologies replaces the
community-reports index with an empty one and drops the graph's community
vertices (the rebuild sweep, §5.2).
- **Query time.** Every strategy class declares `methodology`
(`BaseSearchStrategy.methodology`): `graphrag` for `local`, `global`,
`drift` and `simple`, `lightrag` for `mix`, `hybrid` and `naive`,
`hipporag` for `ppr`, `raptor` for `tree`; a
wrapper strategy (`iterative`) sets it per instance from the strategy it
wraps. A strategy that reads less than its methodology requires declares
its artifacts at registration (`StrategySpec.required_artifacts`):
`naive` reads the text units alone, and `simple` sweeps the indexes the
namespace holds (the text units at least), so any namespace holding the
text units serves both, a `raptor`-only or `lightrag`-only one included.
`GraphRAGChain` reads the namespace's build record through the document
retriever (`aread_build_record`, cached per suffix for
`methodology_check.HELD_TTL_SECONDS`, read again before a refusal) and derives what it
holds (`held_methodologies`; when there is no record or it predates
profiles, every methodology whose required artifacts a run of the config
builds, `artifacts_to_build`: a store the default run built before records
holds `hipporag` too, as it holds the relationship vectors `ppr` reads),
and passes the held artifacts to the strategy
(`SearchQuery.metadata["held_artifacts"]`), which reads only the optional
indexes the namespace holds (`simple`'s sweep, `tree`'s graph buckets).
An explicit strategy whose
methodology (or declared artifacts) is not held raises `MethodologyNotIndexedError` (a
`ConfigurationError`; `run-rag` exits 2) before any model call, naming
what the namespace holds, the strategies that serve it and how to add the
methodology. `auto` routes only among the held methodologies' strategies
of `search.auto_routable_strategies`, falling back to `local` or `mix`.
`local` counts as GraphRAG's: it reserves part of its
context for the community-report section, so on a LightRAG-only index it
would answer with that share unused; `mix`/`hybrid` serve the same entity
questions there. When `auto` falls back, it warns once per
configuration and held methodologies, not on every query.
- **Failure modes.** An unreadable record (store error, a backend without
index metadata) reads as the configured methodologies, as before; a
record that over-claims cannot arise from a run (an artifact enters it
only through a completed stage), and one that under-claims refuses
queries until the next run that writes the namespace.
---
## 5. Incremental Indexing
With the doc-status registry on, a run diffs the corpus against the registry
by content hash and processes only new, changed and failed documents. The
registry lists each document's text units, and removal asks the stores what
cites them. A write-ahead protocol lets the next run repair any
interruption, and the result converges to a full build of the current corpus.
How to use it: [Incremental indexing](./guides/incremental.md).

### 5.1 The steps
Names are `IncrementalIndexer` methods (`application/ingestion/incremental.py`)
unless noted.
1. **Delta detection** (`domain/ingestion/delta_detector.py`).
`DocStatusPort.diff(incoming, scope)` classifies documents as new, changed,
unchanged or deleted by content hash. A `doc_id` is
`#`, and the
namespace is the registry partition, so a run reads only its namespace (one
projected DynamoDB `Query`) and two corpora or tenants never share a record.
Only records of the run's scope are deletion candidates; files that failed
to parse or load are reported `failed`, not deleted. A moved corpus's old
records are never adopted (a move cannot be told from another host's corpus
on the same path) until a run retires the old scope
(`indexing.retire_source_scopes`). Documents whose processing keeps killing
the run are quarantined or deferred (§5.3). A seed graph's shards are
documents of the run's scope; one only an extraction setting changed
for reads unchanged (§4.5).
2. **Write-ahead** (`write_ahead`). Before any store write, every new and
changed document is recorded `PENDING` (content hash `"pending"`, which no
document hashes to) with the union of its stored text units and the ones
this run will write, in one batched write. A record over the registry's
item limit (DynamoDB 400 KB, about 10,000 text units) fails the run here,
before anything is written.
3. **Stale cleanup** (`remove_changed_and_deleted`). The text units of changed
and deleted documents are removed in one pass per suffix (step 5), so an
artifact only they state goes too. Deleted documents are first recorded
`PENDING`, so one restored before its removal finished reads changed.
4. **Delta upsert** (`commit`). The stored entities and relationships the
delta touches (found by compact name key, every stored name with fuzzy
matching on) and the stored edges of its entities are read back, the delta
is merged into them and their content derived from the extraction records
(§5.4); delta claims merge with the stored claims of the same id. Neptune
writes with an idempotent `coalesce(unfold, addV)` upsert, OpenSearch by id
into the live alias index; old ids of re-keyed items are deleted only after
the writes succeeded. Before the first write the commit notes the
entities it splits, re-keys or rewires in one record of the suffix's
extraction store (`pending-commit`), deleted once the old ids are: those
decisions live in memory only, and the next commit of the suffix derives
the noted entities, their stored edges and the claims about them again,
so a killed commit's half-written split is finished. It also retires
the noted entities' copies only the vector store holds, with the vector
edges at them (the killed commit got to its vector writes only), and a
run that only removes in the suffix reads the note all the same.
5. **Deletion propagation** (`IndexingManager.remove_text_units`). The stores
are asked what cites the removed text units (`GraphIndexer.find_ids_citing`
for entities, `VectorIndexer.find_documents`); edges are found among the
cited entities' incident edges, and only a resumed removal (some removed
text units already gone from the vector store, or a vector store that
cannot tell) looks them up by text unit with
`find_relationship_ids_citing`, on Neptune a scan of the suffix's edges.
An item citing only removed text units is
deleted from both stores, the vector copy first (a retry finds what to
delete through the graph, so an interrupted run leaves a graph copy, not
an orphaned vector copy); one citing others too is stripped of them, its
weight recomputed and its content derived again from the text units left.
A strip that merges or re-keys items (another spelling of a stripped
entity's name, which need not cite the removed text units), or derives
claims under new ids, notes before its first write the entities on both
sides of the merge, the edges it re-keyed or dropped, the claims about a
split's entities and the new claim ids, in one record of the
suffix's extraction store (`pending-strip`, with those of a note a retry
finishes) and deletes it once their old ids are gone; every removal reads
it, so a resumed one strips them again. The claims about the other stored
spellings of a re-keyed, renamed or split entity's name are derived again
too: one may resolve to it now.
Before writing them, a strip also notes the items it summarized or wrote
unsummarized (`stripped-summaries`, with those of a note a resumed run
reads), whose stored summary the commit summarizes again from the lines
rather than extending it; the note is deleted once a commit's writes
succeed, so a run that dies between its removal and its commit still
converges with a full build.
A community or report goes once none of its text units is stored. A failed
removal keeps the deleted documents' records for a retry and fails the
stage (before the commit when the delta has changed documents).
6. **Registry update** (`commit`). The `PENDING` records are replaced, in one
batched write, by `PROCESSED` or `FAILED` records with each document's text
units, scope and relative path (the five causes of `FAILED`:
[runbook](./operations.md#failed-documents)). `failure_count` counts
consecutive failures of the same content; after
`indexing.max_document_failures` the document reads unchanged. A run that
stops before this step at a stage that failed on all of its units still
records FAILED the documents whose units failed for an error of their own,
so a document that always fails cannot block every later run. Only such
errors count, here and at this commit: unparseable or cut-off output, and
a request the model rejects for its content (the factory's
`is_item_error`: a Bedrock `ValidationException`, HTTP 400/413/422, minus
rejections of the model or a setting). Any other error (an outage,
credentials, a quota, a setting, a bug) counts for none. When every unit
sent fails and its own errors are of one cause (alike in type, code and
HTTP status, not message; or all unparseable or cut-off output; a unit
that failed otherwise, throttled say, is left out), output errors on the
units of more than one document count as each document's own, once a
run, with no known-good request: a server-side output cap cuts them all
and a short request cannot tell, but a batch that always fails must not
stop every run with its deletions (the trade-off: a cap that persists
`indexing.max_document_failures` runs excludes them, until their content
or the extraction settings change). Otherwise the stage sends one
known-good request through the same chain, bypassing the LLM response
cache: failed too (for output errors, unparseable counts as failed), the
cause is the model or a setting and none counts; answered, the errors are
the documents' own. Claim extraction's errors of a unit's own count for
none: claims are optional, so a document whose other stages succeeded is
indexed without those claims and recorded `FAILED` once, for the next run
to extract them again from the stored records, then `PROCESSED` without
them (`incremental.run_failures`). A failure that counts for none
adds one to the record's `retry_count` instead (new content, a success
or an error of its own resets it; a stop or death during extraction
leaves it, one during indexing resets it once the `PENDING` records are
written), and from the second the run warns (`documents_retrying`).
What counts as unparseable or cut-off output: graph extraction output
naming no entity with a section left unclosed (cut off without a stop
reason) is cut off, not retried; with every section closed it is
malformed and retried like unparseable output. Output cut later keeps
the entities before the cut (WARNING `XML output ends with
unclosed`). An answer the model corrects is parsed by its last complete
answer, each section by its last closed occurrence (INFO `XML output
repeats , ...`), and a JSON answer by its last object that
parses. An OpenAI-compatible server's context-length rejection that
counts the requested `max_tokens` is a setting. The response behind a
failed graph or claim extraction check is logged at DEBUG. Extraction
records only removed text units named are then deleted (best effort).
**Limitation:** shared communities and reports keep what a changed or deleted
document contributed, and a re-keyed entity's old id, until a full rebuild
(§5.5); descriptions, names, ids, types, filters, ranks and claims are
derived again on every run.
### 5.2 Full rebuild (`indexing.reset`)
`indexing.reset` skips the diff and rebuilds the whole corpus with the
full-index path, without taking the suffix offline. Quarantined documents
(§5.3) are skipped and keep their records; every other document, and every
indexed one the corpus dropped, is recorded `PENDING`; the vector indices are written into new timestamped indices whose
aliases swap when each is built; the graph is rebuilt in place
(`GraphIndexer.begin_rebuild` tags every vertex and edge the build writes, and
`end_rebuild` drops the suffix's entities, communities and edges without the
tag). The writes upsert in place, so graph queries see previous and rebuilt
items mixed until that final drop. Only after every
document is recorded again are the namespace's other records deleted, so a
failed rebuild drops nothing. A rebuild whose graph writes partly failed
(within `indexing.max_failure_rate`) skips the drop, so a retry still finds the
previous copies, and is reported failed (`graph_sweep`) rather than recorded:
clearing the replaced documents' records would orphan their vertices and edges.
The corpus a reset rebuilds includes the run's configured seed graphs;
when another scope of the namespace holds seed graph shards, the run asks
before its first write (§4.5).
A generation switch (new labels plus a pointer) was rejected: Neptune labels
cannot be renamed, so every retriever would resolve the current generation on
each query and the graph would be stored twice during the rebuild.
**Unchanged text is not embedded again**, by a rebuild or a delta run. Each
vector field stores the digest of the text it embeds next to the vector
(`embedding_digests.`), with the embedding provider, model, dimension
and (outside Bedrock) input cap hashed in. A write reads the stored digests
of its items, one search per write slice of 800 ids, from the alias's live
index: the one an upsert writes, or the one a rebuild is about to replace
(still the alias's target until the swap). It then reads only the vectors
whose digest is that of a text it writes, only those fields, 100 ids per
search and four searches in flight, and embeds the texts whose digest it
does not find. Reading every stored vector of a slice whose texts changed
held over 60 MB of response and parsed floats per search for vectors no
write took: rebuilding 2,000 reports at 1,024 dimensions with a third of the
texts changed peaked at 388 MiB, and peaks at 165 MiB this way. Reusing across a reset is safe because
the digest names the embedding settings: a reset after a model or dimension
change finds no matching digest (nor reads at all: the live index's `_meta`
records its model) and embeds everything. A vector an upsert replaced is
read as well (up to 2,048) and kept for a later write of its text (a
removal rewrites items the commit writes back); the commit, the run's last
write, and a rebuild read none (an upsert of 2,000 changed descriptions
reads 2,000 vector fields instead of 4,000). An index without the digest
field gets it with the other missing ones (§5.1) and its items are embedded
once more.
`--force-rebuild` embeds every text again.
### 5.3 Crash recovery (write-ahead protocol)
The registry is not updated atomically with the stores, so a run can stop
between any two writes. One invariant holds: **before the stores hold
anything the registry cannot account for, the registry already lists it.**
Write-ahead records precede every removal and upsert, deleted documents' rows
go only after their artifacts, and a `PENDING` record is replaced only after
the document's writes landed. The next run repairs an interrupted one:
- `PENDING` never equals a content hash, so a document still in the corpus
reads changed, even if its file went back to the indexed version; one no
longer in the corpus reads deleted, and its text units cover everything the
interrupted run may have written.
- The removal plans from the record stored before the current run's own
`PENDING` record, so a recovery run removes the partial writes first.
- An interruption is not counted toward `indexing.max_document_failures`
(a `PENDING` record has `failure_count` 0).
- Old ids of re-keyed items are deleted only after their new copies are
written, so an interruption leaves at worst both copies, which the retry
derives together.
**Attempts and quarantine** (`domain/ingestion/attempts.py`). A document
whose processing kills the task would stop every later run the same way.
- **Open and close.** After the diff (and when a run resumes past
document_loading) the run opens an attempt on every document it processes
(`incremental.open_attempts`: `attempt_batch` = documents processed
together, `attempted_hash`; a new document gets a `PENDING` record without
text units) and closes them however the process ends: finished, failed,
SIGTERM or Ctrl-C (`close_attempts`). An attempt still open on the next run
is one more death of that content (`attempt_count`) in a batch of
`attempt_batch` (`crash_batch`). A reset opens none (the write-ahead record
keeps the stored attempt fields); a document that produces no text units in
a committing run counts as a death alone.
- **Plan** (`plan_attempts`, at the diff). A changed document whose deaths
reach `indexing.max_document_attempts` is a suspect; one whose last death
was alone (`crash_batch` 1) is `quarantined`. Otherwise the run processes
the first half (by doc id) of the suspects that died in the smallest batch
and classifies every other new or changed document `deferred` (neither
processed nor deleted). A death narrows the suspects to that half and a run
that ends commits it, so one culprit among `n` is isolated in about
`log2(n) + 1` runs and no document is quarantined for dying with it.
- **Quarantined record**: `QUARANTINED` with the attempted content hash, its
deaths and the reason in `error_info`. When the attempts reached indexing
(the record is the write-ahead `PENDING` one), the removal pass removes what
they wrote and no text units are kept; otherwise the indexed version and its
text units stay. It reads unchanged (`diff` lists it in
`DocumentDelta.quarantined_unchanged`) until the content changes or the
limit is raised above its deaths (`release_quarantined` then classifies it
changed); deaths count per content, so new extraction settings quarantine
it again, and a reset skips it too.
- **Resume check** (`delta_still_applies`). A resumed run whose restored
delta names a document whose record is gone, holds other content, reached
the limit or was written by another pipeline id since (`written_by`; a
record without the field counts as another pipeline
id's unless it is `PROCESSED` at the delta's content without an open
attempt, and even then when
it was committed under other extraction settings for a document the delta
re-extracts for a settings change), or deletes a document another
pipeline id (or a record without a writer) has recorded in another scope
since, resumes from document_loading instead. So does one for whose
scope another pipeline id has since added, changed or deleted a document
outside the delta, and one whose `document_loading` inputs changed since
the delta was computed (a reset, retry or corpus setting; WARNING
`Rewinding resume point from ... to 'document_loading'`). A finished
pipeline id run again counts as resumed; a run resumed after its own kill
or stop keeps its delta. A resume past
a stage that recorded failed text units starts at that stage, so the units
are retried rather than recorded `FAILED` again; a reset resumed past the
stages that store extraction records starts at the first whose records
another run deleted. Metrics:
`total_documents_quarantined` (CDK `DocumentsQuarantined` alarm) and
`total_documents_deferred`.
- **Unread files and mass deletions.** The diff never deletes a file the
run could not read: `failed_doc_ids` holds the files that failed to
parse or load and those the parsing stage skipped because no loader
reads their type (`PipelineContext.skipped_source_files`; otherwise an
optional extra dropped from an image would read as the deletion of every
such file). Skipped files are kept apart from failed ones only for the empty
corpus rule: a seed-only scope with a stray unsupported file is still a
seed-only run. Before its first write (`open_attempts`),
`incremental._confirm_mass_deletion` asks the pipeline's
`confirm_deletions` when the run's own deletions exceed
`indexing.max_deletion_share` of `DocumentDelta.scope_documents` (the
scope's records the diff read, counted by `detect_delta` before any
document moves between the lists) in a scope of at least 10 documents.
The retired scopes' records (`DocumentDelta.retired`) are left out: the
run was told to delete them. So are seed graph shards, from both counts
(`DocumentDelta.seed_deleted`, by the records' `is_seed_path`): the
configuration removes them (a seed graph dropped or resharded), and a
seed graph of many shards would dilute the share until a partial sync of
most documents passed unasked. Its own input (`--confirm-deletions` /
`UKR_CONFIRM_DELETIONS`) for the same reason as the reset's. A run with
the registry off is not checked: the stores hold no per-document count
to compare the corpus with (a port method on every backend for one
check), and its empty-corpus case, the likeliest mistake, is already
asked (`_confirm_seeds_alone` with seeds; without them an empty corpus
fails the parsing stage). Metrics: `total_files_failed`,
`total_files_skipped`, `total_documents_excluded`,
`total_documents_deleted`.
The cost is one extra batched registry write per run (two more writes and
reads for the attempts) and a few batched store lookups per suffix.
`tests/unit/test_incremental_write_ahead.py` and
`tests/property/test_incremental_interruption_properties.py` interrupt a run at
every write and check that the next run converges to a fresh full build.
### 5.4 Identity, derived content and merge rules
- **Document identity** (`domain/ingestion/document_identity.py`). A document
version's id hashes its path relative to the corpus root and its full text,
so without the registry a corpus yields the same ids wherever it is checked
out; an incremental run keys text units by the registry `doc_id`, which
also holds the source scope (`document_parsing.source_scope`, else the
resolved source directory), so each belongs to one record and the ids hold
only from the same directory or with a fixed scope. Changing either rule
requires a re-index.
- **Derived content** (`domain/ingestion/derivation.py`). The merge and the
strip keep each touched item's structure (id, text units, per-unit
weights); its content is then derived from the records of the text units it
cites now, by the full build's own code (`merge_contributions`,
`GraphResolver`, `ClaimResolver`), so the item gets what a full build of the
current corpus gives it: a deleted document's text leaves shared
descriptions and claims, names and types follow the current votes, and an
entity whose derived name has another id takes that id
(`Derived.id_remap`), its edges and claims following. An item citing a text
unit without a stored record keeps its stored content (a WARNING and the
indexing stage's `underived_items` metric).
- **Merges a full build undoes.** A stored entity may hold what a full build
keeps apart (two spellings merged while their types agreed): every resolved
entity sharing a name key and a text unit with it is derived, the best
match under its id and each other as its own entity (`Derived.split_from`).
The stored edges of a split or re-keyed entity are derived with it
(`Derived.rewired`), so each follows the part that states it and a
re-pointed edge merges with the derived edge it now equals. An edge a full
build draws that no stored edge is (an earlier merge dropped it as a
self-loop) is added; a stored edge whose endpoints now resolve to one
entity is deleted with the retired ids (`Derived.dropped`), as is a delta
id the cross-run merge folded into another (an interrupted run may have
left its vector copy). Edges take the resolution's per-unit strengths, and
claim statements merge in text-unit id order, as in a full build.
- **Cost.** Derivation runs only where something is stored (a first build
writes the delta as graph resolution made it) and matches through a key
index. A stored summary that only grew (the item still cites every text unit
it did) is summarized from itself and the new description, as LightRAG
merges into the stored one, and a stored description that is no summary is
extended with the delta's summary when graph resolution made one (which
would otherwise go unused). An item that lost a text unit is summarized from
its derived description, so removed text goes, and so is an item this
run's removal summarized; the removal leaves the summary of an item the
delta derives again under its id to the commit (`expect_delta`), so an
edited document's shared entity is summarized once, from its lines, as in a
full build. A stored edge rewritten for its rank only is updated in place,
not embedded again.
- **Merge rules** (`domain/ingestion/merge/merger.py`, ported from MS GraphRAG
`update/*`), all idempotent:
| Artifact | Matched by | Merged as |
|---|---|---|
| Entity | id, else identity key | description lines unioned; `text_unit_ids` and `community_ids` unioned; max confidence and rank; attributes unioned (delta wins) except `filters`, whose values are unioned per key; the stored name and type are kept (the derivation then sets what a full build would) |
| Relationship | id, else (source, target, type) | as entities; weight = sum of the strengths per supporting text unit (`attributes.text_unit_weights`), so the result equals a full build over the union; endpoint names re-derived from the merged entities |
| Claim | id | full-build `ClaimResolver` rules: descriptions and source texts unioned, `text_unit_ids` unioned, the stored status kept |
| Community | content-derived id | appended (§5.5) |
### 5.5 Communities on delta runs
A delta run clusters only the delta subgraph, as MS GraphRAG's update path
appends rather than re-clusters. Community ids are therefore content-derived
(a hash of the level and the sorted member ids), so a delta's communities and
reports are appended next to the corpus's instead of overwriting them. The
display name (`Level {level} Community {id[:8]}`, short id `L{level}_C{id[:8]}`)
derives from that id too: a positional `C{i}` would rename every community
sorted after an added one, and the name is part of a report's record key and
of its parent's roll-up text, so a reset would re-ask the model for
unchanged communities. A corpus community goes once none of
its text units is stored; shared ones keep their report until a full rebuild,
the only path that re-partitions the whole graph
(`indexing.community_drift_threshold` reports when one is due). Each delta's
root communities join every cut of the hierarchy, so global search's cut
grows until a rebuild; the cap and the shallower-cut fallback (§6.1.1) bound
it, and the drift metrics report it (`added_roots`). Rejected alternatives:
leaving delta communities of already clustered entities out of the cuts
(their reports are the only ones describing the delta, and stored community
ids still name removed communities), and re-detecting the full graph on every
delta run (it would regenerate every report, an LLM call each).
Across runs, the suffix's build record carries community counters
(`IndexBuildRecord.communities`): a full build starts them with the
communities it wrote, and `IndexBuildRecord.over` adds each delta commit's
appended communities minus those its removals deleted, keyed by a commit
identity (pipeline id and delta, `community_drift.commit_identity`) so a
resumed commit is counted once. `indexing.community_compaction` recommends a
rebuild from them (`community_drift.reset_reasons`); it never resets on its
own.
A seed graph added by a delta run is clustered on its own shards' entities
the same way, until a rebuild; a delta community only seed units evidence
gets no report (§4.5).
### 5.6 Concurrency
With the registry, a run takes its namespace's write lease before its first
stage and holds it to its end (`application/ingestion/namespace_lease.py`):
one conditional registry write, renewed by a heartbeat every sixth of
`indexing.lease_seconds` (lease requests: short timeouts, 3 attempts). A
second run fails with `NamespaceLeaseError` before it reads or writes.
- **Write fence** (`shared/write_fence.py`). Every incremental registry
write, indexing phase, rebuild sweep, Neptune write batch or drop and
OpenSearch indexing request other than a GET or HEAD checks the lease
first. The check fails once the lease was released, and once less than a
third of the term is left since the last successful renewal was sent (the
store's expiry runs from then), so a run that lost the lease stops while
it still holds it, provided no single write request outlasts a third of
the term. A second fence fails the worker threads' writes once a stop
(SIGTERM or Ctrl-C) was requested; the thread running the pipeline still
writes while it unwinds.
- **No conditional writes.** `BatchWriteItem` takes no condition and store
writes cannot be conditioned on the registry, so the margin is the fence.
The DynamoDB registry checks it before every attempt of a write request
(a botocore before-send hook), not once per call, so its re-sends and
retries stop with the lease too; an embedded (SQLite) write transaction
checks it before it waits for the database's write lock and again once it
holds it.
- **Takeover wait.** A write past the fence lands by the holder's expiry plus
the longest request (its attempts are bounded by the request timeout), so
a run that takes over an expired, unreleased lease holds its first write
(not its reads) until then; a frozen process that sends a built request
later than that is the one case the margin cannot cover.
- **Giving the lease up.** A run that ended or failed released it (its pools
waited for their writes), and the next run writes at once. A stopped run
(SIGTERM or Ctrl-C) leaves it expired, owner kept, since its pools do not
wait for the requests in flight: the next run waits as after a crash, and
the stop message says until when. With every store embedded (SQLite; told
by the adapters the run builds, so an injected remote store counts as
remote) the lease is released: a worker the stop left running fails at its next write
transaction, and one already holding the database's lock commits before
the next run can write that store; a takeover waits at most one store
write (`search.store_timeout_seconds`). A
run that ends during its own takeover wait hands the wait on: the lease
goes back expired at the previous holder's expiry.
- **Checkpoint.** The lease is taken before the S3 checkpoint is downloaded
and given up only after a stopped run uploaded it; the upload checks the
lease is held (`check_fences_held`), not the takeover wait, so a run
stopped during the wait still keeps its checkpoint.
Without the registry nothing locks, and concurrent runs on one suffix race on
its aliases and labels ([runbook](./operations.md#concurrent-runs)).
**Lookups read once per run** (`shared/store_lookups.py`). Only a run's own
writes move its namespace's aliases and mappings while it holds the lease,
so the pipeline installs a run-scoped cache of which index each alias points
at and of each index's mappings, shared by the run's vector indexers (and
their worker threads). An alias swap or removal, an index deletion or a
mapping or `_meta` update through any of them drops the entries it changes
before its request is sent. A run that indexes nothing (an empty delta)
writes no build record and no extraction settings, so it writes nothing but
its lease.
---
## 6. Retrieval: Two Methodologies
`GraphRAGChain` (`application/retrieval/rag_chain.py`, an LCEL Runnable) runs
one chain for every strategy: conversation memory (a follow-up is rewritten as
a standalone question) → strategy resolution → query processing (translation,
entity or keyword extraction) → retrieval → fusion and rerank → (RAG) context
budget and answer. `RAGInput.search_strategy` picks the methodology.


- **Where the chain stops.** `search` / `asearch` return the fused, ranked
results (a `SearchOutput`); `invoke` / `ainvoke` / `stream` run the same
steps, pack the results into the token budget (§7) and return a `RAGOutput`
whose sources are exactly the sections the answer model saw. An empty
budgeted context is answered "cannot answer" without calling the LLM.
- **Graph expansion** is part of `local` and `drift`, and opt-in for `mix` /
`hybrid` (`search.lightrag_search.enable_graph_expansion`). Strategies find
the seed entities in the vector store and pass their ids to the graph
retriever, which expands from those ids only.
- **Under a document filter, expansion runs on the search store.** A graph
holds no document attribute, so the walk goes over the relationships
index instead (`BaseSearchStrategy._expand_over_passing_relationships`):
each hop asks for the passing edges incident to the frontier vertices
whose seed is short of its share, two requests (source and target side;
plus one text-unit check under two or more keys), cached per query, and
one entities-index read at the end (and, under two or more keys, a
second text-unit check of the entities it reached). It keeps the graph walk's bounds
(`max_hops`, `max_results_per_hop`, the budget split per seed, each seed
at most its share) and scoring, but `degree` importance reads as `rank`
(the index holds no degree). A side returns at most `min(max_query_size,
live max_result_window)` edges, best first (a frontier past
`max_terms_count` is asked in batches, each with its own page); when a
side's page is full, the vertices still open are asked together once
more at that side, and when that page is full too, each on its own, in
waves of `search.max_concurrency`: each asks for what its wave may still
take, and a wave leaves out what the waves before it reached. A hop costs
one request per side, one more per full side, then one per open vertex
and full side; under two or more keys each page adds a text-unit check.
Under a caller `id` list the walk stops once it has reached every listed
id (the results keep only those, and a reached vertex's path is fixed).
Edges back to a vertex already reached are left out by the store (a
`must_not` on the other endpoint), so they cannot fill a page; past
`max_terms_count` the exclusion keeps the vertices reached last, so edges
back to the others can. Without the relationships index the graph walk
runs, checked against the entities index. So is a graph walk
under an entity key entity vertices do not store (`name.keyword`,
`attributes.`): the graph drops it, the entities index applies it
(`_graph_unapplied_filters`), to LightRAG's seeds too (its relationship
endpoints were found under no entity key; `local`'s and `drift`'s seeds
were found under the filters).
- **Seed graph facts are entities and relationships, never text.** A
seed graph's text units are stored (removal and derivation read
them) but never retrieved as chunks: the
OpenSearch retriever (OpenSearch and the embedded engine) adds one
`must_not` on their `source_kind` to every text-units search, a fetch
by id included, inside the kNN filter (efficient filtering on lucene
and faiss, so seed units take none of the k). OpenSearch 2.13 refuses a
kNN filter on an unmapped field, so unless the alias's mappings show
`source_kind`, a kNN search first probes once per query for a unit
holding it; an index that holds none gets no clause. An ids-only
filter check keeps them: it returns no text. Local search ranks and
fetches document units only and its `entity_frequency_threshold` counts
document units; the LightRAG chunk follow drops seed units. The
`source_kinds` and `iri` filter keys are probed like the schema
annotations (`LATE_MAPPED_FIELDS`); `source_kinds: document` means
document evidence, items without the field included (OpenSearch
`should[term, must_not exists]`, Gremlin `or(has, hasNot)`, the SQLite
vertex filter alike), and is dropped on an index none of whose
documents holds the field, where it filters nothing. Community reports
carry no source kind (a report summarizes its members, seed-only ones
included; deriving one from the members would go stale whenever a seed
edit changes a member's kinds without regenerating the report), so a
`source_kinds` filter keeping one kind skips them in `local`, the DRIFT
primer and `simple`'s sweep
(`BaseSearchStrategy._source_kinds_skip_reports`), and `global`
refuses the key (no index it reads declares it). A reported source
with seed evidence gets `provenance` (`rag_chain.seed_provenance`:
kind, the seed graphs its document filters name, the entity IRI);
graph expansion projects a vertex's `iri` and `source_kinds` (kept a
list, `graph_expansion._LIST_PROPERTIES`, where a value map's
one-element list is otherwise read as its value), but vertices hold no
document filters: `local` copies a candidate entity hit's
`attributes.filters` onto the graph's copy of it
(`LocalSearchStrategy._with_hit_filters`), and a source a graph walk
alone reached names no seed graph.
- **One index, both methodologies (deliberate extension).** Ingestion builds
the union of what the selected methodologies need, both by default (MS
GraphRAG builds no relationship vector index, LightRAG no communities). A
LightRAG-only deployment selects `indexing.methodologies: [lightrag]` to
skip Leiden and every report call; the namespace then holds no `graphrag`
and its strategies are refused (§4.6).
### 6.1 GraphRAG Methodology (`adapters/search_strategies/`)
- **simple** — vector and lexical search over every built index, no graph; the
claims index joins the sweep only when claim extraction is on.
- **local** — candidate entities → graph expansion → frequency filter → their
text units, plus a community-report section and a relationship section
(MS GraphRAG local search). Each query entity is looked up in the entities
index on its own, concurrently: the entities of its name
(`compact_name_key`) seed the expansion, else its top hit when that scores
at least `search.local_search.seed_score_ratio` (0.6) of the best hit of
any query entity (`seed_floor`: on a hybrid search the ratio is read at
equal weights, where a hit only the kNN side found scores at most 0.5 of
one both found, and rescaled piecewise linearly so 0.5 lands on
`search.hybrid.vector_weight`, that hit's cap at any weights; a vector or
lexical search compares raw scores, where the floor separates less); the seeds share the expansion budget in proportion to
their scores (`seed_shares`; Neptune walks the seeds of each share in a
request of its own). One lookup of the joined names seeded a wrong
neighbour beside every right seed on a synthetic multi-hop corpus. With
`search.local_search.include_bridge_relationships` (default on), the
relationships incident to the expanded entities are fetched too, edges
between two retrieved entities first: they carry the hops of a multi-hop
chain the relationship vector query rarely matches. With claims on,
matching claims are injected (MS GraphRAG covariates). The section lookups
run concurrently with the entity chain. The community reports are, by
default (`search.local_search.report_selection: similarity`), those most
similar to the joined query entities, at every level, which always fill
the report slots (`top_k / 2`). `membership` follows MS GraphRAG local's
(`mixed_context._build_community_context`) with one difference: it takes
the reports of the level-0 (leaf) communities the expanded entities name,
where upstream takes each entity's community at the `community_level` cut
(default 2, the entity's deepest level not past it; upstream numbers
levels from the root), so the measurement below compares leaf reports,
not upstream's cut, with similarity. The communities come from
`community_ids`, which graph
expansion projects from the vertex (the entities index holds none), most
matched entities first, then by report rank, cut to the report slots and
fetched alongside the text units. A community without a report (a seed
graph's alone has none) adds nothing; when no expanded entity names a
community (the walk under a document filter, which reads the entities
index, or a failed expansion), or a `community_id` or `level` filter
admits none of those communities, the similar reports stand in (under the
filter). The search metadata's `report_selection` says which ran; DRIFT
counts its follow-ups by each. On the
synthetic 12-query multi-hop harness, membership put 3.8 reports a query
in the context instead of 5.0, 42% of them naming an entity of the
query's chain instead of 38% (19 such reports instead of 23), for 5%
fewer report tokens (about 1,450 against 1,530); every query kept a
relevant report and its gold text units. A real-model run then measured
the answers: on 200 MuSiQue questions, two repeats each, membership
scored exact match 4.0 points lower (95% CI -7.75 to -0.25) and token F1
4.1 points lower (-7.7 to -0.6) than similarity, for about 0.5% fewer
tokens. So similarity stays the default.
- **global** — MS GraphRAG global search over one cut of the community
hierarchy (below), map-reduced into scored key points.
- **drift** — MS GraphRAG DRIFT search (`drift_search.py`).
*Primer:* a hypothetical answer shaped like the most important report
(`DriftHydePrompt`) selects the `k_followups` most similar reports, split
into `primer_folds` folds, each returning an intermediate answer, a score
and follow-up queries. *Actions:* for `n_depth` rounds the best-scored open
follow-ups each run the local search (without rerank) and are answered with
a score and new follow-ups. *Reduce:* the intermediate answers (one
synthesized section, never a reported source) and the evidence (each
primer fold's reports, each action's local search results), reranked
once, go to the answer model. Without reports or primer follow-ups,
the question itself is the one follow-up.
- Defaults `k_followups` 10, `n_depth` 2: at most 27 LLM calls a query (MS
GraphRAG's 20 and 3: 67). Primer calls and actions run
`search.max_concurrency` at a time.
- The action local searches pack their context by the local type shares
(`search.token_manager.type_budgets`, renormalized over the types
present: about 0.60 text, 0.24 entities, 0.16 relationships), not MS GraphRAG's DRIFT
`local_search_text_unit_prop` 0.9 / `local_search_community_prop` 0.1.
- The primer searches reports of every depth, not MS GraphRAG's one
`community_level`, so similarity picks the granularity (on a synthetic
six-hop corpus a depth-2 cut answered 16 of 21 sub-questions, every depth
21).
- A fatal error fails the query and cancels the calls in flight; any other
failure leaves only its own answer out (`degraded_error`). The query fails
only when a whole step failed and `processing.ignore_errors` is off; a
round whose actions all failed ends the rounds.
- **auto** — an LLM router (`StrategySelectionPrompt`) picks among
`search.auto_routable_strategies` minus those the pipeline cannot serve
(`global` and `drift` need community reports) and, with filters, those
that do not apply every filter key (none left: `InvalidFilterError`,
before any LLM call, so a filter never fails by route); the prompt
describes exactly these candidates, and unrecognized output falls back to
`local` (else the first candidate). The default set is `local`, `mix`,
`global`, `iterative` (`drift` costs up to 27 LLM calls; `iterative`'s
guide entry keeps it to questions that chain facts through an unnamed
bridge entity, §6.3); with one candidate no router
call is made. The query processing the candidates may read (entities for
`local`, keywords for `mix`/`hybrid` and a wrapper what it wraps reads,
the translation) starts alongside
the router (`search.auto_speculative_extraction`), so `auto`'s critical
path is the router or the extraction, whichever is slower, then the
strategy: two model round trips before retrieval became one. A routed
strategy never waits for an extraction it does not read, nor does the
query: it keeps running in the background (`_BackgroundExtractions`),
uncancelled, so its result is cached, and its call counts in the query's
usage from its start (`estimated_calls` until it answers; cancelling it
saved nothing, since a cancelled task does not stop a request already
sent). It is cancelled at the query's deadline or when the chain closes.
We chose this over one router
prompt that also returns entities and keywords because the router prompt
and its decision stay byte-identical (no re-validation of the routing),
`custom_prompts.strategy_selection_*` keeps working, and the router's
output stays one word, at the price of one or two short fast-model
calls a query. The extractions are cached per question, target language,
model and prompt text (`search.query_extraction_cache_size`), so the
repeats and arms of a comparison read the same ones. The
prompt sends questions that chain from a named entity to `local` (synthetic
multi-hop harness: `local` 0.925, `mix` 0.40 exact match).
#### 6.1.1 Global search (`global_search.py`)
**Which reports.** Each report carries its place in the hierarchy (`level`
bottom-up, `depth` from the root, parent and children, `max_cut_depth`). The
cut at depth `D` is every community at depth `D` plus the leaves that stop
above it (`depth <= D <= max_cut_depth`), MS GraphRAG's roll-up that covers
every entity once; MS GraphRAG counts `community_level` from the root, so the
depth selects the cut.
- **Fixed cut (default)** — every report of the cut is mapped, up to a cap:
the cut is browsed by rank, one report more than the cap
(`max_reports_per_level`, at most `max_query_size` - 1, and the live
`max_result_window` - 1, so the probe fits one request; 99 by default) so a
cut of exactly the cap is complete. A larger cut gives way to the deepest
shallower cut that fits (`global_cut_depth`), covering the same entities in
broader reports; MS GraphRAG has no cap and maps the whole cut. Only when even the root cut does not fit, or at once under a filter on a report key (`community_id`, `level`, `rating`: a shallower report passes it on its own, not through its members), does a hybrid search,
restricted to the configured cut, pick that many most similar reports
(`global_level_capped`): the only place similarity enters global search.
The cap counts reports, which bounds the map calls.
- **Dynamic selection** (`use_dynamic_selection`) — MS GraphRAG's
`DynamicCommunitySelection`: rate the root reports for relevance, follow
the children of the relevant ones to the leaves, and rate the whole next
depth when a depth yields none, down to `community_depth` (MS GraphRAG's
`max_level`, which bounds only this fallback). The walk starts at the roots
as upstream's does but follows relevant reports to the leaves, where
upstream's stops at `community_level`. Ratings run
`search.max_concurrency` at a time. A rating is the first number of the
model's answer, clamped to the prompt's 1-10; an answer without one, like
a failed call, keeps its report unrated (MS GraphRAG keeps a report it
could not rate), recorded in `degraded_error` and
`global_unrated_report_count`.
**Map-reduce.** At least `map_reduce_min_results` reports are packed, in the
order the selection gives them (rank for the fixed cut, rating for dynamic
selection, similarity for the similarity fallback; MS GraphRAG shuffles
them), into batches of `map_context_tokens` (MS
GraphRAG's `data_max_tokens`); each map
call (`search.max_concurrency` at a time, awaited on the event loop, so the
query deadline cancels them and starts no queued one) extracts key points scored 0-100,
each naming its `report_ids` (MS GraphRAG's `[Data: Reports (ids)]`; a
report's id in the prompt is its ordinal in the batch, mapped back). Points
at or below `map_relevance_threshold` are dropped, the rest ranked, packed
into `max_map_reduce_tokens` and handed to the answer model as one
synthesized section (no separate reduce call: it only added a rewrite that
could drop facts), followed by the reports they cite; a
point citing none of its batch's reports credits the whole batch.
`evidence_tokens` caps the cited report text (whole reports, best point
first) at 4000 tokens by default; null keeps the reports the answer's
context budget holds, the largest part of a global query's input, while MS
GraphRAG's reduce reads the key points only (`0`). Global answers with the
comprehensive prompt by default, modelled on MS GraphRAG's reduce prompt
(multiple paragraphs, markdown); every other strategy with the concise one
(`search.answer_prompt_style`, merged over that default). Both defaults are
measured choices (§9.1).
**Degradation.** A batch whose map call failed or returned unparseable output
is *unrated*; when no point passed but some reports were never rated, those
reports pass through, so global search still answers, and the search
metadata records it (`degraded_error`). When every map call failed (other than
on a prompt longer than the model's context) and `processing.ignore_errors` is
off, the query fails with the first error, as DRIFT does; a fatal error (no
model access) fails it even under `ignore_errors`. When every batch was
rated and every point rejected, it returns nothing
(`map_reduce_no_relevant_points`) and the chain says it cannot answer, as MS
GraphRAG does.
### 6.2 LightRAG Methodology (`lightrag_search.py`)
Dual-level keyword retrieval (`KeywordsExtractionPrompt` extracts high- and
low-level keywords) on the shared hybrid infrastructure:
- **naive** — chunks only, no graph, under the request's `search_type`
(`hybrid`: BM25 + vector by default; `vector` approximates upstream's
baseline and MS GraphRAG's `basic` search).
- **hybrid** — low-level keywords → entity index, high-level keywords →
relationship index (LightRAG's `relationships_vdb`), one-hop expansion
(entity hits → incident relationships, relationship hits → endpoint
entities), and the source chunks the matched entities and relationships
cite (LightRAG's `_find_related_text_unit_from_entities` /
`_from_relations`).
- **mix** — hybrid plus a naive chunk retrieval.
The cited chunks are picked per side, entities first, as upstream does: the
relationship side drops only the chunks the entity side picked
(`entity_chunks`), and each side is cut to `chunk_stream_top_k`. The two
sides are separate fusion streams (`lightrag_linked_chunks`,
`lightrag_relation_chunks`) beside mix's naive chunks, so RRF interleaves
them as upstream's round robin does, and the text quota cuts the merged
chunks after the rerank (upstream's `chunk_top_k` cut). `kg_chunk_pick_method`
picks within a side: `weight` (the default) is upstream's `WEIGHT` method,
weighted polling with every matched item at least one chunk, without
upstream's per-item sort by how many matched items cite a chunk, and both
sides fetched by id in one request; `vector` is its `VECTOR` method (upstream's
default), one search per side of the cited chunks for the query, under the
request's `search_type`, keeping `related_chunk_number * hits / 2`. A lineage
is read in chunk-id order, the order a full build stores; an incremental
merge appends the delta's chunks in arrival order, so reading the stored
order made the weighted pick depend on how the graph was built. `weight`
stays the default because the two measured no significant difference
(§9.1).
The independent queries run concurrently, then the expansions, then the cited
chunks. Short queries without keywords use the raw query as low-level
keywords (`raw_query_fallback_max_len`). Upstream LightRAG has no multi-hop
traversal, hence graph expansion is opt-in.
### 6.3 Iterative bridge follow-up (`iterative_search.py`, opt-in)
No default selects `iterative`; `auto` may route a multi-hop question to
it (it is in `search.auto_routable_strategies` by default, and its router
guide entry describes questions that chain facts through a bridge entity
the question does not name), and an operator who leaves it out of that
list keeps `auto` off it. A real-model run on 200 MuSiQue questions decided
both (`iterative` over `mix` against `mix`: `answer_contains` +12.5 points,
token F1 +5.2, both significant, for +9.3 s at the median query; [Defaults and
evidence](concepts/defaults-and-evidence.md#iterative-follow-up-rounds)).
Whether the router picks it for single-hop or sensemaking questions it
should leave alone was not measured. It targets one
failure: on 200 MuSiQue questions (v15 benchmark index) about 53 of `mix`'s
96 failures lacked a supporting passage, and the missing one was a later
hop's far more often than the first's (the last hop's 14-16% against the
first's 0-5%): round 0 finds the passage that names the bridge entity, not
the passage about it.
**Data flow.** The strategy wraps a base strategy by composition
(`search.iterative_search.base`: `local` or `mix`, default `mix`), built
through the strategy registry, so there is no dispatch on the base:
1. *Round 0* is the base's own search, unchanged (its fusion, rerank and
quotas). A round 0 the controller calls sufficient returns the base's
results as they are, so `iterative` then answers as the base does.
2. *Control.* A fast-model call (`models.roles.query_controller`,
`IterativeControllerPrompt`) reads the question and the candidates packed
as the answer's context is (the base's `spare_context_budget_first`),
within `controller_context_tokens` (null: the answer's own budget, so
the controller judges what the answer will read; at the earlier 8000,
~1200-token chunks left it 3 of the answer's 11 passages, while the
measured MuSiQue chunks fit either way), plus what was already
searched. It returns JSON (`parse_llm_json` with answer keys):
`sufficient`, `known_facts`, `bridge_entities` (as the context writes
them) and at most `max_next_queries` (2) `next_queries` naming them.
3. *Follow-up round.* The base search reruns seeded with the new bridge
entities (local's `entity_focus`, so each is looked up by name and seeds
the graph walk; mix's `ll_keywords`; the first follow-up query is the
search text) without its own rerank, and each new follow-up query
searches the text units (`round_top_k`, 5) under the request's
`search_type`. A round whose verdict names no new bridge entity (only
new queries) reruns `local`, which then looks its entities up by the
search text, but not `mix`: mix seeds its entity and relationship
searches from keywords alone, and without a bridge those would be the
question's, which round 0 already searched, so the round is its text
searches. (Rerunning mix with empty keywords, as the first version did,
made it fall back to the search text as a keyword, or search no entity
when the text passed `raw_query_fallback_max_len`: 62 of 238 controller
rounds in the measured run.) A query already searched (case-folded) or
a bridge already followed (`compact_name_key`, so `동부 창고` and `동부창고` are one) is
not searched again, a round follows at most five bridge entities (the
most follow-up queries a round may ask for), and the controller is shown the searched list: the
redundant searches ReaRAG (arXiv 2503.21729) reports for open-ended
search agents, which run on until their iteration limit, are what the
caps and the dedupe prevent.
4. *Fusion.* Every round's candidates are fused once more by RRF, one
bucket per round: `iterative_round_` (round n's base search, round 0
the first) and `iterative_round__text` (its follow-up queries' text
units, interleaved), each weighable through `search.fusion.fusion_weights`
(the base's own buckets were fused, with their weights, inside each
round). The first follow-up query is the base rerun's search text, so
its text units are searched twice, by mix's chunk stream (or local's
PageRank passage seeds) and by the round's own text search, at different
`top_k` and min-max normalization: the round bucket's own RRF credit is
part of the measured ranking, so the two concurrent searches are kept. The base's rerank types (`text` for both bases) are reranked
against the question and the follow-up queries joined, and each section
type keeps the larger of round 0's count of it and the type quota
(`TYPE_QUOTA`), so a round-0 item leaves only when a new one outranks
it. Back to 2 while rounds remain (`max_rounds`, 2); the last round is
judged too, so the final verdict is known.
5. *Answer.* The chain packs the final candidates into the usual token
budget and the answer model answers once (the chain's answer step,
output guardrail, grounding check and sources, unchanged).
`decompose: true` replaces 2-4 with one KAG-style pass: one call
(`QueryDecompositionPrompt`) writes at most `max_rounds + 1` single-hop
steps in MuSiQue's notation (`#1` is step 1's answer); each step, with its
references replaced by the earlier answers (an unanswered one by its
question), runs one follow-up round (the step's answers are its bridge
entities; a step without one, step 1 always, keeps the question's own
keywords on `mix`, since the step's answer reads only its own round) and is
answered from those candidates by one call
(`DecompositionStepPrompt`, an empty answer when they state none). The steps'
candidates are fused with round 0's once, as in 4. The step answers are not
passed to the answer model: it reads retrieved evidence only.
**Layers.** The strategy is an adapter (`adapters/search_strategies/`),
registered with `@register_strategy(SearchStrategy.ITERATIVE,
wraps=...)`. `StrategySpec.wraps` names, per config, the strategy a wrapper
runs; `wrapped_strategy()` resolves it, and the chain reads the wrapped
strategy's query inputs (`query_processing.needs_query_entities` /
`needs_dual_keywords` with the config) and context packing
(`spare_context_budget_first`) through it, so `iterative` extracts and
packs exactly what its base does, and needs no index, store or community
report the base does not (same retriever roles, `needs_community_reports`
false). Whatever else is declared per strategy (a methodology a namespace
must have indexed) should be read through `wrapped_strategy()` too. Its
filter checks delegate to the base (`filtered_index_prefixes`,
`filtered_retrievers`, `reserved_filter_keys`). The prompts are in
`domain/prompts/retrieval.py` (overridable as `custom_prompts.
iterative_controller_*`, `query_decomposition_*`, `decomposition_step_*`),
the settings in `search.iterative_search`.
**Languages.** The chain passes the query's language in
`SearchQuery.metadata["query_language"]` (`query_processing.query_language`:
the requested language, else the index's, which is the source language when
translation is off and the target language when it is on); the controller,
decomposition and step prompts write their queries and bridge names in it,
names as the context writes them. Nothing in the strategy parses text but
JSON and `#n`; dedupe uses case folding and `compact_name_key`, which drops
spaces next to Han, Hangul and Kana. The text searches go through the
configured analyzers (the embedded engine's bigrams for dense scripts).
**Filters, scopes and provenance.** Every round's base search is the
caller's query with seeds added (`model_copy`, so its filters, suffix and
`search_type` are kept), and every text search uses `_scoped_filters`, so
a document filter or tenant scope applies in every round with the same
code-injected predicates as the base's. Fusion copies results, so a cited
follow-up passage keeps the provenance the base reports (`text_unit_ids`,
document attributes, `iri`, `source_kinds`, `attributes.filters`); no
synthesized item is added, so every source is retrieved evidence and the
retrieval evaluator scores it as any other.
**Deadline.** A follow-up round starts only while the query deadline
(`search.query_timeout_seconds`) leaves `search.model_timeout_seconds` for
the answer, and the rounds run in one `asyncio.timeout` that ends there:
past it the round in flight (its controller call, base search and text
searches) is cancelled and the answer reads what the rounds before it
found (`stop_reason: deadline`). When the controller had already judged
those candidates insufficient (the deadline cancels the round its verdict
started), the run reports it (`context_sufficient: false`,
`insufficiency_reason: deadline`); a deadline that cuts the controller's
own call leaves the verdict unknown. Without a deadline the round cap alone
bounds it.
**Failure modes.**
| Event | Outcome |
|---|---|
| Controller or step call raises a fatal error (no model access) | The query fails, as in every strategy |
| Any other failed call (throttled past its retries) | The rounds stop; the answer reads the earlier rounds' candidates; `degraded` names the step and `degraded_error` the error type |
| Unreadable controller JSON (no `sufficient`), or a plan with no steps | As above, without `degraded_error` (`controller_unparsed`, `decomposition_unparsed`) |
| A store request of a round fails | Degrades that sub-search to nothing (`_safe_aretrieve`), as in the base; fatal store errors fail the query |
| Deadline reached | The round in flight is cancelled (`deadline`); after an insufficient verdict on what is returned, `context_sufficient = False` |
| Insufficient after the round cap, or nothing new to search | `SearchResult.metadata["context_sufficient"] = False`, `insufficiency_reason` = the stop reason |
**Contract: `context_sufficient`** (`domain/retrieval/abstention.py`). Set (to `False`, absent otherwise)
only on a positive verdict: the controller's last verdict was insufficient
and no further round could run (`max_rounds` reached, no new lead, or the
deadline cancelling the round that verdict started), or, with `decompose`,
the last step found no answer. A degraded run, or one whose deadline came
before the controller judged what it returns, never sets it, its verdict
being unknown. The strategy does not abstain;
`search.abstention.on_insufficient` (§9.2) reads the flag in the answer
step. `metadata["iterative"]` records the base,
mode, rounds, `stop_reason`, round 0's own rerank outcome (`rerank` is the
last fusion's), the searched queries, each verdict (sufficient,
bridge entities, queries, the number of known facts), the steps, the
results added over round 0, and the rounds' own model calls and tokens
(`usage`, by model, rerank requests included; the query's
`metadata["usage"]` counts them too). The follow-up queries, bridge
entities and step questions there are model-written: with
`check_output` the chain screens them as it screens synthesized results
(one check over them all; only when it intervenes each on its own,
masked or left out).
**Cost.** Per query at most `max_rounds + 1` controller calls (3), or
`max_rounds + 2` decomposition and step calls (4), on the fast tier;
`max_rounds` reruns of the base search without rerank, up to
`max_rounds * max_next_queries` text searches, and one rerank call per
round. At the 2026-11 list prices (Haiku 5.5 $0.10 / $0.50 per million
tokens, Cohere Rerank 3.5 $2 per 1,000 search units), a controller call
reading 8,000 context tokens costs about $0.001 (one reading the
answer's 30,000-token budget, the default since, about $0.003 when the
chunks fill it) and a rerank $0.002, so the worst case adds about $0.007
a query to `mix`'s (about $0.013 at a full 30,000-token budget); latency adds one
fast-model call per round plus a base search and a rerank. Measured on 200
MuSiQue questions over `mix` (KAG reports 84-98% stopping after its first
round): 57 queries stopped after round 0, 48 after one follow-up round and
95 after two, at 2.19 controller calls a query; +$0.0018 a query on
average ($0.0553 to $0.0571, the larger answer context included; the
run's usage counted no rerank request, about $0.004 more a query at the
rerank price above, which usage now counts as `rerank_search_units`), and the
median query from 6.8 s to 16.1 s, p95 from 11.1 s to 27.4 s. The answer is
generated once.
**Fidelity: what the papers do and what we changed.** Neither IRCoT nor
KAG is ported as a library (there is nothing to diff against algorithm by
algorithm; their published settings are compared instead):
| | IRCoT (Trivedi et al., ACL 2023, arXiv 2212.10509) | KAG (Liang et al., arXiv 2409.13731) | `iterative` |
|---|---|---|---|
| Loop | Interleaves one chain-of-thought sentence with one retrieval: the first query is the question, each later one the last generated sentence (code: `query_source="question_or_last_generated_sentence"`) | Logical-form planner splits the question into sub-queries with operators (Retrieval, Sort, Math, Deduce, Output) linked by variables; each answered by graph retrieval, falling back to hybrid chunk and subgraph retrieval below a threshold; a Judge decides answerability, SupplyQuery writes a supplementary question (reflection, adopted from ReSP) | Controller loop: a sufficiency verdict, then follow-up queries and bridge entities; `decompose`: natural-language steps with `#n` references, no operators |
| Query per step | Last CoT sentence | Sub-query or supplementary question | Controller-written queries naming the bridge entities, plus the bridge entities as graph seeds |
| Retrieval per step | BM25 (Elasticsearch), K paragraphs, K tuned over {2, 4, 6, 8} (configs default 6) | Graph, then hybrid | The full base search (BM25 + kNN + graph walk for local; mix's dual-level streams) plus `round_top_k` (5) text units, fused by RRF and reranked |
| Collection cap | 15 paragraphs (`global_max_num_paras`) | — | The answer's token budget, each section type at round 0's share |
| Steps cap | 8 in the paper (footnote 4), 10 in the code default (`max_num_sentences`) | Reflection n = 3 rounds (`ref3`; benchmark configs `max_iteration: 3`) | `max_rounds` 2 follow-up rounds (3 retrieval rounds, as `ref3`); `decompose`: 3 steps |
| Stop | The sentence says "answer is", or the cap | Judge says answered, or the cap | Controller says sufficient, nothing new to search, the cap, or the deadline |
| Final answer | A separate reader always (reading the CoT's own answer scored lower: MuSiQue F1 30.8 vs 24.9, Flan-T5-XXL) | Generator over the sub-answers | The answer model once, from retrieved evidence only |
| Models | Flan-T5 (base to XXL), GPT-3 code-davinci-002 | DeepSeek-V2 (baselines mixed backbones) | Controller on the fast tier (Haiku 5.5), answer on the default tier |
Why the changes: an LLM call per sentence (IRCoT) or per logical form (KAG)
costs more per query than a bounded number of fast-model verdicts, and
Adaptive-RAG measured IRCoT-style multi-step retrieval at 2.1-4.7 times the
steps and 3.3-8.8 times the time of single-step retrieval; the graph and
dense signals we already have make the bridge entity a better next query
than the last CoT sentence, whose text is a statement rather than a
question; the logical-form operators target numeric and comparative
questions our benchmarks do not exercise. The reported effects that
motivate it are modest and setting-dependent: IRCoT's QA F1 gain on
MuSiQue was +5.0 (Flan-T5-XXL) and +7.1 (GPT-3), retrieval recall +3.5 and
+12.5; KAG's reflection (ref1 to ref3) gained +4.4 F1 on MuSiQue for its
hybrid variant (44.3 to 48.7) but +0.2 and +1.2 on HotpotQA and 2Wiki. KAG's
current code re-plans the original question on a retry rather than writing
a supplementary question as the paper describes; ours follows the paper's
supplementary-question form. Our expected gain is the own-data ceiling of
about +11 points of answer F1 if every missing last-hop passage were
recovered, realistically +3 to +5, below the 6-8 point minimum effect 200
questions detect: hence the measured experiment before any default.
### 6.4 HippoRAG 2 Personalized PageRank (`ppr`, `local` ranking)
A port of HippoRAG 2's retrieval (Gutiérrez, Shu, Qi, Zhou, Su, *From RAG to
Memory: Non-Parametric Continual Learning for Large Language Models*, ICML
2025, [arXiv 2502.14802](https://arxiv.org/abs/2502.14802); code
[OSU-NLP-Group/HippoRAG](https://github.com/OSU-NLP-Group/HippoRAG), MIT),
reimplemented on the graph and indexes ingestion already builds: the
algorithm is re-written here, none of its code is vendored or depended on.
HippoRAG 2 seeds a Personalized PageRank (PPR) over a graph of entities and
passages from the facts (triples) most similar to the question and from a
dense passage search, and ranks the passages by their PPR score; the paper
reports it above its dense-retrieval baselines on the multi-hop sets
(MuSiQue among them). Both uses here are opt-in: the defaults change only
on a measured answer-level result (§9.1).
- **`ppr` strategy** (`adapters/search_strategies/ppr_search.py`,
`methodology = Methodology.HIPPORAG`, config `search.ppr_search`): the
HippoRAG 2 retrieval as a strategy of its own. No query-time LLM call
besides the answer (no entity or keyword extraction). It reads the
entities, relationships and their vector index, text units and the graph
(the `hipporag` profile of §4.6, what `lightrag` builds); no community
report, so it serves a namespace built with `indexing.methodologies:
[hipporag]` or `[lightrag]`, and a namespace without the relationship
vector index (`[graphrag]` with `build_relationship_vector_index: false`)
refuses it before any model call (`MethodologyNotIndexedError`). The
`local` ranking below stays a `graphrag` strategy. `auto` never routes to it
(`search.auto_routable_strategies` refuses it): on 200 MuSiQue questions
its answers were not significantly different from `mix` or `local` at a
third to half of their cost, and weaker on 4-hop questions, so
it is the cheap, fast option a caller selects
([evidence](concepts/defaults-and-evidence.md#the-ppr-strategy)).
- **`local` ranking** (`search.local_search.text_unit_ranking: ppr`, the
default; `support` is the earlier order): the same PPR replaces the order of local search's text
units (`_rank_text_unit_ids`) over local's own expansion; everything else
of local (seeds, sections, quotas, rerank) is unchanged. DRIFT's follow-up
local searches follow the setting. A retrieval-only screen on 200 MuSiQue
questions (one repeat, real models) measured it, as configured by default,
against `support`: the supporting passages in the context +11.0 points
(95% CI +8.4 to +13.8), recall@5 after the rerank +4.1 (+1.0 to +7.1);
fusing the same passage hits into `support`'s order without the PageRank
gained +6.4, so the PageRank adds 4.7 (+2.5 to +6.8) beyond the passage
hits; seeding from the entities alone gained only +3.9. It cost about
0.4 s a query on the embedded stores (the PageRank itself about 50 ms).
Two answer runs then measured it against `support` (`answer_contains`
+3.5 points, recall@5 +6.1, EM and token F1 n.s., retrieval +0.10 s a
query), so it became the default
([evidence](concepts/defaults-and-evidence.md#local-ppr-ranking)). Its
extra searches add CPU work in the querying process; with many concurrent
queries in one process that work waits on the interpreter lock, which
the embedded engine's id and exclusion lookups, the dense-script test and
the query CLIs' one BLAS thread (`launch.one_blas_thread`) keep small.
**Data flow of `ppr`.**
1. *Seeds*, concurrently, each under the caller's filters and suffix: the
`fact_top_k` (5, HippoRAG's `linking_top_k`) relationships-index hits of
the question (*facts*) by kNN against the relationship descriptions
(`fact_search_type`: HippoRAG's query-to-fact similarity; null for the
request's search), and the `passage_seed_top_k` (20) text-units hits
(*passage seeds*) under the request's `search_type` (BM25 + kNN fused by
default), so vector, lexical and graph signals all seed. With
`entity_seed_top_k` (default 0) the entities-index hits of the question
seed too; should the relationship vector index be off at query time on a
namespace that holds it, they take the facts' place (`fact_top_k` of
them), so the strategy still seeds the graph.
2. *Neighbourhood*: graph expansion (§6, the graph walk or, under a document
filter, the walk over the relationships index) from the facts' endpoints
and the entity hits, `max_hops` (2) deep, at most
`max_neighbourhood_entities` (100) entities besides the seeds, each seed's
share of the budget in proportion to its fact score.
3. *Edges*: the relationships between two entities of the neighbourhood
(`BaseSearchStrategy._neighbourhood_edges`, below), at most
`max_edges_per_vertex` (50) per entity.
4. *Graph* (`domain/retrieval/ppr.py`, `PassageGraph`): one node per entity
and per passage; one undirected edge of weight 1 per relationship (parallel
relationships add up; HippoRAG counts the triples between two phrases),
and one of weight 1 from each entity to each document text unit it cites
(`text_unit_ids`; HippoRAG's passage-to-phrase *context* edges). An entity
citing more than `entity_frequency_threshold` (20) document units adds
no passage edges, a seed neither (it still passes its mass on through its
relationships), which bounds the graph a hub would blow up. The passage
seeds are passage nodes too; one no reached entity cites is a node
without edges.
5. *Reset vector* (HippoRAG 2's seeding, `seed_weights`): the facts' scores
are read relative to the best fact (HippoRAG min-max normalizes them; a
BM25 score would otherwise outweigh the passage weight tenfold), the
entity hits' alike. An entity of the graph scores the mean, over the
facts that name it (each end counted, as HippoRAG counts a self-loop's
two), of the fact's score divided by the number of document units the
entity cites (at least 1), and only the `link_top_k` (5) best entities of
the graph keep their score (an endpoint the stores did not return takes
no slot); an entity hit is scored the same way. A passage seed scores
`passage_node_weight` (0.05) times its hit score min-max normalized over
the passage seeds (all 1 when they tie). Normalized to sum 1.
6. *PPR* (`personalized_pagerank`): power iteration of
`p = (1 - d) r + d (p P + (p · dangling) r)`, damping `d` = `damping`
(0.5, the probability of following an edge), at most `max_iterations`
(30) steps or until the L1 change is below `tolerance` (1e-6); the mass of
a node without edges returns to the reset vector, as in igraph's
(PRPACK) personalized PageRank HippoRAG calls. Nodes are ordered by id, so
a run is deterministic; ties rank by id.
7. *Fusion buckets* (`search.fusion.fusion_weights` keys):
`ppr_passages` (text: the passages by PPR score, the first text quota
`max(2 * top_k, 20)` of them that pass the caller's filters, fetched by
id), `ppr_seed_text_units` (text: the hybrid passage hits, in their
order) and `ppr_facts` (relationship: the facts, in their order). RRF
fuses them through `HybridScorer` (a passage both rank adds both terms),
the reranker reorders the text candidates when `rerank` is on (the
default; off records rerank `skipped`), each type keeps its quota, and
the token budget packs the context as for every strategy.
8. *Answer*: the standard concise answer prompt
(`search.answer_prompt_style`); the sources are the sections the model
saw, with the provenance every strategy reports (§6: document ids,
chunk ids, `text_unit_ids`, seed-graph provenance, `iri`,
`source_kinds`), so `run-eval`'s retrieval evaluator scores it as any
other.
**`local` ranking** is the variant the screen above measured
(`tests/unit/test_ppr_search.py` pins its ranking to the screen's prototype
on a fixture). It builds the same graph from local's expanded entities
(after its frequency filter, which keeps the seeds as nodes; past
`search.local_search.entity_frequency_threshold` a seed links no passage
either) and their text units, plus the endpoints of the question's
`search.local_search.ppr_seed_relationships` (5) relationships-index hits
(kNN, `fact_search_type`; an endpoint outside the neighbourhood is read from
the entities index and joins the graph with its relationships). Its reset
vector (`summed_seed_weights`): an entity's seed hit score plus the mean
score of the relationship hits naming it, both as the searches return them,
divided by its document units, each counted once as `ppr` counts them (no
`link_top_k` cut), plus the passage seeds
(`search.ppr_search.passage_seed_top_k` (20) text-unit hits of the question
under the request's search, weighted by `passage_node_weight`). The PPR
order over every passage node, passage seeds included, replaces
`_rank_text_unit_ids`: the passage hits enter local as seeds and candidates
of that ranking, not as a fusion bucket of their own (as measured), and are
not fetched again (only the ranked units they are not). Unlike `ppr`, which
scales each kind of hit to its best before seeding (`_relative`), local
seeds with the raw scores, as measured: under the default hybrid search
they are already on a 0-1 scale, but under `search_type: lexical` the BM25
scores of the entity seeds outweigh the facts' kNN scores and the
passages' `passage_node_weight`. Kept as measured; scaling them is a
change to measure first.
**DRIFT** follow-ups are local searches, but rank their text units by
`search.drift_search.text_unit_ranking` (`support` by default, what DRIFT
was measured with): `ppr` was measured for `local` alone and adds a
text-unit search, a relationship search, an edge read and an embedding to
each of up to 20 follow-ups. The
damping, iterations, tolerance and edges per entity are
`search.ppr_search`'s. The search metadata records `text_unit_ranking` and
the run's counts (`ppr`: `entities`, `relationships`, `linked_entities`,
`passages`, `entity_seeds`, `passage_seeds`, `fact_seeds`, `iterations`,
`converged`).
**Neighbourhood edges, one contract per backend.**
`BaseGraphRAGRetriever.aneighbourhood_edges(vertex_ids, query,
per_vertex=n)` returns the relationships whose two endpoints are both in
`vertex_ids`, each vertex contributing at most its first `n` such edges by
relationship id (the edges of either endpoint's first `n` are kept), as
`GraphEdge` (id, endpoints, stored weight, source kinds), sorted by
`(source_id, target_id, id)`. `None` (the base's default) means the backend
cannot tell; the strategy then reads the relationships index.
- Neptune / Gremlin Server (`NeptuneRetriever`):
`local(bothE().where(otherV().).order().by("id").limit(n))`
from the set's vertices, in one request (custom vertex ids: `g.V(ids)` and
`hasId`; property ids: `has("id", within(...))`, also while a full build
switches the label). `indexing.neptune.deterministic_order: false` takes
the store's first `n`, as the walk does. The traversal reads every
incident edge of each vertex (a hub of 20,000 edges took 0.4 s on Gremlin
Server, more than the expansion), so the retriever declares
`scans_incident_edges` and, where the relationship vector index is
built, the strategies read the edges from it as below (both endpoints in
one terms query, no per-vertex scan); the graph serves them without it.
- SQLite (`SQLiteGraphRetriever`): one indexed `IN` read of the edges
leaving each batch of 400 vertices (an edge within the set leaves one of
them), the target kept when in the set, the first `n` per vertex by id in
Python (`cap_edges_per_vertex`, once, in `aneighbourhood_edges`).
- Under a document filter (and from a graph that scans incident edges)
the graph cannot tell which edges a passing document states, so the
edges come from the relationships index
(`_incident_relationships`, as the filtered walk reads them: one request per
batch of source ids with the target pinned to the set, a page of at most
`max_query_size` edges best first; under two or more document keys an
edge also needs one passing document by its text units), cut per vertex
alike. A `source_kinds` filter applies to the edges of every path; there
the index also applies the caller's relationship keys (`predicate_iri`,
`in_schema`, `weight`), which the graph path does not apply to edges, as
the graph walk crosses every edge.
`tests/integration/test_retrieval_conformance.py` pins one result for
SQLite, Gremlin Server (both vertex-id modes, `LOCAL_STORES=1`) and Neptune
(`-m aws`).
**Why a truncated neighbourhood can be enough.** PPR is the distribution of
where a walk that starts on the reset vector stops, continuing each step
with probability `d`. A node more than `h` hops from every reset node needs
at least `h + 1` steps, so it holds at most `d^(h+1)` of the mass: 12.5% past
2 hops, 6.25% (0.5^4) past 3 at `d = 0.5`. Restricting the graph to the
`h`-hop ball around the reset nodes changes only walks that step off its
boundary, which also take at least `h + 1` steps, so the scores move by at
most `2 d^(h+1)` in L1 (`tests/property/test_ppr_properties.py` checks the
bound on random graphs). The graph built here is not that ball, so the
bound does not cover it: the expansion's budget and per-vertex caps cut the
entity side, and a passage seed links only to the reached entities that
cite it, never to the other entities that do (an index of which entities
cite a passage would be one more read a query), so one no reached entity
cites holds its own reset mass. How much these cuts cost is part of the
measurement.
**Cost.** No LLM call at index time: the graph, the relationships' vectors
and the text units are what ingestion builds anyway. A `ppr` query costs the
question's embedding, three searches (facts, passages, optional entities),
one graph expansion, one edge read, one passage fetch, a PPR off the event
loop (`numpy`: a median of 2 ms for 50 entities and 200 passages, 8 ms for
150 and 600, 31 ms for 300 and 2,000), the rerank and the answer call: one LLM call,
where `local` makes two (entity extraction and answer). The `local` ranking
adds a passage search, a relationship search (`ppr_seed_relationships`)
and an edge read to a local query.
**Failure modes.** No seed at all (nothing matched): no results, so the
answer is "cannot answer" without a model call. A failed seed search,
expansion or edge read that is not fatal degrades that part only (logged;
fatal store errors fail the query, §8.1): without edges the PPR runs over the
entity-passage links, without an expansion over the seeds read from the
entities index; a reset vector without mass leaves the passage hits in their
own order. The `local` ranking falls back to `support` when the PPR has no
passage to rank. Without the relationships index, the edges under a document
filter come from the graph, unchecked against the filter, as the graph walk's
are (§6): an edge only excluded documents state can still carry mass, never
content.
**Fidelity to HippoRAG 2.** `tests/unit/test_ppr_hipporag_parity.py` runs the
original's PPR (python-igraph `personalized_pagerank`, PRPACK, as
`HippoRAG.run_ppr` calls it) and its seeding
(`graph_search_with_fact_entities`, executed from a checkout named by
`HIPPORAG_SRC`) on the same graphs, reset vectors and damping, and compares
the scores and passage order; it is skipped unless both are available (the
original is never a dependency). The deliberate differences:
| HippoRAG 2 | Here | Why |
|---|---|---|
| PPR over the whole graph | over a bounded neighbourhood of the seeds (`max_hops`, the expansion budget, `max_edges_per_vertex`) | a query reads a few store requests, not the corpus; the mass past `h` hops is at most `d^(h+1)` |
| Recognition memory: an LLM filters the top facts | no filter | no query-time LLM call; the facts are fused hybrid hits |
| Facts by dense similarity to triple embeddings, min-max over all facts | the top relationships-index hits by kNN (`fact_search_type`, lexical or hybrid on request), scores relative to the best | the relationship descriptions are the facts this index holds; the top `fact_top_k` alone are searched |
| Passage weights from a dense score of every passage | the top `passage_seed_top_k` hybrid hits, min-max over them | a bounded search; hybrid like every strategy here |
| PPR order alone ranks the passages | the PPR order and the passage hits are two fusion buckets, then the optional rerank | the hybrid fusion every strategy shares; `fusion_weights: {ppr_seed_text_units: 0}` and `rerank: false` come closest to the original |
| Synonymy edges between similar phrases | none | entity resolution merges duplicates at index time (§4.2) |
| Fact edge weight: the triples between two phrases | 1 per stored relationship (merged over its text units) | one relationship per pair and type after merging |
| Every phrase links to its passages | not past `entity_frequency_threshold` units, seeds included | bounds the graph a hub entity would fill |
| A passage links to every phrase it contains | to the reached entities citing it; a passage hit none cites has no edge | the neighbourhood is read from the seeds outward, not passage by passage |
| (the `local` ranking) | its own seeding, `summed_seed_weights`, on raw search scores | the variant the retrieval screen measured |
| Self-loops kept | dropped | they only hold mass on their node |
### 6.5 RAPTOR summary tree (`raptor`, `tree`, opt-in)
The summary tree reimplements RAPTOR (Sarthi et al., ICLR 2024, arXiv
2401.18059; reference code github.com/parthsarthi03/raptor, MIT, read and
run for comparison only, never vendored): text units are clustered in
embedding space, each cluster is summarized by a model, the summaries are
clustered and summarized again, and a query searches every level at once
(RAPTOR's "collapsed tree"). It is opt-in and unmeasured: `raptor` is not
in the default `indexing.methodologies`, so no default ingestion builds a
tree, and `search.auto_routable_strategies` refuses `tree`. Whether it helps is for the
experiment at the end of this section to decide. The evidence is mixed:
RAPTOR leads on specific QA in one unified benchmark (Zhou et al., arXiv
2503.04338: MuSiQue 24.1 against vanilla RAG's 17.2), but is below vanilla
RAG on GraphRAG-Bench's novel set (arXiv 2506.05690: fact retrieval 49.3
against 58.8). Its case is cheap indexing and cheap queries, not beating
`global` on summaries.
**Methodology (§4.6).** The tree is the `summary_tree` index artifact of the
methodology `raptor` (required: text units and the summary tree), and `tree`
declares `methodology = Methodology.RAPTOR`. The catalogue maps the
artifact to the `summary_tree` stage (`ARTIFACT_STAGES`,
`ARTIFACT_PRODUCERS`), which reads only the indexing stage's stored output
(`STAGE_DEPENDENCIES[SUMMARY_TREE] = {INDEXING}`), so the stage runs only
when `raptor` is selected and `indexing.methodologies: [raptor]` alone
runs parsing, loading, chunking, translation (when configured), indexing of
the text units and the tree: no graph extraction, gleaning, resolution,
claims, graph analysis or communities. With the other methodologies the
tree is added beside their artifacts. The summary tree is not a
`CORPUS_WIDE_ARTIFACTS` member: it is built from what the stores hold, so
adding `raptor` to an existing namespace builds only the tree on the next
run (an empty delta included) instead of rebuilding the namespace, and
adding `graphrag` or `lightrag` to a `raptor`-only namespace extracts over
the documents (their rebuild) and leaves the tree as it is: the text units
do not change, so neither does the leaf-set digest. The indexing stage
writes the build record before the tree exists, so the stage adds
`summary_tree` to the record once the tree is current
(`VectorIndexer.add_build_artifacts`); an incremental record keeps only the
artifacts both it and the stored record name, so a run that leaves the
stage out (or whose tree build fails) records `raptor` as no longer held
until a run builds the tree again. On a `raptor`-only namespace `auto`
falls back to `tree` (§4.6's fallback), the only strategy reading what it
holds.
**Placement and data flow.** A post-indexing stage,
`PipelineStageType.SUMMARY_TREE` (appended after `INDEXING`, so every
earlier stage keeps its cache key), run when `raptor` is selected, `application/ingestion/summary_tree_stage.py`:
1. *Leaves.* The stage reads the namespace's stored text units through
`VectorIndexer.scan_text_units` (an id-ordered page walk, the seed
graphs' units left out as every text search leaves them out): their
id, indexed text (the `target_language` translation when one is
stored, the source text otherwise, as the indexer embeds them), stored
vector, `n_tokens`, file name and document `filters`. Reading the
committed store, not the run's context, makes the tree a function of
what is stored: an incremental run and a full build of the same corpus
build the same tree, and a run with an empty delta can build a tree
that is missing.
2. *Trigger.* The leaf-set digest (the sorted leaf ids, which derive from
the document's content) and the tree fingerprint (every tree setting,
the summary model, prompt and language, the embedding model and
dimension) are compared with the build record in the summary index's
`_meta` (`summary_tree`). Equal and complete: nothing is done. A tree
marked `partial` (a summary failed) is rebuilt; reuse makes that cost
only the failed clusters.
3. *Clustering and assembly* (`domain/ingestion/summary_tree.py`, pure
numpy, deterministic): below.
4. *Summaries* (`adapters/ingestion/tree_summarizer.py`,
`TreeSummaryPrompt`, `ModelRole.SUMMARY_TREE` on the default tier, through
`BatchProcessor` and the run's LLM response cache), one level at a time,
bottom-up. A cluster whose input (its children's texts in id order, the
language and the summary settings: `input_key`) equals a node of the
live tree takes that node's summary and makes no call. *Length:* the
prompt asks for the words (characters for Japanese, Chinese and Korean)
that 90% of `max_summary_tokens` holds at the rate the token counter
that bounds the summary gives the cluster's own texts, so the hint
follows the counter (the script-aware estimate scaled for the model's
tokenizer, or tiktoken) and the language: a fixed 0.75 words per token
asked for 225 words where that counter counts about 2 tokens a word,
and 95% of the summaries of a MuSiQue tree were cut mid-sentence. A
summary still over the budget keeps its longest prefix of whole
sentences within it (`shared/utils/sentences.py`: `。!?.` anywhere,
`.!?…` before whitespace and not a `.` before a lowercase word, a line
break), and is cut at a character only when its first sentence is
longer than the budget.
5. *Embedding and write.* The summaries are embedded by the vector
indexer (`embed_summary_texts`, which takes the stored vector of a text
whose digest the live summary index holds), and the whole tree is
written into a new timestamped index whose alias is then swapped
(`write_summary_tree`), the build record written with it. A tree is
rebuilt, never patched.
The stage runs in its own usage scope (`run-ingestion` reports its calls,
tokens and cost as for any stage) and inside the run's lease and write
fence. It is skipped, with a warning, when the run's indexing stage failed.
It declares `PipelineStage.READS_STORES`: a run of it alone requires no
upstream stage to be backed by the stage cache (`resume._check_upstream_complete`;
after an upgrade the earlier stages' keys change while the stores still
hold their output), and probes only the indices the namespace lacks, as an
incremental run does, rather than every index of a full build.
**Clustering** (each step deterministic: inputs sorted by id, seeded by
`random_state`, coordinates rounded to 1e-6 so a BLAS thread count does not
flip a tie):
- *Reduce.* L2-normalize, center, project onto the top
`min(reduction_dims (10), n - 1)` principal axes (numpy SVD, each axis's
largest loading made positive). This replaces UMAP.
- *Partition one level.* `algorithm: kmeans` (default): Lloyd's iterations
(at most 100), `k = ceil(n / target_children)` (6), hard assignments, an
empty cluster re-seeded at the farthest point; two steps (about `sqrt(k)`
coarse clusters, then each split by its share of `k`) when `n * k` passes
4 million, so a 200,000-leaf level costs about `2 n sqrt(k)` distances.
The initial centers are the farthest-first `k` of the `2k` nodes of lowest
seeded id hash, a sample one added or removed node changes by one at
most: k-means++ samples by cumulative distances, so one inserted node
changed every later pick and a delta re-clustered, and re-summarized, the
whole tree. On 2,000 synthetic leaves (40 blobs, 64 dimensions) a 1% delta
reused 39% of the summaries with this start against 0.8% with k-means++,
a 10% delta 8% against 0.6% (stage 0 below measures it on real vectors).
`algorithm: gmm` (RAPTOR's): its two steps, a global Gaussian mixture,
then a local one inside each global cluster larger than `dims + 1`
(local PCA of the members' vectors in place of upstream's local UMAP);
each mixture is EM with full covariances (regularization 1e-6) started
from k-means, k chosen by BIC over `bic_grid` (`bounded`, the default: at
most 8 values around `n / target_children`; `upstream`: 1..49 as
upstream's `max_clusters` 50), and a node joins every cluster whose
posterior exceeds `soft_threshold` (0.1, upstream's `threshold`), its best
one when none does. Soft membership makes the tree a DAG; lineage is a
union.
- *Token bound.* A cluster whose children exceed `max_cluster_tokens`
(8,000, counted by the token counter, so Han, Hangul and Kana are counted
about a character a token) is clustered again with the same algorithm
and settings, at least two ways, until it fits (a split that does not
shrink falls back to id-order packing).
- *Levels.* The new nodes' summary vectors are clustered again, up to
`max_levels` (5, upstream `num_layers`), until a level holds at most
`min_nodes_to_cluster` nodes (11, upstream's `reduction_dimension + 1`)
or would shrink by less than `min_level_shrink` (20%). A cluster of one
node is not summarized: the node is promoted to the next level. The top
level may hold several roots.
- *Scope.* `scope: corpus` (default): one tree over the namespace's
leaves, or one per value tuple of the `partition_by` document filter
keys. `scope: document`: one tree per document (RAPTOR's own setting),
documents told apart by their stored file path; one with fewer than
`min_leaves_per_tree` (3) leaves gets none. MuSiQue's one-paragraph
documents get no tree under `document`.
**Data model.** `SummaryNode` (`domain/models/summary_node.py`): `id` (a
content id: level, partition and sorted child ids), `level` (1..L; text
units are level 0 and are not copied), `text` (the summary, in the index
language), `child_ids`, `text_unit_ids` (the transitive leaves, sorted,
cut at `max_lineage_text_units`, 10,000: `lineage_complete` false then, and
a query resolves the lineage through the children), `n_leaves`,
`n_tokens`, `partition`, `language`, `input_key`, and `attributes`
(`index`, and the uniform `filters` below). The index is
`indexing.opensearch.summary_nodes_index_prefix`
(`graphrag-summary-nodes`): a kNN field, a `text` field analyzed with the
index language's analyzer (`language_analyzers`: `nori` or `cjk` for
Korean, `cjk` for Japanese and Chinese), keyword arrays for the lineage
and the filters. Neptune and the SQLite graph get nothing, so graph-store
parity is free; OpenSearch and the embedded engine share the indexer code.
Stored text units hold no document id, so a node carries none:
`text_unit_ids`, which the expanded passages and the retrieval evaluator
resolve to files, is its provenance.
**Multilingual.** Leaves are the indexed text, so a translated corpus is
clustered and summarized in its target language and an untranslated one in
its source language: the summaries are written in the index language
(`language_name`, the language the query side extracts in), whatever the
mix of the corpus. Every budget counts with the token counter. Without
translation a mixed-language corpus may cluster by language before topic;
`partition_by` on a language filter key splits the trees.
**Incremental runs, resets and crash recovery.** The rule is rebuild,
never patch: clusters move when the corpus does, so patching lineage up a
corpus tree would not converge with a full build, while a rebuild is the
same function of the same stored leaves (a property test checks it).
Unchanged clusters cost no call (`input_key` reuse) and no embedding
(digest reuse).
1. *Removal, fail-closed.* `IndexingManager.remove_text_units` first
deletes every summary node citing a removed text unit and every node
above it (by `child_ids`, so a node whose lineage was cut is found
too): a summary paraphrases its children, so it goes even where other
children remain. A full build (reset, or a run without the registry)
deletes the nodes citing a text unit it did not write. Either deletion
failing fails the indexing stage like any failed write.
2. *Rebuild.* The `summary_tree` stage then builds the new tree from the
leaves and swaps it in.
3. *Crash between the two.* Queries meet the old tree without the deleted
nodes; new documents are reachable as leaves. The record still holds
the old digest, so the next run rebuilds; an index a killed build left
unswapped is deleted by the next build's reaper.
`refresh: on_reset` keeps step 1 on delta runs and rebuilds only on
`indexing.reset` (or a missing tree), reporting the leaves no level-1 node
covers (`tree_uncovered_leaves`). `scope: document` rebuilds the trees of
every document too, but reuse makes an unchanged document's tree free.
**Filters, scopes and tenants.** One tree per index suffix, never across
suffixes. A node stores a document filter key only when every one of its
leaves has the same value for it (uniform-only, fail-closed); the
retriever's existing rule, "an item without the attribute is excluded",
does the rest: under `attr_k: x` a node passes only if all its documents
have `k = x`. This differs on purpose from reports and entities, which
pass when one document does: a summary sentence can restate an excluded
document directly in the answer context. `partition_by` keeps nodes pure
on its keys. The summary index declares `id`, `text`, `level`,
`text_unit_ids`, `child_ids`, `n_leaves`, `n_tokens`, `lineage_complete`,
`language`, `partition` and the document filters;
`filtered_index_prefixes` of `tree` names the text-units and summary
indexes, so a key neither declares is refused. A key only the text units
declare (`community_ids`, `source_kinds`) would be dropped on the summary
index by the retriever and the summaries returned unfiltered, so `tree`
reads no summary under it (both summary buckets are skipped,
`metadata.summaries_skipped_for_filters` names the keys) and answers from
the text units. Expanded passages are fetched under the caller's filters
too, and the graph bucket's text units are the best-weighted that pass them
(`_passing_ranked_units`), not the best-weighted cut before the check.
**Retrieval: `tree`** (`adapters/search_strategies/tree_search.py`,
`@register_strategy(SearchStrategy.TREE, required_roles=(RetrieverRole.DOCUMENT,))`,
no query-side model call). On a namespace whose build record does not
name the summary tree it is refused before any model call
(`MethodologyNotIndexedError`, §4.6). Its fusion buckets, run concurrently
under the request's `search_type` (BM25 + kNN by default):
| Bucket | What |
|---|---|
| `tree_text_units` | the hybrid text-unit search (`naive`'s) |
| `tree_summaries` | the hybrid search over every level of the summary index (the collapsed tree) |
| `tree_graph_summaries` | the summary nodes citing the text units of the top `graph_seed_entities` (8) entities a hybrid search of the entities index finds with the raw query (LightRAG's raw-query fallback, no model call), each node scored by the seed weights it covers over `sqrt(n_leaves)` so a root does not win by size |
| `tree_graph_text_units` | those entities' text units, ranked the same way |
The graph buckets run when the queried namespace holds the entities index
(`graph_signal: auto`, read from the held artifacts the chain passes in
`SearchQuery.metadata["held_artifacts"]`, the configured methodologies'
without them; `off` turns them off), so a `raptor`-only index answers from the
other two and reports `graph_signal: unavailable`. Text-unit ids are the
join key on purpose: they are never re-keyed by merges or renames, so the
tree needs no update when the graph changes. The graph walk of `local` is
not used: it would need the graph role on every query, which a
`raptor`-only deployment does not have.
Fusion is RRF over the buckets (`fusion_weights` apply), then the
cross-encoder rerank over leaves and summaries together (the one
comparator across the two types), MMR and the type quotas
(`text_top_k` 20, `summary_top_k` 10), then at most `max_nodes_per_level`
(3) summaries of any one level. Each kept summary is expanded to
`expand_children` (2) of its own passages: one search of the text units
within its lineage (`id` filter, cut to the live `max_terms_count`), under
the caller's filters, joined to the text with the summary's score just
below it (`expanded_from`). The context gets the new `summary` section
type, whose share is `search.token_manager.type_budgets.summary` (0.5); the
text units take the spare budget first (`spare_context_budget_first`), so
leaves always ground the answer. A summary reads `Summary (level 2, 14
passages): ...`; its source carries `level`, `child_ids`, `text_unit_ids`,
`n_leaves` and `lineage_complete`, so a user can expand it to the passages,
and the retrieval evaluator attributes it through its lineage (a `summary`
row in `by_source_type`), its files ranked after the passages' as every
lineage-attributed source's are (a summary at its own rank filled the top
`k` with its lineage). A summary is index content written by a model at
ingestion, as a community report is; it is not `synthesized` (query-time
model output) and is reported as a source. Summaries are not used by
`naive`, `global` or `drift` (deferred until measured: each would change a
measured default path).
**Failure modes.**
| Event | Outcome |
|---|---|
| A summary call fails every attempt | The node is dropped and its children promoted; the record is `partial`, so the next run retries (only the failed clusters cost a call) |
| The vector store cannot page text units or embed (a custom backend: the port defaults return `None`) | The stage skips with a warning; no tree |
| More than `max_leaves_in_memory` (200,000) leaves under `scope: corpus` without partitions | The stage fails, naming `partition_by` and `scope: document` |
| Writing the new index fails | The new index is dropped, the old tree (without its stale nodes) stays, the stage fails |
| Indexing failed in the same run | The stage is skipped |
| No summary tree at query time | `tree` is refused when the build record says so (`MethodologyNotIndexedError`); with a record from before methodology profiles it answers from the text units with `metadata["summary_tree"] = "missing"` and a WARNING. `run-ingestion --enabled-stages summary_tree` builds the tree from the stored text units |
**Cost.** Calls ≈ leaves / (children per node − 1), about 0.18 per leaf;
input about 1.1-1.9 times the corpus tokens. At the 2026-11 list prices
(Sonnet 5.5 $2 / $10, Haiku 5.5 $0.10 / $0.50 per million tokens): the
MuSiQue benchmark store (3,318 leaves, 0.37M tokens, about 580 nodes) about
$2 with Sonnet without reasoning tokens (about $8 at high effort; $0.11 to
$0.40 with `models.roles.summary_tree: fast`), against $371.64 for its graph
ingest; a 1M-token corpus of 1,000-token chunks about $3-5. A delta pays
for the clusters whose children changed. At query time `tree` costs a
`naive` query plus up to three more searches, a wider rerank and one
search per expanded summary.
**Fidelity against the original** (`tests/unit/test_raptor_fidelity.py`).
Upstream's outputs on synthetic, pre-reduced points were recorded once by a
script run in upstream's own environment (its code at commit
`7da1d48`, with its model clients stubbed, UMAP replaced by an identity
truncation since it is unseeded upstream, and tiktoken by a word count, so
both sides cluster the same points and count the same tokens). The points
are generated in plain Python from SHA-256 draws
(`tests/fixtures/raptor_fidelity.py`), so the committed fixture
(`tests/fixtures/raptor/upstream.json`) holds only the cases' parameters and
upstream's answers, and the tests run in CI without upstream installed:
- *Tree assembly, exact.* Upstream's cluster assignments are replayed into
our assembly (the partition function is a parameter of the pure
builder), with the deviations below set to upstream's behaviour (single
nodes summarized, no shrink stop): node count per layer, child sets, the
token-bound splits and the stopping layer must be equal. They are, on all
12 recorded trees (40-90 leaves of 150-700 words, 3,500-word bound).
- *Collapsed-tree selection, exact.* `collapsed_tree_reference` (a test
helper, not a product mode: cosine order, `top_k` 10, stop at the first
node over the 3,500-token budget) must select upstream's node ids in
upstream's order (it does on all 40 cases); the shipped `tree` strategy
on the embedded store, vector search without rerank, graph buckets or
expansion, must hold the reference's top 5 in its top 10.
- *Clustering, by agreement.* The BIC-chosen k equals upstream's
(sklearn's) on at least 90% of the cases and is within one on all; the
soft memberships at 0.1 reach an adjusted Rand index of at least 0.9; the
global/local two-step finds upstream's global count and partition, and
every local cluster of either side lies inside one global cluster. Exact
equality is not the bar: our numpy EM is not sklearn's initialization.
Two findings on the way: with full covariances, BIC is well posed only
with enough points a cluster (with about 10 points a blob in 10
dimensions upstream picked near-singleton clusters, 13 for 4 blobs of 57
points; the recorded cases use 50-90 points a blob in 5 dimensions, where
both sides pick the true count on all 40), and upstream's local step
splits a single Gaussian blob of 100-130 points into 6-25 clusters, ours
into 1-16: a BIC overfit no two EM implementations reproduce, so the
local counts are not compared. RAPTOR's small clusters (6.7 children a
parent in the paper) may owe as much to that overfit as to topics.
Deviations from upstream, each deliberate:
| # | Upstream | Here | Why |
|---|---|---|---|
| 1 | UMAP (unseeded) | PCA by numpy SVD, sign-fixed, rounded | determinism, no numba/llvmlite, scale (Zhou et al.: GMM with UMAP did not finish in two days on the largest sets) |
| 2 | GMM, BIC over 1..49 | `kmeans` by default; `gmm` with a bounded grid, `upstream` grid optional | scale, stability on deltas |
| 3 | a one-node cluster is summarized | promoted without a call | no summary that only paraphrases one child |
| 4 | the recursion resets the reduction, threshold and tokenizer to defaults | the recursion keeps the configured ones | an upstream quirk |
| 5 | 3,500 cl100k tokens per cluster | 8,000 by the run's token counter | Claude's window; CJK-correct counts |
| 6 | 100-token chunks | the repository's chunker | one leaf set for every methodology |
| 7 | a generic English prompt | faithfulness rules, conflicts reported, the index language, a length hint | hallucination risk; multilingual requirement |
| 8 | one tree per input text | `corpus` scope by default, `document` available | multi-document corpora |
| 9 | stop at ≤ 11 nodes or 5 layers | the same, plus a stop when a level shrinks less than 20% | no near-identical levels |
| 10 | a node with no posterior above the threshold joins no cluster (it leaves the tree) | it joins its most likely cluster | every leaf stays reachable |
| 10a | GMM and k-means start from scikit-learn's k-means++ | farthest-first from a hash-ranked sample of the node ids | a delta keeps most clusters, so their summaries are reused |
| 11 | collapsed tree: cosine, `top_k` 10, 3,500 tokens | BM25 + kNN + graph signal, RRF, rerank, level quotas, passage expansion | hybrid-retrieval requirement; provenance |
| 12 | an in-memory pickle, no updates | a stored index, rebuilt with reuse, fail-closed removal, filters, tenancy | production indexing |
Against the approved design proposal, three simplifications: the leaf-set
digest is read from the stored text units rather than the registry (it
works without the registry, and the tree is a function of the store
anyway); a summary is reused from the live tree's nodes (`input_key`)
rather than from extraction-store records, so the full build's record
retention needs no change and a summary is reusable while its cluster is in
the last tree; and stale nodes are found by text-unit lineage and their
ancestors rather than by document ids, which stored text units do not
hold.
**Experiment (needs budget).** Stage 0 is free (scripted models, embedded
stores): determinism, permutation invariance, incremental-against-full
convergence, summary reuse after 1% and 10% deltas for `kmeans` and
`gmm` (which decides `refresh` and the default algorithm), filters,
mixed-language and CJK budgets, `tree` latency. Stage 1, a MuSiQue screen
on the repaired benchmark store (no re-ingest; the tree costs about $2-8
with Sonnet): 200 questions, 2 repeats, arms `naive`, `tree`, `mix` and
`tree` with `graph_signal: off`; `answer_match` EM/F1, `retrieval`
hit@k/MRR with `by_source_type`, the cost block; about $15-25. MuSiQue's
unrelated paragraphs test specific QA, not sensemaking: a coherent corpus
(stage 2, about $350-750, data licenses to confirm) is the real test, and
an end-to-end comparison with upstream on 10 QuALITY articles (about
$20-25) the fidelity check with models. Proposed bar: ship `tree` as a
documented option if it beats `naive` on assertion coverage or
summarization questions without losing faithfulness, at no more than twice
`naive`'s query cost.
---
## 7. Hybrid Scoring and Token Management
- **HybridScorer** (`adapters/retrieval/hybrid_scorer.py`) fuses the
per-source results by RRF (`rrf_k`) or weights, reranks, applies MMR
diversity (`diversity_lambda`) and cuts to the per-type quota or `top_k`.
Reranking comes before diversity (as upstream LightRAG reranks every chunk
candidate before truncating), so a cut on fused scores cannot drop what the
reranker ranks first. Each query records its rerank outcome on its own
`RerankTrace`, reported as `SearchResult.metadata["rerank"]`; a rerank model
that cannot be built leaves the fused order (`unavailable`) or raises under
`search.reranking.required`.
- **TokenManager** (`adapters/retrieval/token_manager.py`) packs the context
into the answer model's budget. Each section type gets a hard-capped share
(`search.token_manager.type_budgets`); within it sections go in score order
and the first that overflows is truncated. Budget a type leaves unused goes
to the types that still have candidates (first to the strategy's
`spare_context_budget_first` types, as LightRAG gives chunks what its graph
sections leave), so shares, never cross-type score comparisons, decide the
mix. The shares are of `max_context_tokens`, not upstream's numbers: at
30000, LightRAG mix/hybrid get shares of text 18072, entities 7228 and
relationships 4698 tokens (upstream: about 16000 / 6000 / 8000); after the
512-token prompt buffer, the question and the section headers about 17700
/ 7050 / 4600 are packed. Synthesized global
sections are seated first. `RAGOutput.sources` is
built from this selection, so it lists only what the answer model saw,
truncated text included. Passages, claims and reports render with an
`[id]` prefix; entity and relationship lines without one, since nothing
reads those ids and a UUID cost about a quarter of their tokens. An entity
line shows the entity's type, from either store.
- **Token counting** (`adapters/aws/token_counter.py`,
`adapters/local/token_counter.py`). The Bedrock `count_tokens` API is used
for language models that accept it, with a script-aware estimate otherwise
(`adapters/llm/token_estimate.py`); CountTokens rejects the default Claude
5.x models and every embedding and rerank model, so with the defaults every
count is the estimate. A non-transient failure marks the model unsupported
for the process. Local models use the estimate or tiktoken.
---
## 8. AWS Service Integration
| Service | Module | Purpose |
|---|---|---|
| **Bedrock** | `adapters/aws/bedrock.py`, `bedrock_models.py` (capability catalog) | LLM, embedding and rerank; inference-profile resolution, provider-aware request shaping (adaptive thinking and `effort` on Claude 4.6+, `reasoning.effort` on OpenAI GPT), prompt caching where supported; any model id works through curated rows, provider-family defaults and `aws.bedrock.model_overrides` |
| **Neptune** | `adapters/aws/neptune.py` | Gremlin over `wss://` with SigV4 (`ws://` for a local Gremlin Server); batch upsert and delete, `indexing.neptune.index_concurrency` concurrent batches; every indexer request is retried over a new connection and safe to repeat (vertices and edges are created or matched by id); an edge whose endpoint vertex is missing fails and keeps its stored copy; communities are vertices only (no `MemberOf` edges: an entity names its communities in `community_ids`) |
| **OpenSearch** | `adapters/aws/opensearch.py` | kNN (HNSW, `lucene` engine by default, which supports `cosinesimil` on OpenSearch 2.13) and BM25, sync and async SigV4 clients, alias management, bulk upsert and delete, per-language analyzers |
| **S3** | `adapters/aws/s3_cache.py` | Stage-cache sync (the bucket's default encryption unless `aws.s3.encryption` forces one) |
| **DynamoDB** | `adapters/doc_status/dynamodb.py` | The doc-status registry |
The AWS session is the bundle's (`Providers(boto_session=...)`, so a fake or
moto session can be injected), created from `aws.profile_name` when an AWS
client first asks for it.
### 8.1 Retrieval Error Visibility
Authentication, configuration and store failures are never disguised as "no
results". One policy, `is_fatal_retrieval_error()` (`adapters/store_errors.py`),
decides **by exception type alone**: fatal errors fail the query, the rest
degrade only the section that raised them. Every sub-retrieval goes through
`BaseSearchStrategy._safe_aretrieve`, so a section handler cannot swallow a
fatal error.
| Failure | Raised as | Outcome |
|---|---|---|
| Store unreachable, connection closed, or 429/502/503/504 after the client's retries | `StoreConnectionError` | fatal |
| No answer within `search.store_timeout_seconds` | `StoreTimeoutError` | fatal |
| HTTP 401/403, Neptune `AccessDeniedException` | `StoreAccessDeniedError` | fatal |
| HTTP 400 (a filter value of another type, a vector of another dimension) | `StoreRequestRejectedError` with the server's reason | fatal |
| Bedrock error that is not transient (bad model id, no access, no credentials) | botocore error | fatal |
| Query embedding failed, whatever the cause | `EmbeddingModelError` | fatal (every vector section needs it) |
| Local model server 4xx other than 408/429, or unreachable after retries | `openai` / `httpx` error | fatal |
| A prompt longer than the model's context | `ContextOverflowError` (or a plain 400 from vLLM / llama.cpp) | not fatal: that global-search map batch is left unrated, unless every batch fails |
| One failed request (an HTTP 500 for one search) | any other exception | degrades that section |
| The whole query past `search.query_timeout_seconds`, or a stream's first chunk past `search.model_timeout_seconds` | `QueryTimeoutError`, raised by `GraphRAGChain` once its tasks (and, through `gather_or_cancel`, every section's) are cancelled | fatal (the error answer under `processing.ignore_errors`) |
Neptune specifics: a lost connection is dropped so the next query or retry
opens a new one; a query traversal runs on retriever-owned threads, bounded by
twice `search.store_timeout_seconds`; closing the client fails requests still
waiting for a pooled connection or a late read; the indexer's read timeout is
a fixed 180 s, above Neptune's own 120 s query timeout.
### 8.2 Client Lifecycle / Resource Release
Retrievers open a Neptune WebSocket and thread pool and OpenSearch HTTP
pools, which leak until garbage collection unless closed, so every layer
exposes a best-effort `close()` / `aclose()` that never raises:
- `OpenSearchClient` and `NeptuneClient` close their pools (OpenSearch also
when the event loop changes, awaiting the old client on its own loop).
A closed Neptune retriever opens no connection or worker pool again: a
traversal still running at the close, or started after it, fails with
`StoreConnectionError`.
- `GraphRAGChain.close()` / `aclose()` close what the chain built, then
stop the chain's loop (cancelling the sync calls still in flight), even
when a retriever close or the wait for one fails.
`IndexingManager.close()` closes both its indexers, injected ones too.
Indexers passed to `DataIngestionPipeline(vector_indexer=...,
graph_indexer=...)` are borrowed: the pipeline never closes them, their
owner does. A `Providers` indexer factory returns a new instance per call,
which its caller closes. Whoever builds a chain must close it; a finalizer
is only a safety net. The CLIs close in `finally`.
- `GraphRAGChain` caches retrievers and strategies per event loop; its sync
entry points run on one chain-owned loop thread, so repeated sync calls reuse
one set of clients. Blocking calls run in `asyncio.to_thread`, on a default
executor sized to `processing.io_workers` (which bounds concurrent Bedrock
calls; `configure_event_loop` for async hosts).
- Concurrent queries in one process share one interpreter lock: their
Python work (fusion, MMR, the embedded engine's scoring) runs one at a
time, so a query's latency under N concurrent ones grows with N times its
CPU. The hot paths stay vectorized or compiled (the dense-script test is
one regex; the embedded engine lists only what an id lookup or an
exclusion names), and the query CLIs start with one BLAS thread
(`launch.one_blas_thread`, `OPENBLAS_NUM_THREADS=1` unless set): with a
thread per core per kNN product, 40 concurrent local queries spent about
8 times the CPU for the same scores.
### 8.3 Multilingual Processing
- **Analyzers.** `indexing.opensearch.language_analyzers` maps a language to an
OpenSearch analyzer. Each supported language has a default: the built-in
language analyzers (`german`, `english`, `spanish`, `french`, `italian`,
`portuguese`, `russian`), `cjk` (character bigrams) for `ja` and `zh`, and
`nori` for `ko`. An Amazon OpenSearch
Service domain has `nori` only once its Nori package is associated, so the
CDK deployment maps `ko` to the built-in `cjk` unless
`opensearch_nori_package` associates the package (a custom resource) and
maps `ko` to `nori`; an analyzer the cluster lacks fails index creation
with an error naming it.
- **Identity keys** keep the letters of all scripts (§3).
- **Translation** is skipped when the source and target languages match and
no other target is set (`TranslationConfig.is_noop`). A query is
translated only when ingestion translated: not when the translation stage
is disabled or a no-op, unless the caller asks for a language
(`RAGInput.target_language`). Its entities and keywords are extracted in
the language the index was built in: `source_language` when the stage is
disabled, `target_language` otherwise.
- **One index language.** `cache_keys.index_language(config)` is the one
rule: `target_language` when translation runs, else `source_language`.
Extraction, gleaning, description summaries, community reports (the
report prompt names it) and the summary tree write in it, the text units
are embedded in it, and the graph indexes' text fields are analyzed for it
(`OpenSearchIndexer.language`). The chain records the query's language in
`SearchQuery.metadata["query_language"]` (the requested one, else the
index language); a strategy's own prompts (DRIFT's HyDE, primer and
actions, global's map points, iterative's controller) read it through
`BaseSearchStrategy.query_language`, falling back to the index language.
The extraction-record and answer fingerprints key the index language
under their earlier `target_language` field, so a translated index keeps
its records. Claims carry no language slot: the claim prompt reads the
unit's text in the index language and states facts as written.
- **Language-aware parsing.** Query entities split at `,`, `,`, `、`,
`;`, `;` and line breaks (`domain/retrieval/query_entities.py`). Global
search's relevance rating skips a scale written in Korean, Japanese or
Chinese as in English; LightRAG's raw-query fallback gate counts a
dense-script character as `LATIN_CHARS_PER_TOKEN` characters (upstream
counts characters); claim dates read `2024年3月15日`, `2024년 3월` and
CJK quarters, and corpus-language placeholders (`不明`, `미상`) as unknown.
The follow-up rewrite's refusal check knows English, Korean, Japanese and
Chinese openings; a refusal in another language passes as a rewrite.
- **Encoding.** A text, CSV or JSON file is first read with the locale's
preferred encoding, as `open()` reads (UTF-8 in the container image; it
can be another on a host whose locale is not UTF-8). Only when that read
fails with a decode error is the file read again with the encoding
`charset-normalizer` detects (latin-1 when it detects none). A file that
decodes under the locale's encoding is kept as read, even when it was
written in another encoding.
---
## 9. Evaluation Framework
`EvaluationManager` (`evaluation/`) builds the evaluators through
`_resolve_evaluator_class` (one lazy-import branch per `EvaluatorType`) and
scores every result after all answers are generated. What each evaluator
measures is in [Evaluation: Evaluators](./guides/evaluate.md#evaluators) (how it
scores: [Evaluation output](./reference/evaluation-output.md#evaluator-details)); the design
choices:
- **LLM-free evaluators are first-class.** `answer_match`, `retrieval` and
`graph_aware` are deterministic and free, so they run by default next to the
RAGAS judge. The LangChain judge is opt-in: its correctness metrics repeat
RAGAS' `answer_correctness`.
- **`graph_aware` emits recall only.** Precision and F1 would need every
entity of a free-text answer enumerated, so they would only re-label recall.
Because answer-text coverage grows with length, it also reports the
coverage of the retrieved sources and the answer length.
- **`retrieval` counts files, not sources.** A community report is attributed
to every file of its community, so counting it as one source let random
reports score near-perfect hit@k.
- **Strategy comparison** (`evaluation/comparison.py`, `pairwise.py`). Several
strategies or `--repeats` run the same queries; each metric is averaged per
query over repeats and compared per query with a seeded paired bootstrap
interval (pooled means would mix query difficulty and noise into the
difference). The optional pairwise judge (comprehensiveness, diversity,
empowerment, directness, as in the GraphRAG and LightRAG papers) judges both
answer orders, keeps only order-stable verdicts and reports how often the
orders agreed (`position_consistency`), so a position-biased judge's ties
are not read as equal answers. A failed answer is never dropped (a strategy
failing on the queries it would lose would look as good as one answering
them): it scores 0 on every metric another run scored the query on and its
own strategy scores somewhere (a metric the strategy never produces is no
loss to invent), and a pair with one failed answer goes to the other side
unjudged.
- **Cost is measured, not estimated.** `shared/usage.py` keeps contextvar
scopes (one per ingestion stage, per query, per `run-eval` run and answer
attempt) that a
callback on every factory-built chat model fills from the tokens each
response reports, tracing on or off (one context-variable read outside a
scope); cache-served responses count as hits, not calls. The comparison's
`cost` block pairs each query's calls, tokens and latency like a metric,
and `--fail-if` gates on either. A failed answer keeps what its attempts
spent (its own scope and timer), so failing cannot make a strategy look
cheaper or faster by dropping out of the cost; an answer without a measure
fails a cost gate rather than leaving the delta to the others. Prices are user-supplied
(`evaluation.prices`): a shipped table would be stale. Input tokens
include the prompt-cache reads and writes, also counted apart
(`cache_read_input_tokens`, `cache_write_input_tokens`) and priced at
their own rates when a price gives them; the reranker's requests count
as `rerank_calls` and `rerank_search_units` (one per 100 documents) and
are priced per thousand units (`per_thousand_rerank_search_units`). A call counts once however many
requests botocore sent for it; those resends (`request_retries`, read from
`ResponseMetadata.RetryAttempts`) and calls that failed (`failed_calls`)
are counted apart, since Bedrock counts each accepted request as an
invocation.
- **The judge is not the answer model.** `models.roles.evaluation` defaults
to Opus 5.5 on Bedrock, a model other than the default answer model, since
a judge favours its own answers (§9.1); when the answer model is Opus 5.5
the unset judge falls back to the default tier's model, else Sonnet 5.5
(`Config.judge_model_choice`, logged at INFO); a re-judge
(`--from-results`, `--baseline-results`) also avoids the saved runs'
answer models. `judge_is_answer_model` in
the run manifest and a warning still flag a configured judge that is the
answer model.
- **A comparison is a pure function of saved files.** `paired_comparison`
reads only the runs' results and reports, so `run-eval --from-results`
(`evaluation/saved_results.py`) rebuilds it without a model call, an
interrupted comparison finishes by running only the missing strategies,
and runs scored with different `answer_scoring` are refused rather than
compared. Against `--baseline-results`, each strategy pairs with the
earlier run of the same strategy (`baseline_comparison`: one paired
comparison per pair, merged): pairing every strategy with one baseline
would compare strategies, not runs, and hide a regression of all but the
first.
- **EM/F1 read a span, not the whole answer.** The reading-comprehension
metrics were defined over a short predicted span; over a sentence plus its
support they reward brevity. `evaluation.answer_span` picks the scored
text: the first answer line (default, free), a fast-model extraction
(cached, falling back to the first line) or the whole answer. The choice is
recorded in the manifest and each report, and a comparison warns when its
runs differ.
- **Run manifest.** Ingestion records an `IndexBuildRecord` (pipeline id,
mode, build time, documents, extraction fingerprint and settings, package
version, community counters) in the vector index mappings' `_meta` (`VectorIndexer.record_build`),
read back per queried suffix. The config hash covers only the settings that
shape answers (§12), so it is equal across environments.
- **Benchmarks.** `prepare-benchmark` (`evaluation/benchmarks/musique.py`)
builds a MuSiQue corpus and dataset at run time;
`evaluation.answer_normalization: official_squad` with
`evaluation.answer_span: extracted` brings EM/F1 close to published
numbers (a RAG answer still is not a trained reader's span).
### 9.1 Measured defaults
The defaults set or kept from real-model measurements, with the method, the
numbers and dated prices, are in [Defaults and evidence](./concepts/defaults-and-evidence.md).
### 9.2 Unanswerable questions and abstention (opt-in)
Whether the chain declines a question its corpus cannot answer is measured
with a benchmark split built for it and an explicit decline marker. Both are
opt-in: with no new flag and `search.abstention.mode: off` the corpus, the
dataset, the answer prompts and the scores are those of earlier releases.
**Benchmark** (`evaluation/benchmarks/musique.py`, driven by
`prepare-benchmark musique --unanswerable N [--hops ...]`). MuSiQue-Full
pairs every answerable question with an unanswerable twin of the same id:
the same question with one or more supporting paragraphs replaced by
distractors. Two leaks make a naive pool answerable:
1. *The twin.* An unanswerable item pooled with its own answerable twin
gets the removed paragraph back from the twin's paragraphs. Any
unanswerable item whose twin is in the answerable sample is excluded.
2. *Other questions.* MuSiQue reuses Wikipedia paragraphs across questions:
in a sample of 200 unanswerable plus 200 non-twin answerable items,
28-42% of the unanswerable ones still find every removed paragraph in
the pool (measured offline on the dev split). Selection is greedy over a
seeded shuffle of the candidates: an item is kept only when no removed
paragraph's exact text (whitespace-normalized) is in the pool so far, and
none of its own paragraphs is a removed paragraph of an item already kept;
a kept item's paragraphs join the pool. Either rule excludes it as
`contaminated`. The pool starts from the answerable sample.
The answerable sample is drawn from the answerable file (MuSiQue-Ans: the
default download, or `--data-path`) and the unanswerable items from the full
file (downloaded beside it, or `--unanswerable-data-path`), whose answerable
half supplies each item's twin (which paragraphs were removed). So the
answerable set is the one the same seed draws without `--unanswerable`. The
two files are not interchangeable: every item's paragraph set differs
between them, and a seed draws a different sample from the full file's
answerable half (17 of 200 shared on the dev split at seed 0), so drawing
both from the full file, as a full file given alone as `--data-path` still
does (labelled `musique-full-answerable`), changes the answerable scores.
Unanswerable rows carry `metadata.answerable: false`, no answer or
reference sources, `metadata.benchmark: musique-full-unanswerable` and the
twin's answer (`metadata.twin_answer`, for auditing an answer that names it).
The counts (`unanswerable_filter`: candidates, kept, excluded twins,
contaminated, no twin) are printed and in the JSON output. `--hops` filters
both pools by hop count (`3,4`) or draws an exact answerable quota per hop
(`2=50,3=50,4=100`); sampling stays deterministic per seed. The exact-text
filter does not catch near-duplicate paragraphs or facts a model knows
without the corpus; `twin_answer` exposes the latter. Cost is ingestion's:
about 19 paragraphs a question.
**Decline marker** (`search.abstention`, applied by `GraphRAGChain`). The
chain already answered an empty context with a fixed reply
(`metadata.abstained`), but a model that declines in its own words was
invisible to evaluation. With `mode: mark` the answer step appends
`ABSTENTION_INSTRUCTIONS` (`domain/prompts/retrieval.py`) to the system
prompt of whichever answer prompt runs (concise, comprehensive or a
`custom_prompts` override): when the context does not support the answer,
start with the fixed token `[NO_ANSWER]`, then one sentence in the query's
language naming what is missing. Detection
(`domain/retrieval/abstention.py`) is a pure function over the first
characters after NFKC normalization, so full-width brackets and leading
Markdown emphasis are accepted and the answer's language does not matter
(the token stays ASCII). It sets `metadata.abstained` and
`metadata.abstention_reason` (`no_context`, `marker`, `insufficient_context`)
and strips the token unless `strip_marker: false`; a streamed answer holds
back at most the first 32 characters until the token is ruled in or out.
An abstained answer keeps its sources: the model saw them, and evaluators
audit them (only a guardrail block or an empty context leaves none). The
marker reaches the output guardrail stripped, like any answer.
**Insufficiency hook** (`search.abstention.on_insufficient`). A strategy
that judges its own context (an iterative or sufficiency-checking strategy)
sets `SearchResult.metadata["context_sufficient"] = False` (constant
`CONTEXT_SUFFICIENT_KEY`) and optionally `insufficiency_reason`. Only then
does the answer step apply the policy: `hedge` (default) appends
`HEDGE_INSTRUCTIONS` (answer what the context supports and say what it
does not), `abstain` appends `DECLINE_INSTRUCTIONS` (do not answer; give the
token and the missing part in the query's language) and marks the output
abstained with reason `insufficient_context` whatever the model writes.
Both are LLM-written so the wording follows the query language; neither
costs an extra call. Strategies that never set the flag are unaffected;
`iterative` (§6.3) is the shipped strategy that sets it.
**Scoring** (`EvaluationManager._calculate_abstention_statistics`). An
abstention on an answerable item is a wrong answer: `answer_match` scores it
0 on every metric (no span extraction call) and records `abstained` in the
report. Unanswerable items are scored only by abstention:
`abstention_statistics` adds `answerable` (same shape as `unanswerable`),
`precision` (abstentions that were on unanswerable items), `recall`
(unanswerable items abstained on) and `f1`, and per `answer_match` metric
`coverage` (answerable items answered), `accuracy` (over all answerable
items, abstentions as 0), `selective_accuracy` (over the answered ones) and,
with unanswerable items, `overall_accuracy` (a correct answer or a correct
decline, over both). Coverage and accuracy are reported together so a
strategy cannot look precise by declining more. A failed answer is no
decline: it counts against recall and scores 0 in accuracy (with a
reference answer), so a strategy cannot look better by failing either.
A paired comparison carries the declines as measures: `abstention/recall`
is a per-query 0/1 (declined) on the unanswerable queries, averaged over
repeats and paired like any metric (bootstrap interval, per category, per
query); `abstention/precision` is a ratio over the declines, so its delta is
the difference of the two precisions and its interval comes from
recomputing both on each resample of the queries (`pairs` = queries either
side declined; absent when one side declined none). `--fail-if` reads both.
Failure modes: a model that ignores the marker instruction looks like
`mode: off` (recall falls, nothing breaks); one that writes the token
before a real answer is scored as a decline (precision on answerable items
shows it). The marker instruction changes the answer prompt, so `mark`
runs are a separate arm, not comparable EM-for-EM with `off` runs; the
manifest's config hash differs. A measured run (100 unanswerable + 100
answerable MuSiQue questions, `local` and `mix`, `off` vs `mark`, and
`iterative` `hedge` vs `abstain`) kept `mark` opt-in and `hedge` the
default ([Defaults and evidence](concepts/defaults-and-evidence.md#declines-and-abstention)).
### 9.3 Length-controlled judging, assertions and the closed-book arm
The pairwise judge's evidence for the global strategies was weak in two ways:
an LLM judge prefers the longer answer (Zeng et al., arXiv 2506.06331,
measured LightRAG's win over naive RAG falling from 66.7% to 39.1% once the
length effect is removed), and the global question set came from our own
community reports, so it favours the method that wrote them. Four opt-in
additions, all in the `evaluation` layer and its prompts, measure around
both. None changes a default: a run without the new flags and evaluator
judges and scores exactly as before. Which of them becomes a default is for
a measured experiment to decide.
**Length buckets (always on with `--pairwise-judge`).** `PairwiseJudge`
records each judged pair's answer lengths in estimated tokens
(`adapters.llm.token_estimate.estimate_token_count`: about one token per
Hangul, kana or Han character, about four characters per token elsewhere,
so a Korean or Japanese answer is not measured in whitespace words) and
reports, under `length`, the candidate's win rate per bucket of the length
ratio (candidate over baseline: at most 0.67, 0.67-0.91, 0.91-1.1, 1.1-1.5,
at least 1.5; symmetric in the log ratio, so 1.5 and 1/1.5 sit in mirror
buckets) and, per criterion, the correlation between the relative length
difference and the pair's score. A win rate that rises with the ratio, with
few pairs in the 0.91-1.1 bucket, is a length effect, not a quality one. Free:
no model call.
**Differential mode (`evaluation.pairwise_mode: differential`, or
`run-eval --pairwise-mode differential`).** Per judged pair:
1. One `pairwise_overlap` call (fast tier, `PairwiseOverlapPrompt`) splits
the two answers into the statements both make and the statements only one
makes, copied from the answers in their own language (never translated).
2. The judge (`models.roles.evaluation`, `PairwiseDifferentialPrompt`) sees
the shared statements as common ground that earns neither answer credit,
and decides each criterion on the unique statements only: both orders,
ties, the same criteria (with `overall` under `evaluation.pairwise_overall`)
and the same order-agreement rule and `position_consistency` as the
standard mode.
3. When neither answer has a unique statement, every criterion is a tie
without a judge call (`identical_pairs`).
A failed or malformed overlap call fails the pair (`failed`, held to
`--max-failure-rate`), never a tie. Cost: one fast call plus the two judge
calls per pair, on shorter inputs than the standard mode's. The per-pair
shared and unique token counts go to `length.per_query`, so a reader sees
how much of each answer was common ground. The overlap model can miss a
paraphrase; the shared list is in the saved comparison for review.
**`assertion` evaluator.** A dataset item may carry
`assertions: [{text, kind: positive|negative, source?}]`: statements a good
answer supports (positive) or must not state (negative, for example a known
hallucination). Per answer and trial, one `assertion_judgement` call (the
evaluation role) labels every assertion of the item `supported`,
`contradicted` or `absent`, in a shuffled order seeded by the query id and
trial, so the assertion order is no position bias (the trials of a query
take distinct orders while any is left). An assertion takes the label more
than half of the query's successful trials gave it (a majority vote,
BenchmarkQED's pass rule). Per query: `assertion_recall` (positive
assertions labelled supported), `assertion_contradiction_rate` (positive
ones contradicted) and `negative_assertion_violation_rate` (negative ones
supported). A deterministic judge (temperature 0) labels one order the same
way every time, so an item with fewer orders than trials (one assertion, or
two with three trials) repeats a call that adds nothing; the report records
`distinct_orders`. The judge calls of all queries and trials share
`processing.max_concurrency`. A trial whose call fails, or whose reply misses an assertion or
uses another label, is a failed trial; a query with no successful trial
records its metrics under `failed_metrics`, never as 0. A query without
assertions, or without one of the two kinds, skips the metrics it cannot
score. The scores are ordinary report metrics, so the paired comparison,
`deltas_by_category` and `--fail-if assertion/assertion_recall:...` work
unchanged; the two rates are lower-is-better, so a failed answer is left
unpaired on them rather than filled with 0 (`comparison._UNFILLED`), which
would count a crash as a perfect score. Cost: `evaluation.assertion_trials`
(default 3) judge calls per answer. The labels are fixed English tokens in a
JSON reply, so parsing does not depend on the answer's language.
**Question and assertion generation (`prepare-benchmark assertions`).** A
native reimplementation of the approach of Microsoft's BenchmarkQED
(AutoQ question classes, AutoE-style assertions; code MIT, Copyright (c)
Microsoft Corporation; https://github.com/microsoft/benchmark-qed). Its
prompts are rewritten here and none of its datasets is used or shipped (they
are under the Microsoft Research License). `evaluation/benchmarks/autoq.py`:
1. Reads a corpus directory (`.txt`, `.md`), splits each file into text
units of about `--unit-tokens` estimated tokens (paragraphs packed; an
overlong paragraph is cut by the script-aware estimate, so CJK text
without spaces splits too) with ids `#` and character offsets,
samples at most `--max-units` of them with the seed and embeds them with
the configured embedding model.
2. Clusters the embeddings with seeded k-means (k-means++ start, numpy) into
`--clusters` groups. Each cluster's sample, largest cluster first, is
its member nearest the centroid and that unit's `--units-per-cluster - 1`
nearest untaken units by cosine distance (BenchmarkQED's centroid
representative sampling).
3. Generates questions in five classes, each with the evaluation role:
- `data_local`: from a sample of one cluster's units.
- `data_global`: from local questions of several clusters that share a
topic label, asking what the corpus as a whole says.
- `data_linked`: from two local questions of different clusters that
name a common entity, needing both.
- `activity_local` / `activity_global`: from personas and tasks generated
from a cross-cluster sample (one call), about one cluster's entities or
about the whole corpus.
4. For every data question, assertions by map-reduce over claims: the map
step extracts claims from its source units, each citing unit ids; the
reduce step merges them into at most `--max-assertions` assertions citing
the units of their claims; a validation pass scores each assertion's
grounding, relevance and verifiability (1-5) against the cited unit texts
and drops those below 3, and an assertion citing no known unit is dropped
before validation. Activity questions get no assertions (BenchmarkQED's
assertion scoring does not cover them); they are for the pairwise judge.
5. Writes `eval_data.json` (the `run-eval` format: `category` is the class,
`reference_sources` the cited files, `assertions` with
`source: [unit ids]`), `text_units.jsonl` (each unit's id, file, offsets
and text, so a reviewer can check every assertion against its sources)
and `generation_report.json` (seed, models, counts per class, dropped
items and why, usage per step).
Every prompt names the output language (`--language`, default
`processing.translation.source_language`). Sampling, clustering, the order of
the calls and the selection among the generated candidates depend only on
the seed and the corpus, so a run with the same models' replies writes the
same files (tested with scripted models); the model replies themselves are
not deterministic. A failed or malformed call drops the item it was for and
is counted in the report. Cost is printed from the usage records (with a USD
estimate under `evaluation.prices`); a typical run (20 clusters, 10 questions
per class, 3 to 4 calls per data question) is a few hundred thousand input
tokens.
**Parity with BenchmarkQED, and the deliberate deviations.**
`tests/unit/test_benchmarkqed_parity.py` (skipped unless `benchmark_qed` is
importable; it is not a dependency, install it in a scratch environment)
feeds the same synthetic sample and scripted replies to both
implementations. Identical decisions: claim parsing (non-empty statement,
integer score above 0, sources read as strings and kept only when given),
assertion parsing (score 1-10, 5 when missing; `claim_` ids mapped to the
claims' units; an assertion citing no known claim dropped), the assertion
ranking (weighted reciprocal rank fusion, importance 0.3 and source count
0.7, k 60, competition ranks), the validation rule (grounding, relevance and
verifiability clamped to 1-5, each at least 3), the per-question pass rate
over trials (an assertion passes above a trial mean of 0.5) and the
clustered sampling on fixed clusters; on well-separated groups our k-means
and scikit-learn's find the same partition. Deviations, each on purpose:
- One judge call labels all of an answer's assertions per trial (theirs:
one call per assertion and trial), with three labels (`supported`,
`contradicted`, `absent`; theirs: a 0/1 score) and a shuffled order per
trial: an answer of 10 assertions over 3 trials costs 3 calls, not 30.
A negative assertion is written as the false claim itself and is violated
when supported (theirs: "The answer does not mention X", passed when
absent).
- Validation scores all of a question's assertions in one call (theirs: one
per assertion), and an assertion the reply does not score, or scores with
a value that is not a number, fails; BenchmarkQED defaults such a score to
3, a pass.
- A claim citing only ids the prompt never gave is dropped; BenchmarkQED's
extractor keeps it with its raw sources (its source mapping is skipped
and the later filter only checks that the list is non-empty).
- Booleans are no integer score, and blank statements are dropped after
stripping (Python's `True` is an `int`; BenchmarkQED accepts it).
- Data-local questions come from one call per cluster (theirs: a generate
then expand pair), the kept questions are taken round-robin over the
clusters (theirs: a k-means over question embeddings ranked by similarity
ratio, coverage and assertion count), and the claims of a global or linked
question are its source questions' claims (theirs: claims re-extracted for
the global question). Fewer calls; the selection is less tuned.
- Text units are packed paragraphs measured by the script-aware estimate
(theirs: 600-token tiktoken windows with 100-token overlap), so Korean,
Japanese and Chinese corpora split without a BPE tokenizer.
- Prompts: written here, each naming `{language}`; ours ask for JSON with
fixed keys and cite short numeric ids that the code maps back to unit ids.
BenchmarkQED's claim prompt scores importance 0-100, ours 1-10 (only the
sign matters to the parse).
An end-to-end comparison with the same model on the same small corpus
(BenchmarkQED's AutoQ and AutoE against these) needs a model budget and is
listed as a measured experiment, not run here.
**Closed-book arm (`run-eval --closed-book`).** One more arm, labelled
`closed_book`, answers every question with the answer model alone
(`ClosedBookAnswerPrompt`: no context, no retrieval, no store call) through
an `EvaluationManager` whose chain is `evaluation/closed_book.py`'s runnable.
It runs after the strategies with the same repeats and evaluators and is
compared with the first strategy like any other arm, so its scores bound what
the answer model knows without the corpus: a strategy that does not beat it
on a question set measures prior knowledge, not retrieval. Its results carry
`metadata.retrieval: none` and no sources; the comparison lists it under
`closed_book_arms` and the Markdown report marks it "(no retrieval)";
retrieval and context metrics of it score an empty source list (read only
the answer metrics); `--fail-if` gates skip it. Cost: one answer call per
question and repeat.
**Layer placement.** Everything above lives in `evaluation/` (judges,
evaluator, generator, closed-book runnable), `domain/prompts/evaluation.py`
and `domain/prompts/benchmark.py` (prompts, overridable through
`custom_prompts`), the domain models (`EvaluationAssertion`, the evaluator
and metric enum members, the `pairwise_overlap` model role) and the two CLIs'
flags. The `assertion` evaluator is one branch in
`EvaluationManager._resolve_evaluator_class`. No store, retriever or
ingestion code changes.
---
## 10. Visualization & Analytics
Ingestion renders nothing: the community-detection stage writes the graph
and its community hierarchy as JSON (`graph.visualization.export_data`,
`domain/ingestion/visualization_data.py`). `run-visualization` renders that
file without ingestion through the renderers of `adapters/renderers/`
(`BaseRenderer`, `@register_renderer`, `RenderContext`, `resolve_renderers`,
`render_resolved`):
`InteractiveRenderer` (pyvis network and community hierarchy) and
`StaticRenderer` (degree, centrality and community-size charts as HTML with
inline SVG, written without a plotting package and with every name
HTML-escaped), computing a seeded spring layout and the configured
centrality first. The interactive renderer is the `viz` extra: the adapters
import the renderers on use, so the registry, `BaseRenderer` and `static`
work on a core install, and a missing extra fails the run
(`BaseRenderer.check_dependencies`) before anything renders.
Using it: [Visualization](./guides/visualize.md).
---
## 11. Prompts and Prompt Tuning
- **Prompts** (`domain/prompts/`) are `BasePrompt` classes whose templates and
variables are class attributes, version-controlled as code. All but five
can be overridden from config (`custom_prompts`); the
[Domain adaptation guide](./guides/domain-adaptation.md#prompts-without-an-override)
lists the five.
- **Prompt tuning** (`application/prompts/tuner.py`, after MS GraphRAG
`prompt_tune`) profiles a corpus sample (domain, language, persona, entity
types) and writes a domain-adapted `custom_prompts` fragment to review and
apply; nothing is applied at run time. A tuned prompt replaces only the
persona preamble and keeps the built-in output rules (the XML schema, the
verbatim `source_text` grounding), so the parser and grounding guard keep
working. The rules carry the `{entity_types}` and `{relationship_types}`
variables, rendered from config at call time
(`GraphExtractionPrompt.config_variables`). A variable that stands for
rule text the template would otherwise spell out declares it
(`inlined_slots`): the extraction-record fingerprint hashes the template
with it filled in, so moving rule text into a variable re-keys no record.
- **Schema drafts** (`run-prompt-tuning --schema-out`, `SchemaDraft` in
`application/prompts/tuner.py`): the profile's entity types (the
configured specs when the config declares them), the configured
relationship types, then those the grounded examples state most often,
counted by `schema_key` (at most `MAX_DRAFT_RELATIONSHIP_TYPES` more).
Configured patterns are kept, and a type that has them gets no other;
any other type gets patterns only when every (source type, target type)
the examples give it between two draft entity types appears
`MIN_PATTERN_SUPPORT` times, so `align` never counts the draft's own
examples off pattern. A draft over the loaded schema therefore drops
nothing it declares; the CLI still refuses to write over that file or,
without `--force`, any existing one. The file is a
`schema_source: {kind: file}` document validated as a schema before it is
written, and names `align`, never `strict`: a draft from a few chunks
would drop what they did not show. A pure function of the profile and the
records, so a fixed model gives the same file.
---
## 12. Configuration System
The configuration is one nested Pydantic tree (`domain/models/config.py`,
root `Config`) loaded by `application/config.py` (`get_config`).
[docs/reference/configuration.md](./reference/configuration.md) is generated from it by
`scripts/gen_config_reference.py`, and `tests/unit/test_config_reference.py`
fails when it is stale; `config-template.yaml` is a short starting file with
the keys most deployments set (`tests/unit/test_config_template_defaults.py`
checks that its values are the defaults and each key is in the reference).
[docs/reference/cli.md](./reference/cli.md) is generated the same way from the CLI parsers
(`scripts/gen_cli_reference.py`, `tests/unit/test_cli_reference.py`).
- **Loading** (`Config.as_input`). The config is validated once from its
layers: defaults < `backend` preset < file < environment. `UKR____`
sets any key, resolved against the model tree (no variable table to keep in
step); `AWS_PROFILE` / `AWS_REGION` fill only keys the file leaves unset. Unknown keys fail the
load unless `allow_unknown_keys`, a moved key (`MOVED_KEYS`) fails
naming its replacement, and a removed key (`REMOVED_KEYS`) logs a warning
and is ignored. `Config` keeps the input it was validated from, so
`with_provider_overrides` (the CLI provider flags) rebuilds from it plus
every later change, and switching `--backend` drops only what the old preset
filled in.
- **Model selection** (`ModelsConfig`). One `models` section names every
model: the two tiers, per-role overrides (`ModelRole`), the embedding model
with its dimension and the rerank model. Each family's provider stays
`providers..kind`, and `Config._check_models` validates the ids
against it. Values stay as written and resolve on read (`Config.model_id`,
`embedding_model_id`, `rerank_model_id`); a setting of a provider the config
does not select is reported at load.
- **Stage cache keys** (`domain/ingestion/cache_keys.py`). `CONFIG_KEYING`
classifies every field: keyed from the first stage whose output it shapes,
or unkeyed with the reason. A stage's key hashes the keyed fields in effect
for it and every upstream stage (a disabled block contributes its switch
alone), the package version and the corpus manifest. An unclassified field
keys every stage (a miss, never a stale hit), and
`tests/unit/test_cache_key_coverage.py` fails until it is classified. The
same table decides what the `run-eval` manifest's `retrieval_config_sha256`
hashes (`answer_settings`): every field except the unkeyed ones marked
`shapes_answers=False` (call routing, throughput, telemetry, provisioning,
cache storage) and a few location fields (corpus and registry paths, the
table name, the guardrail id).
- **Extraction settings.** The keyed fields marked `per_document` form a
document's extraction settings: their fingerprint is recorded on each
committed document and the settings once per registry scope, compared field
by field on the next run (a setting a release adds is no change while it
holds its default). A field added to a keyed section whose default keeps
the earlier behaviour is listed in `_KEYED_ONCE_SET`: it counts for no key
until set otherwise, so an upgrade re-keys no stage cache or extraction
record. The description summary settings key graph resolution and are
no extraction setting (`per_document=False`: they summarize merged
descriptions, in no unit's record, and a summary records them in
`summarized_with`). A unit record's key holds them as the default
configuration has them (`_summary_key`, the same whatever the run's
`models` tiers), and a unit without a record under it is looked up under
the keys earlier `main` builds stored it under too (no release stored
the extraction store; `legacy_unit_record_fingerprints`: them at their
defaults resolved through the run's tiers, and at the run's values, the
key of a record stored while they keyed graph extraction):
`ExtractionStore.load` stores a record found there under the current
key, the one its unit names. `changed_settings` ignores them in a
scope's earlier record. So stored records are found, documents read as
unchanged, and a summary change asks no unit again. The lookup is
kept until the first release after 0.2.3 and removed in it.
The schema keys count as extraction sees them
(`cache_keys._as_extracted`): a type by its label and description (a
string item as written, a mapping as the string that reads back the
same), a pattern by its relation and ends, `schema_mode` only where
`SchemaConformance.applies` would differ, never an IRI. What only
indexing reads of the schema (the IRIs, `off` against `align` with entity
types alone) is the annotation digest (`SchemaAnnotator.digest`), keyed
into the indexing stage. The registry records it once per index
namespace (`DocStatusPort.get/put_annotation_digest`), not per scope:
a pass rewrites every scope's items, so a per-scope record misses a
schema reverted on one scope after another scope's pass. Before its
first store write, an incremental run whose digest differs from the
record sets it to `REANNOTATING_DIGEST` (`incremental.annotations_due`),
rewrites the namespace's annotations without extracting
(`reannotate_on_schema_change` → `IndexingManager.reannotate`) and
records its digest only once the pass completed, so a pass that stopped
is made again by the next run under any schema. A namespace without a
record is unknown (one pass), unless the registry held none of its
documents before the run and neither store holds an item of the suffix
(`holds_any_item`, one bounded read each; recorded without reading): a
lost or replaced registry is no proof the stores are empty. A reset marks its
namespaces before the rebuild and records its digest when every item
was written (`record_rebuilt_annotations`). Adding a section: §15.
- **Extraction schema** (`domain/models/schema.py`). The entity and
relationship types and the allowed patterns live inline in
`processing.graph_extraction` (`entity_types`, `relationship_types`,
`relationship_patterns`, `schema_mode`), read as one frozen
`ExtractionSchema` (types with an optional ontology `iri`, patterns, mode,
a content `digest`). Labels compare by `schema_key` (NFKC, each run of
whitespace and `_` one `_`, case-folded and normalized again, so a key is
its own key) and nothing looser: no synonyms, plurals or edit distance. `schema_source` names where the schema comes
from: `inline`, or a registered source (`file`, `owl`) that
`application/bootstrap.resolve_extraction_schema` loads once at start-up
(`DataIngestionPipeline`, `EvaluationManager`, `PromptTuner`) into the
inline keys of a config copy, whose source is then `inline`.
`GraphExtractionConfig.extraction_schema()` raises `ConfigurationError`
on a config whose source is not loaded yet, so a component built straight
from one (an `IndexingManager`, an extractor) fails instead of reading
the default schema. The extractor, the prompts
and the cache keys read only the inline keys, so the loaded content is
keyed and its location (`schema_source.path`) is not: a moved file
re-extracts nothing, an edited one re-extracts. The extractor and the
gleaner apply it through `domain/ingestion/schema_conformance.py`
(`SchemaConformance`, built by `BaseProcessor`): it applies in `strict`,
and in `align` once relationship types are declared (entity types alone
only shape the prompt, so configs from before the schema keys keep their
records). An in-schema label is written as declared when parsed, before
the ids are built; `conform` then checks each unit's statements after
grounding and the confidence threshold, where `strict` drops off-schema
entities (rejection `off_schema`, lifted by an in-schema statement of the
same entity) with their relationships, off-schema relationship types, and
relationships a pattern of their type disallows between two endpoints the
unit types with declared types. The check is per unit and order-free, so
incremental runs converge with a full build; its drops are not failures.
Indexing then stores, from the same schema, whether each merged item's
type is declared (where the schema applies) and its type's IRI (whenever
one is declared and the mode is not `off`; §4.2, "Schema annotations"); the
filter schema (`adapters/storage/filter_schema.py`) declares `in_schema`
and `type_iri` on entities and Neptune entity vertices and `in_schema` and
`predicate_iri` on relationships, but no `type` on relationships, which
would empty that index under `mix` and `hybrid`. `GraphRAGChain` refuses a
filter on an annotation the configured schema writes on no item
(`SchemaAnnotator.fields`), which could only empty the streams reading it,
and the OpenSearch retriever probes the annotation keys as it probes
`attr_*` keys, so an index that does not map them matches no value of
them instead of failing the kNN filter.
---
## 13. Testing Strategy
Tests (`tests/{unit,integration,property,fixtures/fakes}/`) run **without AWS
by default**.
- **Port-based fakes** (`tests/fixtures/fakes/`) implement the stores and the
registry in memory; `moto` covers the DynamoDB and S3 adapters.
- **Property tests** (hypothesis) cover merge laws, incremental-versus-full
convergence (under a `strict` extraction schema too), interrupted runs,
fusion monotonicity, MinHash and token-estimate equivalence, and the
extraction schema (`strict` keeps a subset of `align`, the per-unit check
is idempotent and order-free, `schema_key` is its own key); `tests/integration/test_doc_status_conformance.py` checks the
`DocStatusPort.diff` partition against every registry.
- **Store conformance suites**
(`tests/integration/test_{graph_store,vector_store,retrieval}_conformance.py`)
write each store contract once and run it on every backend through the
`StoreHarness` (`tests/fixtures/store_backends.py`): the fakes, the embedded
SQLite stores, the `docker/compose.local.yaml` containers (`LOCAL_STORES=1`)
and Neptune and OpenSearch Service (`-m aws`); `test_store_parity.py`
checks that SQLite and an OpenSearch container rank a synthetic corpus alike
(same lexical and hybrid top 10, kNN recall@10 ≥ 0.98).
- **Markers**: `unit`, `integration`, `property`, `aws` (real AWS, excluded in
CI), `slow`. Run `uv run pytest -m "not aws" --cov=unified_kg_rag`.
---
## 14. CI/CD and Security
- **CI** (`.github/workflows/`): `quality` gates pull requests and pushes to
`main` (lint, types, tests with the coverage floor, the image build, the IaC
synth with cdk-nag), `licenses` checks dependency licenses, `security` runs
an ASH scan that fails on an unsuppressed finding at MEDIUM or above
(configuration and path-scoped suppressions in `.ash/.ash.yaml`); the local
checks are in [CONTRIBUTING.md](../CONTRIBUTING.md#checks-to-run-before-pushing).
- **Dependabot** updates the `uv` locks (root and `iac/`) and the SHA-pinned
Actions weekly, 7 days after a release at the earliest (security updates
at once); known-breaking bumps are held back with a recorded reason.
- **Hardening.** Content hashes use SHA-256 only; credentials come from the
environment, never from code.
- **Guardrail at the query's edges** (`ports/guardrail.py`,
`adapters/guardrail/bedrock.py`). `GraphRAGChain` screens the raw query
(source `INPUT`) before memory, routing and retrieval, and the final answer
(`OUTPUT`) after generation; a block short-circuits, a mask replaces the
text, and a guardrail error fails the query closed. The model calls in
between run unguarded: an attached guardrail judges the whole prompt, so on
Converse it treats the retrieved corpus and the instructions as user input
(false prompt-attack blocks, cost of context times calls), and on
InvokeModel its prompt-attack filter is inert without input tags. Ingestion
and evaluation models are never guarded. The opt-in contextual grounding
check rides on the answer's check: the chain hands the guard a
`GroundingContext` (the standalone question and the context string the
answer prompt carried, plus its sections by priority) and the adapter
sends it as `grounding_source` / `query` blocks, cut to Bedrock's limits
by rank, with the answer first so a masked reply's first output is the
answer.
---
## 15. Extension Guide
This section is the single reference for extending the framework. Extensions come in two kinds:
- **Plugins**, which live in your own package and import only from `unified_kg_rag` (§2.5): backends through the provider registry or constructor injection, and renderers, parsers, schema sources and seed sources through their registries. None needs a change to the framework's code.
- **In-tree changes**, made in a fork or contributed upstream: a search strategy and an evaluator. `register_strategy` is keyed by the closed `SearchStrategy` enum, which `RAGInput.search_strategy`, `--search-strategy` and `search.auto_routable_strategies` validate against, so a new strategy adds an enum member; an evaluator adds an `EvaluatorType` member and a branch in `EvaluationManager._resolve_evaluator_class`. The registry still keeps the chain's dispatch code unchanged.
- **New search strategy** (in-tree): Add a `SearchStrategy` enum member (`domain/models/retrieval.py`; the CLI choices follow it) + subclass `BaseSearchStrategy` + `@register_strategy(SearchStrategy.X, required_roles=(...), query_inputs=frozenset({QueryInput.ENTITIES}))` + export from `adapters/search_strategies/__init__.py` + an entry in `STRATEGY_ROUTING_GUIDE` (`domain/prompts/retrieval.py`; a unit test requires one for every strategy but `auto`). `query_inputs` declares the query-side LLM extractions it reads (`ENTITIES` for `entity_focus`, `DUAL_KEYWORDS` for `hl_keywords`/`ll_keywords`); the chain skips the rest. Set the class attribute `methodology` (a `Methodology`: the one whose index artifacts it reads, §4.6) so a query on a namespace that does not hold it is refused before any model call and `auto` routes to it only where it is held; a new methodology adds a `Methodology` member, its `METHODOLOGY_PROFILES` entry (required and optional `IndexArtifact`s) and, for an artifact no stage builds yet, the stage, its `ARTIFACT_STAGES`/`ARTIFACT_PRODUCERS` entries and its removal from `UNBUILT_ARTIFACTS` (`domain/ingestion/methodologies.py`; a corpus-wide artifact also joins `CORPUS_WIDE_ARTIFACTS`). `needs_community_reports=True` is kept for a strategy that declares no methodology but answers from reports. A strategy that runs another one's search (as `iterative` runs `local` or `mix`) passes `wraps=` a function of the config naming it: the chain then extracts what that one reads and packs its context as that one does (`wrapped_strategy`), the wrapper builds its base through `get_strategy_spec(...).strategy_class`, not by name, and it declares no `methodology` of its own: it reads the wrapped one's (`strategy_registry.strategy_methodology`), which it also sets on its instance. Fetch by id through `BaseSearchStrategy._fetch_by_ids` (batched by the live limits), and cut or batch any other list filter (`terms`) to the live `max_terms_count` from `aindex_limits` of the index it reads: a longer list is refused (`InvalidFilterError`). Override `filtered_index_prefixes` with the indexes it searches with the caller's filters, so a filter key none of them declares is refused instead of dropped (the default checks every index), and `filtered_retrievers` with the retrievers it applies them to (the default: every one it holds; `mix` and `hybrid` leave out the graph without graph expansion); build each sub-search's filters with `_scoped_filters`, which keeps the caller's and intersects a key both set (`id`/`source_id`/`target_id`: the scope's values the caller allows; the chain refuses a range on them); `reserved_filter_keys` names the keys it scopes by with values a caller value cannot be intersected with (`global`: its cut ranges or browse from the roots, and the multi-valued `community_ids`, where a text unit in a caller's community and a scope's one passes both filters but the two lists may not intersect), which the chain refuses. No edit to `rag_chain`; add it to `search.auto_routable_strategies` if `auto` may pick it. Regenerate the references (the new member is a `--search-strategy` choice and a config value): `uv run python scripts/gen_cli_reference.py` and `uv run python scripts/gen_config_reference.py`. A minimal strategy, a document search fused and reranked like `simple` (`adapters/search_strategies/simple_search.py` is the full version, with the index gates):
```python
# unified_kg_rag/adapters/search_strategies/keyword_search.py
# (after adding SearchStrategy.KEYWORD = "keyword" to domain/models/retrieval.py)
import time
from langchain_core.runnables import RunnableConfig
from unified_kg_rag.adapters.retrieval.base import BaseSearchStrategy
from unified_kg_rag.adapters.retrieval.strategy_registry import register_strategy
from unified_kg_rag.domain.models import (
Methodology,
RetrieverRole,
SearchQuery,
SearchResult,
SearchStrategy,
)
@register_strategy(SearchStrategy.KEYWORD, required_roles=(RetrieverRole.DOCUMENT,))
class KeywordSearchStrategy(BaseSearchStrategy):
methodology = Methodology.LIGHTRAG # reads text units only
async def asearch(
self, query: SearchQuery, config: RunnableConfig | None = None
) -> SearchResult:
start, rerank = time.time(), self._new_rerank_trace()
hits = []
if self.document_retriever: # bound through required_roles
hits = await self._safe_aretrieve(
self.document_retriever, query, "Document retrieval"
)
results = await self._fuse_and_rerank(
{"documents": hits},
rerank_trace=rerank,
top_k=query.top_k,
retrieval_multiplier=query.retrieval_multiplier,
query=query.query,
)
return SearchResult(
query=query,
results=results,
total_results=len(results),
search_strategy="keyword_search",
processing_time=time.time() - start,
metadata={"rerank": rerank.summary()},
)
```
- **New storage/LLM backend**: Implement the relevant port, then either register a builder for a new `providers..kind` with `@register_provider(ProviderRole.X, "kind")` (`adapters/provider_registry.py`) and select it in config, or pass the port to the constructor that uses it (see "Custom backends" below). Do not hardcode it into a manager's `__init__`.
- **New evaluator** (in-tree): Subclass `BaseGraphRAGEvaluator` (`from unified_kg_rag.adapters.evaluators.base import BaseGraphRAGEvaluator`) + add a branch in `EvaluationManager._resolve_evaluator_class` + an `EvaluatorType` enum. Regenerate the configuration reference, whose `evaluation.enabled_evaluators` lists the types: `uv run python scripts/gen_config_reference.py`.
- **New renderer**: Subclass `BaseRenderer` + `@register_renderer("name")`. Registration happens on import: `run-visualization` sees those that `adapters/renderers/__init__.py` imports. Override `check_dependencies` to fail fast when an optional package the renderer imports is missing, and set `needs_centrality = True` when `render` reads `RenderContext.centrality`: `run-visualization` computes the centrality metrics (betweenness alone is O(V·E)) only when a selected renderer sets it. Regenerate the CLI reference, where the name is a `--renderers` choice: `uv run python scripts/gen_cli_reference.py` (and `uv run python scripts/gen_config_reference.py` when it adds a `graph.visualization` settings section).
- **New parser / file format**: `ParserFactory.register_loader(".ext", MyLangChainLoader, loader_kwargs=..., file_type_name=...)` — any `langchain_core` `BaseLoader` subclass (the built-in loaders in `adapters/ingestion/file_loaders.py` are examples); no edit to the factory, and the extension is then auto-discovered + parseable. Override a built-in by registering its extension.
- **New schema source** (an ontology format or service): register a loader
for a new `schema_source.kind` with `@register_schema_source("kind")`
(exported from `unified_kg_rag`; the built-in `file` and `owl` loaders
live in `adapters/schema_sources/`, imported by the package `__init__`,
and read an `s3://` path through `adapters/source_files/locations.py`:
HeadObject size check, paged prefix listing, S3 errors as
`SchemaSourceError`, or the error type a `SourceTerms` names; what the
built-in sources read: [Extraction schema](./guides/extraction-schema.md),
[Ontology mapping](./reference/ontology-mapping.md)).
A loader that imports an optional package declares it:
`@register_schema_source("kind", requires=("module", "how to install
it"))`, and the CLIs check that the module is importable before any model
call, exiting 2 with the install hint (`owl` declares `("rdflib", "the
ontology extra: uv sync --extra ontology ...")`). Import the package
inside the loader, not at module level, so registering the kind needs
nothing, and raise `SchemaSourceError` naming the extra when the import
fails; re-registering a kind replaces its loader and its requirement. It
receives a `SchemaSourceContext` and returns a `SchemaDocument`: the
`entity_types` (or `None` to keep the config's), `relationship_types` and
`relationship_patterns` it supplies, each a string or a spec. The context
holds:
- `source`: the `schema_source` settings: `kind`, `path` (left as written
when it is a URL), `options` (a free mapping for the loader's own
settings, not keyed: the loaded content is) and `secret_arn` (a Secrets
Manager secret to read credentials from at load time).
- `config`: the run's config (its schema keys are not loaded yet).
- `boto_session()`: the run's AWS session (the injected `Providers`
one, else one from `aws.profile_name` and `aws.region_name`), built on
the first call only, and `client(service)`, a client in the region the
config sends that service to (`aws.bedrock_region` for Bedrock,
`aws.region_name` for the rest).
- `location`: `path` without the userinfo, query and fragment of a URL.
Log and raise with it, never with `path` or the secret's value.
The loader must return the types in the same order for the same content
(sort what a set, a graph or a paged API yields): the order is part of
the prompt and of the cache keys, so a reordered snapshot re-extracts the
corpus. It translates, it does not reason.
**Reusing the ontology mapping.** The `owl` source is split so that its
mapping does not depend on RDF:
- `ontology_model.py`: `OntologyModel`, a pure Pydantic model of the named
classes and properties a reader found and what it stated about them
(annotations as language-tagged `Text`, parents, equivalents,
deprecation; per property its kind, domains and ranges as sets of
unions, inverses and symmetry) plus the prefix bindings. Statements,
not inferences, and no blank nodes.
- `ontology_mapping.py`: `MappingOptions` (selection, labels, collisions,
descriptions and hints, patterns, caps) and `map_ontology(model,
options) -> OntologyMapping` (`document`, the `SchemaDocument`, and
`report`, a `MappingReport` of what was dropped, lifted, merged,
skipped or qualified). It owns every rule: the RDFS-style closure, the
flat hierarchy with push-down and lift, labels, order and the caps,
and depends only on `domain`.
- `owl_reader.py` builds the model from parsed statements; `owl.py` is
the loader: it locates and checks the files (formats, size, and the
shapes the parsers take quadratic time over, refused before parsing),
parses their bytes, reads the model and maps it with
`OwlSourceOptions`, which is `MappingOptions` plus the reader's
`format` and `max_bytes`.
- The file handling is not owl's own: `adapters/source_files/` holds it
for any source that reads RDF. `rdf_files.locate`/`locate_s3` pick the
files and the format of each, `rdf_files.check_bytes` refuses what a
parser must not see, and `rdf_parse.parse` (the only module there
importing rdflib) parses the bytes; `rdf_terms` holds the RDF
vocabulary IRIs and the pure naming helpers (`local_name`,
`upper_snake`, `best_text`, `Text`, ...) the mapping shares with the
`rdf` seed source. A `SourceTerms` record carries the exception type
and how messages name the source, its options, its size cap
(`max_bytes`, which a seed's terms name `processing.seed_graphs[].max_bytes`)
and its kind, and any `SourceClientProvider` (`location`, `client(service)`)
gives `locations.SourceReader` its S3 client.
A loader for an ontology held elsewhere (an ontology service, or a
non-RDF format such as LinkML) added to `adapters/schema_sources/` builds
the same `OntologyModel` from its snapshot and calls `map_ontology`, so
its users get the same labels, collisions, closure, patterns, hints,
order and caps as `kind: owl`, and the same IRIs end up in `type_iri` and
`predicate_iri`. Its options model subclasses `MappingOptions` with the
reader's own settings, validated from `context.source.options` (wrap a
`ValidationError` in `SchemaSourceError`, as `owl._options` does).
These modules are unstable internals, not part of the public API (§2.5):
`unified_kg_rag` does not export `map_ontology`, `MappingOptions`,
`OntologyModel`, `OntologyClass`, `OntologyProperty` or the vocabulary
constants, and their names, fields and mapping rules may change in any
release without a "Breaking" entry. A loader that imports them pins the
package version and runs its own tests on each upgrade:
```python
from pydantic import Field
from unified_kg_rag import SchemaDocument, SchemaSourceContext, register_schema_source
from unified_kg_rag.adapters.schema_sources.ontology_mapping import (
MappingOptions,
map_ontology,
)
from unified_kg_rag.adapters.schema_sources.ontology_model import (
RDFS_LABEL,
OntologyClass,
OntologyModel,
OntologyProperty,
Text,
)
class ServiceOptions(MappingOptions):
graph: str = Field(description="The service's graph to read")
@register_schema_source("ontology_service")
def load_ontology_service(context: SchemaSourceContext) -> SchemaDocument:
options = ServiceOptions.model_validate(context.source.options)
snapshot = fetch_snapshot(context.source.path, graph=options.graph)
model = OntologyModel(
classes=tuple(
OntologyClass(
iri=c["iri"],
annotations={RDFS_LABEL: (Text(value=c["name"], language="en"),)},
parents=frozenset(c.get("parents", ())),
)
for c in snapshot["classes"]
),
properties=tuple(
OntologyProperty(
iri=p["iri"],
domains=frozenset({frozenset(p["domain"])}),
ranges=frozenset({frozenset(p["range"])}),
)
for p in snapshot["properties"]
),
)
return map_ontology(model, options).document
```
The order of the model's terms does not matter (`map_ontology` sorts).
A loader that maps its snapshot itself returns a `SchemaDocument`
directly, as a SHACL loader mapping node shapes would. A service loader
(an ontology API, or an MCP server's schema tool) fetches its snapshot
once:
```python
import json
from unified_kg_rag import (
SchemaDocument,
SchemaSourceContext,
SchemaSourceError,
register_schema_source,
)
@register_schema_source("ontology_service")
def load_ontology_service(context: SchemaSourceContext) -> SchemaDocument:
source = context.source
token = None
if source.secret_arn:
secret = context.client("secretsmanager").get_secret_value(
SecretId=source.secret_arn
)
token = json.loads(secret["SecretString"])["token"]
try:
# fetch_snapshot: your client of the service (HTTP, or an MCP
# session calling the server's schema tool).
snapshot = fetch_snapshot(
source.path, graph=source.options.get("graph"), token=token
)
except OSError as e:
raise SchemaSourceError(
f"Cannot read the ontology service at '{context.location}': {e}"
) from e
return SchemaDocument(
entity_types=[
{"label": c["name"], "description": c.get("doc", ""), "iri": c["iri"]}
for c in sorted(snapshot["classes"], key=lambda c: c["iri"])
],
relationship_types=[
{"label": p["name"], "iri": p["iri"]}
for p in sorted(snapshot["properties"], key=lambda p: p["iri"])
],
)
```
selected with `schema_source: {kind: ontology_service, path:
"https://ontology.example.com/v1", options: {graph: products},
secret_arn: ...}`. Registration happens on import, and the CLIs import
only the built-in sources, so a run with a custom source starts from
Python after importing its module. Nothing else changes: start-up writes
the result into the inline keys, the config validates it (unique labels,
patterns naming declared types), and the content keys the caches, so the
snapshot a run used is the one it is keyed by. Raise `SchemaSourceError`
for anything unreadable; it is a `ConfigurationError`, so the CLIs exit 2.
- **New seed source** (a graph format, or a service holding a graph):
register a loader for a new `processing.seed_graphs[].kind` with
`@register_seed_source("kind")` (exported from `unified_kg_rag` with
`SeedSourceContext`, `SeedSourceOptions`, `SeedGraph`, `SeedNode`,
`SeedEdge` and `SeedGraphError`; the built-in `jsonl`, `csv` and `rdf` loaders live in
`adapters/seed_sources/`, imported by the package `__init__`; their
formats: [Seed graph formats](./reference/seed-graph-formats.md)). A loader
that imports an optional package declares it, `requires=("module", "how
to install it")`, and the CLIs check it before any model call, as for
schema sources (`rdf` declares rdflib). The loader receives a
`SeedSourceContext` and returns a `SeedGraph` of `SeedNode`s and
`SeedEdge`s:
- `source`: the `processing.seed_graphs` item (`name`, `kind`, `path`,
left as written when it is a URL, `options`, `secret_arn` and the
caps). Validate `options` with `context.options(Model)`, a subclass of
`SeedSourceOptions` (`from unified_kg_rag import SeedSourceOptions`),
which carries `dangling` and `qualifier_property` and forbids unknown
keys.
- `config`: the run's config, the extraction schema already in the
inline keys (`config.processing.graph_extraction.extraction_schema()`),
for a loader that maps class IRIs itself.
- `boto_session()`, `client(service)` and `location`, as on
`SchemaSourceContext`; and `terms`, whose call builds a
`SeedGraphError` naming the seed (`raise context.terms("...")`). Log
and raise with `location`, never with `path` or the secret's value.
What it returns must depend only on what it read and the options, never
on the order (`convert_seed` sorts, applies the schema, the collisions,
the caps and the sharding). Ids are the seed's own (edges name them);
identity in the graph is the node's name. Nothing else changes: the
conversion's digest keys the stages, and the shards are documents of the
run's corpus (§4.5).
**Reusing the readers.** `adapters/source_files/` serves seed sources as
it serves schema sources: `locations.resolve_source_path` turns a `path`
into a local `Path` (a plain path or a `file://` URL) or the `s3://` URL,
refusing other schemes, as every built-in kind does;
`locations.SourceReader(context, terms=context.terms)` heads, lists and
reads S3 objects within a byte cap, and `rdf_files` / `rdf_parse` locate, check and parse RDF files
with the `owl` source's safety checks (pass `context.terms`);
`rdf_terms` names RDF terms as both built-in RDF readers do. The
property-graph helpers of the built-ins (`property_graph.FileReader`,
`GraphBuilder`: sized reads, line caps, refusals naming the file and
line) are for kinds added inside `adapters/seed_sources/`. A reader of
RDF from elsewhere (a SPARQL endpoint's `CONSTRUCT` result, say) hands
its triples as `abox_mapping.RdfTerm` tuples to
`abox_mapping.map_abox(triples, options, schema, terms=context.terms)`
(pure, no rdflib) and gets the `rdf` kind's rules: instances, names and
descriptions by language, the most specific mapped class, CURIE
property keys, nothing inferred. Its options model subclasses
`AboxMappingOptions`.
A service loader fetches a snapshot once per run:
```python
import json
from pydantic import Field
from unified_kg_rag import (
SeedEdge,
SeedGraph,
SeedNode,
SeedSourceContext,
SeedSourceOptions,
register_seed_source,
)
class ServiceOptions(SeedSourceOptions):
graph: str = Field(description="The service's graph to read")
@register_seed_source("ontology_service")
def load_ontology_service(context: SeedSourceContext) -> SeedGraph:
options = context.options(ServiceOptions)
source = context.source
token = None
if source.secret_arn:
secret = context.client("secretsmanager").get_secret_value(
SecretId=source.secret_arn
)
token = json.loads(secret["SecretString"])["token"]
try:
# fetch_instances: your client of the service (HTTP, SPARQL, or an
# MCP session calling the server's tool).
snapshot = fetch_instances(source.path, graph=options.graph, token=token)
except OSError as e:
raise context.terms(f"cannot read '{context.location}': {e}") from e
return SeedGraph(
nodes=tuple(
SeedNode(id=n["iri"], iri=n["iri"], name=n["label"], type_iri=n["class"])
for n in snapshot["instances"]
),
edges=tuple(
SeedEdge(source=e["from"], target=e["to"], predicate_iri=e["property"])
for e in snapshot["links"]
),
)
```
selected with `seed_graphs: [{name: catalog, kind: ontology_service,
path: "https://ontology.example.com/v1", options: {graph: products},
secret_arn: ...}]`, from Python after importing the module (the CLIs
import only the built-in sources).
**Copy, or federate.** Such a loader copies: the snapshot becomes part
of the graph, embedded, clustered and keyed like the rest, and its IRIs
line up with the service's through `Entity.iri`, `type_iri` and
`predicate_iri`. Federation, querying the service at retrieval time
instead, is not built. Its seam would be a retrieval-time port (look up
the IRIs of names, a node's neighbours, a node's description) that
expands stored entities by `iri` without copying; nodes it returns would
be in neither Leiden nor the vectors, so global search would not see
them, and the `source_kinds` vocabulary can name them. The registry,
`Entity.iri` and `source_kinds` are the seams either way.
- **New config section**: Define a Pydantic `BaseModel` → attach it to its parent via `Field(default_factory=...)` → describe every field and regenerate `docs/reference/configuration.md`; add a key to `config-template.yaml` only when most deployments set it (see §12).
### Custom backends (run without AWS)
Every cross-service dependency is behind a port. Each port is a role in the
provider bundle whose adapter `providers..kind` selects through the
provider registry, and the orchestrators also accept any port by
**constructor injection**, so a custom backend needs no subclassing or
dispatch edit. The ports, their default adapters and the local ones (the
`[local]` extra):
| Port | Contract | Default adapter (local adapters) | Inject via |
|---|---|---|---|
| `LLMFactoryPort` / `EmbeddingFactoryPort` / `RerankFactoryPort` (`ports/model_factory.py`, `Protocol`) | `get_model()` / `get_model_info()` returning a LangChain-compatible model; optional LLM-factory hooks: `is_transient_error(exc)` (which errors the ingestion batches retry; default `adapters.llm.retry.looks_transient`: network errors, HTTP 408/429/5xx), `is_item_error(exc)` (which errors count against a document, `indexing.max_document_failures`; default `looks_like_item_error`: HTTP 400/413/422 other than a rejected model or setting, read from `status_code` or `response.status_code`) and `transient_retry` (a `TransientRetryConfig`: with `is_transient_error`, query chains are retried under it) | `BedrockLanguageModelFactory` / `BedrockEmbeddingModelFactory` / `BedrockRerankModelFactory` (`openai_compatible`, `ollama`; `cross_encoder`, `none`) | `providers=` (below) on `GraphRAGChain`, `DataIngestionPipeline`, `EvaluationManager`; `GraphRAGChain(model_factory=...)` is an LLM-only bundle |
| `TokenCounterPort` (`ports/model_factory.py`, `Protocol`) | `count_tokens()` / `truncate_to_token_limit()` | `BedrockTokenCounter` (`LocalTokenCounter`: estimate or tiktoken; follows the LLM kind) | `Providers(token_counter_factory=...)` |
| `VectorIndexer` / `GraphIndexer` (`ports/indexer.py`, ABC) | `index_*` / `upsert_*` / `delete_by_id`; the incremental lookups (`GraphIndexer.find_ids_citing` / `find_relationship_ids_citing` / `find_entity_ids_by_name_key`, `VectorIndexer.find_documents` / `delete_extraction_records` / `retain_extraction_records` / `prune_text_unit_communities`) default to "cannot tell" (`None`: nothing is found through them, the merge reads every name) and to deleting nothing; the extraction-record store (`VectorIndexer.read_extraction_records` / `write_extraction_records` / `stored_extraction_record_keys`, by record key) defaults to keeping none (`{}`: every text unit is extracted, nothing is stored), and `stored_extraction_record_keys` to the keys `read_extraction_records` returns; `VectorIndexer.check_index_creation(suffix, rebuild=)` (called once per run before the first model call, for the indices the run will create: all of them on a `rebuild` (full build or reset), the missing ones otherwise; raises `StoreRequestRejectedError` for settings the store refuses) defaults to accepting them; `VectorIndexer.read_vector_copies` defaults to `{}` (every item an incremental run touches is written in full); `GraphIndexer.read_entities` / `read_relationships` default to `[]`, so without overrides the delta merge overwrites the stored descriptions, lineage (`text_unit_ids`) and weights; `GraphIndexer.begin_rebuild` / `end_rebuild` (in-place full build, §5.2) default to `False` (the full build clears each label first); `initialize()` (no-op by default) / `clear()` return `None` and raise a typed store error (`StoreConnectionError`, `StoreConfigurationError`, ...) on failure: a returned `False` is ignored; an `in_process = True` attribute (on the indexer and on the registry) says the store is embedded in this process, and only when all three say so does a stopped run release its lease at once (absent: remote) | `OpenSearchIndexer` / `NeptuneIndexer` (`gremlin`: the same adapter on Gremlin Server; `sqlite`: `OpenSearchIndexer` on `SQLiteSearchEngine` / `SQLiteGraphIndexer`) | `Providers(vector_indexer_factory=..., graph_indexer_factory=...)` (a new instance per call; its caller closes it), `DataIngestionPipeline(..., vector_indexer=..., graph_indexer=...)` (borrowed: never closed by the pipeline) or `IndexingManager(vector_indexer=..., graph_indexer=...)` (closed by `IndexingManager.close()`; §8.2) |
| retriever (role-keyed builder) | `BaseGraphRAGRetriever.aretrieve`; a document retriever must honour `SearchQuery.exclude` (leave out what it matches before the page is cut: the filtered walk's edge pages rely on it); `aindex_limits(prefix, suffix)` (the hits one search returns, the values one `terms` clause takes) defaults to the configured `index_settings`, so a backend whose live limits can be smaller overrides it; the strategies size every list they send by it (fetches by id in batches of `IndexLimits.id_batch_size`, walk frontiers and exclusions by `max_terms_count`, at most `search.max_concurrency` batches at once), so `aretrieve` may cut a search to its limits and need not batch; `filter_fields()` declares the filter keys the retriever applies (`None`, the default, accepts every key) and `filter_fields_for(prefixes)` those of the indexes a strategy filters (`BaseSearchStrategy.filtered_index_prefixes`), by default `filter_fields()`; `GraphRAGChain` rejects a caller key none of a strategy's `filtered_retrievers` declares, checking a custom retriever against the union of its keys | `OpenSearchRetriever` / `NeptuneRetriever` (`sqlite`: `OpenSearchRetriever` on `SQLiteSearchEngine` / `SQLiteGraphRetriever`) | `Providers(retriever_builders=...)` or `GraphRAGChain(retriever_builders={RetrieverRole.GRAPH: lambda: MyGraphRetriever(...)})` |
| `ContentGuardPort` (`ports/guardrail.py`, `Protocol`) | `async acheck(text, source, *, grounding=None)` returning a `GuardVerdict` (`none` / `masked` / `blocked`, the text to continue with, and `grounding`, a `GroundingCheck`, when a `GroundingContext` was given); raises when it cannot decide. `grounding` is passed only while `aws.bedrock.guardrail.contextual_grounding.enabled` is set | `BedrockGuardrail` (built when `aws.bedrock.guardrail.identifier` is set) | `GraphRAGChain(guard=...)` |
| `DocStatusPort` (`ports/doc_status.py`, `Protocol`) | `get` / `get_many` / `put` / `put_many` / `delete` / `list_namespace` / `diff` (which must take `extraction_fingerprint` and fill `DocumentDelta.quarantined_unchanged`), `record_fits`, the write lease (`try_acquire_lease` / `lease_holder` / `release_lease`) a scope's last extraction settings (`get_extraction_settings` / `put_extraction_settings`) and the schema annotations a namespace's stores hold (`get_annotation_digest` / `put_annotation_digest`), all called directly; a record's namespace is the `#` prefix of its `doc_id` (`doc_id_namespace`), and `diff` with a scope reads only that namespace. A subclass inherits `get_many` / `put_many` / `record_fits` defaults (one record per call, no size limit) | `DynamoDBDocStatusStore` (`SQLiteDocStatusStore`) | `Providers(doc_status=...)` or `DataIngestionPipeline(..., doc_status=...)` (either turns incremental indexing on); pass `tests/integration/test_doc_status_conformance.py` |
**Errors from a custom backend** are classified by type, never by message
(§8.1): raise a `ConnectionError` or `ConfigurationError` subclass (the typed
store errors in `shared/exceptions.py`) to fail the query; any other
exception degrades only the section that raised it.
**Protocols, not base classes.** The model-factory and doc-status ports are
`runtime_checkable` `Protocol`s, so a custom class only needs the right method
shapes:
```python
from unified_kg_rag import GraphRAGChain
# Not exported from unified_kg_rag: an internal path that may change in any
# release (§2.5); pin the package version when you import it.
from unified_kg_rag.domain.models.config import TransientRetryConfig
class MyLLMFactory: # structurally an LLMFactoryPort
def get_model(self, model_id, **kwargs): ... # returns a LangChain chat model
def get_model_info(self, model_id): ... # returns a LanguageModelInfo
# Optional hooks (defaults: adapters.llm.retry.looks_transient /
# looks_like_item_error; no query-chain retry without transient_retry):
def is_transient_error(self, exc): ... # retry this error?
def is_item_error(self, exc): ... # the document's own error?
transient_retry = TransientRetryConfig() # query-chain retry policy
chain = GraphRAGChain(config=cfg, model_factory=MyLLMFactory())
```
A factory must keep the `**kwargs` catch-all and ignore keys it does not
understand: callers pass framework arguments such as
`model_purpose=ModelPurpose.INGESTION` (the per-path policy: response cache,
ingestion effort, query retry), and a strict signature raises `TypeError` on
the first one.
**One bundle, every component.** `Providers` (`adapters/providers.py`) holds
the boto3 session, the model and token-counter providers and the store and
registry builders; `application/bootstrap.py` (`build_providers`) is the
composition root. Each orchestrator builds or receives one bundle and passes
it to everything it constructs (the chain's prompts, strategies, reranker,
token managers and retrievers; the pipeline's stages and indexers; the
evaluation judges, which reuse the chain's bundle by default).
```python
providers = Providers(
cfg,
llm_factory=MyLLMFactory(),
embedding_factory=MyEmbeddingFactory(),
# Reranking is on by default (providers.rerank.kind: bedrock) and would
# otherwise call Bedrock; pass a factory or set providers.rerank.kind: none.
rerank_factory=MyRerankFactory(),
token_counter_factory=lambda model_id, **_: MyTokenCounter(model_id),
)
chain = GraphRAGChain(config=cfg, providers=providers)
pipeline = DataIngestionPipeline(cfg, pipeline_config, providers=providers)
```
- Orchestrator arguments (`retriever_builders=`, `doc_status=`,
`vector_indexer=` / `graph_indexer=`, `GraphRAGChain(model_factory=...)`)
are shorthand for a bundle built with them; passing one together with
`providers=` raises `ValueError`.
- Anything not supplied is built lazily by the configured kind's builder (the
model factories at most once per bundle); a component built without a bundle
builds a default one. There is deliberately no DI container: the bundle
covers exactly what the framework consumes.
- Conversation memory belongs to the chain and keys each conversation by index
suffix, `user_id` and `conversation_id`; pass one `MemoryManager(cfg)` as
`memory_manager=` to share history across chains.
**Provider registry.** `adapters/provider_registry.py` maps
`(ProviderRole, kind)` to a builder that receives the bundle; a new kind needs
no edit to the bundle, the config model or any call site:
```python
from unified_kg_rag import ProviderRole, register_provider
@register_provider(ProviderRole.LLM, "my_server")
def _build(providers):
return MyServerFactory(providers.config)
```
`providers.llm.kind: my_server` then selects it (a non-Bedrock LLM kind also
needs a token-counter builder of the same kind). A kind that connects to a
server declares its setting (`endpoint=("", "")`), and
one that imports an optional package declares it
(`requires=("", "")`): the CLIs refuse an unset
endpoint or a missing package at start-up (exit 2). `backend: local` is a config
preset (`domain/models/provider_config.py`), not a code path: it fills in
`providers`, `aws.neptune`, `aws.opensearch` and the context budget only
where the config leaves them unset.
**Reference implementations.** The in-memory fakes of `tests/fixtures/fakes/`
implement the indexer ports and run the whole pipeline
(`tests/integration/test_ingestion_stages.py`): the starting point for a
custom store. The embedded SQLite stores show the two ways to add a backend:
the vector store (`adapters/local/search_engine/`) implements the OpenSearch
client calls (`SearchEngineClient`), so the unchanged OpenSearch indexer and
retriever run on it; Gremlin cannot be emulated that way, so the graph store
is its own `GraphIndexer` (`SQLiteGraphIndexer`) plus a
`GraphExpansionRetriever` subclass implementing only the traversal and,
for the PageRank of §6.4, `_edges_within` (optional: without it the
strategies read the edges from the relationships index).
### Deliberate Design Boundaries
Two boundaries are intentional:
- **`SearchQuery` carries adapter vocabulary (index and label prefixes).** A
prefix names a logical collection each store pairing maps to its own storage
(the embedded SQLite stores reuse them). The boundaries that matter, the
indexer and model-factory ports, are abstracted and injected; a
backend-neutral query model would be a large, risky churn that only a
backend unable to map named collections would justify.
- **One physical OpenSearch index per index suffix and artifact type.** Fine
for a handful of tenants; tens of thousands of suffixes multiply the shard
count. The scale-out fix (one index per artifact type with an enforced
tenant field and routing) changes the index, search and delete paths and is
deferred. Metadata filters are relevance filters, not access control:
isolation comes from separate suffixes
([runbook](./operations.md#query-access-and-tenant-isolation)).
---
## 16. Further Reading
Microsoft Research posts on GraphRAG and its follow-up methods, useful background for §6:
- [GraphRAG: Unlocking LLM Discovery on Narrative Private Data](https://www.microsoft.com/en-us/research/blog/graphrag-unlocking-llm-discovery-on-narrative-private-data/)
- [GraphRAG: New Tool for Complex Data Discovery Now on GitHub](https://www.microsoft.com/en-us/research/blog/graphrag-new-tool-for-complex-data-discovery-now-on-github/)
- [GraphRAG Auto-Tuning Provides Rapid Adaptation to New Domains](https://www.microsoft.com/en-us/research/blog/graphrag-auto-tuning-provides-rapid-adaptation-to-new-domains/)
- [Introducing DRIFT Search: Combining Global and Local Search Methods to Improve Quality and Efficiency](https://www.microsoft.com/en-us/research/blog/introducing-drift-search-combining-global-and-local-search-methods-to-improve-quality-and-efficiency/)
- [GraphRAG: Improving Global Search via Dynamic Community Selection](https://www.microsoft.com/en-us/research/blog/graphrag-improving-global-search-via-dynamic-community-selection/)
- [LazyGraphRAG: Setting a New Standard for Quality and Cost](https://www.microsoft.com/en-us/research/blog/lazygraphrag-setting-a-new-standard-for-quality-and-cost/)
- [Introducing GraphRAG 1.0](https://www.microsoft.com/en-us/research/blog/moving-to-graphrag-1-0-streamlining-ergonomics-for-developers-and-users/)