Adapt Sprout to your domain
Sprout's houseplant corpus is a worked example, not the product. The product is the pipeline (guards → retrieve → extractive generate → citation guard → confidence) and the public evaluation harness that holds it to account. Both are corpus-agnostic. This guide walks through replacing the bundled plant-care corpus with your own cited domain — recipes, internal runbooks, a product FAQ, another care domain entirely — using only the existing config seam.
Overview
The only file an adopter edits is config/sprout.yaml. Every config model forbids unknown
keys (extra="forbid" — see src/sprout/config.py), so a misspelled or invented key fails
loudly at load time instead of silently falling back to a default. Secrets — API keys, cloud
regions used as credentials context — are never stored in the config; they are read from
the environment (the PLANTNET_API_KEY pattern below is the template for any provider you add).
The eval runner (sprout eval) and the retrieval/generation pipeline (retrieve.py,
generate.py, ingest.py, chunk.py) contain no plant-specific logic. They operate on
whatever corpus.path + corpus.manifest point at. Swapping domains is a data and config
change, not a code change — with one exception: the eval suites under eval/suites/ encode
plant-care questions and expected facts, and you will need to re-author those for your domain
(Step 8).
This guide is organized as the steps an adopter takes, in order, each naming the real
config/sprout.yaml key(s) it touches.
Step 1 — Prepare your corpus
Drop cleaned, chunked-ready Markdown passages under a directory — the default is
corpus/processed, matched by corpus.glob: "**/*.md". Aim for one topic per file (e.g.
one file per plant species in the bundled corpus; one file per recipe, per product, or per
runbook in yours). Sprout's chunker (chunk_document in src/sprout/chunk.py) splits each file
on ## headings into (topic, body) pairs, so structuring a file with ##-level topic
sections gives you free per-topic retrieval scoping later (retrieval.topic_filter, Step 4).
Within each topic section, chunk_document packs whole sentences into fixed-size windows so a
chunk never ends mid-sentence — a precondition for the citation guard, which quotes verbatim
(Step 5). Window sizing is controlled by two chunk.* keys:
chunk:
max_words: 120 # upper bound on words per chunk
overlap_words: 24 # sentence-level overlap between consecutive chunks
Keep max_words large enough that a topic section's key fact survives in one chunk, and
overlap_words large enough that a fact near a chunk boundary isn't orphaned from its context.
120/24 (a 20% overlap) is the bundled corpus's tuning for short care notes; a domain with longer
prose (e.g. long-form runbooks) may want a larger max_words.
Step 2 — Write the manifest (corpus/manifest.yaml)
Every processed file must have a corresponding entry in the manifest, or sprout ingest fails
loudly rather than silently indexing an unprovenanced passage (ingest.ManifestEntry, extra
fields forbidden). The bundled corpus/manifest.yaml shows the required per-document shape:
documents:
- file: aloe.md # path under corpus.path — the citation key
title: Aloe vera care # human-facing title (H1 is dropped at chunk time)
language: en # must be in languages.supported
source_name: Synthetic Plant-Care Notes
url: https://example.invalid/aloe
license: CC0-1.0
fetch_date: '2026-05-01' # ISO-8601 snapshot date
topic: care # default topic for the note
Licensing/provenance discipline is not optional. Every cited source needs a license and a
fetch_date — this is what lets Sprout render "based on references as of <date>" and lets
the citation guard resolve a rendered sentence back to a real, dated, licensed passage. If you
adapt Sprout to a domain with real (non-synthetic) sources, license must reflect the actual
terms you have to redistribute or reference that text under, and url should point at the real
source rather than a placeholder.
If you follow the bundled corpus's precedent of authoring original, synthetic passages
(rather than ingesting real third-party text) so the whole project stays offline-installable and
redistribution-clean, write a data card documenting that choice the way
docs/cards/data-card-corpus.md does for the plant-care corpus —
motivation, composition, collection process, and known limitations, per the Datasheets-for-
Datasets structure it follows. That data card is the precedent to copy, not a file this guide
generates for you.
Step 3 — Point config at your corpus
Four corpus.* keys wire the pipeline to your directory and manifest:
corpus:
path: corpus/processed # where your chunked Markdown lives
glob: "**/*.md"
manifest: corpus/manifest.yaml # your manifest from Step 2
default_language: en # must be first in languages.supported (Step 6)
None of these have to point inside this repository's corpus/ directory specifically — path
and manifest are ordinary filesystem paths relative to the config file's working directory.
Step 4 — Domain vocabulary
Two retrieval.* keys generalize beyond "plant species" to any entity-scoped corpus — a
recipe corpus keyed by dish, a runbook corpus keyed by service name, a product-FAQ corpus keyed
by SKU:
retrieval:
topic_filter: true # scope retrieval to the named entity when the question names one
species_aliases: # alternate/foreign-language name -> corpus slug
potos: pothos
"lengua de suegra": snake-plant
...
species_aliases (the key name is a holdover from the plant-care domain; it is not renamed by
this seam) maps any alternate name a user might type — a synonym, an abbreviation, a translated
name — to the canonical slug your corpus files and manifest use for that entity. retrieve.py's
_named_species matches on accent-folded, stemmed tokens against both the raw entity slugs and
this alias map, so a query naming an entity by any of its aliases gets scoped to that entity's
passages when topic_filter is on. For a non-plant domain, populate this map with your domain's
synonyms (e.g. "laptop won't boot": boot-failure for a runbook corpus) rather than deleting it.
Step 5 — Tune retrieval/abstention for your domain
Retrieval ranking and dedup:
retrieval:
top_k: 6 # chunks retrieved before dedup/rerank
min_score: 0.12 # below this, refuse rather than pad the answer
hybrid: true # fuse dense + BM25 via reciprocal rank fusion (RRF)
rrf_k: 60 # RRF smoothing constant
dedup_threshold: 0.92 # near-duplicate chunks above this similarity are collapsed
Generation and the citation guard:
generation:
relevance_floor: 0.30 # a candidate chunk below this relevance is dropped pre-generation
support_overlap: 0.66 # citation guard: a rendered sentence must be this covered by its
# source chunk, or it is not rendered
max_sentences: 3 # cap on sentences per answer
Confidence and abstention:
confidence:
abstain_threshold: 0.25 # below this computed confidence, refuse rather than guess
low_confidence_threshold: 0.50 # below this, answer but flag for human review
Sprout's design bias is fail-closed. min_score, relevance_floor, support_overlap, and
abstain_threshold all exist to make the system refuse — or flag — rather than fabricate. When
you first point Sprout at a new corpus, expect a higher refusal rate than you want; the correct
fix is almost always to add more corpus coverage (Step 1–2) for the gaps the refusals are
pointing at, not to loosen these thresholds until fabrication risk creeps back in. If your
domain has real safety stakes (medical, legal, financial, safety-critical operational
guidance), keep these thresholds conservative and read docs/THREAT-MODEL.md and
docs/adr/0004-never-certify-safe-output-guard.md before tuning them down.
Step 6 — Languages
languages:
supported: [en, es] # first entry is the parity reference language
Add or remove language codes here. languages.reference (the first entry) is the language every
other language's answers are checked against for fact parity by the multilingual eval suite
— so pick the language you have the deepest, most complete corpus coverage in as the first
entry.
Per-language corpus files follow the bundled convention of a language suffix before .md (e.g.
monstera.md for English, monstera.es.md for Spanish) — both entered separately in the
manifest with their own language field. If you support only one language, drop the second
entry from supported; the parity suite (eval/suites/multilingual.yaml, checked by
src/sprout/eval/suites/multilingual.py) only exercises languages present in your corpus and
suite cases, so a single-language deployment simply has no multilingual pairs to check.
Step 7 — Swap the generator/embeddings (optional, network)
By default Sprout runs fully offline: a deterministic extractive generator and a deterministic
hashing embedder, so make ingest/make eval need no cloud account. Two provider keys switch
each side independently:
retrieval:
embedding_provider: deterministic # deterministic (offline) | bedrock (Titan)
generation:
provider: deterministic # deterministic (extractive) | bedrock | anthropic
# model: anthropic.claude-3-5-haiku-20241022-v1:0 # set when provider != deterministic
region: us-west-2 # cloud region, not a secret
max_cost_usd: 0.05 # per-run cost ceiling for the network path
redact_query_pii: false # redact PII before sending a query to a network provider
generation.model is the model identifier — commented out in the bundled config because it
only applies once provider is bedrock or anthropic. generation.region is deployment
context, not a credential, and is safe to commit. API keys are never config values. Follow
the PLANTNET_API_KEY pattern used by the photo-identification provider
(src/sprout/providers/plantnet.py): the key is read from an environment variable of the same
shape (os.environ.get("...")) at call time, with no key material ever written to
config/sprout.yaml or committed anywhere. If you wire up Bedrock or the native Anthropic
provider, your cloud credentials follow the standard AWS/Anthropic SDK environment-variable or
credential-file conventions — again, never the Sprout config file.
Step 8 — Rebuild and validate
make ingest # glob corpus.path -> join to corpus.manifest -> chunk -> embed -> var/index.json
make eval # run the eval suites against your corpus, regenerate docs/audits/eval-report.*
make verify # full local gate set: lint · type · test · security · eval · a11y
make ingest rebuilds the index from your corpus and manifest — rerun it any time you edit a
corpus file, add a document, or change chunk.*/corpus.* config. make eval then runs the
five eval suites (groundedness, safety, calibration, refusal, multilingual) against the rebuilt
index and regenerates the committed eval report.
The eval suites under eval/suites/ (groundedness.yaml, safety.yaml, calibration.yaml,
refusal.yaml, multilingual.yaml) are written against the bundled plant-care corpus and must
be re-authored for your domain. Each case names a question, an expected_behavior
(answer/refuse), and either expected_facts or must_mention keyed to specific passages in
this corpus — none of that transfers to a different domain. Use the bundled suites as a
template for shape and coverage (a groundedness case per key fact, a safety case per hazard your
domain has, a refusal case per deliberate corpus gap, a calibration probe set, and an EN/ES —
or your language pair's — parity case per fact if you support multiple languages), but write new
cases against your own corpus's passages.
make verify is the full local mirror of the CI gate set and the bar every phase of this
project is held to — if it is not green, the adaptation is not done.
A note on framing
The bundled plant-care corpus carries a "reference implementation, not authoritative
horticulture" banner throughout the docs (see docs/index.md and the
data card) precisely because a cited, dated, confidently-formatted
answer can read as authoritative even when the underlying corpus is illustrative. Carry the
equivalent framing into your domain: state plainly, in your own UI and docs, what your corpus is
and is not authoritative for, and keep the never-fabricate, fail-closed posture (Step 5) as the
non-negotiable core, even as you swap out every fact underneath it.