ragaudit-cli 0.3.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.
- ragaudit/__init__.py +1 -0
- ragaudit/adapters/__init__.py +0 -0
- ragaudit/adapters/base.py +109 -0
- ragaudit/adapters/callable_adapter.py +15 -0
- ragaudit/cli.py +540 -0
- ragaudit/cli_easy.py +458 -0
- ragaudit/cli_v2.py +507 -0
- ragaudit/env.py +46 -0
- ragaudit/generate/__init__.py +0 -0
- ragaudit/generate/pipeline.py +486 -0
- ragaudit/generate/prompts.py +116 -0
- ragaudit/generate/testset_generator.py +146 -0
- ragaudit/generate/text.py +75 -0
- ragaudit/ingest/__init__.py +0 -0
- ragaudit/ingest/chunker.py +53 -0
- ragaudit/ingest/corpus.py +120 -0
- ragaudit/ingest/loader.py +49 -0
- ragaudit/ingest/versioning.py +92 -0
- ragaudit/llm/__init__.py +0 -0
- ragaudit/llm/client.py +86 -0
- ragaudit/llm/factory.py +70 -0
- ragaudit/llm/json_output.py +72 -0
- ragaudit/llm/mock.py +552 -0
- ragaudit/models/__init__.py +0 -0
- ragaudit/models/adapter.py +39 -0
- ragaudit/models/document.py +28 -0
- ragaudit/models/evaluation.py +70 -0
- ragaudit/models/question.py +152 -0
- ragaudit/models/run.py +44 -0
- ragaudit/models/scoring.py +82 -0
- ragaudit/models/testset.py +30 -0
- ragaudit/models/tune.py +56 -0
- ragaudit/report/__init__.py +0 -0
- ragaudit/report/generator.py +155 -0
- ragaudit/report/plain_summary.py +122 -0
- ragaudit/report/templates/_v2_tooltip.jinja +43 -0
- ragaudit/report/templates/generate_report.html.jinja +175 -0
- ragaudit/report/templates/report.html.jinja +252 -0
- ragaudit/report/templates/style.css +1095 -0
- ragaudit/report/templates/tune_report.html.jinja +158 -0
- ragaudit/report/templates/v2.css +286 -0
- ragaudit/report/v2.py +380 -0
- ragaudit/runner/__init__.py +0 -0
- ragaudit/runner/ablation.py +46 -0
- ragaudit/runner/evaluate.py +325 -0
- ragaudit/runner/executor.py +98 -0
- ragaudit/runner/locked.py +37 -0
- ragaudit/runner/summary.py +45 -0
- ragaudit/scaffold.py +252 -0
- ragaudit/scoring/__init__.py +0 -0
- ragaudit/scoring/correctness.py +94 -0
- ragaudit/scoring/groundedness.py +86 -0
- ragaudit/scoring/retrieval.py +42 -0
- ragaudit/scoring/text_match.py +51 -0
- ragaudit/scoring/verdict.py +14 -0
- ragaudit/storage/__init__.py +0 -0
- ragaudit/storage/db.py +81 -0
- ragaudit/storage/lock.py +36 -0
- ragaudit/storage/repository.py +216 -0
- ragaudit/storage/store.py +536 -0
- ragaudit/tune/__init__.py +7 -0
- ragaudit/tune/devset.py +16 -0
- ragaudit/tune/loop.py +208 -0
- ragaudit/tune/search_space.py +140 -0
- ragaudit/tune/strategies.py +197 -0
- ragaudit/yaml_io.py +199 -0
- ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
- ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
- ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
- ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
- ragaudit_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
ragaudit/yaml_io.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Read and write the small YAML files v2 uses (pipeline configs, tuning search spaces).
|
|
2
|
+
|
|
3
|
+
Uses PyYAML when it's installed (`pip install "ragaudit-cli[yaml]"`); otherwise
|
|
4
|
+
falls back to a built-in parser for the subset these files need, so YAML
|
|
5
|
+
support adds no required dependency:
|
|
6
|
+
|
|
7
|
+
- nested mappings by indentation, `# comments`, blank lines;
|
|
8
|
+
- scalars: integers, floats, true/false, null/~, quoted or plain strings;
|
|
9
|
+
- lists, either inline (`[1, 2, "a"]`) or as `- item` blocks of scalars;
|
|
10
|
+
- inline mappings (`{min: 0, max: 1}`).
|
|
11
|
+
|
|
12
|
+
Files written here use only that subset, so they round-trip either way.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
try: # pragma: no cover - exercised only when PyYAML is installed
|
|
20
|
+
import yaml as _pyyaml
|
|
21
|
+
except ImportError: # pragma: no cover
|
|
22
|
+
_pyyaml = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class YAMLError(ValueError):
|
|
26
|
+
"""Raised for YAML the reader can't parse."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_INT_RE = re.compile(r"^[-+]?\d+$")
|
|
30
|
+
_FLOAT_RE = re.compile(r"^[-+]?(\d+\.\d*|\.\d+|\d+)([eE][-+]?\d+)?$")
|
|
31
|
+
_PLAIN_SAFE_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_./-]*$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _strip_comment(line: str) -> str:
|
|
35
|
+
in_quote = None
|
|
36
|
+
for i, char in enumerate(line):
|
|
37
|
+
if char in "\"'" and in_quote in (None, char):
|
|
38
|
+
in_quote = None if in_quote == char else char
|
|
39
|
+
elif char == "#" and in_quote is None and (i == 0 or line[i - 1].isspace()):
|
|
40
|
+
return line[:i].rstrip()
|
|
41
|
+
return line.rstrip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _split_top_level(text: str, sep: str = ",") -> list[str]:
|
|
45
|
+
parts, depth, quote, current = [], 0, None, ""
|
|
46
|
+
for char in text:
|
|
47
|
+
if quote:
|
|
48
|
+
current += char
|
|
49
|
+
if char == quote:
|
|
50
|
+
quote = None
|
|
51
|
+
continue
|
|
52
|
+
if char in "\"'":
|
|
53
|
+
quote = char
|
|
54
|
+
elif char in "[{":
|
|
55
|
+
depth += 1
|
|
56
|
+
elif char in "]}":
|
|
57
|
+
depth -= 1
|
|
58
|
+
if char == sep and depth == 0:
|
|
59
|
+
parts.append(current.strip())
|
|
60
|
+
current = ""
|
|
61
|
+
else:
|
|
62
|
+
current += char
|
|
63
|
+
if current.strip():
|
|
64
|
+
parts.append(current.strip())
|
|
65
|
+
return parts
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_scalar(text: str):
|
|
69
|
+
text = text.strip()
|
|
70
|
+
if text == "" or text in {"null", "~", "Null", "NULL"}:
|
|
71
|
+
return None
|
|
72
|
+
if text in {"true", "True", "TRUE"}:
|
|
73
|
+
return True
|
|
74
|
+
if text in {"false", "False", "FALSE"}:
|
|
75
|
+
return False
|
|
76
|
+
if text.startswith("[") and text.endswith("]"):
|
|
77
|
+
return [_parse_scalar(part) for part in _split_top_level(text[1:-1])]
|
|
78
|
+
if text.startswith("{") and text.endswith("}"):
|
|
79
|
+
mapping = {}
|
|
80
|
+
for part in _split_top_level(text[1:-1]):
|
|
81
|
+
key, _, value = part.partition(":")
|
|
82
|
+
mapping[str(_parse_scalar(key))] = _parse_scalar(value)
|
|
83
|
+
return mapping
|
|
84
|
+
if text[0] == '"':
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(text)
|
|
87
|
+
except json.JSONDecodeError as exc:
|
|
88
|
+
raise YAMLError(f"bad double-quoted string: {text}") from exc
|
|
89
|
+
if text[0] == "'":
|
|
90
|
+
if not text.endswith("'"):
|
|
91
|
+
raise YAMLError(f"bad single-quoted string: {text}")
|
|
92
|
+
return text[1:-1].replace("''", "'")
|
|
93
|
+
if _INT_RE.match(text):
|
|
94
|
+
return int(text)
|
|
95
|
+
if _FLOAT_RE.match(text):
|
|
96
|
+
return float(text)
|
|
97
|
+
return text
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_block(lines: list[tuple[int, str]], start: int, indent: int):
|
|
101
|
+
"""Parse lines[start:] at this indent into a dict or list. Returns (value, next index)."""
|
|
102
|
+
if lines[start][1].startswith("- "):
|
|
103
|
+
items = []
|
|
104
|
+
index = start
|
|
105
|
+
while index < len(lines) and lines[index][0] == indent and lines[index][1].startswith("- "):
|
|
106
|
+
items.append(_parse_scalar(lines[index][1][2:]))
|
|
107
|
+
index += 1
|
|
108
|
+
return items, index
|
|
109
|
+
|
|
110
|
+
mapping = {}
|
|
111
|
+
index = start
|
|
112
|
+
while index < len(lines):
|
|
113
|
+
line_indent, text = lines[index]
|
|
114
|
+
if line_indent < indent:
|
|
115
|
+
break
|
|
116
|
+
if line_indent > indent:
|
|
117
|
+
raise YAMLError(f"unexpected indentation: {text!r}")
|
|
118
|
+
key, sep, rest = text.partition(":")
|
|
119
|
+
if not sep:
|
|
120
|
+
raise YAMLError(f"expected 'key: value', got {text!r}")
|
|
121
|
+
key = str(_parse_scalar(key))
|
|
122
|
+
index += 1
|
|
123
|
+
if rest.strip():
|
|
124
|
+
mapping[key] = _parse_scalar(rest)
|
|
125
|
+
elif index < len(lines) and lines[index][0] > indent:
|
|
126
|
+
mapping[key], index = _parse_block(lines, index, lines[index][0])
|
|
127
|
+
elif index < len(lines) and lines[index][0] == indent and lines[index][1].startswith("- "):
|
|
128
|
+
mapping[key], index = _parse_block(lines, index, indent)
|
|
129
|
+
else:
|
|
130
|
+
mapping[key] = None
|
|
131
|
+
return mapping, index
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def loads(text: str):
|
|
135
|
+
"""Parse YAML text (the subset above, or anything PyYAML accepts when it's installed)."""
|
|
136
|
+
if _pyyaml is not None:
|
|
137
|
+
try:
|
|
138
|
+
return _pyyaml.safe_load(text)
|
|
139
|
+
except _pyyaml.YAMLError as exc:
|
|
140
|
+
raise YAMLError(str(exc)) from exc
|
|
141
|
+
|
|
142
|
+
lines = []
|
|
143
|
+
for raw in text.splitlines():
|
|
144
|
+
if "\t" in raw[: len(raw) - len(raw.lstrip())]:
|
|
145
|
+
raise YAMLError("tabs are not allowed for indentation")
|
|
146
|
+
stripped = _strip_comment(raw)
|
|
147
|
+
if stripped.strip() and stripped.strip() != "---":
|
|
148
|
+
lines.append((len(stripped) - len(stripped.lstrip()), stripped.strip()))
|
|
149
|
+
if not lines:
|
|
150
|
+
return None
|
|
151
|
+
value, index = _parse_block(lines, 0, lines[0][0])
|
|
152
|
+
if index != len(lines):
|
|
153
|
+
raise YAMLError(f"could not parse line: {lines[index][1]!r}")
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def load(path: str | Path):
|
|
158
|
+
return loads(Path(path).read_text(encoding="utf-8"))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _format_scalar(value) -> str:
|
|
162
|
+
if value is None:
|
|
163
|
+
return "null"
|
|
164
|
+
if isinstance(value, bool):
|
|
165
|
+
return "true" if value else "false"
|
|
166
|
+
if isinstance(value, (int, float)):
|
|
167
|
+
return repr(value)
|
|
168
|
+
if isinstance(value, (list, tuple)):
|
|
169
|
+
return "[" + ", ".join(_format_scalar(v) for v in value) + "]"
|
|
170
|
+
if isinstance(value, dict):
|
|
171
|
+
return "{" + ", ".join(f"{_format_scalar(str(k))}: {_format_scalar(v)}" for k, v in value.items()) + "}"
|
|
172
|
+
text = str(value)
|
|
173
|
+
if _PLAIN_SAFE_RE.match(text) and _parse_scalar(text) == text:
|
|
174
|
+
return text
|
|
175
|
+
return json.dumps(text)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def dumps(data: dict, header: str | None = None) -> str:
|
|
179
|
+
"""Serialize a mapping (nested mappings, lists and scalars) as block-style YAML."""
|
|
180
|
+
lines = [f"# {line}" if line else "#" for line in header.splitlines()] if header else []
|
|
181
|
+
|
|
182
|
+
def emit(mapping: dict, indent: int) -> None:
|
|
183
|
+
for key, value in mapping.items():
|
|
184
|
+
prefix = " " * indent + f"{_format_scalar(str(key))}:"
|
|
185
|
+
if isinstance(value, dict) and value:
|
|
186
|
+
lines.append(prefix)
|
|
187
|
+
emit(value, indent + 2)
|
|
188
|
+
else:
|
|
189
|
+
lines.append(f"{prefix} {_format_scalar(value)}")
|
|
190
|
+
|
|
191
|
+
emit(data, 0)
|
|
192
|
+
return "\n".join(lines) + "\n"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def dump(data: dict, path: str | Path, header: str | None = None) -> Path:
|
|
196
|
+
path = Path(path)
|
|
197
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
path.write_text(dumps(data, header), encoding="utf-8")
|
|
199
|
+
return path
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ragaudit-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Test a RAG pipeline in minutes: catch correct answers that retrieval didn't earn (lucky passes), without re-indexing.
|
|
5
|
+
Project-URL: Homepage, https://aliismail293.github.io/rag-tester/
|
|
6
|
+
Project-URL: Documentation, https://github.com/Aliismail293/rag-tester#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/Aliismail293/rag-tester
|
|
8
|
+
Project-URL: Issues, https://github.com/Aliismail293/rag-tester/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/Aliismail293/rag-tester/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Ali Ekramy Ismail <alii.ekramy.ismail@gmail.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: evaluation,langchain,llamaindex,llm,ollama,rag,retrieval-augmented-generation,testing
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Science/Research
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
25
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
26
|
+
Classifier: Topic :: Software Development :: Testing
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Requires-Dist: httpx>=0.27
|
|
29
|
+
Requires-Dist: jinja2>=3.1
|
|
30
|
+
Requires-Dist: pydantic>=2.0
|
|
31
|
+
Requires-Dist: typer>=0.12
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: twine>=5.1; extra == 'dev'
|
|
37
|
+
Provides-Extra: langchain
|
|
38
|
+
Requires-Dist: langchain-core>=0.2; extra == 'langchain'
|
|
39
|
+
Provides-Extra: llamaindex
|
|
40
|
+
Requires-Dist: llama-index-core>=0.10; extra == 'llamaindex'
|
|
41
|
+
Provides-Extra: yaml
|
|
42
|
+
Requires-Dist: pyyaml>=6; extra == 'yaml'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# ragaudit
|
|
46
|
+
|
|
47
|
+
## Test your RAG in 5 minutes
|
|
48
|
+
|
|
49
|
+
You need Python 3.11+, a folder of your documents (`.txt` or `.md`), and an
|
|
50
|
+
LLM for ragaudit to write questions and judge answers: any OpenAI-compatible
|
|
51
|
+
API, or a local [Ollama](https://ollama.com). Your pipeline is not re-indexed.
|
|
52
|
+
|
|
53
|
+
**0. No pipeline yet? Use `--framework demo` to see a full report in 2 minutes.**
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install ragaudit-cli
|
|
57
|
+
mkdir docs && cp ~/notes/*.md docs/ # any few .txt or .md files
|
|
58
|
+
ragaudit init --framework demo
|
|
59
|
+
ragaudit check --docs ./docs --adapter ragaudit_adapter.py --questions 5
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The demo adapter is a small, complete pipeline (paragraphs from `./docs`, top 3
|
|
63
|
+
by word overlap, answered by the same LLM ragaudit uses), so there is nothing to
|
|
64
|
+
edit. Set the three `RAGAUDIT_*` settings from step 2 first, or add `--llm mock`
|
|
65
|
+
to try it offline with a fake LLM. Then follow steps 1 to 5 for your own pipeline.
|
|
66
|
+
|
|
67
|
+
**1. Install**
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install ragaudit-cli
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The package is `ragaudit-cli` on PyPI; the command and the Python import are
|
|
74
|
+
`ragaudit`. (Until the first PyPI release: `pip install git+https://github.com/Aliismail293/rag-tester`.)
|
|
75
|
+
|
|
76
|
+
**2. Check your setup**
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
export RAGAUDIT_API_BASE=https://api.openai.com/v1 # Ollama: http://localhost:11434/v1
|
|
80
|
+
export RAGAUDIT_API_KEY=sk-... # Ollama: ollama
|
|
81
|
+
export RAGAUDIT_MODEL=gpt-4o-mini # Ollama: llama3.1:8b
|
|
82
|
+
ragaudit doctor
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Each check prints PASS or FAIL with the exact fix. The adapter check fails
|
|
86
|
+
until step 4. Settings can also go in a `.env` file (step 3 writes an example).
|
|
87
|
+
|
|
88
|
+
**3. Create an adapter**
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
ragaudit init --framework plain # or langchain, llamaindex
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This writes `ragaudit_adapter.py` and `.env.example`.
|
|
95
|
+
|
|
96
|
+
**4. Fill in the adapter**
|
|
97
|
+
|
|
98
|
+
Open `ragaudit_adapter.py` and connect your pipeline at the one `TODO`: return
|
|
99
|
+
its answer and the text of the passages it retrieved. Run `ragaudit doctor`
|
|
100
|
+
again until every line says PASS.
|
|
101
|
+
|
|
102
|
+
**5. Run the check**
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
ragaudit check --docs ./docs --adapter ragaudit_adapter.py
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
It shows how many LLM calls it will make (about 3 per question, 20 questions
|
|
109
|
+
by default) and asks before starting. Then it writes questions from your docs,
|
|
110
|
+
runs them through your pipeline, opens an HTML report, and prints a summary
|
|
111
|
+
like this:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
ragaudit check: summary
|
|
115
|
+
Verdict: NEEDS WORK. Only 4 of 13 answers were correct and from the right passage; main problem: ...
|
|
116
|
+
Retrieval hit rate: 31% (4 of 13 found the passage the question came from, matched by text)
|
|
117
|
+
Answer correctness: 100% (13 of 13 judged correct)
|
|
118
|
+
Lucky passes: 9 (correct answer, but the right passage was not retrieved); ...
|
|
119
|
+
Most useful fix: don't trust the correct answers yet. ...
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Reading the four verdicts.** Every question gets one:
|
|
123
|
+
|
|
124
|
+
| Verdict | Answer | Right passage retrieved? | What it means |
|
|
125
|
+
|---|---|---|---|
|
|
126
|
+
| `TRUE_PASS` | correct | yes | The pipeline worked. This is the number to protect. |
|
|
127
|
+
| `LUCKY_PASS` | correct | no | The model answered from memory; retrieval did nothing. Other evals count this as a pass. |
|
|
128
|
+
| `GENERATION_FAILURE` | wrong | yes | Retrieval worked; the prompt or model didn't use it. |
|
|
129
|
+
| `RETRIEVAL_FAILURE` | wrong | no | Retrieval missed. Raise top-k, add keyword/hybrid search, or change chunking. |
|
|
130
|
+
|
|
131
|
+
## Try it in 60 seconds
|
|
132
|
+
|
|
133
|
+
Requires Docker and an OpenAI-compatible API key.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
export RAGAUDIT_API_KEY=sk-...
|
|
137
|
+
export RAGAUDIT_API_BASE=https://api.openai.com/v1 # any OpenAI-compatible endpoint
|
|
138
|
+
export RAGAUDIT_MODEL=gpt-4o-mini
|
|
139
|
+
|
|
140
|
+
docker compose up --build
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
This runs three scenarios and writes three reports to `demo/output/`:
|
|
144
|
+
|
|
145
|
+
| Report | Corpus | Adapter | What it shows |
|
|
146
|
+
|---|---|---|---|
|
|
147
|
+
| `report-healthy.html` | `demo/sample_corpus/` (invented facts) | `example_adapter.py` | Retrieval working, answers grounded. |
|
|
148
|
+
| `report-degraded.html` | `demo/sample_corpus/` (invented facts) | `weak_adapter.py` | Retrieval quietly broken (30% random chunks), failures visible. |
|
|
149
|
+
| `report-lucky.html` | `demo/lucky_corpus/` (well-known facts) | `ungrounded_adapter.py` | Answers correct anyway, retrieval contributing nothing. |
|
|
150
|
+
|
|
151
|
+
`demo/sample_corpus/` (18 chunks, deliberately overlapping topics —
|
|
152
|
+
multiple chunks touch the same product or the same SLA terms) describes
|
|
153
|
+
an invented company, product, and internal processes — facts that don't
|
|
154
|
+
exist anywhere for an LLM to have learned during pretraining. That's
|
|
155
|
+
deliberate: any answer an adapter gets right in the first two reports
|
|
156
|
+
had to actually come from the retrieved context, so a `LUCKY_PASS` there
|
|
157
|
+
would be a real, meaningful finding, not an artifact of the model
|
|
158
|
+
already knowing the answer. Comparing `report-healthy.html` against
|
|
159
|
+
`report-degraded.html` — same test set, same generation model, only
|
|
160
|
+
retrieval quality changed — shows how much retrieval quality alone moves
|
|
161
|
+
the verdict breakdown.
|
|
162
|
+
|
|
163
|
+
`demo/lucky_corpus/` (13 chunks: world capitals, the periodic table and
|
|
164
|
+
other basic chemistry, famous historical dates, basic physics constants)
|
|
165
|
+
flips the premise: these are facts any LLM already knows cold. It's
|
|
166
|
+
ingested into its own database (`ragaudit-lucky.db`) rather than sharing
|
|
167
|
+
the sample corpus's, so ingesting it can't overwrite chunks the other two
|
|
168
|
+
reports depend on. Running `ungrounded_adapter.py` against it, with
|
|
169
|
+
ablation enabled, means retrieval frequently returns the wrong chunk, or
|
|
170
|
+
a random one — yet the model answers correctly anyway, from pretraining,
|
|
171
|
+
and ablation (re-asking with no context at all) confirms it stayed
|
|
172
|
+
correct. `report-lucky.html` is what a corpus full of `LUCKY_PASS`
|
|
173
|
+
actually looks like: the case ragaudit exists to catch, that a
|
|
174
|
+
correctness-only eval would score as a clean pass.
|
|
175
|
+
|
|
176
|
+
- `demo/example_adapter.py` — keyword/BM25 retrieval, no embeddings, no
|
|
177
|
+
vector DB, top-1 only — "healthy but mediocre."
|
|
178
|
+
- `demo/weak_adapter.py` — identical retrieval, except a seeded 30% of
|
|
179
|
+
queries get a random chunk instead of the true top match, simulating a
|
|
180
|
+
retrieval index that has quietly gone stale — "degraded." Its
|
|
181
|
+
generation prompt strictly says "only use the provided context," so a
|
|
182
|
+
bad retrieval mostly shows up as a visible failure, not a lucky guess.
|
|
183
|
+
- `demo/ungrounded_adapter.py` — same retrieval logic, degraded further
|
|
184
|
+
(70% random), but with a permissive generation prompt: the context is
|
|
185
|
+
offered as background, and the model is told to answer from its own
|
|
186
|
+
knowledge if the context doesn't help. That prompt difference is the
|
|
187
|
+
whole point — it's what turns bad retrieval into a *hidden* failure
|
|
188
|
+
instead of a visible one, which is exactly what `report-lucky.html`
|
|
189
|
+
is built to catch.
|
|
190
|
+
|
|
191
|
+
Audits RAG pipelines by checking whether retrieval actually did the work —
|
|
192
|
+
not just whether the final answer was correct.
|
|
193
|
+
|
|
194
|
+
A RAG system can return a correct answer while retrieving the wrong
|
|
195
|
+
documents, because the model answered from pretraining rather than from
|
|
196
|
+
context. Standard evals score that as a pass. ragaudit catches it and
|
|
197
|
+
labels it `LUCKY_PASS`.
|
|
198
|
+
|
|
199
|
+
## Status
|
|
200
|
+
|
|
201
|
+
Functional end to end: ingestion, test-set generation, running, scoring
|
|
202
|
+
(including ablation), storage, reporting, and the CLI are all implemented.
|
|
203
|
+
See `demo/` for a working example.
|
|
204
|
+
|
|
205
|
+
v2 (0.2.0) adds question generation with verification, cached and
|
|
206
|
+
regression evaluation, and config tuning with a locked test split; see
|
|
207
|
+
[v2: generate, evaluate, tune](#v2-generate-evaluate-tune).
|
|
208
|
+
|
|
209
|
+
## How it works
|
|
210
|
+
|
|
211
|
+
1. Ingest a corpus of documents; ragaudit assigns stable chunk IDs.
|
|
212
|
+
2. Generate a test set: an LLM writes questions whose answers live in each
|
|
213
|
+
chunk, storing `(question, expected_answer, source_chunk_id)`.
|
|
214
|
+
3. Run your RAG pipeline through an adapter.
|
|
215
|
+
4. Score answer correctness, retrieval hit rate, and groundedness.
|
|
216
|
+
5. Cross-reference correctness against retrieval:
|
|
217
|
+
- correct + retrieved → `TRUE_PASS`
|
|
218
|
+
- correct + NOT retrieved → `LUCKY_PASS` (the headline output)
|
|
219
|
+
- wrong + retrieved → `GENERATION_FAILURE`
|
|
220
|
+
- wrong + NOT retrieved → `RETRIEVAL_FAILURE`
|
|
221
|
+
6. For candidate `LUCKY_PASS` cases, re-run the question with retrieval
|
|
222
|
+
disabled. If the answer is still correct, retrieval contributed
|
|
223
|
+
nothing — this empirically confirms the verdict.
|
|
224
|
+
7. Emit a static HTML report.
|
|
225
|
+
|
|
226
|
+
## Matching retrieval: by id or by text
|
|
227
|
+
|
|
228
|
+
ragaudit needs to know whether your pipeline retrieved the passage a
|
|
229
|
+
question was written from. `run` and `evaluate` can decide that two ways,
|
|
230
|
+
chosen with `--match-by`:
|
|
231
|
+
|
|
232
|
+
**`--match-by id` (the default).** A hit means the `source_chunk_id`
|
|
233
|
+
ragaudit assigned during ingestion appears in the `retrieved_chunk_ids`
|
|
234
|
+
your adapter returns. This only works if your pipeline is indexed against
|
|
235
|
+
the chunks ragaudit produced:
|
|
236
|
+
|
|
237
|
+
1. Run `ragaudit ingest ./docs` to chunk your corpus and assign IDs.
|
|
238
|
+
2. Index *those chunks* into your own pipeline, so retrieved documents
|
|
239
|
+
carry ragaudit's chunk IDs as metadata.
|
|
240
|
+
3. Your adapter's `query()` returns those same IDs in `retrieved_chunk_ids`.
|
|
241
|
+
|
|
242
|
+
If your pipeline chunks the corpus itself, id matching reads retrieval as
|
|
243
|
+
0% and misreports every correct answer as `LUCKY_PASS`, because the tool
|
|
244
|
+
can't recognize IDs it didn't assign.
|
|
245
|
+
|
|
246
|
+
**`--match-by text`.** A hit means one of the `contexts` your adapter
|
|
247
|
+
already returns overlaps the gold chunk's text. Your pipeline can keep its
|
|
248
|
+
own chunking and IDs; nothing is re-indexed. The score is the share of the
|
|
249
|
+
gold chunk's word pairs found in the context, measured against whichever
|
|
250
|
+
side is shorter, so a big chunk that contains the gold passage matches and
|
|
251
|
+
so does a smaller chunk cut from inside it. Case, punctuation and
|
|
252
|
+
whitespace are ignored. The default threshold is 0.5; change it with
|
|
253
|
+
`--match-threshold`. `ragaudit check` uses text matching by default.
|
|
254
|
+
|
|
255
|
+
| | `--match-by id` | `--match-by text` |
|
|
256
|
+
|---|---|---|
|
|
257
|
+
| Setup | index ragaudit's chunks, return their ids | return the retrieved texts in `contexts` |
|
|
258
|
+
| Precision | exact: same chunk or not | approximate: a threshold on word overlap |
|
|
259
|
+
| False hits | none | a near-duplicate passage elsewhere in the corpus can count |
|
|
260
|
+
| False misses | none, if ids are returned | heavy rewriting of chunks (summaries, translations) can miss |
|
|
261
|
+
| `evaluate` needs | nothing extra | `--corpus`, for the gold chunk texts |
|
|
262
|
+
|
|
263
|
+
Use id matching when you can index ragaudit's chunks; use text matching to
|
|
264
|
+
test the pipeline you already have. The two modes are cached as separate
|
|
265
|
+
configs in `evaluate`, so switching never reuses the other mode's results.
|
|
266
|
+
|
|
267
|
+
## Adapter interface
|
|
268
|
+
|
|
269
|
+
Implement one function:
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
def query(question: str) -> RAGResponse:
|
|
273
|
+
...
|
|
274
|
+
|
|
275
|
+
# RAGResponse = { answer: str, retrieved_chunk_ids: list[str], contexts: list[str] }
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Adapters ship for: a raw callable, LangChain, LlamaIndex.
|
|
279
|
+
|
|
280
|
+
## CLI
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
ragaudit init --framework plain # starter adapter (plain|langchain|llamaindex|demo) + .env.example
|
|
284
|
+
ragaudit doctor # check Python, RAGAUDIT_* settings, one LLM call, and the adapter
|
|
285
|
+
ragaudit check --docs ./docs --adapter ragaudit_adapter.py # all of the below in one go, then open the report
|
|
286
|
+
ragaudit ingest ./docs # load, chunk, and persist a corpus
|
|
287
|
+
ragaudit generate --n 100 # generate a test set (n questions/chunk)
|
|
288
|
+
ragaudit run --adapter my_adapter.py # run + score + ablate against the latest test set
|
|
289
|
+
ragaudit run --adapter my_adapter.py --match-by text # same, matching retrieval by context text
|
|
290
|
+
ragaudit report --out report.html # render the latest run as a static HTML report
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Every command takes `--db` (default `./ragaudit.db`) to point at a
|
|
294
|
+
specific database file. All commands read `RAGAUDIT_*` settings from the
|
|
295
|
+
environment or from a `.env` file in the current folder (exported
|
|
296
|
+
variables win); `ragaudit init` writes a `.env.example` to copy.
|
|
297
|
+
|
|
298
|
+
## v2: generate, evaluate, tune
|
|
299
|
+
|
|
300
|
+
v1 audits one run. v2 adds a loop for improving a pipeline without fooling
|
|
301
|
+
yourself: build a question set that can't be gamed, evaluate cheaply as the
|
|
302
|
+
corpus changes, and let ragaudit search pipeline configs while keeping a
|
|
303
|
+
test split it never tunes on.
|
|
304
|
+
|
|
305
|
+
### What it does
|
|
306
|
+
|
|
307
|
+
- **`generate --corpus`** extracts facts from each chunk, writes a question
|
|
308
|
+
per fact, rewrites it away from the passage's wording, and has a second
|
|
309
|
+
model verify it is answerable, unambiguous and supported. Questions that
|
|
310
|
+
still copy the passage, use relative time ("recently", "last quarter"),
|
|
311
|
+
or fail verification are rejected with a reason. `--multi-hop` adds
|
|
312
|
+
questions that need two chunks.
|
|
313
|
+
- **`evaluate`** runs your pipeline on the dev split. Results are cached per
|
|
314
|
+
(question, config, corpus version), so an unchanged config re-scores
|
|
315
|
+
instantly. When the corpus changes, questions on edited or deleted chunks
|
|
316
|
+
go stale, and only new questions plus a seeded sample of old ones are
|
|
317
|
+
re-run.
|
|
318
|
+
- **`tune`** searches a declared space of pipeline knobs (grid, seeded
|
|
319
|
+
random, or an LLM that reads dev failures and proposes the next config),
|
|
320
|
+
then scores the baseline and the best config once each on the locked test
|
|
321
|
+
split.
|
|
322
|
+
- **`report --kind generate|tune`** renders both as self-contained HTML.
|
|
323
|
+
|
|
324
|
+
### Why the scores are honest
|
|
325
|
+
|
|
326
|
+
- **Questions don't share wording with their passage.** If over half of a
|
|
327
|
+
question's content words appear in its source chunk (configurable with
|
|
328
|
+
`--lexical-threshold`), it gets one more rewrite and is then rejected.
|
|
329
|
+
Keyword search alone can't ace the set.
|
|
330
|
+
- **A second model verifies every question.** The verifier must be a
|
|
331
|
+
different model from the generator.
|
|
332
|
+
- **The split is per chunk, fixed by hash.** Each chunk goes to dev (70% of
|
|
333
|
+
chunks) or test (30%) from a salted hash of its chunk id, and every
|
|
334
|
+
question from that chunk inherits its split, so no chunk feeds both
|
|
335
|
+
sides. Tuning on dev can't teach the pipeline a test chunk. Hashing the
|
|
336
|
+
id, not the content, means editing a chunk never moves it. A multi-hop
|
|
337
|
+
question is only kept when all its chunks share a split; otherwise it's
|
|
338
|
+
rejected as `cross_split` (and `generate` only pairs same-split chunks).
|
|
339
|
+
- **Tuning can't see the test split.** Reading test questions raises an
|
|
340
|
+
error unless one function, used only for the final score, unlocks it. A
|
|
341
|
+
test also parses the tuning code and fails if it imports the lock or the
|
|
342
|
+
database directly. The LLM proposer only ever sees dev data.
|
|
343
|
+
- **A pass needs retrieval and a correct answer.** The score is the share of
|
|
344
|
+
questions where the gold chunk was in the top k *and* the answer was
|
|
345
|
+
judged correct. A correct answer without the gold chunk counts as a
|
|
346
|
+
retrieval miss, so tuning can't reward a model answering from memory.
|
|
347
|
+
- **Overfitting is flagged.** If the best config's dev score beats its test
|
|
348
|
+
score by more than `--overfit-threshold` points (default 10), the run says
|
|
349
|
+
so.
|
|
350
|
+
|
|
351
|
+
### Quickstart
|
|
352
|
+
|
|
353
|
+
Offline, with the bundled demo and the deterministic mock LLM:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
pip install -e .
|
|
357
|
+
sh demo/v2/run_demo.sh # reports in demo/v2/output/
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Against your own corpus and pipeline, with a real model:
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
export RAGAUDIT_API_KEY=sk-... RAGAUDIT_MODEL=gpt-4o-mini
|
|
364
|
+
ragaudit generate --corpus corpus.jsonl --verifier-model gpt-4o
|
|
365
|
+
ragaudit evaluate --adapter my_pipeline.py --corpus corpus.jsonl
|
|
366
|
+
ragaudit tune --adapter my_pipeline.py --search-space space.yaml --strategy llm
|
|
367
|
+
ragaudit report --kind tune --out tune-report.html
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
A corpus is a `.jsonl` file, one chunk per line:
|
|
371
|
+
`{"chunk_id": "...", "text": "...", "timestamp": "2026-01-12", "doc_id": "...", "position": 0}`
|
|
372
|
+
(only `chunk_id` and `text` are required), or a directory of `.txt`/`.md`
|
|
373
|
+
files, which get ids like `guide.md#3`. Chunk ids must stay stable across
|
|
374
|
+
edits so ragaudit can tell an edited chunk from a new one.
|
|
375
|
+
|
|
376
|
+
For `tune`, the adapter exposes `make_adapter(config) -> adapter` (see
|
|
377
|
+
`demo/v2/toy_adapter.py`); plain v1 adapters still work with `evaluate`.
|
|
378
|
+
The search space is YAML:
|
|
379
|
+
|
|
380
|
+
```yaml
|
|
381
|
+
knobs:
|
|
382
|
+
chunk_size: [200, 400, 800]
|
|
383
|
+
top_k: [3, 5, 10]
|
|
384
|
+
hybrid_weight: {min: 0.0, max: 1.0, step: 0.25}
|
|
385
|
+
reranker: [none, cross_encoder]
|
|
386
|
+
baseline: # optional; defaults to each knob's first value
|
|
387
|
+
chunk_size: 400
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`tune` writes the best config to `best_config.yaml`, which `evaluate
|
|
391
|
+
--config` accepts directly. Reading YAML uses PyYAML if installed
|
|
392
|
+
(`pip install "ragaudit-cli[yaml]"`) and a built-in parser for simple files
|
|
393
|
+
otherwise.
|
|
394
|
+
|
|
395
|
+
### CLI reference
|
|
396
|
+
|
|
397
|
+
| Command | Key options |
|
|
398
|
+
|---|---|
|
|
399
|
+
| `ragaudit generate --corpus PATH` | `--n` (max facts per chunk, default 3), `--multi-hop`, `--multi-hop-pairs 10`, `--lexical-threshold 0.5`, `--max-chunks`, `--max-llm-calls`, `--generator-model`, `--verifier-model`, `--retry-processed` |
|
|
400
|
+
| `ragaudit evaluate --adapter FILE` | `--config YAML`, `--corpus PATH`, `--split dev\|test`, `--k 5`, `--regression-sample 50`, `--seed`, `--full`, `--ablation`, `--judge-model`, `--match-by id\|text` (text needs `--corpus`), `--match-threshold 0.5` |
|
|
401
|
+
| `ragaudit tune --adapter FILE --search-space YAML` | `--strategy grid\|random\|llm`, `--max-trials 20`, `--max-llm-calls`, `--seed`, `--k 5`, `--overfit-threshold 10`, `--out best_config.yaml`, `--judge-model`, `--proposer-model` |
|
|
402
|
+
| `ragaudit report --kind generate\|tune` | `--run-id` (generate run or tune id; default latest), `--out` |
|
|
403
|
+
|
|
404
|
+
Every v2 command takes `--store` (default `.ragaudit/store.db`, separate
|
|
405
|
+
from v1's `ragaudit.db`) and `--llm openai|mock` (or `RAGAUDIT_LLM`).
|
|
406
|
+
`--llm mock` runs everything offline with a deterministic mock that has
|
|
407
|
+
deliberate flaws, for demos and tests; its numbers say nothing about a
|
|
408
|
+
real pipeline. Without `--corpus`, `generate` is the unchanged v1 command,
|
|
409
|
+
and `report` defaults to the v1 run report.
|
|
410
|
+
|
|
411
|
+
### Known limitations
|
|
412
|
+
|
|
413
|
+
- The real-LLM path was smoke-tested on `generate` with local Ollama models
|
|
414
|
+
(qwen2.5:7b generator, llama3.1:8b verifier) and its bugs were fixed, but
|
|
415
|
+
a full real-LLM `evaluate`/`tune` run is still pending.
|
|
416
|
+
|
|
417
|
+
## Releasing
|
|
418
|
+
|
|
419
|
+
Pushing a tag `vX.Y.Z` that matches `version` in `pyproject.toml` runs
|
|
420
|
+
`.github/workflows/publish.yml`: tests, build, `twine check`, then upload
|
|
421
|
+
to PyPI through trusted publishing (no API token in GitHub). It needs a
|
|
422
|
+
one-time trusted publisher on pypi.org for project `ragaudit-cli`, this
|
|
423
|
+
repository, workflow `publish.yml`, environment `pypi`. (The distribution is
|
|
424
|
+
`ragaudit-cli` because PyPI treats `ragaudit` as the same name as the existing
|
|
425
|
+
`rag-audit` project.)
|
|
426
|
+
|
|
427
|
+
## License
|
|
428
|
+
|
|
429
|
+
MIT
|