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.
Files changed (71) hide show
  1. ragaudit/__init__.py +1 -0
  2. ragaudit/adapters/__init__.py +0 -0
  3. ragaudit/adapters/base.py +109 -0
  4. ragaudit/adapters/callable_adapter.py +15 -0
  5. ragaudit/cli.py +540 -0
  6. ragaudit/cli_easy.py +458 -0
  7. ragaudit/cli_v2.py +507 -0
  8. ragaudit/env.py +46 -0
  9. ragaudit/generate/__init__.py +0 -0
  10. ragaudit/generate/pipeline.py +486 -0
  11. ragaudit/generate/prompts.py +116 -0
  12. ragaudit/generate/testset_generator.py +146 -0
  13. ragaudit/generate/text.py +75 -0
  14. ragaudit/ingest/__init__.py +0 -0
  15. ragaudit/ingest/chunker.py +53 -0
  16. ragaudit/ingest/corpus.py +120 -0
  17. ragaudit/ingest/loader.py +49 -0
  18. ragaudit/ingest/versioning.py +92 -0
  19. ragaudit/llm/__init__.py +0 -0
  20. ragaudit/llm/client.py +86 -0
  21. ragaudit/llm/factory.py +70 -0
  22. ragaudit/llm/json_output.py +72 -0
  23. ragaudit/llm/mock.py +552 -0
  24. ragaudit/models/__init__.py +0 -0
  25. ragaudit/models/adapter.py +39 -0
  26. ragaudit/models/document.py +28 -0
  27. ragaudit/models/evaluation.py +70 -0
  28. ragaudit/models/question.py +152 -0
  29. ragaudit/models/run.py +44 -0
  30. ragaudit/models/scoring.py +82 -0
  31. ragaudit/models/testset.py +30 -0
  32. ragaudit/models/tune.py +56 -0
  33. ragaudit/report/__init__.py +0 -0
  34. ragaudit/report/generator.py +155 -0
  35. ragaudit/report/plain_summary.py +122 -0
  36. ragaudit/report/templates/_v2_tooltip.jinja +43 -0
  37. ragaudit/report/templates/generate_report.html.jinja +175 -0
  38. ragaudit/report/templates/report.html.jinja +252 -0
  39. ragaudit/report/templates/style.css +1095 -0
  40. ragaudit/report/templates/tune_report.html.jinja +158 -0
  41. ragaudit/report/templates/v2.css +286 -0
  42. ragaudit/report/v2.py +380 -0
  43. ragaudit/runner/__init__.py +0 -0
  44. ragaudit/runner/ablation.py +46 -0
  45. ragaudit/runner/evaluate.py +325 -0
  46. ragaudit/runner/executor.py +98 -0
  47. ragaudit/runner/locked.py +37 -0
  48. ragaudit/runner/summary.py +45 -0
  49. ragaudit/scaffold.py +252 -0
  50. ragaudit/scoring/__init__.py +0 -0
  51. ragaudit/scoring/correctness.py +94 -0
  52. ragaudit/scoring/groundedness.py +86 -0
  53. ragaudit/scoring/retrieval.py +42 -0
  54. ragaudit/scoring/text_match.py +51 -0
  55. ragaudit/scoring/verdict.py +14 -0
  56. ragaudit/storage/__init__.py +0 -0
  57. ragaudit/storage/db.py +81 -0
  58. ragaudit/storage/lock.py +36 -0
  59. ragaudit/storage/repository.py +216 -0
  60. ragaudit/storage/store.py +536 -0
  61. ragaudit/tune/__init__.py +7 -0
  62. ragaudit/tune/devset.py +16 -0
  63. ragaudit/tune/loop.py +208 -0
  64. ragaudit/tune/search_space.py +140 -0
  65. ragaudit/tune/strategies.py +197 -0
  66. ragaudit/yaml_io.py +199 -0
  67. ragaudit_cli-0.3.0.dist-info/METADATA +429 -0
  68. ragaudit_cli-0.3.0.dist-info/RECORD +71 -0
  69. ragaudit_cli-0.3.0.dist-info/WHEEL +4 -0
  70. ragaudit_cli-0.3.0.dist-info/entry_points.txt +2 -0
  71. 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