sieve-context 0.1.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.
- sieve/__init__.py +29 -0
- sieve/benchmark.py +132 -0
- sieve/calibrate.py +140 -0
- sieve/cli.py +248 -0
- sieve/config.py +69 -0
- sieve/documents.py +123 -0
- sieve/integrations/__init__.py +1 -0
- sieve/integrations/langchain.py +62 -0
- sieve/llm.py +87 -0
- sieve/mcp/__init__.py +1 -0
- sieve/mcp/server.py +34 -0
- sieve/mcp/tools.py +79 -0
- sieve/metrics.py +21 -0
- sieve/models.py +125 -0
- sieve/scorer.py +72 -0
- sieve/sieve.py +245 -0
- sieve/ui.py +182 -0
- sieve/vector_store.py +86 -0
- sieve_context-0.1.0.dist-info/METADATA +310 -0
- sieve_context-0.1.0.dist-info/RECORD +23 -0
- sieve_context-0.1.0.dist-info/WHEEL +4 -0
- sieve_context-0.1.0.dist-info/entry_points.txt +3 -0
- sieve_context-0.1.0.dist-info/licenses/LICENSE +21 -0
sieve/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Sieve: Context compression middleware for RAG."""
|
|
2
|
+
|
|
3
|
+
from sieve.models import (
|
|
4
|
+
Document,
|
|
5
|
+
Chunk,
|
|
6
|
+
ScoredChunk,
|
|
7
|
+
SieveResult,
|
|
8
|
+
FilterMetrics,
|
|
9
|
+
Savings,
|
|
10
|
+
Benchmark,
|
|
11
|
+
)
|
|
12
|
+
from sieve.sieve import Sieve, filter_context
|
|
13
|
+
from sieve.scorer import Scorer, get_default_scorer
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Sieve",
|
|
19
|
+
"filter_context",
|
|
20
|
+
"Document",
|
|
21
|
+
"Chunk",
|
|
22
|
+
"ScoredChunk",
|
|
23
|
+
"SieveResult",
|
|
24
|
+
"FilterMetrics",
|
|
25
|
+
"Savings",
|
|
26
|
+
"Benchmark",
|
|
27
|
+
"Scorer",
|
|
28
|
+
"get_default_scorer",
|
|
29
|
+
]
|
sieve/benchmark.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import numpy as np
|
|
3
|
+
from sentence_transformers import SentenceTransformer
|
|
4
|
+
from sieve.config import Config
|
|
5
|
+
from sieve.models import Benchmark
|
|
6
|
+
from sieve.vector_store import VectorStore
|
|
7
|
+
from sieve.scorer import Scorer
|
|
8
|
+
from sieve.sieve import Sieve
|
|
9
|
+
from sieve.llm import LLM, count_tokens
|
|
10
|
+
from sieve.metrics import calculate_savings
|
|
11
|
+
|
|
12
|
+
class Evaluator:
|
|
13
|
+
"""Evaluates whether two LLM answers are factually equivalent."""
|
|
14
|
+
def judge_equivalence(self, question: str, standard_answer: str, sieve_answer: str, llm: LLM) -> dict:
|
|
15
|
+
prompt = f"""
|
|
16
|
+
You are an impartial judge evaluating two AI systems.
|
|
17
|
+
Question: {question}
|
|
18
|
+
Standard Answer: {standard_answer}
|
|
19
|
+
Sieve Answer: {sieve_answer}
|
|
20
|
+
|
|
21
|
+
Do these two answers contain the exact same factual information in response to the question?
|
|
22
|
+
If Sieve's answer is missing information that Standard provided, it is not equivalent.
|
|
23
|
+
|
|
24
|
+
Output your response exactly in this format on two lines:
|
|
25
|
+
SCORE: <number between 0 and 100>
|
|
26
|
+
VERDICT: <one short sentence explaining the score>
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
res = llm.answer(question, prompt)
|
|
31
|
+
lines = res.answer.strip().split('\n')
|
|
32
|
+
score = 100
|
|
33
|
+
verdict = "Could not parse judge response."
|
|
34
|
+
|
|
35
|
+
for line in lines:
|
|
36
|
+
if line.startswith("SCORE:"):
|
|
37
|
+
try:
|
|
38
|
+
score = int(line.replace("SCORE:", "").strip())
|
|
39
|
+
except ValueError:
|
|
40
|
+
pass
|
|
41
|
+
elif line.startswith("VERDICT:"):
|
|
42
|
+
verdict = line.replace("VERDICT:", "").strip()
|
|
43
|
+
|
|
44
|
+
return {"score": score, "verdict": verdict}
|
|
45
|
+
except Exception as e:
|
|
46
|
+
return {"score": 0, "verdict": f"Judge failed: {str(e)}"}
|
|
47
|
+
|
|
48
|
+
class Runner:
|
|
49
|
+
"""Runs end-to-end benchmarking for Sieve ON vs OFF."""
|
|
50
|
+
def __init__(self, config: Config, vector_store: VectorStore, scorer: Scorer, llm: LLM):
|
|
51
|
+
self.config = config
|
|
52
|
+
self.vector_store = vector_store
|
|
53
|
+
self.scorer = scorer
|
|
54
|
+
self.llm = llm
|
|
55
|
+
|
|
56
|
+
self.sieve = Sieve(
|
|
57
|
+
threshold=config.sieve.threshold,
|
|
58
|
+
max_tokens=config.sieve.max_tokens,
|
|
59
|
+
context_window=config.sieve.context_window
|
|
60
|
+
)
|
|
61
|
+
self.evaluator = Evaluator()
|
|
62
|
+
|
|
63
|
+
def run_standard(self, question: str) -> Benchmark:
|
|
64
|
+
"""Run the query using naive RAG (sending all retrieved chunks)."""
|
|
65
|
+
t0 = time.perf_counter()
|
|
66
|
+
|
|
67
|
+
all_chunks = self.vector_store.chunks
|
|
68
|
+
original_context = "\n\n".join([c.text for c in all_chunks])
|
|
69
|
+
original_tokens = count_tokens(original_context)
|
|
70
|
+
|
|
71
|
+
retrieval_latency = time.perf_counter() - t0
|
|
72
|
+
|
|
73
|
+
# LLM Call
|
|
74
|
+
std_llm_res = self.llm.answer(question, original_context)
|
|
75
|
+
std_metrics = calculate_savings(std_llm_res.input_tokens, std_llm_res.output_tokens, self.config)
|
|
76
|
+
|
|
77
|
+
std_total_latency = retrieval_latency + std_llm_res.latency
|
|
78
|
+
|
|
79
|
+
return Benchmark(
|
|
80
|
+
question=question,
|
|
81
|
+
standard_retrieval_tokens=original_tokens,
|
|
82
|
+
standard_llm_input_tokens=std_llm_res.input_tokens,
|
|
83
|
+
standard_cost=std_metrics.estimated_cost,
|
|
84
|
+
standard_latency_retrieval=retrieval_latency,
|
|
85
|
+
standard_latency_total=std_total_latency,
|
|
86
|
+
standard_answer=std_llm_res.answer,
|
|
87
|
+
# Fill empty for sieve fields
|
|
88
|
+
sieve_retrieval_tokens=0, sieve_output_tokens=0, sieve_tokens_removed=0, sieve_reduction_pct=0,
|
|
89
|
+
sieve_cost=0, sieve_latency_retrieval=0, sieve_latency_sieve=0, sieve_latency_total=0, sieve_answer="",
|
|
90
|
+
cost_reduction_pct=0, latency_improvement_pct=0, semantic_similarity=None
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def run_sieve(self, question: str, progress_callback=None) -> Benchmark:
|
|
94
|
+
"""Run the query using Sieve to compress the context first."""
|
|
95
|
+
t0 = time.perf_counter()
|
|
96
|
+
|
|
97
|
+
all_chunks = self.vector_store.chunks
|
|
98
|
+
original_context = "\n\n".join([c.text for c in all_chunks])
|
|
99
|
+
original_tokens = count_tokens(original_context)
|
|
100
|
+
retrieval_latency = time.perf_counter() - t0
|
|
101
|
+
|
|
102
|
+
if progress_callback:
|
|
103
|
+
progress_callback({"stage": "starting"})
|
|
104
|
+
|
|
105
|
+
# Score and compress
|
|
106
|
+
scored_chunks = self.scorer.score_chunks(question, all_chunks, progress_callback=progress_callback)
|
|
107
|
+
sieve_res = self.sieve.filter_context(scored_chunks, count_tokens)
|
|
108
|
+
|
|
109
|
+
if progress_callback:
|
|
110
|
+
progress_callback({"stage": "calling_llm"})
|
|
111
|
+
|
|
112
|
+
# LLM Call
|
|
113
|
+
sieve_llm_res = self.llm.answer(question, sieve_res.compressed_context)
|
|
114
|
+
sieve_metrics = calculate_savings(sieve_llm_res.input_tokens, sieve_llm_res.output_tokens, self.config)
|
|
115
|
+
|
|
116
|
+
sieve_total_latency = retrieval_latency + sieve_res.latency + sieve_llm_res.latency
|
|
117
|
+
|
|
118
|
+
return Benchmark(
|
|
119
|
+
question=question,
|
|
120
|
+
standard_retrieval_tokens=0, standard_llm_input_tokens=0, standard_cost=0,
|
|
121
|
+
standard_latency_retrieval=0, standard_latency_total=0, standard_answer="",
|
|
122
|
+
sieve_retrieval_tokens=original_tokens,
|
|
123
|
+
sieve_output_tokens=sieve_llm_res.input_tokens,
|
|
124
|
+
sieve_tokens_removed=sieve_res.removed_tokens,
|
|
125
|
+
sieve_reduction_pct=sieve_res.reduction_percentage,
|
|
126
|
+
sieve_cost=sieve_metrics.estimated_cost,
|
|
127
|
+
sieve_latency_retrieval=retrieval_latency,
|
|
128
|
+
sieve_latency_sieve=sieve_res.latency,
|
|
129
|
+
sieve_latency_total=sieve_total_latency,
|
|
130
|
+
sieve_answer=sieve_llm_res.answer,
|
|
131
|
+
cost_reduction_pct=0, latency_improvement_pct=0, semantic_similarity=None
|
|
132
|
+
)
|
sieve/calibrate.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from sieve.sieve import filter_context
|
|
7
|
+
from sieve.llm import LLM, MockLLM, GoogleLLM, count_tokens
|
|
8
|
+
from sieve.config import load_config
|
|
9
|
+
|
|
10
|
+
class BenchmarkCase(BaseModel):
|
|
11
|
+
question: str
|
|
12
|
+
context: str
|
|
13
|
+
expected_answer: str
|
|
14
|
+
|
|
15
|
+
class ThresholdResult(BaseModel):
|
|
16
|
+
threshold: Optional[float]
|
|
17
|
+
label: str
|
|
18
|
+
context_kept_pct: float
|
|
19
|
+
accuracy_pct: float
|
|
20
|
+
correct_count: int
|
|
21
|
+
total_count: int
|
|
22
|
+
retained_tokens_avg: float
|
|
23
|
+
latency_ms_avg: float
|
|
24
|
+
|
|
25
|
+
class CalibrationSummary(BaseModel):
|
|
26
|
+
results: List[ThresholdResult]
|
|
27
|
+
min_sufficient_threshold: Optional[float]
|
|
28
|
+
min_sufficient_context_pct: float
|
|
29
|
+
min_sufficient_accuracy_pct: float
|
|
30
|
+
|
|
31
|
+
def score_answer(expected: str, answer_or_context: str) -> bool:
|
|
32
|
+
"""Check case-insensitive substring match of expected answer."""
|
|
33
|
+
return expected.strip().lower() in answer_or_context.lower()
|
|
34
|
+
|
|
35
|
+
def run_calibration(
|
|
36
|
+
benchmark_path: str,
|
|
37
|
+
thresholds: List[float] = [0.20, 0.35, 0.50, 0.65, 0.80],
|
|
38
|
+
llm: Optional[LLM] = None,
|
|
39
|
+
) -> CalibrationSummary:
|
|
40
|
+
"""Sweep thresholds across a benchmark and report accuracy and context reduction."""
|
|
41
|
+
with open(benchmark_path, "r", encoding="utf-8") as f:
|
|
42
|
+
data = json.load(f)
|
|
43
|
+
|
|
44
|
+
cases = [BenchmarkCase(**item) for item in data]
|
|
45
|
+
total_cases = len(cases)
|
|
46
|
+
if total_cases == 0:
|
|
47
|
+
raise ValueError("Benchmark dataset is empty.")
|
|
48
|
+
|
|
49
|
+
results: List[ThresholdResult] = []
|
|
50
|
+
|
|
51
|
+
# 1. Baseline (without Sieve)
|
|
52
|
+
baseline_correct = 0
|
|
53
|
+
baseline_tokens = []
|
|
54
|
+
baseline_latencies = []
|
|
55
|
+
|
|
56
|
+
for c in cases:
|
|
57
|
+
tokens = count_tokens(c.context)
|
|
58
|
+
baseline_tokens.append(tokens)
|
|
59
|
+
if llm is not None:
|
|
60
|
+
t0 = time.perf_counter()
|
|
61
|
+
resp = llm.answer(c.question, c.context)
|
|
62
|
+
elapsed = (time.perf_counter() - t0) * 1000.0
|
|
63
|
+
baseline_latencies.append(elapsed)
|
|
64
|
+
if score_answer(c.expected_answer, resp.answer):
|
|
65
|
+
baseline_correct += 1
|
|
66
|
+
else:
|
|
67
|
+
# Check presence in context
|
|
68
|
+
baseline_latencies.append(0.0)
|
|
69
|
+
if score_answer(c.expected_answer, c.context):
|
|
70
|
+
baseline_correct += 1
|
|
71
|
+
|
|
72
|
+
results.append(ThresholdResult(
|
|
73
|
+
threshold=None,
|
|
74
|
+
label="— (WITHOUT SIEVE)",
|
|
75
|
+
context_kept_pct=100.0,
|
|
76
|
+
accuracy_pct=round((baseline_correct / total_cases) * 100.0, 1),
|
|
77
|
+
correct_count=baseline_correct,
|
|
78
|
+
total_count=total_cases,
|
|
79
|
+
retained_tokens_avg=round(sum(baseline_tokens) / total_cases, 1),
|
|
80
|
+
latency_ms_avg=round(sum(baseline_latencies) / total_cases, 1),
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
# 2. Threshold sweep
|
|
84
|
+
for th in sorted(thresholds):
|
|
85
|
+
correct = 0
|
|
86
|
+
kept_tokens_list = []
|
|
87
|
+
latencies = []
|
|
88
|
+
|
|
89
|
+
for c in cases:
|
|
90
|
+
res = filter_context(query=c.question, context=c.context, threshold=th)
|
|
91
|
+
kept_tokens = res.metrics.filtered_tokens
|
|
92
|
+
kept_tokens_list.append(kept_tokens)
|
|
93
|
+
|
|
94
|
+
if llm is not None:
|
|
95
|
+
t0 = time.perf_counter()
|
|
96
|
+
resp = llm.answer(c.question, res.filtered_context)
|
|
97
|
+
elapsed = (time.perf_counter() - t0) * 1000.0 + res.metrics.filtering_latency_ms
|
|
98
|
+
latencies.append(elapsed)
|
|
99
|
+
if score_answer(c.expected_answer, resp.answer):
|
|
100
|
+
correct += 1
|
|
101
|
+
else:
|
|
102
|
+
latencies.append(res.metrics.filtering_latency_ms)
|
|
103
|
+
if score_answer(c.expected_answer, res.filtered_context):
|
|
104
|
+
correct += 1
|
|
105
|
+
|
|
106
|
+
avg_kept_pct = (sum(kept_tokens_list) / sum(baseline_tokens)) * 100.0 if sum(baseline_tokens) > 0 else 0.0
|
|
107
|
+
acc_pct = (correct / total_cases) * 100.0
|
|
108
|
+
|
|
109
|
+
results.append(ThresholdResult(
|
|
110
|
+
threshold=th,
|
|
111
|
+
label=f"{th:.2f}",
|
|
112
|
+
context_kept_pct=round(avg_kept_pct, 1),
|
|
113
|
+
accuracy_pct=round(acc_pct, 1),
|
|
114
|
+
correct_count=correct,
|
|
115
|
+
total_count=total_cases,
|
|
116
|
+
retained_tokens_avg=round(sum(kept_tokens_list) / total_cases, 1),
|
|
117
|
+
latency_ms_avg=round(sum(latencies) / total_cases, 1),
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
# Determine minimum sufficient context:
|
|
121
|
+
# Filter among thresholds that maintain accuracy >= baseline accuracy (or max accuracy observed)
|
|
122
|
+
max_acc = max(r.accuracy_pct for r in results if r.threshold is not None)
|
|
123
|
+
qualifying = [r for r in results if r.threshold is not None and r.accuracy_pct >= max_acc]
|
|
124
|
+
if qualifying:
|
|
125
|
+
# Pick the one with lowest context kept
|
|
126
|
+
best = min(qualifying, key=lambda x: x.context_kept_pct)
|
|
127
|
+
best_th = best.threshold
|
|
128
|
+
best_context_pct = best.context_kept_pct
|
|
129
|
+
best_acc = best.accuracy_pct
|
|
130
|
+
else:
|
|
131
|
+
best_th = None
|
|
132
|
+
best_context_pct = 100.0
|
|
133
|
+
best_acc = 100.0
|
|
134
|
+
|
|
135
|
+
return CalibrationSummary(
|
|
136
|
+
results=results,
|
|
137
|
+
min_sufficient_threshold=best_th,
|
|
138
|
+
min_sufficient_context_pct=best_context_pct,
|
|
139
|
+
min_sufficient_accuracy_pct=best_acc,
|
|
140
|
+
)
|
sieve/cli.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import typer
|
|
5
|
+
from rich.prompt import Prompt
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from sieve.config import load_config
|
|
10
|
+
from sieve.ui import (
|
|
11
|
+
console, print_header, run_sieve, run_standard,
|
|
12
|
+
render_mode_dashboard, render_stats_table, print_env_savings,
|
|
13
|
+
to_safe_ascii, SAFE_BOX, STANDARD_COLOR, SIEVE_COLOR, SIEVE_ACCENT
|
|
14
|
+
)
|
|
15
|
+
from sieve.documents import parse_document, process_documents
|
|
16
|
+
from sieve.vector_store import VectorStore
|
|
17
|
+
from sieve.scorer import Scorer
|
|
18
|
+
from sieve.llm import MockLLM, GoogleLLM
|
|
19
|
+
from sieve.benchmark import Runner
|
|
20
|
+
from sieve.sieve import filter_context
|
|
21
|
+
from sieve.calibrate import run_calibration
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="Sieve: Intelligent context compression for RAG")
|
|
24
|
+
|
|
25
|
+
def setup(offline: bool = False):
|
|
26
|
+
config = load_config()
|
|
27
|
+
vector_store = VectorStore(config.workspace.index_dir, config.models.embedding)
|
|
28
|
+
scorer = Scorer(config.models.reranker)
|
|
29
|
+
|
|
30
|
+
if offline or not config.llm.api_key:
|
|
31
|
+
llm = MockLLM()
|
|
32
|
+
else:
|
|
33
|
+
llm = GoogleLLM(api_key=config.llm.api_key, model_name=config.llm.model)
|
|
34
|
+
|
|
35
|
+
return config, vector_store, scorer, llm
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
def index():
|
|
39
|
+
"""Ingest and index documents in the workspace."""
|
|
40
|
+
config, vector_store, scorer, llm = setup(offline=True)
|
|
41
|
+
doc_dir = config.workspace.documents_dir
|
|
42
|
+
|
|
43
|
+
if not os.path.exists(doc_dir):
|
|
44
|
+
console.print(f"[warning]Documents directory not found at {doc_dir}. Created it. Please add files and run index again.[/warning]")
|
|
45
|
+
os.makedirs(doc_dir)
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
files = []
|
|
49
|
+
for ext in ["*.pdf", "*.txt", "*.md", "*.html"]:
|
|
50
|
+
files.extend(glob.glob(os.path.join(doc_dir, ext)))
|
|
51
|
+
|
|
52
|
+
if not files:
|
|
53
|
+
console.print(f"[warning]No supported documents found in {doc_dir}. Please add some PDFs or text files.[/warning]")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
console.print(f"[info]Found {len(files)} documents. Indexing...[/info]")
|
|
57
|
+
|
|
58
|
+
all_chunks = []
|
|
59
|
+
for f in files:
|
|
60
|
+
coarse = parse_document(f)
|
|
61
|
+
fine = process_documents(coarse)
|
|
62
|
+
all_chunks.extend(fine)
|
|
63
|
+
|
|
64
|
+
vector_store.add_chunks(all_chunks)
|
|
65
|
+
console.print(f"[success]Successfully indexed {len(all_chunks)} chunks into the vector store![/success]")
|
|
66
|
+
|
|
67
|
+
@app.command()
|
|
68
|
+
def ask(question: str, offline: bool = typer.Option(False, "--offline", help="Run in offline mode")):
|
|
69
|
+
"""Ask a question using Sieve RAG."""
|
|
70
|
+
config, vector_store, scorer, llm = setup(offline)
|
|
71
|
+
runner = Runner(config, vector_store, scorer, llm)
|
|
72
|
+
|
|
73
|
+
console.rule(
|
|
74
|
+
f"[bold {SIEVE_COLOR}]SIEVE :: SIEVE MODE[/bold {SIEVE_COLOR}] [dim]- cross-encoder filtered context[/dim]",
|
|
75
|
+
style=SIEVE_COLOR,
|
|
76
|
+
characters="-",
|
|
77
|
+
)
|
|
78
|
+
console.print(f"\n[bold]Query:[/bold] {to_safe_ascii(question)}\n")
|
|
79
|
+
|
|
80
|
+
res = run_sieve(runner, question)
|
|
81
|
+
|
|
82
|
+
console.print(Panel(to_safe_ascii(res.sieve_answer), title="Sieve's answer", border_style=SIEVE_COLOR, box=SAFE_BOX))
|
|
83
|
+
console.print()
|
|
84
|
+
console.print(render_mode_dashboard("sieve", res, SIEVE_COLOR))
|
|
85
|
+
print_env_savings("sieve", res, res.sieve_retrieval_tokens)
|
|
86
|
+
console.print()
|
|
87
|
+
console.rule(style=SIEVE_COLOR, characters="-")
|
|
88
|
+
|
|
89
|
+
@app.command()
|
|
90
|
+
def benchmark(offline: bool = typer.Option(False, "--offline", help="Run in offline mode")):
|
|
91
|
+
"""Run a side-by-side benchmark (Standard vs Sieve)."""
|
|
92
|
+
print_header()
|
|
93
|
+
|
|
94
|
+
config, vector_store, scorer, llm = setup(offline)
|
|
95
|
+
if not vector_store.chunks:
|
|
96
|
+
console.print("[warning]No indexed documents. Please run 'sieve index' first.[/warning]")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
question = Prompt.ask("\n[bold]Enter your question[/bold]")
|
|
100
|
+
|
|
101
|
+
runner = Runner(config, vector_store, scorer, llm)
|
|
102
|
+
|
|
103
|
+
console.rule(f"[bold {STANDARD_COLOR}]STANDARD RAG[/bold {STANDARD_COLOR}]", style=STANDARD_COLOR, characters="-")
|
|
104
|
+
std_res = run_standard(runner, question)
|
|
105
|
+
console.print(Panel(to_safe_ascii(std_res.standard_answer), title="Standard's answer", border_style=STANDARD_COLOR, box=SAFE_BOX))
|
|
106
|
+
|
|
107
|
+
console.print()
|
|
108
|
+
console.rule(f"[bold {SIEVE_COLOR}]SIEVE RAG[/bold {SIEVE_COLOR}]", style=SIEVE_COLOR, characters="-")
|
|
109
|
+
sieve_res = run_sieve(runner, question)
|
|
110
|
+
console.print(Panel(to_safe_ascii(sieve_res.sieve_answer), title="Sieve's answer", border_style=SIEVE_COLOR, box=SAFE_BOX))
|
|
111
|
+
|
|
112
|
+
console.print()
|
|
113
|
+
|
|
114
|
+
# Merge results to pass into render_stats_table
|
|
115
|
+
sieve_res.standard_llm_input_tokens = std_res.standard_llm_input_tokens
|
|
116
|
+
sieve_res.standard_cost = std_res.standard_cost
|
|
117
|
+
sieve_res.standard_latency_total = std_res.standard_latency_total
|
|
118
|
+
|
|
119
|
+
console.print(render_stats_table(sieve_res))
|
|
120
|
+
|
|
121
|
+
console.print()
|
|
122
|
+
with console.status(f"[bold {SIEVE_ACCENT}]Judging factual agreement...[/bold {SIEVE_ACCENT}]"):
|
|
123
|
+
comparison = runner.evaluator.judge_equivalence(question, std_res.standard_answer, sieve_res.sieve_answer, llm)
|
|
124
|
+
|
|
125
|
+
console.print(
|
|
126
|
+
Panel(
|
|
127
|
+
f"[bold]{comparison['score']}% agreement[/bold]\n{to_safe_ascii(comparison['verdict'])}",
|
|
128
|
+
title="Accuracy - LLM-as-judge",
|
|
129
|
+
border_style=SIEVE_ACCENT,
|
|
130
|
+
box=SAFE_BOX,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
console.print()
|
|
135
|
+
console.rule(style=SIEVE_COLOR, characters="-")
|
|
136
|
+
|
|
137
|
+
@app.command()
|
|
138
|
+
def calibrate(
|
|
139
|
+
benchmark_file: str = typer.Option("examples/benchmark.json", "--benchmark", "-b", help="Path to benchmark JSON"),
|
|
140
|
+
thresholds: str = typer.Option("0.20,0.35,0.50,0.65,0.80", "--thresholds", "-t", help="Comma-separated threshold list"),
|
|
141
|
+
offline: bool = typer.Option(True, "--offline", help="Run in offline mode without calling LLM API"),
|
|
142
|
+
):
|
|
143
|
+
"""Calibrate optimal filtering threshold across a benchmark dataset."""
|
|
144
|
+
th_list = [float(x.strip()) for x in thresholds.split(",") if x.strip()]
|
|
145
|
+
config, _, _, llm = setup(offline=offline)
|
|
146
|
+
|
|
147
|
+
console.print(f"[info]Running Sieve Calibrate on '{benchmark_file}' with thresholds {th_list}...[/info]")
|
|
148
|
+
active_llm = None if offline else llm
|
|
149
|
+
|
|
150
|
+
summary = run_calibration(benchmark_file, thresholds=th_list, llm=active_llm)
|
|
151
|
+
|
|
152
|
+
table = Table(title="SIEVE CALIBRATION RESULTS", box=SAFE_BOX)
|
|
153
|
+
table.add_column("Threshold", style="bold")
|
|
154
|
+
table.add_column("Context Kept", justify="right")
|
|
155
|
+
table.add_column("Accuracy", justify="right")
|
|
156
|
+
table.add_column("Correct", justify="right")
|
|
157
|
+
table.add_column("Avg Latency", justify="right")
|
|
158
|
+
|
|
159
|
+
for r in summary.results:
|
|
160
|
+
table.add_row(
|
|
161
|
+
r.label,
|
|
162
|
+
f"{r.context_kept_pct}%",
|
|
163
|
+
f"{r.accuracy_pct}%",
|
|
164
|
+
f"{r.correct_count}/{r.total_count}",
|
|
165
|
+
f"{r.latency_ms_avg:.1f}ms",
|
|
166
|
+
)
|
|
167
|
+
console.print(table)
|
|
168
|
+
|
|
169
|
+
console.print(
|
|
170
|
+
f"\n[bold {SIEVE_COLOR}]Minimum sufficient context:[/bold {SIEVE_COLOR}] "
|
|
171
|
+
f"Threshold {summary.min_sufficient_threshold if summary.min_sufficient_threshold is not None else 'N/A'}, "
|
|
172
|
+
f"{summary.min_sufficient_context_pct}% of context retained, "
|
|
173
|
+
f"{summary.min_sufficient_accuracy_pct}% accuracy."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
@app.command()
|
|
177
|
+
def filter(
|
|
178
|
+
query: str = typer.Argument(..., help="Query to filter context against"),
|
|
179
|
+
context_file: Optional[str] = typer.Option(None, "--file", "-f", help="Path to context text file"),
|
|
180
|
+
context_text: Optional[str] = typer.Option(None, "--text", help="Raw context text to filter"),
|
|
181
|
+
threshold: float = typer.Option(0.50, "--threshold", "-t", help="Relevance threshold"),
|
|
182
|
+
max_tokens: Optional[int] = typer.Option(None, "--max-tokens", help="Maximum tokens in filtered context"),
|
|
183
|
+
):
|
|
184
|
+
"""Filter retrieved context directly for a given query."""
|
|
185
|
+
raw_context = ""
|
|
186
|
+
if context_file:
|
|
187
|
+
if not os.path.exists(context_file):
|
|
188
|
+
console.print(f"[red]Error: file '{context_file}' does not exist.[/red]")
|
|
189
|
+
raise typer.Exit(code=1)
|
|
190
|
+
with open(context_file, "r", encoding="utf-8") as f:
|
|
191
|
+
raw_context = f.read()
|
|
192
|
+
elif context_text:
|
|
193
|
+
raw_context = context_text
|
|
194
|
+
else:
|
|
195
|
+
# Default to demo context if available
|
|
196
|
+
demo_path = "examples/context.txt"
|
|
197
|
+
if os.path.exists(demo_path):
|
|
198
|
+
with open(demo_path, "r", encoding="utf-8") as f:
|
|
199
|
+
raw_context = f.read()
|
|
200
|
+
else:
|
|
201
|
+
console.print("[red]Error: Please provide --file or --text.[/red]")
|
|
202
|
+
raise typer.Exit(code=1)
|
|
203
|
+
|
|
204
|
+
result = filter_context(query=query, context=raw_context, threshold=threshold, max_tokens=max_tokens)
|
|
205
|
+
|
|
206
|
+
console.print(Panel(result.filtered_context, title="Filtered Context", border_style=SIEVE_COLOR, box=SAFE_BOX))
|
|
207
|
+
console.print(
|
|
208
|
+
f"\n[bold]Metrics:[/bold] Original: {result.metrics.original_tokens} tokens | "
|
|
209
|
+
f"Filtered: {result.metrics.filtered_tokens} tokens | "
|
|
210
|
+
f"Reduction: {result.metrics.reduction_percent}% | "
|
|
211
|
+
f"Latency: {result.metrics.filtering_latency_ms} ms"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@app.command()
|
|
215
|
+
def doctor():
|
|
216
|
+
"""Check system status."""
|
|
217
|
+
print_header()
|
|
218
|
+
config = load_config()
|
|
219
|
+
console.print("[bold]SIEVE DOCTOR[/bold]")
|
|
220
|
+
|
|
221
|
+
doc_dir = config.workspace.documents_dir
|
|
222
|
+
docs_exist = os.path.exists(doc_dir) and len(os.listdir(doc_dir)) > 0
|
|
223
|
+
console.print(f"[{'success' if docs_exist else 'warning'}] Workspace documents: {'Found' if docs_exist else 'Empty'}")
|
|
224
|
+
|
|
225
|
+
idx_dir = config.workspace.index_dir
|
|
226
|
+
idx_exist = os.path.exists(idx_dir) and len(os.listdir(idx_dir)) > 0
|
|
227
|
+
console.print(f"[{'success' if idx_exist else 'warning'}] Vector index: {'Found' if idx_exist else 'Empty'}")
|
|
228
|
+
|
|
229
|
+
key_exists = bool(config.llm.api_key)
|
|
230
|
+
console.print(f"[{'success' if key_exists else 'warning'}] LLM API Key: {'Configured' if key_exists else 'Not found (will use offline mode)'}")
|
|
231
|
+
|
|
232
|
+
@app.command()
|
|
233
|
+
def interactive():
|
|
234
|
+
"""Start interactive session."""
|
|
235
|
+
print_header()
|
|
236
|
+
while True:
|
|
237
|
+
cmd = Prompt.ask("\n[bold]sieve>[/bold] ")
|
|
238
|
+
if cmd.lower() in ['exit', 'quit']:
|
|
239
|
+
break
|
|
240
|
+
elif cmd.lower().startswith('ask '):
|
|
241
|
+
ask(cmd[4:], offline=False)
|
|
242
|
+
elif cmd.lower() == 'benchmark':
|
|
243
|
+
benchmark(offline=False)
|
|
244
|
+
else:
|
|
245
|
+
console.print("Commands: ask <question>, benchmark, calibrate, exit")
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
app()
|
sieve/config.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
class WorkspaceConfig(BaseModel):
|
|
7
|
+
documents_dir: str = "./workspace/documents"
|
|
8
|
+
index_dir: str = "./workspace/indexes"
|
|
9
|
+
benchmarks_dir: str = "./workspace/benchmarks"
|
|
10
|
+
|
|
11
|
+
class RetrievalConfig(BaseModel):
|
|
12
|
+
top_k: int = 10
|
|
13
|
+
|
|
14
|
+
class SieveConfig(BaseModel):
|
|
15
|
+
threshold: float = 0.60
|
|
16
|
+
max_tokens: int = 2000
|
|
17
|
+
context_window: int = 1
|
|
18
|
+
|
|
19
|
+
class ModelsConfig(BaseModel):
|
|
20
|
+
embedding: str = "all-MiniLM-L6-v2"
|
|
21
|
+
reranker: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
|
22
|
+
|
|
23
|
+
class PricingConfig(BaseModel):
|
|
24
|
+
input_per_1m: float = 0.075
|
|
25
|
+
output_per_1m: float = 0.300
|
|
26
|
+
|
|
27
|
+
class LLMConfig(BaseModel):
|
|
28
|
+
provider: str = "google"
|
|
29
|
+
model: str = "gemini-3.5-flash-lite"
|
|
30
|
+
pricing: PricingConfig = PricingConfig()
|
|
31
|
+
api_key: str = ""
|
|
32
|
+
|
|
33
|
+
class SustainabilityConfig(BaseModel):
|
|
34
|
+
energy_per_million_tokens_kwh: float = 0.003
|
|
35
|
+
carbon_intensity_g_per_kwh: float = 450.0
|
|
36
|
+
|
|
37
|
+
class Config(BaseModel):
|
|
38
|
+
workspace: WorkspaceConfig = WorkspaceConfig()
|
|
39
|
+
retrieval: RetrievalConfig = RetrievalConfig()
|
|
40
|
+
sieve: SieveConfig = SieveConfig()
|
|
41
|
+
models: ModelsConfig = ModelsConfig()
|
|
42
|
+
llm: LLMConfig = LLMConfig()
|
|
43
|
+
sustainability: SustainabilityConfig = SustainabilityConfig()
|
|
44
|
+
|
|
45
|
+
def load_config(config_path: str = "sieve.yaml") -> Config:
|
|
46
|
+
load_dotenv()
|
|
47
|
+
|
|
48
|
+
config_data = {}
|
|
49
|
+
if os.path.exists(config_path):
|
|
50
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
51
|
+
config_data = yaml.safe_load(f) or {}
|
|
52
|
+
|
|
53
|
+
config = Config(**config_data)
|
|
54
|
+
|
|
55
|
+
# Overrides from environment
|
|
56
|
+
if os.getenv("SIEVE_LLM_PROVIDER"):
|
|
57
|
+
config.llm.provider = os.getenv("SIEVE_LLM_PROVIDER")
|
|
58
|
+
|
|
59
|
+
if os.getenv("SIEVE_GOOGLE_MODEL"):
|
|
60
|
+
config.llm.model = os.getenv("SIEVE_GOOGLE_MODEL")
|
|
61
|
+
|
|
62
|
+
if os.getenv("SIEVE_GOOGLE_API_KEY"):
|
|
63
|
+
config.llm.api_key = os.getenv("SIEVE_GOOGLE_API_KEY")
|
|
64
|
+
|
|
65
|
+
return config
|
|
66
|
+
|
|
67
|
+
# Global config instance for simple access if needed
|
|
68
|
+
# Typically better to pass it around, but a default instance is handy
|
|
69
|
+
default_config = load_config()
|