Citation & Coverage Validators

AI Evaluation & Safety intermediate Building with AI ~20 min read

Overview

Citation and Coverage Validators are regex-driven evaluators that verify whether an LLM’s response correctly cites sources from its retrieved context. In retrieval-augmented generation (RAG) systems, the model is expected to ground its answers in specific documents by referencing them with citation markers like [resource_id] or [doc_3]. These validators parse those citation spans, compare them against the set of actually-retrieved documents, and score the response for citation accuracy and coverage.

The problem these validators solve is trust verification at scale. When an RAG system serves thousands of queries per day, manual spot-checking of citations is impractical. Automated validators run as part of the evaluation pipeline, flagging responses that hallucinate citations (referencing documents not in the context), miss key sources (failing to cite highly relevant documents), or cite out-of-range identifiers that don’t correspond to any document.

In a Go-based RAG evaluation pipeline, these validators plug into the embeddable evaluation runner as Evaluator implementations. They receive the generated response along with metadata about which documents were retrieved, extract all citation markers via regex, and produce a structured result containing precision (what fraction of citations are valid), recall (what fraction of relevant documents are cited), and a list of specific violations.

Core Concepts

Citation Extraction via Regex

The validator scans the LLM output using a configurable regular expression pattern (typically \[([a-zA-Z0-9_]+)\]) to extract all citation markers. Each match is parsed into a resource identifier that is then validated against the set of documents provided in the retrieval context. The regex pattern is configurable because different systems use different citation formats: bracket notation, superscript numbers, or inline footnotes.

Citation Precision

Citation precision measures the fraction of extracted citations that reference valid, actually-retrieved documents. A precision of 1.0 means every citation in the response points to a real document from the context. Low precision indicates hallucinated citations—the model is referencing documents that were never retrieved, which undermines user trust and can propagate misinformation.

Citation Recall (Coverage)

Coverage measures what fraction of the relevant retrieved documents are actually cited in the response. A coverage score of 1.0 means the model referenced every relevant document. Low coverage suggests the model is ignoring parts of its context, potentially missing important information. The “relevant” set can be all retrieved documents or a subset marked as ground-truth relevant by human annotators.

Out-of-Range Detection

Beyond precision and recall, the validator detects out-of-range citations: identifiers that syntactically match the citation pattern but fall outside the valid ID space. For example, if the context contains documents doc_1 through doc_5, a citation of doc_12 is out-of-range. These are a specific subclass of hallucinated citations that often indicate the model is confabulating document IDs rather than copying them from the context.

How It Works

The validator operates in three phases: extraction, validation, and scoring. Each phase produces intermediate data that feeds into the final evaluation result.

Citation Extractor

package eval

import (
    "regexp"
    "strings"
)

type CitationExtractor struct {
    pattern *regexp.Regexp
}

func NewCitationExtractor(pattern string) *CitationExtractor {
    if pattern == "" {
        pattern = `\[([a-zA-Z0-9_]+)\]`
    }
    return &CitationExtractor{
        pattern: regexp.MustCompile(pattern),
    }
}

type Citation struct {
    ResourceID string
    StartPos   int
    EndPos     int
    RawMatch   string
}

func (ce *CitationExtractor) Extract(text string) []Citation {
    matches := ce.pattern.FindAllStringSubmatchIndex(text, -1)
    citations := make([]Citation, 0, len(matches))

    for _, match := range matches {
        fullStart, fullEnd := match[0], match[1]
        groupStart, groupEnd := match[2], match[3]

        citations = append(citations, Citation{
            ResourceID: text[groupStart:groupEnd],
            StartPos:   fullStart,
            EndPos:     fullEnd,
            RawMatch:   text[fullStart:fullEnd],
        })
    }
    return citations
}

func (ce *CitationExtractor) UniqueIDs(text string) []string {
    citations := ce.Extract(text)
    seen := make(map[string]bool, len(citations))
    unique := make([]string, 0, len(citations))

    for _, c := range citations {
        id := strings.ToLower(c.ResourceID)
        if !seen[id] {
            seen[id] = true
            unique = append(unique, c.ResourceID)
        }
    }
    return unique
}

Citation Coverage Evaluator

package eval

import (
    "context"
    "fmt"
    "strings"
)

type CitationValidator struct {
    extractor *CitationExtractor
}

func NewCitationValidator(citationPattern string) *CitationValidator {
    return &CitationValidator{
        extractor: NewCitationExtractor(citationPattern),
    }
}

func (cv *CitationValidator) Name() string {
    return "citation_coverage"
}

