Fact Verification¶
align_citations tags each answer segment with "supported", "partial", or "unsupported". That is a sentence-level read. A sentence that combines a verified fact with an unverified one can land on "partial" without telling you which half was the problem. verify_facts, defined in src/cite_right/fact_verification.py, splits the answer into atomic claims first and verifies each one independently, so you can see exactly which assertions survived.
from cite_right import SourceDocument, verify_facts
answer = "The product launched in March and sales exceeded 10 million units."
sources = [
SourceDocument(
id="press_release",
text="The new product line was introduced to the market in March 2024.",
)
]
result = verify_facts(answer, sources)
print(result.num_claims, result.num_verified, result.num_unverified)
print(result.partial_claims, result.unverified_claims)
The same pipeline that powers align_citations runs underneath. The difference is that the answer is decomposed before alignment and the per-span status is folded into per-claim results with aggregate metrics.
What verify_facts Does¶
verify_facts(answer, sources, ...) segments the answer, decomposes each segment into atomic claims, runs align_citations on each claim's text against the same sources, and folds the per-span results into per-claim verdicts.
flowchart TD
A["answer"] --> B["answer_segmenter.segment"]
B --> S1["AnswerSpan 1"]
B --> S2["AnswerSpan 2"]
S1 --> D["claim_decomposer.decompose"]
S2 --> D
D --> C1["Claim 1"]
D --> C2["Claim 2"]
D --> C3["Claim 3"]
C1 --> A1["align_citations(claim.text, sources)"]
C2 --> A1
C3 --> A1
A1 --> V["ClaimVerification per claim"]
V --> M["FactVerificationMetrics"]
The end-to-end control flow: segment the answer, decompose into claims, align each claim, fold into per-claim verdicts, then aggregate metrics.
If the answer produces no claims at all (empty string, or every sentence stripped out), verify_facts returns an empty FactVerificationMetrics with num_claims=0, verification_rate=1.0, avg_confidence=1.0, and min_confidence=1.0. The empty path is the "nothing to verify" path; it is not a clean bill of health for the answer.
Claim Decomposition¶
Decomposition is the step that turns a sentence into one or more atomic claims. verify_facts accepts any object that implements the ClaimDecomposer protocol from src/cite_right/claims.py:
A Claim is a frozen Pydantic model with text, char_start, char_end, the originating source_span (AnswerSpan), and a claim_index within that span. The char_start / char_end offsets are absolute 0-based half-open intervals in the original answer string, the same convention as Citation offsets.
SimpleClaimDecomposer¶
SimpleClaimDecomposer is the default when you do not pass claim_decomposer. It returns the input AnswerSpan as a single Claim wrapping the entire span, with claim_index=0. Use it when sentence-level granularity is enough or when you do not want to add a dependency for clause splitting. It has no extra dependencies.
SpacyClaimDecomposer¶
SpacyClaimDecomposer loads a spaCy model (default "en_core_web_sm") and walks the dependency parse to find conj (conjunct) tokens. Each conjunct gets a split boundary built from the coordinating conjunction and any whitespace, or, when no cc child is present, from the preceding comma or semicolon. The resulting character boundaries are sorted, deduplicated, and applied to the span text to extract one claim per segment between boundaries. Claims with fewer than min_claim_tokens (default 2) tokens are dropped.
The decomposer requires the spaCy extra. Install it and the model before constructing one:
The __init__ raises RuntimeError with a clear message if spacy is not importable, or if the named model is not installed. Pass model="..." to use a different spaCy pipeline, and min_claim_tokens=... to tighten or relax the per-claim token floor.
If no conj tokens are found, SpacyClaimDecomposer falls back to a single Claim wrapping the whole span, the same shape SimpleClaimDecomposer returns. So the spaCy path is a refinement, not a guarantee of more claims.
from cite_right import verify_facts
from cite_right import SpacyClaimDecomposer
decomposer = SpacyClaimDecomposer()
result = verify_facts(answer, sources, claim_decomposer=decomposer)
Reuse one SpacyClaimDecomposer instance across calls. The constructor loads the spaCy pipeline; calling verify_facts repeatedly with a fresh instance pays that cost every time.
What verify_facts Accepts¶
The signature mirrors align_citations for the parts that the per-claim alignment needs, plus the decomposition knob and the verification thresholds.
verify_facts(
answer,
sources,
*,
config: FactVerificationConfig | None = None,
claim_decomposer: ClaimDecomposer | None = None,
answer_segmenter: AnswerSegmenter | None = None,
source_segmenter: Segmenter | None = None,
tokenizer: Tokenizer | None = None,
embedder: Embedder | None = None,
backend: Literal["auto", "python", "rust"] = "auto",
) -> FactVerificationMetrics
sources accepts the same forms as align_citations: bare str, SourceDocument, or SourceChunk. Plain strings get auto-assigned ids ("source_0", "source_1", ...).
answer_segmenter defaults to SimpleAnswerSegmenter. source_segmenter, tokenizer, and embedder are forwarded into the per-claim align_citations call. backend follows the same "auto" / "python" / "rust" rules as align_citations; see Rust Acceleration for the fallback semantics.
FactVerificationConfig¶
FactVerificationConfig is a frozen Pydantic model with three fields.
verified_coverage_threshold is the minimum answer_coverage for a claim to be tagged "verified". Default 0.6, which matches the default supported_answer_coverage on CitationConfig so the two surfaces line up.
partial_coverage_threshold is the minimum answer_coverage for a claim to be tagged "partial". Below it the claim is "unverified". Default 0.3.
citation_config is the CitationConfig forwarded into each per-claim align_citations call. When unset, verify_facts builds one with top_k=3, min_answer_coverage=0.2, and supported_answer_coverage=cfg.verified_coverage_threshold, so the per-claim "supported" / "partial" / "unsupported" semantics match verify_facts's "verified" / "partial" / "unverified" rollup at the default threshold.
from cite_right import CitationConfig, FactVerificationConfig, verify_facts
config = FactVerificationConfig(
verified_coverage_threshold=0.7,
partial_coverage_threshold=0.4,
citation_config=CitationConfig(top_k=5, min_answer_coverage=0.3),
)
result = verify_facts(answer, sources, config=config)
The thresholds are applied to the best citation's answer_coverage component for each claim. The best citation is the one with the highest score among the citations align_citations returns for that claim.
Per-Claim Verdicts¶
Each claim gets a ClaimVerification. The shape is a frozen Pydantic model:
class ClaimVerification(BaseModel):
claim: Claim
status: Literal["verified", "partial", "unverified"]
confidence: float
best_citation: Citation | None = None
all_citations: list[Citation] = []
source_ids: list[str] = []
status is derived from the best citation's answer_coverage. At or above verified_coverage_threshold it is "verified". At or above partial_coverage_threshold it is "partial". Otherwise it is "unverified". When the per-claim align_citations call returns no citations at all, the status is "unverified" with confidence=0.0 and an empty source_ids.
confidence is the best citation's answer_coverage component. It is a coverage number in the same sense as the alignment pipeline's answer_coverage; it is not a calibrated probability.
best_citation is the highest-score Citation from the per-claim alignment, or None when no citation survived. all_citations is the full list. source_ids is the deduplicated set of source_id values across all citations on this claim.
Aggregate Metrics¶
FactVerificationMetrics is the top-level return type.
class FactVerificationMetrics(BaseModel):
num_claims: int
num_verified: int
num_partial: int
num_unverified: int
verification_rate: float
avg_confidence: float
min_confidence: float
claim_verifications: list[ClaimVerification] = []
verified_claims: list[Claim] = []
unverified_claims: list[Claim] = []
partial_claims: list[Claim] = []
num_claims is the count after decomposition. num_verified, num_partial, and num_unverified are the per-status totals. verification_rate is num_verified / num_claims for non-empty input, or 1.0 for empty input. avg_confidence and min_confidence are taken across the per-claim confidences, with 1.0 substituted for empty input.
claim_verifications is the per-claim list in decomposition order. verified_claims, partial_claims, and unverified_claims are the same Claim objects partitioned by status, useful when you only need the text of the unsupported claims:
The Claim.char_start and Claim.char_end are offsets in the original answer string, so they line up with the same half-open convention as Citation offsets on the source side. A UI that highlights the answer can use the claim offsets to mark up the specific span that failed verification.
Status Comes From Localized Citations¶
A claim's status is driven by the per-claim align_citations run, exactly the same pipeline that powers sentence-level status. That means:
- Status is set by the best exact
Citation'sanswer_coverage, not by embedding similarity. A high embedding score that never localizes isretrieval_support, not aCitation, and does not flip a claim to"verified". - Contradiction (negation, number mismatch, leftover n-gram slot, entity swap) on the candidate passage is what makes a span
"partial"in the alignment pipeline.verify_factsreuses that signal: a claim whose best citation exists but contradicts the claim text lands in"partial", not"unverified". - The default
verified_coverage_thresholdof0.6matches the defaultsupported_answer_coverage, so a claim with the same per-claim alignment as a sentence-level"supported"span also gets"verified". See Citation Alignment for the citation pipeline in detail.
When To Use verify_facts vs compute_hallucination_metrics¶
verify_facts and compute_hallucination_metrics are complementary surfaces, not alternatives.
compute_hallucination_metrics rolls the alignment results up to a single groundedness score and a hallucination rate. It is the right tool for a quality gate, a dashboard, or a regression test. See Hallucination Detection for the rollup view and the RAGTruth test numbers behind it.
verify_facts is the right tool when you need to point at the exact claim that failed. UIs that let users click on a flagged sentence and see "this part is supported, this part is not" need the per-claim offsets and texts. Pipelines that rewrite a single unsupported claim and resubmit need the per-claim granularity. Logs that need a list of the unsupported assertions need unverified_claims.
The two share the underlying align_citations output, so running both on the same answer is not a doubling of work if you already have the SpanCitations; for one-shot use, calling verify_facts once and reading the metrics is usually simpler than calling align_citations and then compute_hallucination_metrics separately.
Installation And Dependencies¶
verify_facts, FactVerificationConfig, FactVerificationMetrics, ClaimVerification, SimpleClaimDecomposer, and Claim are in the base install. SpacyClaimDecomposer is in the base install too, but constructing one needs the spaCy runtime and the named model. See Installation for the full set of extras and the Python version requirement.
pip install cite-right==0.4.0
pip install "cite-right[spacy]==0.4.0"
python -m spacy download en_core_web_sm
Limits Of Claim Decomposition¶
Decomposition is syntactic. SpacyClaimDecomposer finds conjunct tokens in the dependency parse; it does not understand whether the two halves of a coordinated sentence are independent facts. A sentence that combines two claims in a non-coordinated structure ("The vaccine is safe, which is why it was approved.") will pass through as a single claim. A sentence that contains a coordinated structure that is not two independent facts ("We grew and we learned.") will be split anyway.
The verification itself only checks explicit textual support. Logical inferences that are true given the source but not stated verbatim are not verified. Numerical claims that match the source approximately but not exactly will be tagged by the same coverage thresholds as the underlying alignment, not by a separate numerical match step. The RAGTruth numbers on Hallucination Detection carry over: the tagger overflags, and many spans tagged "unverified" are not gold hallucinations.
Related Pages¶
- Citation Alignment — the per-span
align_citationscontract thatverify_factsruns underneath. - Hallucination Detection — the rollup view of the same alignment output, with the RAGTruth test numbers.
- Segmenters — the answer and source segmenters forwarded into
verify_facts. - Installation — the
[spacy]extra and theen_core_web_smmodel download. - How It Works — the index-first retrieval path and the embedder add-ons.
- Rust Acceleration — the
backendselection and the fallback whencite_right._coreis missing.