Skip to content

Distill a corpus

Some material will never fit at any fidelity. distill reads all of it and writes something shorter: each chunk is summarized against your question, then the notes are synthesized into one answer.

It is the only reduction strategy that spends inference, so it is a separate function rather than a select() strategy, and it reports the call count and aggregate usage so the multiplier is never a surprise.

The basic shape

import anyinfer as ai
from anyinfer import context

client = ai.AsyncClient([
    ai.ProviderSettings.of("anthropic", api_key="env://ANTHROPIC_API_KEY"),
])

result = await context.distill(
    changelog_text,
    "What changed for end users in this release?",
    client=client,
    target="anthropic:claude-sonnet-4-5",
)

print(result.text)
print(f"{result.calls} calls, {result.usage.output_tokens} output tokens")

source may be raw text or ContextDocument values. Documents split per document, because a document boundary is a natural chunk boundary.

Know the cost before you commit

A distillation over 200 chunks is 201 requests. Size it first:

documents = [context.ContextDocument.of(p, t) for p, t in collected]
chunks = sum(len(context.split_document(d)) for d in documents)

estimate = client.budget(
    [ai.user("...one representative chunk...")],
    target="anthropic:claude-sonnet-4-5",
).estimated_cost

print(f"about {chunks + 1} calls")
if estimate is not None:
    print(f"roughly ${estimate.low * chunks:.2f}–${estimate.high * chunks:.2f}")

Afterwards, result.usage.cost_usd is what it actually cost, wherever the provider reports cost.

Spend nothing on the reduce phase

If your notes merge structurally — a union of entries, a concatenation, a JSON merge — supply a reducer and the reduce call disappears entirely:

import json

def merge_findings(notes):
    findings = []
    for note in notes:
        try:
            findings.extend(json.loads(note)["findings"])
        except (ValueError, KeyError):
            continue
    return json.dumps({"findings": findings}, indent=2)

result = await context.distill(
    documents,
    "List every configuration key this code reads.",
    client=client,
    target="anthropic:claude-sonnet-4-5",
    map_instructions=(
        'Return JSON: {"findings": [{"key": "...", "file": "..."}]}. '
        "Use only what this part contains."
    ),
    reducer=merge_findings,
)

assert result.calls == result.chunk_count   # map phase only

The map phase is still N calls; the reduce is free and reproducible.

Own the framing

The built-in prompts are mechanical scaffolding — "here is part 3 of 9, take notes" — not application prose. Replace them when the framing matters:

result = await context.distill(
    transcripts,
    "What did customers complain about?",
    client=client,
    target="anthropic:claude-sonnet-4-5",
    map_instructions=(
        "Read this support transcript excerpt. List each distinct complaint with the "
        "product area it concerns. Quote the customer's own words where possible."
    ),
    reduce_instructions=(
        "Group these complaints by product area, most frequent first. Preserve the "
        "customer quotes."
    ),
)

Hierarchical reduce, when there are many notes

With enough chunks, the notes themselves exceed the window. distill handles that by reducing in batches — sized by what actually fits, not by note count — and then reducing those summaries:

result = await context.distill(huge_corpus, question, client=client, target=target)
print(result.reduce_depth)   # 1 for a single pass, higher when it recursed

A naive single-pass merge would simply overflow here.

Bound the fan-out

Concurrency defaults to 4. A fan-out is somebody's rate limit:

result = await context.distill(
    documents, question, client=client, target=target, concurrency=2,
)

Failures propagate as normal provider errors, so retry and fallback stay where they belong — on your Route, not duplicated inside the reducer.

From a synchronous application

with ai.Client([ai.ProviderSettings.of("anthropic", api_key="env://ANTHROPIC_API_KEY")]) as client:
    result = context.distill_sync(
        corpus, question, client=client, target="anthropic:claude-sonnet-4-5",
    )

Chunks process one at a time — concurrency is the async path's feature. Use distill() with an AsyncClient if you want the parallel map phase.

Generating module digests (the caching recipe)

The tiered strategy renders app-supplied module digests but never generates them — spending inference to summarize is your decision, not a side effect of packing. Here is the pattern, with caching:

surfaces = context.module_surfaces(documents, depth=2)

digests = {}
for module, surface in surfaces.items():
    key = hashlib.sha256(surface.encode()).hexdigest()
    if (cached := digest_cache.get(key)) is not None:
        digests[module] = cached
        continue
    summary = await client.generate(
        f"Describe what this module does in two sentences:\n\n{surface}",
        target="anthropic:claude-haiku-4-5",
    )
    digests[module] = summary.text
    digest_cache[key] = summary.text

reduction = context.select(
    documents, query, max_tokens=max_tokens, strategy="tiered",
    module_digests=digests,
)

module_surfaces() is deterministic, so the digest cache key is stable across runs. The cache itself stays app-side.

See also