ragcheck-eval 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ragcheck/__init__.py +18 -0
- ragcheck/adapters/__init__.py +7 -0
- ragcheck/adapters/base.py +39 -0
- ragcheck/adapters/function.py +19 -0
- ragcheck/adapters/langchain.py +59 -0
- ragcheck/cache.py +67 -0
- ragcheck/cli.py +210 -0
- ragcheck/config.py +54 -0
- ragcheck/datasets/__init__.py +6 -0
- ragcheck/datasets/loaders.py +35 -0
- ragcheck/datasets/models.py +32 -0
- ragcheck/datasets/synthetic.py +255 -0
- ragcheck/demo.py +168 -0
- ragcheck/judge/__init__.py +5 -0
- ragcheck/judge/judge.py +93 -0
- ragcheck/judge/prompts/answer_relevance.md +21 -0
- ragcheck/judge/prompts/answers_equivalent.md +16 -0
- ragcheck/judge/prompts/chunk_relevance.md +13 -0
- ragcheck/judge/prompts/citation_support.md +13 -0
- ragcheck/judge/prompts/dataset_paraphrase.md +10 -0
- ragcheck/judge/prompts/dataset_qa_cross.md +18 -0
- ragcheck/judge/prompts/dataset_qa_multi.md +15 -0
- ragcheck/judge/prompts/dataset_qa_single.md +15 -0
- ragcheck/judge/prompts/dataset_unanswerable.md +15 -0
- ragcheck/judge/prompts/faithfulness_decompose.md +18 -0
- ragcheck/judge/prompts/faithfulness_verify.md +13 -0
- ragcheck/judge/prompts/refusal_detection.md +13 -0
- ragcheck/judge/validation.py +119 -0
- ragcheck/llm.py +186 -0
- ragcheck/metrics/__init__.py +59 -0
- ragcheck/metrics/base.py +56 -0
- ragcheck/metrics/generation/__init__.py +7 -0
- ragcheck/metrics/generation/citation_accuracy.py +87 -0
- ragcheck/metrics/generation/faithfulness.py +99 -0
- ragcheck/metrics/generation/relevance.py +55 -0
- ragcheck/metrics/retrieval/__init__.py +8 -0
- ragcheck/metrics/retrieval/context_precision.py +70 -0
- ragcheck/metrics/retrieval/context_recall.py +81 -0
- ragcheck/metrics/retrieval/hit_rate.py +42 -0
- ragcheck/metrics/retrieval/mrr.py +41 -0
- ragcheck/metrics/robustness/__init__.py +6 -0
- ragcheck/metrics/robustness/paraphrase_consistency.py +96 -0
- ragcheck/metrics/robustness/refusal_calibration.py +81 -0
- ragcheck/report/__init__.py +5 -0
- ragcheck/report/cli_summary.py +45 -0
- ragcheck/report/html.py +145 -0
- ragcheck/report/models.py +55 -0
- ragcheck/report/regression.py +94 -0
- ragcheck/runner.py +166 -0
- ragcheck_eval-0.1.0.dist-info/METADATA +190 -0
- ragcheck_eval-0.1.0.dist-info/RECORD +54 -0
- ragcheck_eval-0.1.0.dist-info/WHEEL +4 -0
- ragcheck_eval-0.1.0.dist-info/entry_points.txt +2 -0
- ragcheck_eval-0.1.0.dist-info/licenses/LICENSE +21 -0
ragcheck/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""RAGCheck: pytest for RAG systems."""
|
|
2
|
+
|
|
3
|
+
from ragcheck.adapters.base import RAGAdapter, RAGResponse, RetrievedChunk
|
|
4
|
+
from ragcheck.datasets.models import EvalDataset, EvalSample, QAPair
|
|
5
|
+
from ragcheck.metrics.base import Metric, MetricResult
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"EvalDataset",
|
|
11
|
+
"EvalSample",
|
|
12
|
+
"Metric",
|
|
13
|
+
"MetricResult",
|
|
14
|
+
"QAPair",
|
|
15
|
+
"RAGAdapter",
|
|
16
|
+
"RAGResponse",
|
|
17
|
+
"RetrievedChunk",
|
|
18
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Adapters that wrap arbitrary RAG pipelines for evaluation."""
|
|
2
|
+
|
|
3
|
+
from ragcheck.adapters.base import RAGAdapter, RAGResponse, RetrievedChunk
|
|
4
|
+
from ragcheck.adapters.function import FunctionAdapter
|
|
5
|
+
from ragcheck.adapters.langchain import LangChainAdapter
|
|
6
|
+
|
|
7
|
+
__all__ = ["FunctionAdapter", "LangChainAdapter", "RAGAdapter", "RAGResponse", "RetrievedChunk"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Core adapter interface: wrap any RAG pipeline so RAGCheck can evaluate it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RetrievedChunk(BaseModel):
|
|
11
|
+
"""One chunk returned by the retrieval stage."""
|
|
12
|
+
|
|
13
|
+
content: str
|
|
14
|
+
source_id: str
|
|
15
|
+
score: float | None = None
|
|
16
|
+
metadata: dict = Field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RAGResponse(BaseModel):
|
|
20
|
+
"""Everything a pipeline produced for one question."""
|
|
21
|
+
|
|
22
|
+
answer: str
|
|
23
|
+
retrieved_chunks: list[RetrievedChunk]
|
|
24
|
+
latencies_ms: dict[str, float] = Field(default_factory=dict)
|
|
25
|
+
token_usage: dict[str, int] = Field(default_factory=dict)
|
|
26
|
+
refused: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RAGAdapter(ABC):
|
|
30
|
+
"""Wrap any RAG pipeline so RAGCheck can evaluate it."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def query(self, question: str) -> RAGResponse:
|
|
34
|
+
"""Answer a single question, returning the answer plus retrieval trace."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def batch_query(self, questions: list[str]) -> list[RAGResponse]:
|
|
38
|
+
"""Override for pipelines with native batching. Default: sequential."""
|
|
39
|
+
return [self.query(q) for q in questions]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Adapter that wraps a plain Python callable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from ragcheck.adapters.base import RAGAdapter, RAGResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FunctionAdapter(RAGAdapter):
|
|
11
|
+
"""Wrap any ``(question: str) -> RAGResponse`` callable as a RAGAdapter."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, fn: Callable[[str], RAGResponse]) -> None:
|
|
14
|
+
"""Store the pipeline function to delegate queries to."""
|
|
15
|
+
self._fn = fn
|
|
16
|
+
|
|
17
|
+
def query(self, question: str) -> RAGResponse:
|
|
18
|
+
"""Delegate the question to the wrapped function."""
|
|
19
|
+
return self._fn(question)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Adapter for LangChain retriever + chain pipelines.
|
|
2
|
+
|
|
3
|
+
LangChain is not a dependency of ragcheck; objects are duck-typed. The
|
|
4
|
+
retriever must expose ``invoke(question) -> list[Document]`` (LangChain's
|
|
5
|
+
standard retriever interface) and the chain ``invoke(inputs) -> str | Message``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
from ragcheck.adapters.base import RAGAdapter, RAGResponse, RetrievedChunk
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Invokable(Protocol):
|
|
17
|
+
def invoke(self, __input: Any) -> Any: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LangChainAdapter(RAGAdapter):
|
|
21
|
+
"""Wrap a LangChain retriever + generation chain as a RAGAdapter.
|
|
22
|
+
|
|
23
|
+
The chain is invoked with ``{"question": ..., "context": ...}`` where
|
|
24
|
+
context is the concatenated retrieved documents. Document source ids come
|
|
25
|
+
from ``doc.metadata[source_key]`` (falling back to a positional id).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
retriever: _Invokable,
|
|
31
|
+
chain: _Invokable,
|
|
32
|
+
source_key: str = "source",
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Bind a retriever and a chain; ``source_key`` names the id metadata field."""
|
|
35
|
+
self.retriever = retriever
|
|
36
|
+
self.chain = chain
|
|
37
|
+
self.source_key = source_key
|
|
38
|
+
|
|
39
|
+
def query(self, question: str) -> RAGResponse:
|
|
40
|
+
"""Retrieve documents, run the chain, and normalize into a RAGResponse."""
|
|
41
|
+
t0 = time.perf_counter()
|
|
42
|
+
documents = self.retriever.invoke(question)
|
|
43
|
+
t1 = time.perf_counter()
|
|
44
|
+
chunks = [
|
|
45
|
+
RetrievedChunk(
|
|
46
|
+
content=getattr(doc, "page_content", str(doc)),
|
|
47
|
+
source_id=str(getattr(doc, "metadata", {}).get(self.source_key, f"doc_{i}")),
|
|
48
|
+
)
|
|
49
|
+
for i, doc in enumerate(documents)
|
|
50
|
+
]
|
|
51
|
+
context = "\n\n".join(f"[{c.source_id}] {c.content}" for c in chunks)
|
|
52
|
+
raw = self.chain.invoke({"question": question, "context": context})
|
|
53
|
+
t2 = time.perf_counter()
|
|
54
|
+
answer = str(getattr(raw, "content", raw)).strip()
|
|
55
|
+
return RAGResponse(
|
|
56
|
+
answer=answer,
|
|
57
|
+
retrieved_chunks=chunks,
|
|
58
|
+
latencies_ms={"retrieval": (t1 - t0) * 1000, "generation": (t2 - t1) * 1000},
|
|
59
|
+
)
|
ragcheck/cache.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""SQLite-backed cache for LLM judgments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_key(*parts: str) -> str:
|
|
12
|
+
"""Build a stable SHA256 cache key from ordered string components.
|
|
13
|
+
|
|
14
|
+
Callers pass (metric_name, prompt_version, question, context, answer, ...);
|
|
15
|
+
components are length-prefixed so no two part sequences collide.
|
|
16
|
+
"""
|
|
17
|
+
h = hashlib.sha256()
|
|
18
|
+
for part in parts:
|
|
19
|
+
encoded = part.encode("utf-8")
|
|
20
|
+
h.update(str(len(encoded)).encode("ascii"))
|
|
21
|
+
h.update(b":")
|
|
22
|
+
h.update(encoded)
|
|
23
|
+
return h.hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JudgmentCache:
|
|
27
|
+
"""Persistent key-value store for judge outputs.
|
|
28
|
+
|
|
29
|
+
Thread-safe (judged metrics run samples in parallel). Hits are counted
|
|
30
|
+
on ``hits`` / ``misses`` so the runner can report cache effectiveness
|
|
31
|
+
on re-runs.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, path: str | Path) -> None:
|
|
35
|
+
"""Open (or create) the cache database at ``path``."""
|
|
36
|
+
self._conn = sqlite3.connect(str(path), check_same_thread=False)
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._conn.execute(
|
|
39
|
+
"CREATE TABLE IF NOT EXISTS judgments (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
|
|
40
|
+
)
|
|
41
|
+
self._conn.commit()
|
|
42
|
+
self.hits = 0
|
|
43
|
+
self.misses = 0
|
|
44
|
+
|
|
45
|
+
def get(self, key: str) -> str | None:
|
|
46
|
+
"""Return the cached value for ``key``, or None on a miss."""
|
|
47
|
+
with self._lock:
|
|
48
|
+
row = self._conn.execute(
|
|
49
|
+
"SELECT value FROM judgments WHERE key = ?", (key,)
|
|
50
|
+
).fetchone()
|
|
51
|
+
if row is None:
|
|
52
|
+
self.misses += 1
|
|
53
|
+
return None
|
|
54
|
+
self.hits += 1
|
|
55
|
+
return str(row[0])
|
|
56
|
+
|
|
57
|
+
def set(self, key: str, value: str) -> None:
|
|
58
|
+
"""Store ``value`` under ``key``, overwriting any previous entry."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._conn.execute(
|
|
61
|
+
"INSERT OR REPLACE INTO judgments (key, value) VALUES (?, ?)", (key, value)
|
|
62
|
+
)
|
|
63
|
+
self._conn.commit()
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
"""Close the underlying SQLite connection."""
|
|
67
|
+
self._conn.close()
|
ragcheck/cli.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""RAGCheck command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ragcheck.config import load_config
|
|
11
|
+
from ragcheck.report.cli_summary import print_summary
|
|
12
|
+
from ragcheck.runner import run_eval
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="pytest for RAG systems.", no_args_is_help=True)
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback()
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""RAGCheck: evaluate RAG pipelines."""
|
|
21
|
+
# Explicit callback keeps subcommand names (`ragcheck run ...`) even while
|
|
22
|
+
# only one command exists; typer otherwise collapses single-command apps.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command()
|
|
26
|
+
def run(
|
|
27
|
+
config: Path = typer.Argument(..., help="Path to an eval config YAML."),
|
|
28
|
+
yes: bool = typer.Option(
|
|
29
|
+
False, "--yes", help="Proceed without confirmation on large LLM-judged runs."
|
|
30
|
+
),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Run an evaluation and print the scorecard."""
|
|
33
|
+
eval_config = load_config(config)
|
|
34
|
+
if yes:
|
|
35
|
+
eval_config.assume_yes = True
|
|
36
|
+
report, out_path = run_eval(eval_config)
|
|
37
|
+
print_summary(report, console)
|
|
38
|
+
console.print(f"[green]Report written to[/green] {out_path}")
|
|
39
|
+
if eval_config.html:
|
|
40
|
+
console.print(f"[green]HTML report:[/green] {out_path.with_suffix('.html')}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command()
|
|
44
|
+
def compare(
|
|
45
|
+
old: Path = typer.Argument(..., help="Baseline report JSON."),
|
|
46
|
+
new: Path = typer.Argument(..., help="New report JSON."),
|
|
47
|
+
fail_if: list[str] = typer.Option(
|
|
48
|
+
[],
|
|
49
|
+
"--fail-if",
|
|
50
|
+
help="Exit 1 when a metric's delta drops below a threshold, e.g. 'faithfulness<-0.05'. "
|
|
51
|
+
"Repeatable.",
|
|
52
|
+
),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Diff two eval reports; non-zero exit on threshold breach (CI-friendly)."""
|
|
55
|
+
from ragcheck.report.regression import (
|
|
56
|
+
compare_reports,
|
|
57
|
+
load_report,
|
|
58
|
+
markdown_diff,
|
|
59
|
+
parse_fail_if,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
diffs = compare_reports(load_report(old), load_report(new), parse_fail_if(fail_if))
|
|
63
|
+
console.print(markdown_diff(diffs, old.name, new.name))
|
|
64
|
+
breached = [d for d in diffs if d.breached]
|
|
65
|
+
if breached:
|
|
66
|
+
names = ", ".join(d.metric_name for d in breached)
|
|
67
|
+
console.print(f"\n[red]Regression threshold breached:[/red] {names}")
|
|
68
|
+
raise typer.Exit(code=1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def demo(
|
|
73
|
+
output: Path = typer.Option(Path("ragcheck_output"), help="Where to write the reports."),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Run the zero-key demo: canned pipeline, offline judge, full reports in ~5 seconds."""
|
|
76
|
+
from ragcheck.config import EvalConfig, MetricSpec
|
|
77
|
+
from ragcheck.demo import DemoPipeline, demo_dataset
|
|
78
|
+
from ragcheck.runner import evaluate
|
|
79
|
+
|
|
80
|
+
console.print(
|
|
81
|
+
"[bold]RAGCheck demo[/bold] - no API key needed.\n"
|
|
82
|
+
"[dim]Canned pipeline answers (two deliberately wrong) + a deterministic offline "
|
|
83
|
+
"judge, so you can see the workflow. Real runs use Claude/Groq judges.[/dim]\n"
|
|
84
|
+
)
|
|
85
|
+
config = EvalConfig(
|
|
86
|
+
dataset=Path("demo"),
|
|
87
|
+
adapter="ragcheck.demo:DemoPipeline",
|
|
88
|
+
metrics=[
|
|
89
|
+
MetricSpec(name="hit_rate", params={"k": 3}),
|
|
90
|
+
MetricSpec(name="mrr"),
|
|
91
|
+
MetricSpec(name="faithfulness"),
|
|
92
|
+
MetricSpec(name="answer_relevance"),
|
|
93
|
+
MetricSpec(name="refusal_calibration"),
|
|
94
|
+
],
|
|
95
|
+
judge_provider="offline-demo",
|
|
96
|
+
output_dir=output,
|
|
97
|
+
cache_path=output / "demo_cache.sqlite",
|
|
98
|
+
run_name="demo",
|
|
99
|
+
)
|
|
100
|
+
report, out_path = evaluate(
|
|
101
|
+
DemoPipeline(), demo_dataset(), config, adapter_name="demo_pipeline"
|
|
102
|
+
)
|
|
103
|
+
print_summary(report, console)
|
|
104
|
+
faith = next(m for m in report.metrics if m.metric_name == "faithfulness")
|
|
105
|
+
refusal = next(m for m in report.metrics if m.metric_name == "refusal_calibration")
|
|
106
|
+
console.print(
|
|
107
|
+
f"\n[bold]What just happened:[/bold] faithfulness caught the planted hallucination "
|
|
108
|
+
f"({[c['claim'] for c in faith.details['failed_claims']][:1]}), and refusal "
|
|
109
|
+
f"calibration flagged the invented answer "
|
|
110
|
+
f"(false-answer rate: {refusal.details['false_answer_rate']:.2f}).\n"
|
|
111
|
+
f"[green]Open the HTML report to see the failing samples with their contexts:[/green] "
|
|
112
|
+
f"{out_path.with_suffix('.html')}\n"
|
|
113
|
+
"[dim]Next: wrap your own pipeline (docs/quickstart.md) and set "
|
|
114
|
+
"ANTHROPIC_API_KEY or GROQ_API_KEY for real judging.[/dim]"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command("generate-dataset")
|
|
119
|
+
def generate_dataset_cmd(
|
|
120
|
+
corpus_dir: Path = typer.Argument(..., help="Directory of .txt/.md corpus files."),
|
|
121
|
+
n: int = typer.Option(200, help="Base QA pairs to generate (before paraphrase expansion)."),
|
|
122
|
+
unanswerable_frac: float = typer.Option(0.15, help="Fraction of n that is unanswerable."),
|
|
123
|
+
paraphrase_groups: int = typer.Option(
|
|
124
|
+
20, help="Answerable questions to expand with 4 paraphrases each."
|
|
125
|
+
),
|
|
126
|
+
out: Path = typer.Option(Path("dataset.jsonl"), help="Output JSONL path."),
|
|
127
|
+
provider: str = typer.Option("anthropic", help="Generator provider: anthropic or groq."),
|
|
128
|
+
model: str | None = typer.Option(None, help="Generator model (provider default if omitted)."),
|
|
129
|
+
seed: int = typer.Option(13, help="Sampling seed (keep fixed for resumability)."),
|
|
130
|
+
cache_path: Path = typer.Option(
|
|
131
|
+
Path(".ragcheck_cache.sqlite"), help="Generation cache (makes re-runs resumable)."
|
|
132
|
+
),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Generate an eval dataset (difficulty tiers, unanswerables, paraphrases) from a corpus."""
|
|
135
|
+
from collections import Counter
|
|
136
|
+
|
|
137
|
+
from ragcheck.cache import JudgmentCache
|
|
138
|
+
from ragcheck.datasets.synthetic import SyntheticGenerator
|
|
139
|
+
from ragcheck.judge.judge import Judge
|
|
140
|
+
from ragcheck.llm import build_client
|
|
141
|
+
|
|
142
|
+
cache = JudgmentCache(cache_path)
|
|
143
|
+
judge = Judge(build_client(provider, model), cache)
|
|
144
|
+
generator = SyntheticGenerator(judge, seed=seed)
|
|
145
|
+
dataset = generator.generate(
|
|
146
|
+
corpus_dir, n=n, unanswerable_frac=unanswerable_frac, paraphrase_groups=paraphrase_groups
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
out.write_text("\n".join(p.model_dump_json(exclude_none=True) for p in dataset.pairs) + "\n")
|
|
151
|
+
|
|
152
|
+
tiers = Counter(p.difficulty for p in dataset.pairs if p.answerable)
|
|
153
|
+
n_groups = len({p.paraphrase_group for p in dataset.pairs if p.paraphrase_group})
|
|
154
|
+
console.print(
|
|
155
|
+
f"[green]Wrote {len(dataset.pairs)} pairs to[/green] {out}\n"
|
|
156
|
+
f"tiers: {dict(tiers)} | unanswerable: "
|
|
157
|
+
f"{sum(1 for p in dataset.pairs if not p.answerable)} | "
|
|
158
|
+
f"paraphrase groups: {n_groups} | "
|
|
159
|
+
f"cache: {cache.hits} hits / {cache.misses} misses"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command("validate-judge")
|
|
164
|
+
def validate_judge_cmd(
|
|
165
|
+
labels: Path = typer.Argument(..., help="JSONL of {question, answer, context, human_label}."),
|
|
166
|
+
metric: str = typer.Option("faithfulness", help="LLM-judged metric to validate."),
|
|
167
|
+
provider: str = typer.Option("anthropic", help="Judge provider: anthropic or groq."),
|
|
168
|
+
model: str | None = typer.Option(None, help="Judge model (provider default if omitted)."),
|
|
169
|
+
threshold: float = typer.Option(
|
|
170
|
+
0.5, help="Per-sample metric score at or above which the judge label is 'pass'."
|
|
171
|
+
),
|
|
172
|
+
concurrency: int = typer.Option(4, help="Parallel judge calls."),
|
|
173
|
+
cache_path: Path = typer.Option(Path(".ragcheck_cache.sqlite"), help="Judgment cache."),
|
|
174
|
+
output: Path = typer.Option(
|
|
175
|
+
Path("ragcheck_output"), help="Directory for the validation report JSON."
|
|
176
|
+
),
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Measure LLM-judge vs. human agreement (Cohen's kappa) on labeled samples."""
|
|
179
|
+
from rich.table import Table
|
|
180
|
+
|
|
181
|
+
from ragcheck.cache import JudgmentCache
|
|
182
|
+
from ragcheck.judge.judge import Judge
|
|
183
|
+
from ragcheck.judge.validation import load_labels, validate_judge
|
|
184
|
+
from ragcheck.llm import build_client
|
|
185
|
+
|
|
186
|
+
judge = Judge(build_client(provider, model), JudgmentCache(cache_path))
|
|
187
|
+
report = validate_judge(
|
|
188
|
+
load_labels(labels), metric, judge, threshold=threshold, concurrency=concurrency
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
table = Table(title=f"Judge validation - {report.metric_name}")
|
|
192
|
+
table.add_column("Stat")
|
|
193
|
+
table.add_column("Value", justify="right")
|
|
194
|
+
table.add_row("judge model", f"{report.judge_model} ({report.prompt_version})")
|
|
195
|
+
table.add_row("samples", str(report.n_samples))
|
|
196
|
+
table.add_row("agreement", f"{report.agreement:.3f}")
|
|
197
|
+
table.add_row("Cohen's kappa", f"{report.kappa:.3f}")
|
|
198
|
+
c = report.confusion
|
|
199
|
+
confusion = f"tp={c['tp']} fp={c['fp']} fn={c['fn']} tn={c['tn']}"
|
|
200
|
+
table.add_row("confusion (judge vs human)", confusion)
|
|
201
|
+
console.print(table)
|
|
202
|
+
|
|
203
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
out_path = output / f"judge_validation_{report.metric_name}.json"
|
|
205
|
+
out_path.write_text(report.model_dump_json(indent=2))
|
|
206
|
+
console.print(f"[green]Validation report written to[/green] {out_path}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
app()
|
ragcheck/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Eval run configuration: Pydantic models + YAML loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MetricSpec(BaseModel):
|
|
12
|
+
"""A metric selection with optional constructor params."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
params: dict = Field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EvalConfig(BaseModel):
|
|
19
|
+
"""Everything needed to run one evaluation."""
|
|
20
|
+
|
|
21
|
+
dataset: Path
|
|
22
|
+
adapter: str
|
|
23
|
+
metrics: list[MetricSpec]
|
|
24
|
+
judge_provider: str = "anthropic"
|
|
25
|
+
judge_model: str | None = None
|
|
26
|
+
concurrency: int = 4
|
|
27
|
+
html: bool = True
|
|
28
|
+
judge_validation: Path | None = None # embed this validation report, if present
|
|
29
|
+
confirm_above: int = 200 # LLM-judged runs larger than this need assume_yes
|
|
30
|
+
assume_yes: bool = False
|
|
31
|
+
output_dir: Path = Path("ragcheck_output")
|
|
32
|
+
cache_path: Path = Path(".ragcheck_cache.sqlite")
|
|
33
|
+
run_name: str | None = None
|
|
34
|
+
|
|
35
|
+
@field_validator("metrics", mode="before")
|
|
36
|
+
@classmethod
|
|
37
|
+
def _coerce_metric_specs(cls, value: object) -> object:
|
|
38
|
+
"""Allow bare metric names in YAML alongside {name, params} mappings."""
|
|
39
|
+
if isinstance(value, list):
|
|
40
|
+
return [{"name": v} if isinstance(v, str) else v for v in value]
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config(path: str | Path) -> EvalConfig:
|
|
45
|
+
"""Load an EvalConfig from YAML, resolving paths relative to the file."""
|
|
46
|
+
path = Path(path)
|
|
47
|
+
data = yaml.safe_load(path.read_text())
|
|
48
|
+
config = EvalConfig.model_validate(data)
|
|
49
|
+
base = path.parent
|
|
50
|
+
for field in ("dataset", "output_dir", "cache_path"):
|
|
51
|
+
value: Path = getattr(config, field)
|
|
52
|
+
if not value.is_absolute():
|
|
53
|
+
setattr(config, field, base / value)
|
|
54
|
+
return config
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Load evaluation datasets from JSONL or CSV files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ragcheck.datasets.models import EvalDataset, QAPair
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_dataset(path: str | Path) -> EvalDataset:
|
|
13
|
+
"""Load a dataset from a ``.jsonl`` or ``.csv`` file.
|
|
14
|
+
|
|
15
|
+
JSONL: one QAPair object per line. CSV: columns matching QAPair fields;
|
|
16
|
+
``relevant_source_ids`` is a semicolon-separated string.
|
|
17
|
+
"""
|
|
18
|
+
path = Path(path)
|
|
19
|
+
if path.suffix == ".jsonl":
|
|
20
|
+
pairs = [
|
|
21
|
+
QAPair.model_validate(json.loads(line))
|
|
22
|
+
for line in path.read_text().splitlines()
|
|
23
|
+
if line.strip()
|
|
24
|
+
]
|
|
25
|
+
elif path.suffix == ".csv":
|
|
26
|
+
pairs = []
|
|
27
|
+
with path.open(newline="") as f:
|
|
28
|
+
for row in csv.DictReader(f):
|
|
29
|
+
raw_ids = row.pop("relevant_source_ids", "") or ""
|
|
30
|
+
data: dict[str, object] = {k: v for k, v in row.items() if v not in (None, "")}
|
|
31
|
+
data["relevant_source_ids"] = [s for s in raw_ids.split(";") if s]
|
|
32
|
+
pairs.append(QAPair.model_validate(data))
|
|
33
|
+
else:
|
|
34
|
+
raise ValueError(f"Unsupported dataset format: {path.suffix} (use .jsonl or .csv)")
|
|
35
|
+
return EvalDataset(name=path.stem, pairs=pairs)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Dataset data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from ragcheck.adapters.base import RAGResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QAPair(BaseModel):
|
|
11
|
+
"""A single evaluation question with optional ground truth."""
|
|
12
|
+
|
|
13
|
+
question: str
|
|
14
|
+
ground_truth_answer: str | None = None
|
|
15
|
+
relevant_source_ids: list[str] = Field(default_factory=list)
|
|
16
|
+
answerable: bool = True
|
|
17
|
+
difficulty: str = "medium"
|
|
18
|
+
paraphrase_group: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EvalDataset(BaseModel):
|
|
22
|
+
"""A named collection of QA pairs."""
|
|
23
|
+
|
|
24
|
+
name: str = "dataset"
|
|
25
|
+
pairs: list[QAPair]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EvalSample(BaseModel):
|
|
29
|
+
"""A QA pair joined with the pipeline's response - the unit metrics consume."""
|
|
30
|
+
|
|
31
|
+
qa: QAPair
|
|
32
|
+
response: RAGResponse
|