sentinelrag 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.
Files changed (61) hide show
  1. sentinelrag/__init__.py +3 -0
  2. sentinelrag/api/__init__.py +1 -0
  3. sentinelrag/api/app.py +257 -0
  4. sentinelrag/eval/__init__.py +1 -0
  5. sentinelrag/eval/agreement.py +54 -0
  6. sentinelrag/eval/golden_io.py +38 -0
  7. sentinelrag/eval/judge.py +111 -0
  8. sentinelrag/eval/judges/v1/faithfulness.txt +17 -0
  9. sentinelrag/eval/judges/v1/manifest.yaml +8 -0
  10. sentinelrag/eval/judges/v1/relevance.txt +13 -0
  11. sentinelrag/eval/latency.py +15 -0
  12. sentinelrag/eval/metrics.py +39 -0
  13. sentinelrag/eval/phase1_retrieval.py +110 -0
  14. sentinelrag/eval/phase2_rag.py +123 -0
  15. sentinelrag/eval/trajectory.py +40 -0
  16. sentinelrag/gate/__init__.py +6 -0
  17. sentinelrag/gate/adapter.py +66 -0
  18. sentinelrag/gate/cli.py +114 -0
  19. sentinelrag/gate/evidence.py +105 -0
  20. sentinelrag/gate/policy.py +125 -0
  21. sentinelrag/gate/reports/__init__.py +1 -0
  22. sentinelrag/gate/reports/junit.py +50 -0
  23. sentinelrag/gate/reports/markdown.py +54 -0
  24. sentinelrag/gate/reports/sarif.py +62 -0
  25. sentinelrag/gate/runner.py +93 -0
  26. sentinelrag/generation/__init__.py +5 -0
  27. sentinelrag/generation/llm.py +190 -0
  28. sentinelrag/ingest/__init__.py +1 -0
  29. sentinelrag/ingest/chunking.py +113 -0
  30. sentinelrag/ingest/filters.py +21 -0
  31. sentinelrag/ingest/hygiene.py +90 -0
  32. sentinelrag/ingest/loaders.py +21 -0
  33. sentinelrag/ingest/pipeline.py +124 -0
  34. sentinelrag/ingest/provenance.py +43 -0
  35. sentinelrag/ingest/schema.py +23 -0
  36. sentinelrag/observability/__init__.py +1 -0
  37. sentinelrag/observability/cost.py +43 -0
  38. sentinelrag/observability/tracing.py +26 -0
  39. sentinelrag/paths.py +35 -0
  40. sentinelrag/redteam/__init__.py +1 -0
  41. sentinelrag/redteam/attacks.py +80 -0
  42. sentinelrag/redteam/defenses.py +28 -0
  43. sentinelrag/redteam/harness.py +126 -0
  44. sentinelrag/redteam/post_retrieval.py +58 -0
  45. sentinelrag/retrieval/__init__.py +1 -0
  46. sentinelrag/retrieval/agentic.py +68 -0
  47. sentinelrag/retrieval/bm25_index.py +37 -0
  48. sentinelrag/retrieval/embeddings.py +60 -0
  49. sentinelrag/retrieval/fusion.py +30 -0
  50. sentinelrag/retrieval/reranker.py +51 -0
  51. sentinelrag/retrieval/stores.py +145 -0
  52. sentinelrag/retrieval/strategies.py +84 -0
  53. sentinelrag/router/__init__.py +1 -0
  54. sentinelrag/router/complexity.py +19 -0
  55. sentinelrag/router/policy.py +42 -0
  56. sentinelrag/settings.py +100 -0
  57. sentinelrag-0.2.0.dist-info/METADATA +183 -0
  58. sentinelrag-0.2.0.dist-info/RECORD +61 -0
  59. sentinelrag-0.2.0.dist-info/WHEEL +4 -0
  60. sentinelrag-0.2.0.dist-info/entry_points.txt +3 -0
  61. sentinelrag-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """SentinelRAG — adaptive, attack-tested, eval-gated RAG."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1 @@
1
+ """FastAPI application package."""
sentinelrag/api/app.py ADDED
@@ -0,0 +1,257 @@
1
+ """FastAPI entrypoint: health, ingest, and retrieval-backed query."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated, Literal
7
+
8
+ from fastapi import FastAPI, Query
9
+ from pydantic import BaseModel, Field
10
+
11
+ from sentinelrag import __version__
12
+ from sentinelrag.eval.phase2_rag import run_phase2_eval
13
+ from sentinelrag.generation.llm import Message, get_llm
14
+ from sentinelrag.ingest.pipeline import IndexState, ingest_corpus_dir, ingest_document_body
15
+ from sentinelrag.paths import mini_corpus_dir
16
+ from sentinelrag.retrieval.agentic import AgenticMultiHopRetriever
17
+ from sentinelrag.retrieval.embeddings import build_embedder
18
+ from sentinelrag.retrieval.reranker import build_reranker
19
+ from sentinelrag.retrieval.stores import ScoredChunk
20
+ from sentinelrag.retrieval.strategies import HybridRerankRetriever, NaiveRetriever, StrategyName
21
+ from sentinelrag.router.policy import route_query
22
+ from sentinelrag.settings import Settings, get_settings, settings_without_dotenv
23
+
24
+ ApiStrategy = StrategyName | Literal["agentic", "auto"]
25
+
26
+ HELLO_PASSAGE = {
27
+ "doc_id": "doc-hello-001",
28
+ "chunk_id": "chunk-hello-001",
29
+ "text": (
30
+ "SentinelRAG is an adaptive, attack-tested, eval-gated RAG platform. "
31
+ "Phase 0 proves a hello query path with MockLLM and citations."
32
+ ),
33
+ }
34
+
35
+
36
+ class HealthResponse(BaseModel):
37
+ status: str
38
+ version: str
39
+ env: str
40
+ llm_provider: str
41
+ index_ready: bool
42
+
43
+
44
+ class IngestRequest(BaseModel):
45
+ corpus_path: str | None = Field(
46
+ default=None,
47
+ description="Filesystem path to a markdown corpus directory",
48
+ )
49
+ doc_id: str | None = None
50
+ title: str | None = None
51
+ text: str | None = None
52
+
53
+
54
+ class IngestResponse(BaseModel):
55
+ chunks_indexed: int
56
+ corpus_path: str | None = None
57
+
58
+
59
+ class QueryRequest(BaseModel):
60
+ query: str = Field(..., min_length=1, max_length=4000)
61
+
62
+
63
+ class Citation(BaseModel):
64
+ doc_id: str
65
+ chunk_id: str
66
+ snippet: str
67
+
68
+
69
+ class QueryResponse(BaseModel):
70
+ answer: str
71
+ citations: list[Citation]
72
+ strategy: str
73
+ provider: str
74
+
75
+
76
+ class EvalRunResponse(BaseModel):
77
+ mean_faithfulness: float
78
+ mean_relevance: float
79
+ mean_recall_at_k: float
80
+ judge_prompt_version: str
81
+ n_golden_items: int
82
+
83
+
84
+ def create_app(settings: Settings | None = None) -> FastAPI:
85
+ cfg = settings or get_settings()
86
+ app = FastAPI(title=cfg.app_name, version=__version__)
87
+ app.state.settings = cfg
88
+ app.state.index = IndexState()
89
+
90
+ def _maybe_bootstrap_index(current: Settings) -> None:
91
+ state: IndexState = app.state.index
92
+ if state.is_ready():
93
+ return
94
+ path = mini_corpus_dir()
95
+ if path.is_dir():
96
+ ingest_corpus_dir(path, state, settings=current)
97
+
98
+ @app.get("/health", response_model=HealthResponse)
99
+ def health() -> HealthResponse:
100
+ current = app.state.settings
101
+ assert isinstance(current, Settings)
102
+ state: IndexState = app.state.index
103
+ return HealthResponse(
104
+ status="ok",
105
+ version=__version__,
106
+ env=current.env,
107
+ llm_provider=current.llm_provider,
108
+ index_ready=state.is_ready(),
109
+ )
110
+
111
+ @app.post("/ingest", response_model=IngestResponse)
112
+ def ingest(body: IngestRequest) -> IngestResponse:
113
+ current = app.state.settings
114
+ assert isinstance(current, Settings)
115
+ state: IndexState = app.state.index
116
+ if body.text and body.doc_id:
117
+ count = ingest_document_body(
118
+ doc_id=body.doc_id,
119
+ title=body.title or body.doc_id,
120
+ text=body.text,
121
+ state=state,
122
+ settings=current,
123
+ )
124
+ return IngestResponse(chunks_indexed=count, corpus_path=None)
125
+ corpus = Path(body.corpus_path) if body.corpus_path else mini_corpus_dir()
126
+ count = ingest_corpus_dir(corpus, state, settings=current)
127
+ return IngestResponse(chunks_indexed=count, corpus_path=str(corpus))
128
+
129
+ @app.post("/query", response_model=QueryResponse)
130
+ def query_post(
131
+ body: QueryRequest,
132
+ strategy: Annotated[ApiStrategy, Query()] = "hybrid",
133
+ ) -> QueryResponse:
134
+ current = app.state.settings
135
+ assert isinstance(current, Settings)
136
+ _maybe_bootstrap_index(current)
137
+ state: IndexState = app.state.index
138
+ llm = get_llm(current)
139
+
140
+ if not state.is_ready() or state.store is None or state.embedder is None:
141
+ prompt = (
142
+ f"Context: {HELLO_PASSAGE['text']}\n\n"
143
+ f"Question: {body.query}\n\n"
144
+ "Answer using only the context."
145
+ )
146
+ answer = llm.complete([Message(role="user", content=prompt)])
147
+ return QueryResponse(
148
+ answer=answer,
149
+ citations=[
150
+ Citation(
151
+ doc_id=HELLO_PASSAGE["doc_id"],
152
+ chunk_id=HELLO_PASSAGE["chunk_id"],
153
+ snippet=HELLO_PASSAGE["text"][:160],
154
+ )
155
+ ],
156
+ strategy="hello_stub",
157
+ provider=llm.name,
158
+ )
159
+
160
+ resolved = strategy
161
+ if strategy == "auto":
162
+ resolved = route_query(body.query, current).strategy
163
+
164
+ embedder = state.embedder or build_embedder(
165
+ current.embedding_model,
166
+ dim=current.embed_dim,
167
+ seed=current.benchmark_seed,
168
+ )
169
+ hits: list[ScoredChunk]
170
+ if resolved == "naive":
171
+ hits = NaiveRetriever(state.store, embedder).retrieve(
172
+ body.query, top_k=current.retrieval_top_k
173
+ )
174
+ elif resolved == "agentic":
175
+ hybrid = HybridRerankRetriever(
176
+ state.store,
177
+ state.bm25,
178
+ embedder,
179
+ build_reranker(current.reranker_model),
180
+ alpha=current.hybrid_alpha,
181
+ chunks_by_id=state.chunks,
182
+ )
183
+ agent = AgenticMultiHopRetriever(hybrid, max_hops=current.agent_max_hops)
184
+ hits = agent.retrieve(body.query, top_k=current.retrieval_top_k).chunks
185
+ resolved = "agentic"
186
+ else:
187
+ hits = HybridRerankRetriever(
188
+ state.store,
189
+ state.bm25,
190
+ embedder,
191
+ build_reranker(current.reranker_model),
192
+ alpha=current.hybrid_alpha,
193
+ chunks_by_id=state.chunks,
194
+ ).retrieve(body.query, top_k=current.retrieval_top_k)
195
+ resolved = "hybrid"
196
+ context = "\n\n".join(h.text for h in hits) if hits else HELLO_PASSAGE["text"]
197
+ prompt = f"Context:\n{context}\n\nQuestion: {body.query}\n\nAnswer using only the context."
198
+ answer = llm.complete([Message(role="user", content=prompt)])
199
+ citations = [
200
+ Citation(doc_id=h.doc_id, chunk_id=h.chunk_id, snippet=h.text[:160]) for h in hits
201
+ ]
202
+ if not citations:
203
+ citations = [
204
+ Citation(
205
+ doc_id=HELLO_PASSAGE["doc_id"],
206
+ chunk_id=HELLO_PASSAGE["chunk_id"],
207
+ snippet=HELLO_PASSAGE["text"][:160],
208
+ )
209
+ ]
210
+ return QueryResponse(
211
+ answer=answer,
212
+ citations=citations,
213
+ strategy=resolved,
214
+ provider=llm.name,
215
+ )
216
+
217
+ @app.post("/eval/run", response_model=EvalRunResponse)
218
+ def eval_run() -> EvalRunResponse:
219
+ current = app.state.settings
220
+ assert isinstance(current, Settings)
221
+ offline = settings_without_dotenv(
222
+ llm_provider="mock",
223
+ embedding_model="mock-embedder",
224
+ reranker_model="mock-reranker",
225
+ vector_store_backend="memory",
226
+ env=current.env,
227
+ )
228
+ report = run_phase2_eval(settings=offline)
229
+ return EvalRunResponse(
230
+ mean_faithfulness=float(report["mean_faithfulness"]),
231
+ mean_relevance=float(report["mean_relevance"]),
232
+ mean_recall_at_k=float(report["mean_recall_at_k"]),
233
+ judge_prompt_version=str(report["judge_prompt_version"]),
234
+ n_golden_items=int(report["n_golden_items"]),
235
+ )
236
+
237
+ return app
238
+
239
+
240
+ app = create_app()
241
+
242
+
243
+ def main() -> None:
244
+ """CLI entry for `uv run sentinelrag` / uvicorn."""
245
+ import uvicorn
246
+
247
+ settings = get_settings()
248
+ uvicorn.run(
249
+ "sentinelrag.api.app:app",
250
+ host=settings.host,
251
+ port=settings.port,
252
+ reload=False,
253
+ )
254
+
255
+
256
+ if __name__ == "__main__":
257
+ main()
@@ -0,0 +1 @@
1
+ """Golden datasets, metrics, and LLM-as-judge (Phase 2+)."""
@@ -0,0 +1,54 @@
1
+ """Judge vs human agreement on a calibration set."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from sentinelrag.eval.judge import Judge, MockJudge
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class AgreementReport:
15
+ prompt_version: str
16
+ n_samples: int
17
+ mean_absolute_error: float
18
+ within_0_2: float
19
+
20
+
21
+ def load_calibration(path: Path) -> tuple[str, list[dict[str, Any]]]:
22
+ payload = json.loads(path.read_text(encoding="utf-8"))
23
+ version = str(payload.get("judge_prompt_version", "v1"))
24
+ samples = list(payload.get("samples", []))
25
+ return version, samples
26
+
27
+
28
+ def judge_agreement(
29
+ path: Path,
30
+ *,
31
+ judge: Judge | None = None,
32
+ ) -> AgreementReport:
33
+ version, samples = load_calibration(path)
34
+ j = judge or MockJudge(prompt_version=version)
35
+ if not samples:
36
+ return AgreementReport(version, 0, 0.0, 0.0)
37
+ abs_errors: list[float] = []
38
+ within: list[float] = []
39
+ for sample in samples:
40
+ human = float(sample["human_faithfulness"])
41
+ scored = j.faithfulness(
42
+ context=str(sample["context"]),
43
+ question=str(sample["question"]),
44
+ answer=str(sample["answer"]),
45
+ )
46
+ err = abs(scored.score - human)
47
+ abs_errors.append(err)
48
+ within.append(1.0 if err <= 0.2 else 0.0)
49
+ return AgreementReport(
50
+ prompt_version=version,
51
+ n_samples=len(samples),
52
+ mean_absolute_error=sum(abs_errors) / len(abs_errors),
53
+ within_0_2=sum(within) / len(within),
54
+ )
@@ -0,0 +1,38 @@
1
+ """Golden dataset JSONL interchange."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def load_jsonl(path: Path) -> list[dict[str, Any]]:
11
+ rows: list[dict[str, Any]] = []
12
+ for line in path.read_text(encoding="utf-8").splitlines():
13
+ line = line.strip()
14
+ if not line:
15
+ continue
16
+ rows.append(json.loads(line))
17
+ return rows
18
+
19
+
20
+ def export_jsonl(items: list[dict[str, Any]], path: Path) -> None:
21
+ lines = [json.dumps(item, ensure_ascii=False) for item in items]
22
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
23
+
24
+
25
+ def from_golden_dataset(dataset_path: Path, out_path: Path) -> None:
26
+ data = json.loads(dataset_path.read_text(encoding="utf-8"))
27
+ items = []
28
+ for row in data.get("items", []):
29
+ items.append(
30
+ {
31
+ "id": row["id"],
32
+ "query": row["query"],
33
+ "expected_chunk_ids": row.get("relevant_chunk_ids", []),
34
+ "kind": row.get("item_type", "single_hop"),
35
+ "citations_required": True,
36
+ }
37
+ )
38
+ export_jsonl(items, out_path)
@@ -0,0 +1,111 @@
1
+ """LLM-as-judge with versioned prompts and MockJudge for CI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from sentinelrag.generation.llm import LLMProvider, Message, get_llm
13
+ from sentinelrag.settings import Settings, get_settings
14
+
15
+ _JUDGE_ROOT = Path(__file__).resolve().parent / "judges"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class JudgeScore:
20
+ score: float
21
+ reason: str
22
+ prompt_version: str
23
+
24
+
25
+ class Judge(ABC):
26
+ @abstractmethod
27
+ def faithfulness(self, *, context: str, question: str, answer: str) -> JudgeScore:
28
+ """Score how well ``answer`` is supported by ``context``."""
29
+
30
+ @abstractmethod
31
+ def relevance(self, *, question: str, answer: str) -> JudgeScore:
32
+ """Score how well ``answer`` addresses ``question``."""
33
+
34
+
35
+ def load_prompt(version: str, name: str) -> str:
36
+ path = _JUDGE_ROOT / version / f"{name}.txt"
37
+ if not path.is_file():
38
+ msg = f"Missing judge prompt: {path}"
39
+ raise FileNotFoundError(msg)
40
+ return path.read_text(encoding="utf-8")
41
+
42
+
43
+ def _parse_judge_json(raw: str) -> tuple[float, str]:
44
+ match = re.search(r"\{.*\}", raw, flags=re.DOTALL)
45
+ payload = json.loads(match.group(0) if match else raw)
46
+ score = float(payload["score"])
47
+ reason = str(payload.get("reason", ""))
48
+ return max(0.0, min(1.0, score)), reason
49
+
50
+
51
+ class MockJudge(Judge):
52
+ """Deterministic lexical judge for offline CI (not a human surrogate)."""
53
+
54
+ def __init__(self, *, prompt_version: str = "v1") -> None:
55
+ self.prompt_version = prompt_version
56
+
57
+ def faithfulness(self, *, context: str, question: str, answer: str) -> JudgeScore:
58
+ _ = question
59
+ ctx_tokens = set(context.lower().split())
60
+ ans_tokens = [t for t in answer.lower().split() if len(t) > 3]
61
+ if not ans_tokens:
62
+ return JudgeScore(0.0, "empty answer", self.prompt_version)
63
+ supported = sum(1 for t in ans_tokens if t in ctx_tokens)
64
+ score = supported / len(ans_tokens)
65
+ # Stable tie-break from content hash for reproducibility.
66
+ digest = hashlib.sha256(f"{context}:{answer}".encode()).hexdigest()
67
+ jitter = (int(digest[:4], 16) % 7) / 100.0
68
+ score = min(1.0, score * 0.85 + jitter)
69
+ return JudgeScore(round(score, 4), "mock lexical overlap", self.prompt_version)
70
+
71
+ def relevance(self, *, question: str, answer: str) -> JudgeScore:
72
+ q = set(question.lower().split())
73
+ a = set(answer.lower().split())
74
+ overlap = len(q & a) / max(len(q), 1)
75
+ score = min(1.0, overlap + 0.5)
76
+ return JudgeScore(round(score, 4), "mock question overlap", self.prompt_version)
77
+
78
+
79
+ class LLMJudge(Judge):
80
+ """Uses configured LLM provider with versioned prompts (network in prod)."""
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ llm: LLMProvider | None = None,
86
+ prompt_version: str = "v1",
87
+ settings: Settings | None = None,
88
+ ) -> None:
89
+ self._llm = llm or get_llm(settings or get_settings())
90
+ self.prompt_version = prompt_version
91
+
92
+ def faithfulness(self, *, context: str, question: str, answer: str) -> JudgeScore:
93
+ template = load_prompt(self.prompt_version, "faithfulness")
94
+ prompt = template.format(context=context, question=question, answer=answer)
95
+ raw = self._llm.complete([Message(role="user", content=prompt)])
96
+ score, reason = _parse_judge_json(raw)
97
+ return JudgeScore(score, reason, self.prompt_version)
98
+
99
+ def relevance(self, *, question: str, answer: str) -> JudgeScore:
100
+ template = load_prompt(self.prompt_version, "relevance")
101
+ prompt = template.format(question=question, answer=answer)
102
+ raw = self._llm.complete([Message(role="user", content=prompt)])
103
+ score, reason = _parse_judge_json(raw)
104
+ return JudgeScore(score, reason, self.prompt_version)
105
+
106
+
107
+ def get_judge(settings: Settings | None = None) -> Judge:
108
+ cfg = settings or get_settings()
109
+ if cfg.llm_provider == "mock":
110
+ return MockJudge()
111
+ return LLMJudge(settings=cfg)
@@ -0,0 +1,17 @@
1
+ You are evaluating whether an assistant answer is faithful to the provided context.
2
+
3
+ Rules:
4
+ - Score 1.0 only if every factual claim in the answer is supported by the context.
5
+ - Score 0.0 if the answer contradicts the context or invents major facts not in the context.
6
+ - Partial support → score between 0.0 and 1.0.
7
+
8
+ Respond with JSON only: {"score": <number>, "reason": "<short explanation>"}
9
+
10
+ Context:
11
+ {context}
12
+
13
+ Question:
14
+ {question}
15
+
16
+ Answer:
17
+ {answer}
@@ -0,0 +1,8 @@
1
+ version: v1
2
+ created: "2026-11-08"
3
+ prompts:
4
+ faithfulness: faithfulness.txt
5
+ relevance: relevance.txt
6
+ description: >
7
+ Judges score 0.0-1.0 with a one-line rationale. JSON-only model output:
8
+ {"score": <float>, "reason": "<string>"}
@@ -0,0 +1,13 @@
1
+ You are evaluating whether an assistant answer addresses the user question.
2
+
3
+ Rules:
4
+ - Score 1.0 if the answer directly addresses the question.
5
+ - Score 0.0 if it is off-topic or refuses without cause.
6
+
7
+ Respond with JSON only: {"score": <number>, "reason": "<short explanation>"}
8
+
9
+ Question:
10
+ {question}
11
+
12
+ Answer:
13
+ {answer}
@@ -0,0 +1,15 @@
1
+ """Lightweight latency helpers for gate evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from typing import TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ def timed_ms(fn: Callable[[], T]) -> tuple[T, float]:
13
+ t0 = time.perf_counter()
14
+ value = fn()
15
+ return value, (time.perf_counter() - t0) * 1000.0
@@ -0,0 +1,39 @@
1
+ """Retrieval and grounding evaluation metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ def recall_at_k(retrieved_ids: list[str], relevant_ids: set[str], *, k: int) -> float:
9
+ if not relevant_ids:
10
+ return 0.0
11
+ top = set(retrieved_ids[:k])
12
+ return len(top & relevant_ids) / len(relevant_ids)
13
+
14
+
15
+ def hit_rate_at_k(retrieved_ids: list[str], relevant_ids: set[str], *, k: int) -> float:
16
+ """1.0 if at least one relevant id appears in top-k, else 0.0."""
17
+ if not relevant_ids:
18
+ return 0.0
19
+ return 1.0 if set(retrieved_ids[:k]) & relevant_ids else 0.0
20
+
21
+
22
+ def mrr(retrieved_ids: list[str], relevant_ids: set[str]) -> float:
23
+ for rank, cid in enumerate(retrieved_ids, start=1):
24
+ if cid in relevant_ids:
25
+ return 1.0 / rank
26
+ return 0.0
27
+
28
+
29
+ _CITATION = re.compile(r"\[(?:cite|source|doc|chunk)[:\s]?([^\]]+)\]", re.IGNORECASE)
30
+
31
+
32
+ def citation_coverage(answer: str, retrieved_ids: list[str]) -> float:
33
+ """Share of citation markers that point at retrieved chunk ids (0–1)."""
34
+ marks = _CITATION.findall(answer)
35
+ if not marks:
36
+ return 0.0
37
+ allowed = set(retrieved_ids)
38
+ hits = sum(1 for m in marks if m.strip() in allowed)
39
+ return hits / len(marks)
@@ -0,0 +1,110 @@
1
+ """Phase 1 retrieval benchmark (library entry for scripts and tests)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from sentinelrag.eval.metrics import hit_rate_at_k, mrr, recall_at_k
13
+ from sentinelrag.ingest.pipeline import IndexState, ingest_corpus_dir
14
+ from sentinelrag.paths import mini_corpus_dir, mini_qrels_path
15
+ from sentinelrag.retrieval.reranker import build_reranker
16
+ from sentinelrag.retrieval.strategies import HybridRerankRetriever, NaiveRetriever, StrategyName
17
+ from sentinelrag.settings import Settings, settings_without_dotenv
18
+
19
+
20
+ def _default_benchmark_settings() -> Settings:
21
+ """Offline benchmark defaults (mock embedder; ignores ``.env`` file only)."""
22
+ return settings_without_dotenv(
23
+ embedding_model="mock-embedder",
24
+ reranker_model="mock-reranker",
25
+ vector_store_backend="memory",
26
+ )
27
+
28
+
29
+ def corpus_content_hash(corpus_dir: Path) -> str:
30
+ digests: list[str] = []
31
+ for path in sorted(corpus_dir.glob("*.md")):
32
+ digests.append(hashlib.sha256(path.read_bytes()).hexdigest())
33
+ joined = "|".join(digests)
34
+ return hashlib.sha256(joined.encode()).hexdigest()[:16]
35
+
36
+
37
+ def load_qrels(path: Path) -> tuple[int, list[dict[str, Any]]]:
38
+ payload = json.loads(path.read_text(encoding="utf-8"))
39
+ seed = int(payload.get("seed", 42))
40
+ queries = list(payload.get("queries", []))
41
+ return seed, queries
42
+
43
+
44
+ def run_benchmark(
45
+ *,
46
+ settings: Settings | None = None,
47
+ strategies: tuple[StrategyName, ...] = ("naive", "hybrid"),
48
+ corpus_dir: Path | None = None,
49
+ qrels_path: Path | None = None,
50
+ ) -> dict[str, Any]:
51
+ cfg = settings if settings is not None else _default_benchmark_settings()
52
+ corpus_dir = corpus_dir if corpus_dir is not None else mini_corpus_dir()
53
+ qrels_path = qrels_path if qrels_path is not None else mini_qrels_path()
54
+ seed, qrels = load_qrels(qrels_path)
55
+
56
+ state = IndexState()
57
+ ingest_corpus_dir(corpus_dir, state, settings=cfg)
58
+ assert state.store is not None and state.embedder is not None
59
+
60
+ embedder = state.embedder
61
+ k = cfg.retrieval_top_k
62
+ rows: list[dict[str, Any]] = []
63
+
64
+ for strategy in strategies:
65
+ retriever: NaiveRetriever | HybridRerankRetriever
66
+ if strategy == "naive":
67
+ retriever = NaiveRetriever(state.store, embedder)
68
+ else:
69
+ retriever = HybridRerankRetriever(
70
+ state.store,
71
+ state.bm25,
72
+ embedder,
73
+ build_reranker(cfg.reranker_model),
74
+ alpha=cfg.hybrid_alpha,
75
+ chunks_by_id=state.chunks,
76
+ )
77
+ recalls: list[float] = []
78
+ hits_rate: list[float] = []
79
+ mrrs: list[float] = []
80
+ latencies: list[float] = []
81
+ for item in qrels:
82
+ query = str(item["query"])
83
+ relevant = set(item["relevant_chunk_ids"])
84
+
85
+ t0 = time.perf_counter()
86
+ retrieved_ids = [h.chunk_id for h in retriever.retrieve(query, top_k=k)]
87
+ latencies.append((time.perf_counter() - t0) * 1000.0)
88
+ recalls.append(recall_at_k(retrieved_ids, relevant, k=k))
89
+ hits_rate.append(hit_rate_at_k(retrieved_ids, relevant, k=k))
90
+ mrrs.append(mrr(retrieved_ids, relevant))
91
+ rows.append(
92
+ {
93
+ "strategy": strategy,
94
+ "recall_at_k": sum(recalls) / len(recalls) if recalls else 0.0,
95
+ "hit_rate_at_k": sum(hits_rate) / len(hits_rate) if hits_rate else 0.0,
96
+ "mrr": sum(mrrs) / len(mrrs) if mrrs else 0.0,
97
+ "latency_ms_mean": sum(latencies) / len(latencies) if latencies else 0.0,
98
+ }
99
+ )
100
+
101
+ return {
102
+ "generated_at": datetime.now(tz=UTC).isoformat(),
103
+ "embedding_model": cfg.embedding_model,
104
+ "reranker_model": cfg.reranker_model,
105
+ "corpus_hash": corpus_content_hash(corpus_dir),
106
+ "seed": seed,
107
+ "retrieval_top_k": k,
108
+ "hybrid_alpha": cfg.hybrid_alpha,
109
+ "results": rows,
110
+ }