func (cv *CitationValidator) Evaluate(
    ctx context.Context, ex Example, out Output,
) (EvalResult, error) {
    retrievedIDs, ok := ex.Input["retrieved_ids"].([]interface{})
    if !ok {
        return EvalResult{}, fmt.Errorf("missing retrieved_ids in example input")
    }

    retrievedSet := make(map[string]bool, len(retrievedIDs))
    for _, id := range retrievedIDs {
        retrievedSet[strings.ToLower(fmt.Sprint(id))] = true
    }

    citedIDs := cv.extractor.UniqueIDs(out.Response)

    validCitations := 0
    invalidCitations := 0
    var violations []string

    for _, id := range citedIDs {
        if retrievedSet[strings.ToLower(id)] {
            validCitations++
        } else {
            invalidCitations++
            violations = append(violations,
                fmt.Sprintf("hallucinated citation: [%s]", id))
        }
    }

    coveredCount := 0
    for _, id := range retrievedIDs {
        idStr := strings.ToLower(fmt.Sprint(id))
        for _, cited := range citedIDs {
            if strings.ToLower(cited) == idStr {
                coveredCount++
                break
            }
        }
    }

    totalCited := validCitations + invalidCitations
    precision := 1.0
    if totalCited > 0 {
        precision = float64(validCitations) / float64(totalCited)
    }

    recall := 0.0
    if len(retrievedIDs) > 0 {
        recall = float64(coveredCount) / float64(len(retrievedIDs))
    }

    f1 := 0.0
    if precision+recall > 0 {
        f1 = 2 * precision * recall / (precision + recall)
    }

    pass := precision >= 0.9 && recall >= 0.5
    reason := ""
    if !pass {
        reason = fmt.Sprintf(
            "precision=%.2f recall=%.2f; violations: %s",
            precision, recall, strings.Join(violations, "; "))
    }

    return EvalResult{
        Pass:     pass,
        Score:    f1,
        Reason:   reason,
        Keywords: violations,
    }, nil
}

Out-of-Range Detector

package eval

import (
    "context"
    "fmt"
    "strconv"
    "strings"
)

type OutOfRangeDetector struct {
    extractor *CitationExtractor
    prefix    string
}

func NewOutOfRangeDetector(prefix string) *OutOfRangeDetector {
    return &OutOfRangeDetector{
        extractor: NewCitationExtractor(""),
        prefix:    prefix,
    }
}

func (d *OutOfRangeDetector) Name() string {
    return "citation_out_of_range"
}

func (d *OutOfRangeDetector) Evaluate(
    ctx context.Context, ex Example, out Output,
) (EvalResult, error) {
    maxID, ok := ex.Input["max_document_id"].(float64)
    if !ok {
        return EvalResult{}, fmt.Errorf("missing max_document_id")
    }

    citations := d.extractor.Extract(out.Response)
    outOfRange := 0
    var violations []string

    for _, c := range citations {
        if !strings.HasPrefix(c.ResourceID, d.prefix) {
            continue
        }
        numStr := strings.TrimPrefix(c.ResourceID, d.prefix)
        num, err := strconv.Atoi(numStr)
        if err != nil {
            continue
        }
        if num < 1 || num > int(maxID) {
            outOfRange++
            violations = append(violations,
                fmt.Sprintf("[%s] out of range [1,%d]",
                    c.ResourceID, int(maxID)))
        }
    }

    pass := outOfRange == 0
    score := 1.0
    if len(citations) > 0 {
        score = 1.0 - float64(outOfRange)/float64(len(citations))
    }

    return EvalResult{
        Pass:     pass,
        Score:    score,
        Reason:   strings.Join(violations, "; "),
        Keywords: violations,
    }, nil
}

Practical Applications

In a production RAG pipeline, citation validators run as part of every evaluation suite. When a new retrieval model or prompt template is deployed, the validators measure whether the model’s citation behavior has regressed. A drop in citation precision below 0.9 blocks the deployment, since hallucinated citations directly erode user trust.

The coverage score feeds into a retrieval quality dashboard. If coverage drops, it often indicates that the retrieval stage is returning too many documents, overwhelming the model’s context window and causing it to ignore some sources. This signal helps the team tune the top-k parameter for retrieval.

Out-of-range detection catches a specific failure mode where the model generates plausible-looking but fabricated document IDs. This is particularly common when the model has been fine-tuned on examples with sequential IDs—it learns the pattern and extrapolates beyond the actual document set.

Implementation Patterns

Configurable Citation Formats

Different systems use different citation conventions. Academic-style systems might use [1], [2], while document-grounded systems use [doc_id]. The extractor’s regex pattern is injected at construction time, making the same validator reusable across projects. For multi-format systems, chain multiple extractors and merge their results.

Weighted Coverage Scoring

Not all retrieved documents are equally important. A weighted variant assigns relevance scores to each document (from the retrieval stage’s ranking) and computes coverage as the sum of weights of cited documents divided by the total weight. This penalizes missing the top-ranked document more than missing a low-ranked one.

Threshold Tuning via Replay

The pass/fail thresholds (precision ≥ 0.9, recall ≥ 0.5) are tuned using replay mode from the evaluation runner. Teams replay historical outputs with different threshold values and examine the resulting pass rates. The goal is to set thresholds that flag genuine regressions without creating excessive false positives that erode trust in the CI signal.

Common Pitfalls

Further Reading