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/__init__.py +30 -0
- rerankgate/bm25.py +70 -0
- rerankgate/cli.py +142 -0
- rerankgate/config.py +77 -0
- rerankgate/documents.py +97 -0
- rerankgate/embeddings.py +76 -0
- rerankgate/gate.py +171 -0
- rerankgate/judge.py +88 -0
- rerankgate/metrics.py +106 -0
- rerankgate/pipeline.py +301 -0
- rerankgate/rerank.py +94 -0
- rerankgate/retrieval.py +99 -0
- rerankgate/rewrite.py +38 -0
- rerankgate/store.py +78 -0
- rerankgate/tokenize.py +29 -0
- rerankgate/vectorstore.py +66 -0
- rerankgate-0.2.0.dist-info/METADATA +134 -0
- rerankgate-0.2.0.dist-info/RECORD +21 -0
- rerankgate-0.2.0.dist-info/WHEEL +5 -0
- rerankgate-0.2.0.dist-info/entry_points.txt +2 -0
- rerankgate-0.2.0.dist-info/top_level.txt +1 -0
rerankgate/__init__.py
ADDED
|
@@ -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"
|
rerankgate/bm25.py
ADDED
|
@@ -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)
|
rerankgate/cli.py
ADDED
|
@@ -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())
|
rerankgate/config.py
ADDED
|
@@ -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)
|
rerankgate/documents.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Documents and deterministic chunking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Document:
|
|
14
|
+
"""A source document before chunking."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
text: str
|
|
18
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def title(self) -> str:
|
|
22
|
+
return str(self.metadata.get("title", ""))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Chunk:
|
|
27
|
+
"""A retrievable unit produced by chunking a Document."""
|
|
28
|
+
|
|
29
|
+
id: str # f"{doc_id}#c{n}"
|
|
30
|
+
doc_id: str
|
|
31
|
+
text: str
|
|
32
|
+
position: int
|
|
33
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def chunk_text(
|
|
37
|
+
text: str,
|
|
38
|
+
doc_id: str,
|
|
39
|
+
size: int = 500,
|
|
40
|
+
overlap: int = 50,
|
|
41
|
+
metadata: dict | None = None,
|
|
42
|
+
) -> list[Chunk]:
|
|
43
|
+
"""Split text into sentence-aware sliding-window chunks.
|
|
44
|
+
|
|
45
|
+
Deterministic: same input always yields the same chunks.
|
|
46
|
+
"""
|
|
47
|
+
text = text.strip()
|
|
48
|
+
if not text:
|
|
49
|
+
return []
|
|
50
|
+
sentences = [s.strip() for s in _SENTENCE_SPLIT.split(text) if s.strip()]
|
|
51
|
+
if not sentences:
|
|
52
|
+
sentences = [text]
|
|
53
|
+
|
|
54
|
+
chunks: list[Chunk] = []
|
|
55
|
+
buf: list[str] = []
|
|
56
|
+
buf_len = 0
|
|
57
|
+
n = 0
|
|
58
|
+
|
|
59
|
+
def flush() -> None:
|
|
60
|
+
nonlocal n, buf, buf_len
|
|
61
|
+
if buf:
|
|
62
|
+
chunks.append(
|
|
63
|
+
Chunk(
|
|
64
|
+
id=f"{doc_id}#c{n}",
|
|
65
|
+
doc_id=doc_id,
|
|
66
|
+
text=" ".join(buf),
|
|
67
|
+
position=n,
|
|
68
|
+
metadata=dict(metadata or {}),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
n += 1
|
|
72
|
+
|
|
73
|
+
for sent in sentences:
|
|
74
|
+
# A single over-long sentence becomes its own chunk (hard-split).
|
|
75
|
+
while len(sent) > size:
|
|
76
|
+
flush()
|
|
77
|
+
chunks.append(
|
|
78
|
+
Chunk(
|
|
79
|
+
id=f"{doc_id}#c{n}",
|
|
80
|
+
doc_id=doc_id,
|
|
81
|
+
text=sent[:size],
|
|
82
|
+
position=n,
|
|
83
|
+
metadata=dict(metadata or {}),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
n += 1
|
|
87
|
+
sent = sent[size - overlap :]
|
|
88
|
+
if buf_len + len(sent) + 1 > size and buf:
|
|
89
|
+
flush()
|
|
90
|
+
# overlap: carry the tail of the previous chunk forward
|
|
91
|
+
tail = " ".join(buf)
|
|
92
|
+
buf = [tail[-overlap:]] if len(tail) > overlap else []
|
|
93
|
+
buf_len = sum(len(b) + 1 for b in buf)
|
|
94
|
+
buf.append(sent)
|
|
95
|
+
buf_len += len(sent) + 1
|
|
96
|
+
flush()
|
|
97
|
+
return chunks
|
rerankgate/embeddings.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Pluggable embedding backends.
|
|
2
|
+
|
|
3
|
+
The default path is local-first and LLM-free. Heavy model dependencies
|
|
4
|
+
are lazy: `SentenceTransformerBackend` only imports sentence-transformers
|
|
5
|
+
when instantiated, and the pipeline degrades to BM25-only if embeddings
|
|
6
|
+
are unavailable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EmbeddingBackend(Protocol):
|
|
16
|
+
"""Contract every embedding backend implements."""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def dim(self) -> int: ...
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str: ...
|
|
22
|
+
def embed(self, texts: Sequence[str]) -> list[list[float]]: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SentenceTransformerBackend:
|
|
26
|
+
"""Local dense embeddings via sentence-transformers (lazy import)."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
|
|
29
|
+
try:
|
|
30
|
+
from sentence_transformers import SentenceTransformer # type: ignore
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise RuntimeError(
|
|
33
|
+
"sentence-transformers is not installed. "
|
|
34
|
+
"Install it with `pip install rerankgate[local]`, or run "
|
|
35
|
+
"the pipeline with use_vectors=False for BM25-only mode."
|
|
36
|
+
) from exc
|
|
37
|
+
self._model = SentenceTransformer(model_name)
|
|
38
|
+
self._model_name = model_name
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
return f"sentence-transformers:{self._model_name}"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def dim(self) -> int:
|
|
46
|
+
return int(self._model.get_sentence_embedding_dimension())
|
|
47
|
+
|
|
48
|
+
def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
|
49
|
+
vecs = self._model.encode(list(texts), normalize_embeddings=True)
|
|
50
|
+
return [list(map(float, v)) for v in vecs]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FakeEmbeddings:
|
|
54
|
+
"""Deterministic toy backend for tests — NOT for production use."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, dim: int = 16) -> None:
|
|
57
|
+
self._dim = dim
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def name(self) -> str:
|
|
61
|
+
return "fake-test-embeddings"
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def dim(self) -> int:
|
|
65
|
+
return self._dim
|
|
66
|
+
|
|
67
|
+
def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
|
68
|
+
import hashlib
|
|
69
|
+
|
|
70
|
+
out: list[list[float]] = []
|
|
71
|
+
for t in texts:
|
|
72
|
+
h = hashlib.sha256(t.encode()).digest()
|
|
73
|
+
vec = [((h[i % len(h)] / 255.0) - 0.5) * 2 for i in range(self._dim)]
|
|
74
|
+
norm = sum(x * x for x in vec) ** 0.5 or 1.0
|
|
75
|
+
out.append([x / norm for x in vec])
|
|
76
|
+
return out
|
rerankgate/gate.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Heuristic sufficiency gate: "is this retrieval enough to answer?".
|
|
2
|
+
|
|
3
|
+
Works WITHOUT any LLM call. Every decision carries machine-readable reason
|
|
4
|
+
codes plus a human explanation, so a rejection can be audited and the
|
|
5
|
+
threshold tuned with `rerankgate eval`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from time import perf_counter
|
|
12
|
+
|
|
13
|
+
from rerankgate.config import GateConfig
|
|
14
|
+
from rerankgate.retrieval import ScoredChunk
|
|
15
|
+
from rerankgate.tokenize import content_tokens, tokenize
|
|
16
|
+
|
|
17
|
+
# Reason codes (stable strings; safe to match on in calling code).
|
|
18
|
+
PASS = "PASS"
|
|
19
|
+
LOW_TOP_SCORE = "LOW_TOP_SCORE"
|
|
20
|
+
LOW_COVERAGE = "LOW_COVERAGE"
|
|
21
|
+
NO_MARGIN = "NO_MARGIN"
|
|
22
|
+
NO_CONSENSUS = "NO_CONSENSUS"
|
|
23
|
+
NO_EVIDENCE = "NO_EVIDENCE"
|
|
24
|
+
|
|
25
|
+
_EXPLANATIONS = {
|
|
26
|
+
PASS: "Retrieved evidence is sufficient to answer.",
|
|
27
|
+
LOW_TOP_SCORE: "Best chunk score is below the minimum — nothing looks strongly relevant.",
|
|
28
|
+
LOW_COVERAGE: "Too few query terms appear in the evidence — likely off-topic retrieval.",
|
|
29
|
+
NO_MARGIN: "Top chunks score almost identically — no clear winner, ambiguous retrieval.",
|
|
30
|
+
NO_CONSENSUS: "Top chunks don't corroborate each other — evidence is fragmented.",
|
|
31
|
+
NO_EVIDENCE: "Retrieval returned no candidates at all.",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class GateFeatures:
|
|
37
|
+
top_score: float = 0.0 # normalized top-1 rerank score [0,1]
|
|
38
|
+
coverage: float = 0.0 # fraction of query content-terms in evidence
|
|
39
|
+
margin: float = 0.0 # (s0 - s1) / s0 — decisiveness of the ranking
|
|
40
|
+
support: float = 0.0 # mean pairwise token overlap of top-3 chunks
|
|
41
|
+
depth: int = 0 # chunks above the score floor
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"top_score": round(self.top_score, 4),
|
|
46
|
+
"coverage": round(self.coverage, 4),
|
|
47
|
+
"margin": round(self.margin, 4),
|
|
48
|
+
"support": round(self.support, 4),
|
|
49
|
+
"depth": self.depth,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class GateDecision:
|
|
55
|
+
passed: bool
|
|
56
|
+
score: float
|
|
57
|
+
features: GateFeatures
|
|
58
|
+
reasons: list[str] = field(default_factory=list)
|
|
59
|
+
explanation: str = ""
|
|
60
|
+
threshold: float = 0.0
|
|
61
|
+
ms: float = 0.0
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict:
|
|
64
|
+
return {
|
|
65
|
+
"passed": self.passed,
|
|
66
|
+
"score": round(self.score, 4),
|
|
67
|
+
"threshold": self.threshold,
|
|
68
|
+
"reasons": self.reasons,
|
|
69
|
+
"explanation": self.explanation,
|
|
70
|
+
"features": self.features.to_dict(),
|
|
71
|
+
"ms": self.ms,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SufficiencyGate:
|
|
76
|
+
"""Decide whether reranked evidence is sufficient to answer the query."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, config: GateConfig | None = None) -> None:
|
|
79
|
+
self.config = config or GateConfig()
|
|
80
|
+
|
|
81
|
+
def evaluate(self, query: str, evidence: list[ScoredChunk]) -> GateDecision:
|
|
82
|
+
t0 = perf_counter()
|
|
83
|
+
cfg = self.config
|
|
84
|
+
features = self._features(query, evidence)
|
|
85
|
+
reasons: list[str] = []
|
|
86
|
+
|
|
87
|
+
if not evidence:
|
|
88
|
+
return GateDecision(
|
|
89
|
+
passed=False,
|
|
90
|
+
score=0.0,
|
|
91
|
+
features=features,
|
|
92
|
+
reasons=[NO_EVIDENCE],
|
|
93
|
+
explanation=_EXPLANATIONS[NO_EVIDENCE],
|
|
94
|
+
threshold=cfg.threshold,
|
|
95
|
+
ms=_ms(t0),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Hard floors first: these fail regardless of the weighted score.
|
|
99
|
+
if features.top_score < cfg.min_top_score:
|
|
100
|
+
reasons.append(LOW_TOP_SCORE)
|
|
101
|
+
if features.coverage < cfg.min_coverage:
|
|
102
|
+
reasons.append(LOW_COVERAGE)
|
|
103
|
+
|
|
104
|
+
score = (
|
|
105
|
+
cfg.w_top_score * features.top_score
|
|
106
|
+
+ cfg.w_coverage * features.coverage
|
|
107
|
+
+ cfg.w_margin * features.margin
|
|
108
|
+
+ cfg.w_support * features.support
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
passed = score >= cfg.threshold and not reasons
|
|
112
|
+
if passed:
|
|
113
|
+
reasons = [PASS]
|
|
114
|
+
else:
|
|
115
|
+
# Soft diagnostics: explain *why* a near-miss failed.
|
|
116
|
+
if features.margin < 0.05:
|
|
117
|
+
reasons.append(NO_MARGIN)
|
|
118
|
+
if features.support < 0.05 and features.depth >= 2:
|
|
119
|
+
reasons.append(NO_CONSENSUS)
|
|
120
|
+
reasons = reasons or [f"SCORE_BELOW_THRESHOLD ({score:.2f} < {cfg.threshold:.2f})"]
|
|
121
|
+
|
|
122
|
+
explanation = " ".join(_EXPLANATIONS.get(r, r) for r in reasons)
|
|
123
|
+
return GateDecision(
|
|
124
|
+
passed=passed,
|
|
125
|
+
score=round(score, 4),
|
|
126
|
+
features=features,
|
|
127
|
+
reasons=reasons,
|
|
128
|
+
explanation=explanation,
|
|
129
|
+
threshold=cfg.threshold,
|
|
130
|
+
ms=_ms(t0),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def _features(self, query: str, evidence: list[ScoredChunk]) -> GateFeatures:
|
|
134
|
+
q_terms = set(content_tokens(query))
|
|
135
|
+
scores = [c.score for c in evidence]
|
|
136
|
+
top = scores[0] if scores else 0.0
|
|
137
|
+
# Normalize top score to [0,1] against the observed max for stability.
|
|
138
|
+
norm_top = min(1.0, top)
|
|
139
|
+
|
|
140
|
+
ev_toks: set[str] = set()
|
|
141
|
+
for c in evidence:
|
|
142
|
+
ev_toks |= set(tokenize(c.chunk.text))
|
|
143
|
+
coverage = len(q_terms & ev_toks) / len(q_terms) if q_terms else 0.0
|
|
144
|
+
|
|
145
|
+
margin = 0.0
|
|
146
|
+
if len(scores) >= 2 and scores[0] > 0:
|
|
147
|
+
margin = max(0.0, (scores[0] - scores[1]) / scores[0])
|
|
148
|
+
|
|
149
|
+
support = 0.0
|
|
150
|
+
top3 = evidence[:3]
|
|
151
|
+
if len(top3) >= 2:
|
|
152
|
+
pairs = 0
|
|
153
|
+
acc = 0.0
|
|
154
|
+
sets = [set(content_tokens(c.chunk.text)) for c in top3]
|
|
155
|
+
for i in range(len(sets)):
|
|
156
|
+
for j in range(i + 1, len(sets)):
|
|
157
|
+
a, b = sets[i], sets[j]
|
|
158
|
+
union = a | b
|
|
159
|
+
if union:
|
|
160
|
+
acc += len(a & b) / len(union)
|
|
161
|
+
pairs += 1
|
|
162
|
+
support = acc / pairs if pairs else 0.0
|
|
163
|
+
|
|
164
|
+
depth = sum(1 for s in scores if s >= 0.2)
|
|
165
|
+
return GateFeatures(
|
|
166
|
+
top_score=norm_top, coverage=coverage, margin=margin, support=support, depth=depth
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _ms(t0: float) -> float:
|
|
171
|
+
return round((perf_counter() - t0) * 1000, 2)
|