Skip to content

Evaluate a corpus

The failure this prevents: you swap the embedding model, the answer rate goes up, everyone celebrates, and what actually happened is that the system started answering questions it should have refused. Answer rate on its own is a metric that rewards hallucination.

  1. Write a golden CSV of rows the corpus can answer. The column the library reads is expected — not expected_support, which is silently ignored. The question column may be question or query.

    question,expected
    "Can the employee disclose confidential information?","shall not disclose"
    "What notice does termination require?","thirty days"

    A row passes when the content tokens of expected are a subset of the answer’s — a lightweight check that the right evidence made it into the text.

  2. Run it.

    report = rag.evaluate("golden.csv")
    print(report.groundedness_rate) # grounded / answered
    print(report.citation_rate) # cited / answered
    print(report.expected_support_rate) # expected_supported / TOTAL
  3. Keep the must-refuse questions in a separate list and assert the decision directly. This is the abstention regression gate.

    from citenexus.answer.result import Decision
    MUST_REFUSE = [
    "What is the capital of France?", # not in the corpus at all
    "What is the maximum security deposit?", # adjacent topic, no clause
    ]
    for question in MUST_REFUSE:
    result = rag.ask(question)
    assert result.evidence.decision is Decision.refused, question
  4. Gate CI on the direction of travel, not on an absolute:

    assert report.groundedness_rate >= baseline.groundedness_rate
    assert report.citation_rate >= baseline.citation_rate
    assert refused_count == len(MUST_REFUSE)

Why the must-refuse list is the one that matters

Section titled “Why the must-refuse list is the one that matters”

A model that answers everything scores well on any metric that only looks at answers — which is exactly what groundedness_rate and citation_rate do: both divide by answered, so a system that refuses everything it is unsure of and answers three questions perfectly reads 100% / 100%. Those two rates prove that what it did say was grounded and cited. They say nothing about what it should not have said.

The must-refuse assertions are the other half. A change that makes the system more willing to answer will flip one of those rows before it moves any rate, and a flipped assertion names the question. That is the early warning; the rates are the confirmation.

When a row flips, the signals say why before you open anything:

sig = response.evidence
sig.decision # answered | refused | partial
sig.supporting_sources # 0 -> retrieval failed, not generation
sig.distinct_documents # 1 -> single-source, no corroboration
sig.retrieval_score_spread # near 0 -> everything ranked alike; ranking is not discriminating
sig.unsupported_claims_removed # >0 -> the generator over-reached and got trimmed
sig.conflicts_detected # >0 -> the corpus disagrees with itself
sig.unsupported_scripts # non-empty -> a capability gap, not an evidence gap
sig.authority_floor_applied # True -> a refusal may mean "no standing", not "no evidence"

supporting_sources = 0 and unsupported_claims_removed > 0 are different bugs in different halves of the pipeline. The first is retrieval, the second is generation — and treating them the same is how teams spend a week tuning a prompt to fix an indexing problem.

A refusal is not always an evidence gap. In strict mode it can also mean the authority floor withheld everything it found (authority_floor_applied), or that two grounded sources contradict each other (conflicts_detected). Those are three different failures with three different fixes.

retrieve() is the engine under ask(), exposed so you can measure ranking without a generator in the loop:

for c in rag.retrieve("termination notice period", k=10):
print(f"{c.score:.4f} {c.document_id} {c.citable_text[:60]}")

If the right passage is not in the top-k here, no amount of prompt work will save the answer.

The law worked example runs this end to end against California landlord–tenant statutes with live models — including the reason its own expected_support_rate reads 45% while groundedness and citation are both 100%.