ADR 0029: A chunk is not extracted alone

Status

Accepted. [0011 domain schemas prompt but do not

constrain](0011-domain-schemas-prompt-but-do-not-constrain.md) **stands, and

is extended**: both mechanisms here are prompt content and neither validates

anything, which is the same position that ADR takes about a domain's

vocabulary. [0005 temporal inference on

read](0005-temporal-inference-on-read.md) stands untouched -- nothing here

emits an edge. [0009 the extraction fold resolves through

aliases](0009-the-extraction-fold-resolves-through-aliases.md) stands: the

carryover reduces how much work that fold and consolidation are handed, and

changes neither.

0008 the two non-store ports stands

unamended, and that is the load-bearing part of this decision:

LlmProvider.extract(text, schema, *, system_prompt) was sufficient for both

mechanisms without widening. A design that had needed a messages list, a

conversation handle, or a previous_response_id would have put a chat API's

shape back into the port that exists to keep it out.

Context

Extraction sent each chunk to the model alone, once, with a fixed system

prompt. Two consequences followed, and neither was visible in the output --

the output simply had fewer, or more numerous, entities than it should.

A chunk after the first begins mid-argument. It says "Lovelace" where an

earlier chunk said "Ada Lovelace". In a library whose entity ids are derived

from (tenant, source, entity_type, normalized_name), that is not a stylistic

wobble: it manufactures a second entity. merge_extractions cannot combine

the two -- it combines only what is the same entity by construction --

so the pair reaches consolidation, which pays a model call to decide they

are one person and writes an EntitiesMerged to record it. Naming drift at a

chunk boundary is therefore billed twice.

A model asked once for the entities in a dense paragraph stops early. Not

because the paragraph is exhausted, but because the answer feels complete.

This is well attested: Microsoft GraphRAG runs a "gleaning" loop that feeds

the extraction back and asks what was missed, and Graphiti runs a reflexion

step after its first pass for the same reason.

Both problems are addressed everywhere else in this field by giving the model

more context, and the two obvious ways to do that are the two this decision

rejects.

Decision

Each chunk's prompt names what earlier chunks found

extraction/carryover.py accumulates (name, entity_type) pairs from every

chunk's mapped result and appends a bounded, oldest-first list to the system

prompt for the next chunk, with an instruction to reuse those spellings and to

list nothing the current text does not mention. On by default

(carryover_entities=32), because it costs no model call and the defect it

prevents costs one.

In the system prompt, not prepended to the chunk. The list is an

instruction about naming, not content to extract from. Inside the chunk it is

indistinguishable from the document, and the failure is exact: the model

reports carried names as entities of the current chunk, and

PipelineResult.chunks then attributes them to a passage that never mentioned

them -- so a chunk retrieved for an entity does not contain it. That would

corrupt the corpus 0023 built.

Built from mapped entities, not from the raw answer. The mapper normalizes

names and drops rows the domain refuses. A carryover built from the wire shape

would offer later chunks a spelling no entity in the document has.

Bounded by recency. A prompt cannot carry a long document's entities. An

unresolved short form refers to something named nearby, so recency is the

right side to keep; it is admittedly the wrong side for a protagonist named

once at the start.

Rejected: carrying names across documents. It is the same operation as

cross-document entity resolution, and doing it in a prompt would do it with no

event, nothing to audit and nothing to undo -- which is precisely what

0004 and merging.py refuse. A

Carryover belongs to one extract call and is discarded with it.

Rejected: giving the model the previous chunk's text, as Graphiti gives an

episode its previous four messages. It carries the same information at many

times the tokens, and it re-presents prose the model will extract from again

-- the attribution problem above, in a form no instruction can suppress.

A chunk may be shown its own answer and asked what it missed

extraction/gleaning.py, reached by gleanings=N. Off by default: it is

one extra model call per chunk per pass, and this pipeline is sequential over

chunks because the reference deployment serves one request at a time, so

gleanings=1 roughly doubles a run. Recall is worth paying for and is not

worth paying for by accident.

The two answers are combined as Extractions, before mapping. Not a

choice about where to put a fold: _map_relationships resolves an endpoint

name against the entities in the same answer, so an edge stated by the second

pass between one of its entities and one of the first pass's is resolvable

only if the mapper sees a single Extraction. Merged after mapping, exactly

the edges a second pass exists to find are counted unresolved and dropped.

Repeating is explicitly permitted in the prompt, which is the opposite of

the carryover's instruction and for a reason that generalises: a repeat here

is deduplicated by derived id and costs only tokens, while a model told

sternly not to repeat itself also withholds the entity it has just realised it

mis-typed.

A failed gleaning never propagates, whatever skip_failed_chunks says. A

failed chunk is a hole in the document; a failed gleaning is a chunk that got

one pass instead of two, with a complete first answer in hand. Discarding that

answer would trade a smaller extraction for none. It is counted on

failed_gleanings rather than logged, because fewer entities is what a

successful run also looks like.

Rejected: gleaning on by default with a small N. GraphRAG defaults to one

pass. Here the cost is not amortised across a fleet -- it is a doubling of

wall-clock on a single-GPU server -- and a library that silently doubles what

a caller pays for a model has made a decision that was the caller's.

Consequences

Both mechanisms are prompt content. Neither validates, neither constrains, and

neither can fail a run: a model that ignores the carryover produces exactly

what it produced before, and a gleaning pass that fails leaves the first

answer standing. That is the same "shapes but does not enforce" position

0011 takes, and it is

what makes both safe to have on a path with no ability to reject an answer.

ExtractionPipeline.system_prompt now reports the configured base rather than

what any chunk was sent. A caller logging it sees configuration; the carryover

block is per-chunk and is not configuration.

**The graded corpus cannot see either change, and that is a gap this decision

knowingly accepts.** All five documents in tests/accuracy/ are a single

chunk, so the carryover never acts on them and a gleaning pass has no second

chunk to be compared across. The commit gate proves the mechanisms are wired

and correct; it does not prove they improve extraction, and no committed suite

currently can. BACKLOG.md records what a multi-chunk graded document would

take.

Gleaning stays off, now measured rather than only argued. Once ADR 0031

made the graded corpus cheap to run, gleanings=1 was compared against

gleanings=0 on it: identical results, 12 entity true positives and 4

false positives either way. The second pass reported nothing missed, so the

combined answer equalled the first answer exactly and the extra call per chunk

bought nothing.

That is a null result on a corpus that cannot show gleaning's benefit --

recall is already 1.000 with no headroom, and every document is one short

chunk, which is the opposite of the long dense chunk a second pass is for. So

this neither vindicates nor condemns the mechanism; it says the default is

correct on the evidence available, and that B115's third property (entities a

good model plausibly misses) is what would change the answer.

Two counters are added to PipelineResult (gleaning_passes,

failed_gleanings) and one argument to each of ExtractionPipeline and

build_graph. build_graph exposes both because it constructs the pipeline,

so a caller would otherwise have no reach to them -- and because turning the

carryover off is what makes a before/after comparison possible at all.