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.
- rerankgate-0.2.0/PKG-INFO +134 -0
- rerankgate-0.2.0/README.md +113 -0
- rerankgate-0.2.0/pyproject.toml +43 -0
- rerankgate-0.2.0/setup.cfg +4 -0
- rerankgate-0.2.0/src/rerankgate/__init__.py +30 -0
- rerankgate-0.2.0/src/rerankgate/bm25.py +70 -0
- rerankgate-0.2.0/src/rerankgate/cli.py +142 -0
- rerankgate-0.2.0/src/rerankgate/config.py +77 -0
- rerankgate-0.2.0/src/rerankgate/documents.py +97 -0
- rerankgate-0.2.0/src/rerankgate/embeddings.py +76 -0
- rerankgate-0.2.0/src/rerankgate/gate.py +171 -0
- rerankgate-0.2.0/src/rerankgate/judge.py +88 -0
- rerankgate-0.2.0/src/rerankgate/metrics.py +106 -0
- rerankgate-0.2.0/src/rerankgate/pipeline.py +301 -0
- rerankgate-0.2.0/src/rerankgate/rerank.py +94 -0
- rerankgate-0.2.0/src/rerankgate/retrieval.py +99 -0
- rerankgate-0.2.0/src/rerankgate/rewrite.py +38 -0
- rerankgate-0.2.0/src/rerankgate/store.py +78 -0
- rerankgate-0.2.0/src/rerankgate/tokenize.py +29 -0
- rerankgate-0.2.0/src/rerankgate/vectorstore.py +66 -0
- rerankgate-0.2.0/src/rerankgate.egg-info/PKG-INFO +134 -0
- rerankgate-0.2.0/src/rerankgate.egg-info/SOURCES.txt +32 -0
- rerankgate-0.2.0/src/rerankgate.egg-info/dependency_links.txt +1 -0
- rerankgate-0.2.0/src/rerankgate.egg-info/entry_points.txt +2 -0
- rerankgate-0.2.0/src/rerankgate.egg-info/requires.txt +8 -0
- rerankgate-0.2.0/src/rerankgate.egg-info/top_level.txt +1 -0
- rerankgate-0.2.0/tests/test_bm25.py +45 -0
- rerankgate-0.2.0/tests/test_chunking.py +34 -0
- rerankgate-0.2.0/tests/test_cli.py +102 -0
- rerankgate-0.2.0/tests/test_final_audit.py +178 -0
- rerankgate-0.2.0/tests/test_metrics_judge.py +89 -0
- rerankgate-0.2.0/tests/test_pipeline.py +145 -0
- rerankgate-0.2.0/tests/test_rerank_gate.py +105 -0
- 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,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)
|