Generating Questions
Writing an eval set by hand does not scale past a handful of questions. generate() drafts
one automatically from a corpus’s chunks — the trade is that a draft needs review before it
is trustworthy ground truth. This page covers generate() itself, the two built-in question
generators, and how to read what it skipped.
import contextgrid as cgimport contextgrid.evalset as es
print(type(cg.generate)) # <class 'module'> -- wrong oneprint(type(es.generate)) # <class 'function'> -- this is the one you want<class 'module'><class 'function'>generate()
generate( chunks: Sequence[Chunk], generator: QuestionGenerator, *, sample: int | None = 50, seed: int = 0, min_chunk_words: int = 25, evalset_id: str = "generated",) -> GenerationIt filters out chunks shorter than min_chunk_words, then samples from what’s left —
sample chunks by default, spread across documents rather than picked uniformly from
the pool, so one long document in an otherwise small corpus doesn’t end up supplying every
question. sample=None uses every chunk that passed the word-count filter instead of
sampling — useful for a small corpus, potentially slow or costly for an LLM generator on a
big one, since it calls the generator once per usable chunk.
The return value is a Generation, not an EvalSet — get the questions via .evalset:
Generation(evalset: EvalSet, warnings: WarningLog, chunks_sampled: int = 0, chunks_skipped: int = 0).count is len(self.evalset). Generation is not cg.Generation — import it as
contextgrid.evalset.Generation if you need the type by name; most code just uses the
value generate() returns.
A run with a real skip
Two chunks pass the default min_chunk_words=25, one doesn’t:
import contextgrid as cgfrom contextgrid.core.types import Chunk, Span
def make(doc, i, text): return Chunk(id=f"{doc}::{i}", span=Span(doc_id=doc, start=0, end=len(text)), text=text)
chunks = [ make("handbook.pdf", 0, "Employees accrue one and a half days of paid leave for every full " "month of continuous service completed at the company, prorated for part time staff on the payroll."), make("handbook.pdf", 1, "See section 2 for details."), # 5 words -- too short make("policy.pdf", 0, "Resignation requires thirty days of written notice delivered in " "person to the direct manager and to human resources well in advance of the intended last working day."),]
generator = cg.KeywordProbeGenerator()draft = cg.evalset.generate(chunks, generator) # every keyword left at its default
print("count:", draft.count)print("chunks_sampled:", draft.chunks_sampled)print("chunks_skipped:", draft.chunks_skipped)for item in draft.evalset: print(" ", item.id, repr(item.question))for w in draft.warnings.entries: print("warning:", str(w))count: 2chunks_sampled: 2chunks_skipped: 1 handbook.pdf::0#probe 'accrue company completed continuous employees every' policy.pdf::0#probe 'advance delivered direct human intended last'warning: INFO [evalset]: 2 questions drafted from 2 chunks by 'keyword-probe'. Nothing has filtered them and nobody has read them, so they are not ground truth yet -- run the filters, then the review queuechunks_skipped: 1 is “See section 2 for details.” — five words, well under the
min_chunk_words=25 default. generate() doesn’t tell you which chunk it dropped beyond
that count; if you need to know, filter your own chunk list by word count before calling it.
KeywordProbeGenerator — no model required
KeywordProbeGenerator(terms: int = 6, seed: int = 0, corpus_frequencies: dict[str, int] = {}).name is "keyword-probe". It needs no LLM — a “question” is a space-joined bag of the
chunk’s rarest words by corpus frequency (terms of them), which is what you saw in the run
above ('accrue company completed continuous employees every').
generate() returns [] for a chunk if its longest sentence has fewer than 2 distinctive
words (words longer than 3 characters) — short or dense chunks silently produce no probe.
The seed field exists on the dataclass but isn’t used by .generate()/.fit() — there’s
no randomness in this generator despite the field.
LLMQuestionGenerator — real questions, needs a model
LLMQuestionGenerator(llm: LLM, questions_per_chunk: int = 1, max_tokens: int = 600).name is "llm". Unlike the keyword probe, llm is required — there’s no default. You
can test this without a real API key using cg.RecordingLLM, a test double that pops
canned replies off a list:
import jsonimport contextgrid as cgfrom contextgrid.core.types import Chunk, Span
text = ("Employees accrue one and a half days of paid leave for every full month of " "continuous service completed at the company, prorated for part time staff on the payroll.")chunks = [Chunk(id="h::0", span=Span(doc_id="h.pdf", start=0, end=len(text)), text=text)]
reply = json.dumps([{ "question": "How much leave do employees accrue per month?", "quote": "one and a half days of paid leave",}])llm = cg.RecordingLLM(replies=[reply])generator = cg.LLMQuestionGenerator(llm=llm)
draft = cg.evalset.generate(chunks, generator, sample=10, seed=0)print(draft.count, draft.chunks_sampled, draft.chunks_skipped)for item in draft.evalset: print(item.id, item.question, item.anchors)print("prompts sent:", len(llm.prompts))1 1 0h::0#0 How much leave do employees accrue per month? (GoldAnchor(source_id='h.pdf', quote='one and a half days of paid leave', grade=2, page_hint=None, occurrence=0),)prompts sent: 1The item id is the chunk id plus a #-numbered suffix per question (h::0#0 for the first
question drawn from chunk h::0).
On any LLMError, or a reply that doesn’t parse as JSON, .generate() returns [] for
that chunk rather than raising — a broken model call produces an empty draft, not a crash.
A real LLMQuestionGenerator run against a hosted model needs an API key and network
access:
from contextgrid.evalset.llm import get_llm
llm = get_llm("openai:gpt-4o-mini") # reads OPENAI_API_KEY from the environmentgenerator = cg.LLMQuestionGenerator(llm=llm)draft = cg.evalset.generate(chunks, generator, sample=50)Writing your own generator
Anything with a .name string and a .generate(chunk) -> list[EvalItem] method works —
QuestionGenerator is a runtime-checkable Protocol, not a base class you have to inherit
from:
class QuestionGenerator(Protocol): name: str def generate(self, chunk: Chunk) -> list[EvalItem]: ...Reading draft.warnings
Every draft carries a WarningLog in .warnings, and generate() always logs at least one
INFO-severity entry saying the draft is unreviewed — worth reading in full before you
treat a draft as done. draft, chunks, and generator all get reassigned by the examples
above, so this rebuilds the keyword-probe draft from the first example on this page instead
of trusting whatever those names are still holding:
chunks = [ make("handbook.pdf", 0, "Employees accrue one and a half days of paid leave for every full " "month of continuous service completed at the company, prorated for part time staff on the payroll."), make("handbook.pdf", 1, "See section 2 for details."), make("policy.pdf", 0, "Resignation requires thirty days of written notice delivered in " "person to the direct manager and to human resources well in advance of the intended last working day."),]generator = cg.KeywordProbeGenerator()draft = cg.evalset.generate(chunks, generator)
for w in draft.warnings.entries: print(w.severity.value, w.message)info 2 questions drafted from 2 chunks by 'keyword-probe'. Nothing has filtered them and nobody has read them, so they are not ground truth yet -- run the filters, then the review queueA draft is a starting point. The next steps — filtering out bad auto-generated questions and running a human review queue over what’s left — are covered in Eval Set Quality.