Bring your own domain¶
This tutorial covers specializing the kit to your own field. Budget an
afternoon for a first serious pass. Nothing here requires editing
Python; the domain is defined by the domain/ folder, a corpus
manifest, and environment variables.
Worked example throughout: suppose you study membrane materials for water treatment, and you have 60 PDFs of papers, theses, and technical reports.
If you got here from sci-rag-new, the wizard already asked you most of
this. Every answer landed in a file you can keep editing. Nothing here is
generated code, and nothing regenerates behind you:
| What it asked | Where the answer went | How to change it |
|---|---|---|
| project name, description | domain/domain.yaml, pyproject.toml, README.md |
edit the files, or re-run the wizard |
| credentials, models, embedding dimension | .env |
edit .env; changing the dimension needs a migration and re-embedding |
| ontology | domain/domain.yaml |
step 3 below |
| corpus source | data/corpus.jsonl, data/dois.txt, or a make corpus target |
steps 1 and 2 below |
| PDF parser, reranker | pyproject.toml extras, domain/domain.yaml |
step 5 below, and Configuration |
| environment manager | Makefile, CI, Dockerfile, dev container, docs |
re-run the wizard; it renders all five together |
| license, git | LICENSE, the initial commit |
ordinary files |
The rest of this tutorial is the same work done by hand. Read it either way: the wizard picks defaults, and knowing which ones is how you decide whether to keep them.
Step 0: run the setup wizard¶
Same questions as sci-rag-new, run against the checkout you are already
standing in. Every question has a default, so pressing Enter through the
session leaves you with something that runs offline. --dry-run shows
what it would change without touching anything; --defaults skips the
asking entirely.
You can re-run it later. That is how you change several answers at once.
Want only the name and description stamped in, with the demo ontology's guided comments left intact? The narrow path still works:
uv run python scripts/init_domain.py --name "Membrane Materials KB" \
--description "Membrane chemistry, fouling, and performance for water treatment" --apply
Without --apply it just shows you what it would change.
Step 1: collect your documents¶
Gather PDFs, Markdown, or plain-text files into data/raw/. Advice
learned the hard way:
- Favor documents with answers in them. Reviews, reports, and characterization papers beat commentary and slide decks.
- Know your redistribution rights per document. You are about to
build a system that quotes these documents back to people. Public
domain or CC-BY? Fine anywhere. A paywalled publisher PDF you
legitimately hold? Fine for your internal instance, but it must be
marked
restrictedso it can never surface on an open endpoint. - Volume guidance. The pipeline is happy from 5 documents to a few thousand. Start with 20 to 50 good ones; you will learn more from a curated small corpus plus the evaluation harness than from a dump.
Step 2: the corpus manifest¶
A manifest is one JSON line per document, and it is where licensing and
citations come from. It lives at data/corpus.jsonl.
Reads each document's opening pages through the same parsers ingestion uses, and proposes a manifest with title, authors, year, DOI, journal, and a shared source bucket per document.
Review data/corpus.jsonl.proposed, then move it into place, or re-run
with --apply. No API key? Add --print-prompt, paste the result into any
assistant, and feed the reply back with --from-file. See
LLM-assisted setup.
The rights column is yours. Every drafted row says license_class:
unknown, which is the fail-closed default, and the command tells you how
many documents need a decision. A license sentence found verbatim in a
document is quoted into license_source as evidence for you to read. The
field reference below is what you are editing.
Create data/corpus.jsonl:
{"path": "raw/lee-2021-fouling-review.pdf", "title": "Membrane Fouling Mechanisms: A Review", "authors": ["Lee, S.", "Park, J."], "year": 2021, "doi": "10.1000/example", "license_class": "open_commercial", "source": "journal_papers"}
{"path": "raw/epa-membrane-guidance.pdf", "title": "EPA Membrane Filtration Guidance Manual", "authors": ["US EPA"], "year": 2005, "license_class": "public", "source": "agency_reports"}
{"path": "raw/chen-thesis.pdf", "title": "Chen PhD Thesis", "year": 2023, "license_class": "restricted", "source": "theses"}
Field notes, which apply either way:
pathis relative to the manifest file. Onlypathis required.license_classis one ofpublic,open_commercial,open_noncommercial,restricted,unknown(aliases likeCC-BYandcc0are understood). When in doubt, leave it out; the defaultunknownis treated as unsafe, which is the safe mistake.sourceis your own vocabulary for grouping ("journal_papers", "agency_reports"). It becomes a retrieval filter, so choose 3 to 6 meaningful buckets rather than one per document.
You can also skip the manifest and run sci-rag ingest data/raw, which
auto-builds entries with everything defaulted; fine for a first spike,
not for a corpus you will cite.
Step 3: declare your ontology¶
domain/domain.yaml tells the graph extractor what concepts matter in your
field. It ships configured for the demo's agricultural types; replace them with
yours.
Once documents are on disk, the field's own vocabulary is right there, so the useful question is not what this field might contain but what these documents actually talk about.
Review domain/domain.yaml.proposed, or re-run with --apply. Already
have an ontology you mostly like? --refine asks only what the model would
add and remove, with a reason for every removal. Your tuned retrieval:
and compression: blocks are carried over untouched either way.
This is also the fix for the symptom in step 5 below: near zero entities
after graph extract means the ontology and the corpus are talking past
each other.
Open domain/domain.yaml and replace the demo's types:
name: "Membrane Materials KB"
description: >
Membrane chemistry, fouling behavior, and separation performance for
water treatment applications.
entity_types:
- name: Membrane
description: "A membrane type or product (thin-film composite, ceramic UF)"
- name: Material
description: "A polymer, ceramic, or coating material (polyamide, PVDF, graphene oxide)"
- name: Contaminant
description: "A species being removed (NaCl, boron, PFAS, natural organic matter)"
- name: FoulingMechanism
description: "A fouling mode (scaling, biofouling, organic adsorption)"
- name: Process
description: "A treatment process or operation (reverse osmosis, backwashing)"
- name: PerformanceMetric
description: "A measured performance quantity (flux, rejection, permeability)"
- name: Treatment
description: "A cleaning or surface modification (chlorination, zwitterionic coating)"
relation_types:
- name: MADE_OF
description: "Membrane is made of material"
- name: REMOVES
description: "Membrane or process removes contaminant"
- name: SUFFERS_FROM
description: "Membrane or material suffers from fouling mechanism"
- name: MITIGATED_BY
description: "Fouling mechanism is mitigated by treatment"
- name: MEASURED_AT
description: "Metric measured at a condition or value"
- name: IMPROVES
description: "Treatment or material improves a performance metric"
How to choose well, either way:
- 6 to 15 entity types. Fewer and the graph is mush; more and the extractor dithers. Ask: what column headings would an expert use to organize a spreadsheet of this field's facts?
- Descriptions are prompts. The extraction model sees them verbatim. Concrete examples in parentheses do more work than abstract phrasing.
- Relations read as sentences. "source RELATION target" should be sayable out loud: "polyamide SUFFERS_FROM chlorine degradation".
Also update query_classes in the same file. List 3 to 5 kinds of
question your users actually ask, such as performance lookup, mechanism
explanation, or material comparison. Give each one a few trigger keywords
and a one-line instruction for how a document answering it would read.
These steer the HyDE layer.
Step 4: tune the prompts (lightly)¶
Skim domain/prompts/*.md. They are deliberately short and readable, and for
most domains only two of them are worth touching.
Rewords the template in your field's terms while keeping the job identical:
every $SLOT must survive, the output contract must not move, and the
rewrite is re-rendered against dummy values before it is written, because a
template that lost a slot loads fine and fails mid-run.
Only those two commands exist. The judge prompts and the compression prompt are refused by name, with a reason.
entity_extraction.md: keep the rules, adjust the example JSON names to your field so the model sees the register you expect.answer.md: add any domain-specific answer norms ("always report flux in LMH", "state the test conditions with every rejection value").
Leave the judge prompts alone until you have read
evaluation.md; their blindness rules are load-bearing. Prompt
wording moves every downstream number, so re-run
sci-rag eval retrieval --ablation after a rewrite and compare.
Step 5: ingest and build¶
uv run sci-rag ingest --manifest data/corpus.jsonl
uv run sci-rag graph extract
uv run sci-rag graph communities
uv run sci-rag stats
Sanity checks along the way:
sci-rag statsafter ingest: does the chunk count look right (a dense 20-page PDF is typically 15 to 40 chunks)? Are your license classes distributed the way you declared?sci-rag retrieve "some question" --profile interactive: do the top chunks look sane? The stage table shows you which layer found what.- After
graph extract:statsshould show entities in the low hundreds for a 50-document corpus. Near zero means the ontology and the corpus are talking past each other (types too abstract, or documents too thin); thousands means the types are too loose.
Step 6: seed questions, then measure¶
domain/eval_seed_questions.jsonl needs 10 to 20 questions a domain expert can
vouch for. This is the biggest manual step in the tutorial, and the one where
"vouch for" is doing the most work: these questions are what every retrieval
and answer metric is computed against.
Samples real passages from your corpus, asks for questions grounded in them, and then verifies in Python that every quoted evidence phrase actually appears in a passage belonging to a document the question names. Rows that fail are dropped and reported.
Every drafted row carries a drafted tag, and it travels: while any
remains, sci-rag eval retrieval and sci-rag eval answers say in the
report that their ground truth is unreviewed and their numbers provisional.
Read each question, check it against the document it cites, then delete the
tag. That deletion is your sign-off, and nothing does it for you.
Replace the file with your own lines:
{"id": "pfas-rejection", "question": "What PFAS rejection does a polyamide RO membrane achieve?", "reference_answer": "Above 99 percent for long-chain PFAS at typical seawater RO conditions, per Lee 2021.", "reference_titles": ["Membrane Fouling Mechanisms: A Review"], "evidence_phrases": ["99", "long-chain PFAS"], "tags": ["performance"]}
Three rules of thumb, either way. Pick evidence phrases distinctive enough that
finding them means finding the answer, where numbers with units are
perfect. Include one or two multi-hop questions whose answers span
documents. And include one question the corpus cannot answer, tagged
unanswerable, as an honesty probe.
Then:
uv run sci-rag eval retrieval --ablation # which layers contribute on your corpus
uv run sci-rag eval answers # generated answers, graded by the blind judge
Read the ablation table by comparing every row against full_deep. If
no_graph matches full_deep, your graph is not contributing yet
(usually an ontology problem, sometimes just a small corpus). This
feedback loop, edit ontology or corpus, re-run, compare, is the honest
path to a good system, and the reports in eval_results/ keep the
receipts.
Step 7: serve and share¶
Before anyone else touches it, set API keys in .env (see
.env.example) and decide your external license scope. A public or
semi-public endpoint should pin callers to
{"license_classes": ["public", "open_commercial"]}, so your restricted
and unknown documents stay internal. The
API reference covers keys, scopes, and the MCP tools; the
GCP guide covers putting it on Cloud Run.
The improvement loop¶
Corpus and ontology changes are cheap; the eval reports are the evidence that a change helped. A workable rhythm: add or fix a handful of documents, re-run ingest and graph, re-run the two eval commands, read the diffs. When a real user asks a question the system misses, add it as a seed question first, then fix the miss.