rerankgate 0.2.0__tar.gz

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 (34) hide show
  1. rerankgate-0.2.0/PKG-INFO +134 -0
  2. rerankgate-0.2.0/README.md +113 -0
  3. rerankgate-0.2.0/pyproject.toml +43 -0
  4. rerankgate-0.2.0/setup.cfg +4 -0
  5. rerankgate-0.2.0/src/rerankgate/__init__.py +30 -0
  6. rerankgate-0.2.0/src/rerankgate/bm25.py +70 -0
  7. rerankgate-0.2.0/src/rerankgate/cli.py +142 -0
  8. rerankgate-0.2.0/src/rerankgate/config.py +77 -0
  9. rerankgate-0.2.0/src/rerankgate/documents.py +97 -0
  10. rerankgate-0.2.0/src/rerankgate/embeddings.py +76 -0
  11. rerankgate-0.2.0/src/rerankgate/gate.py +171 -0
  12. rerankgate-0.2.0/src/rerankgate/judge.py +88 -0
  13. rerankgate-0.2.0/src/rerankgate/metrics.py +106 -0
  14. rerankgate-0.2.0/src/rerankgate/pipeline.py +301 -0
  15. rerankgate-0.2.0/src/rerankgate/rerank.py +94 -0
  16. rerankgate-0.2.0/src/rerankgate/retrieval.py +99 -0
  17. rerankgate-0.2.0/src/rerankgate/rewrite.py +38 -0
  18. rerankgate-0.2.0/src/rerankgate/store.py +78 -0
  19. rerankgate-0.2.0/src/rerankgate/tokenize.py +29 -0
  20. rerankgate-0.2.0/src/rerankgate/vectorstore.py +66 -0
  21. rerankgate-0.2.0/src/rerankgate.egg-info/PKG-INFO +134 -0
  22. rerankgate-0.2.0/src/rerankgate.egg-info/SOURCES.txt +32 -0
  23. rerankgate-0.2.0/src/rerankgate.egg-info/dependency_links.txt +1 -0
  24. rerankgate-0.2.0/src/rerankgate.egg-info/entry_points.txt +2 -0
  25. rerankgate-0.2.0/src/rerankgate.egg-info/requires.txt +8 -0
  26. rerankgate-0.2.0/src/rerankgate.egg-info/top_level.txt +1 -0
  27. rerankgate-0.2.0/tests/test_bm25.py +45 -0
  28. rerankgate-0.2.0/tests/test_chunking.py +34 -0
  29. rerankgate-0.2.0/tests/test_cli.py +102 -0
  30. rerankgate-0.2.0/tests/test_final_audit.py +178 -0
  31. rerankgate-0.2.0/tests/test_metrics_judge.py +89 -0
  32. rerankgate-0.2.0/tests/test_pipeline.py +145 -0
  33. rerankgate-0.2.0/tests/test_rerank_gate.py +105 -0
  34. rerankgate-0.2.0/tests/test_retrieval.py +49 -0
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: rerankgate
3
+ Version: 0.2.0
4
+ Summary: Agentic RAG quality gate: hybrid retrieval with a sufficiency gate, query-rewrite retries, and per-stage metrics. Local-first.
5
+ Author: Calas Automations Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://calasautomations.com
8
+ Keywords: rag,retrieval,rerank,bm25,quality-gate,agentic
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Provides-Extra: local
16
+ Requires-Dist: numpy>=1.24; extra == "local"
17
+ Requires-Dist: sentence-transformers>=2.2; extra == "local"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7; extra == "dev"
20
+ Requires-Dist: ruff>=0.1; extra == "dev"
21
+
22
+ # rerankgate
23
+
24
+ **Agentic RAG quality gate.** A drop-in retrieval pipeline with a decision point
25
+ between retrieval and generation:
26
+
27
+ ```
28
+ query → hybrid retrieval (BM25 + vectors, RRF fusion)
29
+ → rerank
30
+ → sufficiency gate ── PASS ─→ evidence (+ optional generation & faithfulness check)
31
+ FAIL ─→ rewrite query (RM3, no LLM) → retry (≤2, early-stop)
32
+ exhausted → ABSTAIN with evidence + reasons
33
+ ```
34
+
35
+ **The rule:** if the retrieval wasn't good enough, the answer never gets generated.
36
+ Local-first — the sufficiency gate works with zero LLM calls by default.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install rerankgate # zero hard dependencies (BM25-only mode)
42
+ pip install "rerankgate[local]" # + sentence-transformers, numpy (hybrid vectors)
43
+ ```
44
+
45
+ ## Quickstart
46
+
47
+ ```python
48
+ from rerankgate import RAGPipeline, Document
49
+
50
+ pipe = RAGPipeline() # degrades to BM25-only if embeddings aren't installed
51
+ pipe.ingest([
52
+ Document(id="rag", text="Retrieval-augmented generation pairs a retriever..."),
53
+ Document(id="bm25", text="BM25 is a classic probabilistic ranking function..."),
54
+ ])
55
+
56
+ result = pipe.ask("What is retrieval-augmented generation?")
57
+ print(result.verdict) # "answerable" | "insufficient" | "needs_review"
58
+ print(result.gate.reasons) # e.g. ["PASS"] or ["LOW_COVERAGE", ...]
59
+ print(result.gate.explanation) # human-readable why
60
+
61
+ # Suspected false negative? Override it — the override is recorded in the trace.
62
+ result = pipe.ask("...", gate_override=True)
63
+
64
+ # Optional: run your own generator through the faithfulness check.
65
+ result = pipe.ask("...", generator=lambda q, ev: my_llm(q, [c.chunk.text for c in ev]))
66
+ # verdict flips to "needs_review" if the answer isn't grounded in the evidence.
67
+ ```
68
+
69
+ Every `ask()` returns a full `trace`: per-stage timings, retrieval modes,
70
+ retry count, gate features, and whether the run was degraded.
71
+
72
+ ## CLI
73
+
74
+ ```bash
75
+ # JSONL corpus: {"id": ..., "text": ..., ...metadata}
76
+ rerankgate ingest --input corpus.jsonl --store ./store
77
+
78
+ rerankgate query --store ./store "What is RAG?"
79
+ rerankgate query --store ./store --gate-preset lenient --json "..."
80
+
81
+ # Evaluate retrieval + gate on labeled data, then tune the threshold.
82
+ # qrels JSONL: {"query": ..., "relevant_ids": [...], "answerable": true/false}
83
+ rerankgate eval --store ./store --qrels eval.jsonl
84
+ rerankgate eval --store ./store --qrels eval.jsonl --summary # aggregates only
85
+ ```
86
+
87
+ `rerankgate eval` reports recall@k, MRR (over answerable items), the gate
88
+ confusion matrix (precision/recall of PASS vs your labels), and the abstention
89
+ rate — use it to pick a threshold/preset instead of trusting the defaults.
90
+
91
+ ## How the gate works
92
+
93
+ The sufficiency gate is a fixed weighted heuristic over interpretable features —
94
+ top rerank score, query-term coverage, ranking margin, and cross-chunk support —
95
+ plus hard floors on coverage and top score. Every decision carries stable
96
+ machine-readable reason codes (`PASS`, `LOW_TOP_SCORE`, `LOW_COVERAGE`,
97
+ `NO_MARGIN`, `NO_CONSENSUS`, `NO_EVIDENCE`) and a human explanation. Nothing is
98
+ learned, so there's nothing to overfit; tune it with the eval CLI.
99
+
100
+ Presets: `strict` (0.70) · `balanced` (0.55, default) · `lenient` (0.40).
101
+
102
+ ## Design notes
103
+
104
+ - **Fusion:** Reciprocal Rank Fusion (k=60) — rank-based, no cross-system score
105
+ normalization hacks. Per-system weights available in `PipelineConfig`.
106
+ - **Retries:** RM3-style pseudo-relevance feedback expands the query from the
107
+ failed attempt's top docs — no LLM needed. Hard cap of 2 retries plus
108
+ diminishing-returns early stop: the loop always terminates.
109
+ - **Abstention > hallucination:** on `insufficient` the pipeline returns
110
+ best-effort evidence with gate reasons so the caller can decide — it never
111
+ silently hands weak context to an LLM.
112
+ - **Pluggable:** `EmbeddingBackend`, `Reranker`, and `Judge` are protocols.
113
+ Bring your own embeddings (OpenAI, Cohere…), a cross-encoder, or an
114
+ LLM-as-judge without touching the pipeline.
115
+
116
+ ## Limitations (honest)
117
+
118
+ - The heuristic gate is tuned for English keyword overlap. Synonym-heavy,
119
+ cross-lingual, or highly abstract queries can under-score — use hybrid vectors
120
+ and/or the `lenient` preset, and calibrate with `rerankgate eval`.
121
+ - The default faithfulness judge is token-overlap based: free and instant, but
122
+ gameable by extractive copying. For adversarial settings, plug in `LLMJudge`.
123
+ - Default chunking is a sentence-aware character window, not semantic chunking;
124
+ tables and structured text chunk naively.
125
+ - `pip install rerankgate[local]` pulls PyTorch (~2GB) via sentence-transformers —
126
+ the price of local dense retrieval. BM25-only mode needs nothing.
127
+ - The gate threshold defaults are starting points, not tuned constants — they
128
+ were sanity-checked on a toy corpus, not a benchmark. Calibrate on your data.
129
+ - Not thread-safe; one pipeline per thread. At 500K+ vectors, prefer the numpy
130
+ path (installed with `[local]`) over the pure-Python fallback.
131
+
132
+ ## License
133
+
134
+ MIT — Calas Automations Inc.
@@ -0,0 +1,113 @@
1
+ # rerankgate
2
+
3
+ **Agentic RAG quality gate.** A drop-in retrieval pipeline with a decision point
4
+ between retrieval and generation:
5
+
6
+ ```
7
+ query → hybrid retrieval (BM25 + vectors, RRF fusion)
8
+ → rerank
9
+ → sufficiency gate ── PASS ─→ evidence (+ optional generation & faithfulness check)
10
+ FAIL ─→ rewrite query (RM3, no LLM) → retry (≤2, early-stop)
11
+ exhausted → ABSTAIN with evidence + reasons
12
+ ```
13
+
14
+ **The rule:** if the retrieval wasn't good enough, the answer never gets generated.
15
+ Local-first — the sufficiency gate works with zero LLM calls by default.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install rerankgate # zero hard dependencies (BM25-only mode)
21
+ pip install "rerankgate[local]" # + sentence-transformers, numpy (hybrid vectors)
22
+ ```
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ from rerankgate import RAGPipeline, Document
28
+
29
+ pipe = RAGPipeline() # degrades to BM25-only if embeddings aren't installed
30
+ pipe.ingest([
31
+ Document(id="rag", text="Retrieval-augmented generation pairs a retriever..."),
32
+ Document(id="bm25", text="BM25 is a classic probabilistic ranking function..."),
33
+ ])
34
+
35
+ result = pipe.ask("What is retrieval-augmented generation?")
36
+ print(result.verdict) # "answerable" | "insufficient" | "needs_review"
37
+ print(result.gate.reasons) # e.g. ["PASS"] or ["LOW_COVERAGE", ...]
38
+ print(result.gate.explanation) # human-readable why
39
+
40
+ # Suspected false negative? Override it — the override is recorded in the trace.
41
+ result = pipe.ask("...", gate_override=True)
42
+
43
+ # Optional: run your own generator through the faithfulness check.
44
+ result = pipe.ask("...", generator=lambda q, ev: my_llm(q, [c.chunk.text for c in ev]))
45
+ # verdict flips to "needs_review" if the answer isn't grounded in the evidence.
46
+ ```
47
+
48
+ Every `ask()` returns a full `trace`: per-stage timings, retrieval modes,
49
+ retry count, gate features, and whether the run was degraded.
50
+
51
+ ## CLI
52
+
53
+ ```bash
54
+ # JSONL corpus: {"id": ..., "text": ..., ...metadata}
55
+ rerankgate ingest --input corpus.jsonl --store ./store
56
+
57
+ rerankgate query --store ./store "What is RAG?"
58
+ rerankgate query --store ./store --gate-preset lenient --json "..."
59
+
60
+ # Evaluate retrieval + gate on labeled data, then tune the threshold.
61
+ # qrels JSONL: {"query": ..., "relevant_ids": [...], "answerable": true/false}
62
+ rerankgate eval --store ./store --qrels eval.jsonl
63
+ rerankgate eval --store ./store --qrels eval.jsonl --summary # aggregates only
64
+ ```
65
+
66
+ `rerankgate eval` reports recall@k, MRR (over answerable items), the gate
67
+ confusion matrix (precision/recall of PASS vs your labels), and the abstention
68
+ rate — use it to pick a threshold/preset instead of trusting the defaults.
69
+
70
+ ## How the gate works
71
+
72
+ The sufficiency gate is a fixed weighted heuristic over interpretable features —
73
+ top rerank score, query-term coverage, ranking margin, and cross-chunk support —
74
+ plus hard floors on coverage and top score. Every decision carries stable
75
+ machine-readable reason codes (`PASS`, `LOW_TOP_SCORE`, `LOW_COVERAGE`,
76
+ `NO_MARGIN`, `NO_CONSENSUS`, `NO_EVIDENCE`) and a human explanation. Nothing is
77
+ learned, so there's nothing to overfit; tune it with the eval CLI.
78
+
79
+ Presets: `strict` (0.70) · `balanced` (0.55, default) · `lenient` (0.40).
80
+
81
+ ## Design notes
82
+
83
+ - **Fusion:** Reciprocal Rank Fusion (k=60) — rank-based, no cross-system score
84
+ normalization hacks. Per-system weights available in `PipelineConfig`.
85
+ - **Retries:** RM3-style pseudo-relevance feedback expands the query from the
86
+ failed attempt's top docs — no LLM needed. Hard cap of 2 retries plus
87
+ diminishing-returns early stop: the loop always terminates.
88
+ - **Abstention > hallucination:** on `insufficient` the pipeline returns
89
+ best-effort evidence with gate reasons so the caller can decide — it never
90
+ silently hands weak context to an LLM.
91
+ - **Pluggable:** `EmbeddingBackend`, `Reranker`, and `Judge` are protocols.
92
+ Bring your own embeddings (OpenAI, Cohere…), a cross-encoder, or an
93
+ LLM-as-judge without touching the pipeline.
94
+
95
+ ## Limitations (honest)
96
+
97
+ - The heuristic gate is tuned for English keyword overlap. Synonym-heavy,
98
+ cross-lingual, or highly abstract queries can under-score — use hybrid vectors
99
+ and/or the `lenient` preset, and calibrate with `rerankgate eval`.
100
+ - The default faithfulness judge is token-overlap based: free and instant, but
101
+ gameable by extractive copying. For adversarial settings, plug in `LLMJudge`.
102
+ - Default chunking is a sentence-aware character window, not semantic chunking;
103
+ tables and structured text chunk naively.
104
+ - `pip install rerankgate[local]` pulls PyTorch (~2GB) via sentence-transformers —
105
+ the price of local dense retrieval. BM25-only mode needs nothing.
106
+ - The gate threshold defaults are starting points, not tuned constants — they
107
+ were sanity-checked on a toy corpus, not a benchmark. Calibrate on your data.
108
+ - Not thread-safe; one pipeline per thread. At 500K+ vectors, prefer the numpy
109
+ path (installed with `[local]`) over the pure-Python fallback.
110
+
111
+ ## License
112
+
113
+ MIT — Calas Automations Inc.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "rerankgate"
7
+ version = "0.2.0"
8
+ description = "Agentic RAG quality gate: hybrid retrieval with a sufficiency gate, query-rewrite retries, and per-stage metrics. Local-first."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Calas Automations Inc." }]
12
+ keywords = ["rag", "retrieval", "rerank", "bm25", "quality-gate", "agentic"]
13
+ requires-python = ">=3.10"
14
+ dependencies = []
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Scientific/Engineering :: Information Analysis",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ local = ["numpy>=1.24", "sentence-transformers>=2.2"]
24
+ dev = ["pytest>=7", "ruff>=0.1"]
25
+
26
+ [project.scripts]
27
+ rerankgate = "rerankgate.cli:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://calasautomations.com"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+
35
+ [tool.ruff]
36
+ line-length = 100
37
+ target-version = "py310"
38
+
39
+ [tool.ruff.lint]
40
+ select = ["E", "F", "W", "I", "B", "C4", "UP"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ """rerankgate — agentic RAG quality gate.
2
+
3
+ Drop-in retrieval pipeline with built-in quality gates:
4
+ hybrid search (BM25 + vector) -> rerank -> sufficiency gate ->
5
+ query-rewrite retry or abstain. Local-first; the sufficiency gate
6
+ works without any LLM call by default.
7
+ """
8
+
9
+ from rerankgate.config import GateConfig, PipelineConfig, RetryConfig
10
+ from rerankgate.documents import Document, chunk_text
11
+ from rerankgate.gate import GateDecision, SufficiencyGate
12
+ from rerankgate.pipeline import AnswerResult, RAGPipeline, Verdict
13
+ from rerankgate.retrieval import HybridRetriever, ScoredChunk
14
+
15
+ __all__ = [
16
+ "AnswerResult",
17
+ "Document",
18
+ "GateConfig",
19
+ "GateDecision",
20
+ "HybridRetriever",
21
+ "PipelineConfig",
22
+ "RAGPipeline",
23
+ "RetryConfig",
24
+ "ScoredChunk",
25
+ "SufficiencyGate",
26
+ "Verdict",
27
+ "chunk_text",
28
+ ]
29
+
30
+ __version__ = "0.2.0"
@@ -0,0 +1,70 @@
1
+ """Pure-stdlib BM25 (Okapi) index over chunks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import Counter
7
+
8
+ from rerankgate.documents import Chunk
9
+ from rerankgate.tokenize import tokenize
10
+
11
+
12
+ class BM25Index:
13
+ """In-memory BM25 index. Deterministic; rebuild from chunks."""
14
+
15
+ def __init__(self, k1: float = 1.5, b: float = 0.75) -> None:
16
+ self.k1 = k1
17
+ self.b = b
18
+ self._chunks: list[Chunk] = []
19
+ self._doc_freq: Counter[str] = Counter()
20
+ self._term_freqs: list[Counter[str]] = []
21
+ self._doc_lens: list[int] = []
22
+ self._avg_len: float = 0.0
23
+
24
+ def __len__(self) -> int:
25
+ return len(self._chunks)
26
+
27
+ def add(self, chunks: list[Chunk]) -> None:
28
+ for ch in chunks:
29
+ toks = tokenize(ch.text)
30
+ tf = Counter(toks)
31
+ self._chunks.append(ch)
32
+ self._term_freqs.append(tf)
33
+ self._doc_lens.append(len(toks))
34
+ for term in tf:
35
+ self._doc_freq[term] += 1
36
+ total = sum(self._doc_lens)
37
+ self._avg_len = total / len(self._doc_lens) if self._doc_lens else 0.0
38
+
39
+ def _idf(self, term: str) -> float:
40
+ n = len(self._chunks)
41
+ df = self._doc_freq.get(term, 0)
42
+ # Standard BM25 IDF with +1 smoothing floor; unseen terms get ~0.
43
+ return math.log(1 + (n - df + 0.5) / (df + 0.5)) if df else 0.0
44
+
45
+ def search(self, query: str, k: int = 100) -> list[tuple[Chunk, float]]:
46
+ """Return top-k (chunk, score) pairs, highest score first."""
47
+ q_terms = tokenize(query)
48
+ if not q_terms or not self._chunks:
49
+ return []
50
+ idfs = {t: self._idf(t) for t in set(q_terms)}
51
+ scored: list[tuple[Chunk, float]] = []
52
+ for i, ch in enumerate(self._chunks):
53
+ tf = self._term_freqs[i]
54
+ dl = self._doc_lens[i]
55
+ norm = 1 - self.b + self.b * (dl / self._avg_len) if self._avg_len else 1.0
56
+ score = 0.0
57
+ for t in q_terms:
58
+ f = tf.get(t, 0)
59
+ if f and idfs[t]:
60
+ score += idfs[t] * (f * (self.k1 + 1)) / (f + self.k1 * norm)
61
+ if score > 0:
62
+ scored.append((ch, score))
63
+ scored.sort(key=lambda p: p[1], reverse=True)
64
+ return scored[:k]
65
+
66
+ def term_stats(self, chunk_idx: int) -> Counter[str]:
67
+ return self._term_freqs[chunk_idx]
68
+
69
+ def chunks(self) -> list[Chunk]:
70
+ return list(self._chunks)
@@ -0,0 +1,142 @@
1
+ """rerankgate CLI: ingest | query | eval."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from rerankgate.config import PipelineConfig
10
+ from rerankgate.documents import Document
11
+ from rerankgate.metrics import EvalItem
12
+ from rerankgate.pipeline import RAGPipeline
13
+
14
+
15
+ def _pipeline_for(args: argparse.Namespace, *, for_ingest: bool = False) -> RAGPipeline:
16
+ cfg = PipelineConfig()
17
+ if args.gate_preset:
18
+ cfg.gate_preset = args.gate_preset
19
+ if args.max_retries is not None:
20
+ cfg.retry.max_retries = args.max_retries
21
+ if args.no_vectors:
22
+ cfg.use_vectors = False
23
+ if for_ingest:
24
+ return RAGPipeline(config=cfg)
25
+ return RAGPipeline.load(args.store, config=cfg)
26
+
27
+
28
+ def cmd_ingest(args: argparse.Namespace) -> int:
29
+ docs: list[Document] = []
30
+ with open(args.input, encoding="utf-8") as f:
31
+ for i, line in enumerate(f):
32
+ line = line.strip()
33
+ if not line:
34
+ continue
35
+ obj = json.loads(line)
36
+ text = obj.get("text", "")
37
+ meta = {k: v for k, v in obj.items() if k not in ("id", "text")}
38
+ docs.append(Document(id=str(obj.get("id", f"doc-{i}")), text=text, metadata=meta))
39
+ pipe = _pipeline_for(args, for_ingest=True)
40
+ n_chunks = pipe.ingest(docs)
41
+ pipe.save(args.store)
42
+ print(f"ingested {len(docs)} documents -> {n_chunks} chunks -> {args.store}")
43
+ if pipe.degraded_reason:
44
+ print("note: BM25-only mode (no embeddings installed)")
45
+ return 0
46
+
47
+
48
+ def cmd_query(args: argparse.Namespace) -> int:
49
+ pipe = _pipeline_for(args)
50
+ result = pipe.ask(args.query)
51
+ out = result.to_dict()
52
+ if args.json:
53
+ print(json.dumps(out, indent=2))
54
+ return 0
55
+ print(f"verdict: {out['verdict']}")
56
+ print(
57
+ f"gate: {'PASS' if out['gate']['passed'] else 'FAIL'} "
58
+ f"(score {out['gate']['score']} vs threshold {out['gate']['threshold']})"
59
+ )
60
+ print(f"reasons: {', '.join(out['gate']['reasons'])}")
61
+ print(
62
+ f"attempts: {out['trace']['attempts']} "
63
+ f"modes: {','.join(out['trace']['modes'])} "
64
+ f"total: {out['trace']['total_ms']}ms"
65
+ )
66
+ if out["trace"]["degraded"]:
67
+ print("degraded: BM25-only (embeddings unavailable)")
68
+ print("evidence:")
69
+ for ev in out["evidence"][:10]:
70
+ snippet = ev["text"][:120].replace("\n", " ")
71
+ print(f" [{ev['score']:.3f}] {ev['chunk_id']}: {snippet}...")
72
+ return 0
73
+
74
+
75
+ def cmd_eval(args: argparse.Namespace) -> int:
76
+ pipe = _pipeline_for(args)
77
+ items: list[EvalItem] = []
78
+ with open(args.qrels, encoding="utf-8") as f:
79
+ for line in f:
80
+ line = line.strip()
81
+ if not line:
82
+ continue
83
+ obj = json.loads(line)
84
+ items.append(
85
+ EvalItem(
86
+ query=obj["query"],
87
+ relevant_ids=list(obj.get("relevant_ids", [])),
88
+ answerable=bool(obj.get("answerable", True)),
89
+ )
90
+ )
91
+ report = pipe.evaluate(items)
92
+ if args.summary:
93
+ report = {k: v for k, v in report.items() if k != "per_query"}
94
+ print(json.dumps(report, indent=2))
95
+ return 0
96
+
97
+
98
+ def build_parser() -> argparse.ArgumentParser:
99
+ import rerankgate
100
+
101
+ p = argparse.ArgumentParser(prog="rerankgate", description="Agentic RAG quality gate")
102
+ p.add_argument("--version", action="version", version=f"%(prog)s {rerankgate.__version__}")
103
+ sub = p.add_subparsers(dest="command", required=True)
104
+
105
+ def _common(sp: argparse.ArgumentParser) -> None:
106
+ sp.add_argument("--gate-preset", choices=["strict", "balanced", "lenient"], default=None)
107
+ sp.add_argument("--max-retries", type=int, default=None)
108
+ sp.add_argument("--no-vectors", action="store_true", help="force BM25-only mode")
109
+
110
+ pi = sub.add_parser("ingest", help="ingest a JSONL corpus into a store")
111
+ pi.add_argument("--input", required=True, help="JSONL with {id, text, ...metadata}")
112
+ pi.add_argument("--store", required=True, help="output store directory")
113
+ _common(pi)
114
+ pi.set_defaults(func=cmd_ingest)
115
+
116
+ pq = sub.add_parser("query", help="ask a question against a store")
117
+ pq.add_argument("--store", required=True)
118
+ pq.add_argument("query")
119
+ pq.add_argument("--json", action="store_true")
120
+ _common(pq)
121
+ pq.set_defaults(func=cmd_query)
122
+
123
+ pe = sub.add_parser("eval", help="evaluate retrieval + gate on labeled qrels")
124
+ pe.add_argument("--store", required=True)
125
+ pe.add_argument("--qrels", required=True, help="JSONL with {query, relevant_ids, answerable}")
126
+ pe.add_argument(
127
+ "--summary",
128
+ action="store_true",
129
+ help="print aggregate metrics only, without the per-query detail",
130
+ )
131
+ _common(pe)
132
+ pe.set_defaults(func=cmd_eval)
133
+ return p
134
+
135
+
136
+ def main(argv: list[str] | None = None) -> int:
137
+ args = build_parser().parse_args(argv)
138
+ return args.func(args)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main())
@@ -0,0 +1,77 @@
1
+ """Configuration dataclasses for rerankgate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class RetryConfig:
10
+ """Bounds for the query-rewrite retry loop."""
11
+
12
+ max_retries: int = 2
13
+ # Stop early if the best gate score does not improve by at least this much.
14
+ min_improvement: float = 0.01
15
+ # How many expansion terms the RM3-style rewriter may add per retry.
16
+ expansion_terms: int = 5
17
+
18
+
19
+ @dataclass
20
+ class GateConfig:
21
+ """Tunable knobs for the heuristic sufficiency gate.
22
+
23
+ The gate score is a fixed weighted sum of interpretable features
24
+ (see gate.py). Nothing is learned, so there is nothing to overfit;
25
+ tune the threshold with `rerankgate eval` on your own labeled data.
26
+ """
27
+
28
+ threshold: float = 0.55
29
+ w_top_score: float = 0.35
30
+ w_coverage: float = 0.30
31
+ w_margin: float = 0.20
32
+ w_support: float = 0.15
33
+ # Minimum fraction of (non-stopword) query terms that must appear
34
+ # in the evidence before the gate can pass, regardless of score.
35
+ min_coverage: float = 0.25
36
+ # Minimum normalized top-1 rerank score before the gate can pass.
37
+ min_top_score: float = 0.10
38
+
39
+ def preset(self, name: str) -> GateConfig:
40
+ """Return a copy with a named strictness preset applied."""
41
+ presets = {
42
+ # (threshold, min_coverage): stricter gates demand more evidence.
43
+ "strict": (0.70, 0.35),
44
+ "balanced": (0.55, 0.25),
45
+ "lenient": (0.40, 0.15),
46
+ }
47
+ if name not in presets:
48
+ raise ValueError(f"unknown gate preset {name!r}; choose from {sorted(presets)}")
49
+ import dataclasses
50
+
51
+ threshold, min_coverage = presets[name]
52
+ return dataclasses.replace(self, threshold=threshold, min_coverage=min_coverage)
53
+
54
+
55
+ @dataclass
56
+ class PipelineConfig:
57
+ """Top-level pipeline configuration."""
58
+
59
+ # Retrieval
60
+ retrieve_k: int = 100 # candidates pulled from each retriever before fusion
61
+ rerank_k: int = 10 # evidence chunks returned after reranking
62
+ rrf_k: int = 60 # RRF rank constant
63
+ bm25_weight: float = 1.0 # per-system RRF multipliers
64
+ vector_weight: float = 1.0
65
+ use_vectors: bool = True # degraded to BM25-only if embeddings unavailable
66
+
67
+ # Chunking
68
+ chunk_size: int = 500
69
+ chunk_overlap: int = 50
70
+
71
+ # Gate + retry
72
+ gate: GateConfig = field(default_factory=GateConfig)
73
+ retry: RetryConfig = field(default_factory=RetryConfig)
74
+ gate_preset: str = "balanced"
75
+
76
+ def effective_gate(self) -> GateConfig:
77
+ return self.gate.preset(self.gate_preset)