rerankgate 0.2.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.
rerankgate/rewrite.py ADDED
@@ -0,0 +1,38 @@
1
+ """Query rewriting without an LLM: RM3-style pseudo-relevance feedback.
2
+
3
+ Takes the top documents of a failed attempt and expands the query with
4
+ high-value terms from them — the classic relevance-feedback trick,
5
+ deterministic and free.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import Counter
11
+
12
+ from rerankgate.retrieval import ScoredChunk
13
+ from rerankgate.tokenize import STOPWORDS, tokenize
14
+
15
+
16
+ class RM3Rewriter:
17
+ """Expand the query with discriminative terms from top failed-attempt docs."""
18
+
19
+ def __init__(self, expansion_terms: int = 5, top_docs: int = 3) -> None:
20
+ self.expansion_terms = expansion_terms
21
+ self.top_docs = top_docs
22
+
23
+ def rewrite(self, query: str, evidence: list[ScoredChunk]) -> str:
24
+ """Return an expanded query; returns the original if nothing to add."""
25
+ q_terms = set(tokenize(query))
26
+ term_counts: Counter[str] = Counter()
27
+ for sc in evidence[: self.top_docs]:
28
+ for t in tokenize(sc.chunk.text):
29
+ if t not in STOPWORDS and t not in q_terms and len(t) > 2:
30
+ term_counts[t] += 1
31
+ if not term_counts:
32
+ return query
33
+ # Prefer terms that appear across multiple top docs (consensus terms).
34
+ ranked = sorted(term_counts.items(), key=lambda kv: (-kv[1], kv[0]))
35
+ new_terms = [t for t, _ in ranked[: self.expansion_terms]]
36
+ if not new_terms:
37
+ return query
38
+ return query.rstrip() + " " + " ".join(new_terms)
rerankgate/store.py ADDED
@@ -0,0 +1,78 @@
1
+ """Document store: JSONL persistence for chunks + optional numpy vectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict
8
+
9
+ from rerankgate.documents import Chunk
10
+ from rerankgate.vectorstore import _HAS_NUMPY
11
+
12
+
13
+ class DocumentStore:
14
+ """Persist chunks as JSONL; vectors as .npy when numpy is available."""
15
+
16
+ def __init__(self, path: str) -> None:
17
+ self.path = path
18
+
19
+ @property
20
+ def chunks_file(self) -> str:
21
+ return os.path.join(self.path, "chunks.jsonl")
22
+
23
+ @property
24
+ def vectors_file(self) -> str:
25
+ return os.path.join(self.path, "vectors.npy")
26
+
27
+ @property
28
+ def meta_file(self) -> str:
29
+ return os.path.join(self.path, "meta.json")
30
+
31
+ def save(
32
+ self,
33
+ chunks: list[Chunk],
34
+ vectors: list[list[float]] | None = None,
35
+ meta: dict | None = None,
36
+ ) -> None:
37
+ os.makedirs(self.path, exist_ok=True)
38
+ with open(self.chunks_file, "w", encoding="utf-8") as f:
39
+ for ch in chunks:
40
+ f.write(json.dumps(asdict(ch)) + "\n")
41
+ with open(self.meta_file, "w", encoding="utf-8") as f:
42
+ json.dump(
43
+ {
44
+ "chunk_count": len(chunks),
45
+ "has_vectors": vectors is not None,
46
+ "dim": len(vectors[0]) if vectors else 0,
47
+ **(meta or {}),
48
+ },
49
+ f,
50
+ )
51
+ if vectors is not None:
52
+ if not _HAS_NUMPY:
53
+ raise RuntimeError("numpy is required to persist vectors")
54
+ import numpy as np
55
+
56
+ np.save(self.vectors_file, np.asarray(vectors, dtype=np.float32))
57
+
58
+ def load(self) -> tuple[list[Chunk], list[list[float]] | None, dict]:
59
+ if not os.path.exists(self.chunks_file):
60
+ raise FileNotFoundError(f"no store at {self.path!r}: run `rerankgate ingest` first")
61
+ chunks: list[Chunk] = []
62
+ with open(self.chunks_file, encoding="utf-8") as f:
63
+ for line in f:
64
+ line = line.strip()
65
+ if line:
66
+ chunks.append(Chunk(**json.loads(line)))
67
+ meta: dict = {}
68
+ if os.path.exists(self.meta_file):
69
+ with open(self.meta_file, encoding="utf-8") as f:
70
+ meta = json.load(f)
71
+ vectors: list[list[float]] | None = None
72
+ if meta.get("has_vectors") and os.path.exists(self.vectors_file):
73
+ if not _HAS_NUMPY:
74
+ raise RuntimeError("store has vectors but numpy is not installed")
75
+ import numpy as np
76
+
77
+ vectors = np.load(self.vectors_file).astype(float).tolist()
78
+ return chunks, vectors, meta
rerankgate/tokenize.py ADDED
@@ -0,0 +1,29 @@
1
+ """Shared tokenization and stopwords."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _TOKEN = re.compile(r"[a-z0-9]+")
8
+
9
+ STOPWORDS = frozenset(
10
+ """
11
+ a an the and or but if then else when while of at by for with about into through
12
+ during before after above below to from up down in out on off over under again
13
+ further once here there all any both each few more most other some such no nor
14
+ not only own same so than too very can will just don should now is are was were
15
+ be been being have has had having do does did doing would could ought i you he
16
+ she it we they them his her its our their this that these those am as it s t
17
+ what which who whom how why where when
18
+ """.split()
19
+ )
20
+
21
+
22
+ def tokenize(text: str) -> list[str]:
23
+ """Lowercase alphanumeric tokens."""
24
+ return _TOKEN.findall(text.lower())
25
+
26
+
27
+ def content_tokens(text: str) -> list[str]:
28
+ """Tokens with stopwords removed."""
29
+ return [t for t in tokenize(text) if t not in STOPWORDS]
@@ -0,0 +1,66 @@
1
+ """Cosine-similarity vector store over chunk embeddings.
2
+
3
+ Uses numpy when available; falls back to pure Python otherwise.
4
+ Vectors are expected to be L2-normalized (cosine == dot product).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ from rerankgate.documents import Chunk
12
+
13
+ try:
14
+ import numpy as _np
15
+
16
+ _HAS_NUMPY = True
17
+ except ImportError: # pragma: no cover
18
+ _np = None # type: ignore
19
+ _HAS_NUMPY = False
20
+
21
+
22
+ class VectorStore:
23
+ def __init__(self, dim: int) -> None:
24
+ self.dim = dim
25
+ self._chunks: list[Chunk] = []
26
+ self._vectors: list[list[float]] = []
27
+
28
+ def __len__(self) -> int:
29
+ return len(self._chunks)
30
+
31
+ @property
32
+ def vectors(self) -> list[list[float]]:
33
+ """The stored (normalized) vectors, in chunk order."""
34
+ return [list(v) for v in self._vectors]
35
+
36
+ def add(self, chunks: list[Chunk], vectors: list[list[float]]) -> None:
37
+ if len(chunks) != len(vectors):
38
+ raise ValueError("chunks and vectors must have equal length")
39
+ for v in vectors:
40
+ if len(v) != self.dim:
41
+ raise ValueError(f"expected dim {self.dim}, got {len(v)}")
42
+ self._chunks.extend(chunks)
43
+ self._vectors.extend([list(map(float, v)) for v in vectors])
44
+
45
+ def search(self, query_vec: list[float], k: int = 100) -> list[tuple[Chunk, float]]:
46
+ """Top-k (chunk, cosine similarity) pairs, highest first."""
47
+ if not self._chunks:
48
+ return []
49
+ if _HAS_NUMPY:
50
+ mat = _np.asarray(self._vectors, dtype=_np.float64)
51
+ q = _np.asarray(query_vec, dtype=_np.float64)
52
+ qn = _np.linalg.norm(q) or 1.0
53
+ norms = _np.linalg.norm(mat, axis=1)
54
+ norms[norms == 0] = 1.0
55
+ sims = (mat @ q) / (norms * qn)
56
+ order = _np.argsort(-sims, kind="stable")[:k]
57
+ return [(self._chunks[i], float(sims[i])) for i in order]
58
+ # Pure-python fallback.
59
+ qn = math.sqrt(sum(x * x for x in query_vec)) or 1.0
60
+ scored = []
61
+ for ch, v in zip(self._chunks, self._vectors, strict=True):
62
+ vn = math.sqrt(sum(x * x for x in v)) or 1.0
63
+ sim = sum(a * b for a, b in zip(v, query_vec, strict=True)) / (vn * qn)
64
+ scored.append((ch, sim))
65
+ scored.sort(key=lambda p: p[1], reverse=True)
66
+ return scored[:k]
@@ -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,21 @@
1
+ rerankgate/__init__.py,sha256=LzI5YMTl2bl3ik6MBC3FT9qZQLVYyrVePSpoFszpkOk,855
2
+ rerankgate/bm25.py,sha256=TzRofy42skIxqDKPTJqR2vCoxVM2V6AyemGMtN2Gruk,2471
3
+ rerankgate/cli.py,sha256=tswWUV1f8oRGsOM0L4LxZxnTVUU3HKTQsllZAze5H-g,4994
4
+ rerankgate/config.py,sha256=UlvgtazRfiVs9EpmoxlVpEBiErDH_YN1aolLaVQLczo,2591
5
+ rerankgate/documents.py,sha256=Eu-Bed4tZ2ztixuierwZu91ORwCV6_4PJhrwzzVHp3Q,2475
6
+ rerankgate/embeddings.py,sha256=CafeivlHHPH8meKFCEIQUP5xO30P54FU9OHiLuPIPTQ,2411
7
+ rerankgate/gate.py,sha256=xnc_f6-7bryHDb8XHFEkkS0Yz9Mnh8psNYnogf_FGAM,5955
8
+ rerankgate/judge.py,sha256=DEm4ZtKYSSSgELfhDmDdfBQ_IWIyxIlz6bNt5f42BQc,3010
9
+ rerankgate/metrics.py,sha256=eC4qj38Wcb_KgK7de4BBqGn1WxZt_K3Qq9sNZ1v5UJw,3755
10
+ rerankgate/pipeline.py,sha256=34wFVV8-ghvU42FFQuZKDDYWqmNx2VvDt4rwQyeoM5E,11819
11
+ rerankgate/rerank.py,sha256=93zekOAx-UKpMjUXn8iS7ebPZpDfs7aQLMAuFIoye-Y,3395
12
+ rerankgate/retrieval.py,sha256=_CH1Ap4-H1XsIeCO2xXjocTVc50rmNhH5MqvH8oodbI,3450
13
+ rerankgate/rewrite.py,sha256=HpIKS1CeaVxPipIDDawQI64ZBwwcNfKAiPGohMMu-_4,1498
14
+ rerankgate/store.py,sha256=0vcpUP1WCusmg2OYEkc3hd8yYTJNNdNo-49TXt-pErA,2693
15
+ rerankgate/tokenize.py,sha256=j0mALMp0SLSg2T7nt49hpiT_DGoUY-4aVmI02Suy9nI,984
16
+ rerankgate/vectorstore.py,sha256=snOyoJ7NHq_BPsXHfweDNUhAjvEvUyj7NYvy7H3xJPs,2359
17
+ rerankgate-0.2.0.dist-info/METADATA,sha256=4zXmMbtFCVNVQZxS3vKdsOTMG9aUTvZrkaY_Tiz1qYw,5912
18
+ rerankgate-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
19
+ rerankgate-0.2.0.dist-info/entry_points.txt,sha256=JGTMPUGp3iysG4ift10GXc4D9pmb9XCQ0wwVGoNBL6w,51
20
+ rerankgate-0.2.0.dist-info/top_level.txt,sha256=P95LE_w0SpQ6j-91wwJwYu9bAEf-cYW6xR2gLCJ70IY,11
21
+ rerankgate-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rerankgate = rerankgate.cli:main
@@ -0,0 +1 @@
1
+ rerankgate