ragkitframe 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sindhuja Ramaraj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: ragkitframe
3
+ Version: 0.1.0
4
+ Summary: A modular, fully local reliability layer for RAG pipelines.
5
+ Author: Sindhuja Ramaraj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Sindhu06trs/ragkit
8
+ Project-URL: Repository, https://github.com/Sindhu06trs/ragkit
9
+ Keywords: rag,retrieval,llm,nlp,hallucination,local,offline
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: spacy>=3.0.0
17
+ Requires-Dist: sentence-transformers>=2.2.0
18
+ Requires-Dist: numpy
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
21
+ Requires-Dist: tabulate>=0.9.0; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # ragkitframe
25
+
26
+ A modular, pip-installable reliability layer for Retrieval-Augmented Generation (RAG) pipelines.
27
+
28
+ ## Why ragkit?
29
+ - **100% Fully Local**: Requires zero external API calls, zero hosted LLM inference, and runs completely offline.
30
+ - **Privacy-First**: No data leaves your machine; uses local NLP rules and lightweight, open-source sentence-transformers NLI models.
31
+ - **Framework-Agnostic**: Plugs directly into LangChain, LlamaIndex, or any custom pythonic RAG pipeline.
32
+ - **Import-Only-What-You-Need**: Extremely fast import times with lazy loading of all underlying model dependencies on first invocation.
33
+
34
+ ---
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ # Install the library in editable/dev mode or directly
40
+ pip install ragkitframe
41
+
42
+ # Download the required local English spaCy pipeline
43
+ python -m spacy download en_core_web_sm
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Quickstart Examples
49
+
50
+ ### 1. Query Decomposition
51
+ Split compound queries into atomic questions before retrieval.
52
+ ```python
53
+ from ragkit import QueryDecomposer
54
+
55
+ decomposer = QueryDecomposer()
56
+ sub_questions = decomposer.decompose("What is climate change and how does it affect oceans?")
57
+ print(sub_questions)
58
+ # Output: ['What is climate change?', 'How does it affect oceans?']
59
+ ```
60
+
61
+ ### 2. Confidence Scoring (Hallucination Detection)
62
+ Verify if the generated answer is supported by the retrieved document chunks.
63
+ ```python
64
+ from ragkit import ConfidenceScorer
65
+
66
+ scorer = ConfidenceScorer() # Uses tiny 100MB model by default
67
+ chunks = ["Photosynthesis uses sunlight to convert water and CO2 into oxygen and glucose."]
68
+ answer = "Plants convert carbon dioxide and water into glucose using sunlight. They also produce helium."
69
+
70
+ result = scorer.score(answer, chunks)
71
+ print(f"Score: {result['score']}/100 | Verdict: {result['verdict']}")
72
+ # Output: Score: 50/100 | Verdict: partially_grounded
73
+ print("Reasoning:", result["reasoning"])
74
+
75
+ # For better accuracy, use the larger model:
76
+ # scorer = ConfidenceScorer(model_name="cross-encoder/nli-deberta-v3-base")
77
+ ```
@@ -0,0 +1,54 @@
1
+ # ragkitframe
2
+
3
+ A modular, pip-installable reliability layer for Retrieval-Augmented Generation (RAG) pipelines.
4
+
5
+ ## Why ragkit?
6
+ - **100% Fully Local**: Requires zero external API calls, zero hosted LLM inference, and runs completely offline.
7
+ - **Privacy-First**: No data leaves your machine; uses local NLP rules and lightweight, open-source sentence-transformers NLI models.
8
+ - **Framework-Agnostic**: Plugs directly into LangChain, LlamaIndex, or any custom pythonic RAG pipeline.
9
+ - **Import-Only-What-You-Need**: Extremely fast import times with lazy loading of all underlying model dependencies on first invocation.
10
+
11
+ ---
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ # Install the library in editable/dev mode or directly
17
+ pip install ragkitframe
18
+
19
+ # Download the required local English spaCy pipeline
20
+ python -m spacy download en_core_web_sm
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Quickstart Examples
26
+
27
+ ### 1. Query Decomposition
28
+ Split compound queries into atomic questions before retrieval.
29
+ ```python
30
+ from ragkit import QueryDecomposer
31
+
32
+ decomposer = QueryDecomposer()
33
+ sub_questions = decomposer.decompose("What is climate change and how does it affect oceans?")
34
+ print(sub_questions)
35
+ # Output: ['What is climate change?', 'How does it affect oceans?']
36
+ ```
37
+
38
+ ### 2. Confidence Scoring (Hallucination Detection)
39
+ Verify if the generated answer is supported by the retrieved document chunks.
40
+ ```python
41
+ from ragkit import ConfidenceScorer
42
+
43
+ scorer = ConfidenceScorer() # Uses tiny 100MB model by default
44
+ chunks = ["Photosynthesis uses sunlight to convert water and CO2 into oxygen and glucose."]
45
+ answer = "Plants convert carbon dioxide and water into glucose using sunlight. They also produce helium."
46
+
47
+ result = scorer.score(answer, chunks)
48
+ print(f"Score: {result['score']}/100 | Verdict: {result['verdict']}")
49
+ # Output: Score: 50/100 | Verdict: partially_grounded
50
+ print("Reasoning:", result["reasoning"])
51
+
52
+ # For better accuracy, use the larger model:
53
+ # scorer = ConfidenceScorer(model_name="cross-encoder/nli-deberta-v3-base")
54
+ ```
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ragkitframe"
7
+ version = "0.1.0"
8
+ description = "A modular, fully local reliability layer for RAG pipelines."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Sindhuja Ramaraj" }
14
+ ]
15
+ keywords = ["rag", "retrieval", "llm", "nlp", "hallucination", "local", "offline"]
16
+ urls = { Homepage = "https://github.com/Sindhu06trs/ragkit", Repository = "https://github.com/Sindhu06trs/ragkit" }
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ dependencies = [
23
+ "spacy>=3.0.0",
24
+ "sentence-transformers>=2.2.0",
25
+ "numpy",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=7.0.0",
31
+ "tabulate>=0.9.0",
32
+ ]
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["."]
36
+ include = ["ragkit*"]
@@ -0,0 +1,4 @@
1
+ from ragkit.decompose import QueryDecomposer
2
+ from ragkit.score import ConfidenceScorer
3
+
4
+ __all__ = ["QueryDecomposer", "ConfidenceScorer"]
@@ -0,0 +1,3 @@
1
+ from .decomposer import QueryDecomposer
2
+
3
+ __all__ = ["QueryDecomposer"]
@@ -0,0 +1,180 @@
1
+ import spacy
2
+ from typing import Callable, List, Dict, Any, Optional
3
+
4
+ class QueryDecomposer:
5
+ """
6
+ A rule-based query decomposer that splits compound or multi-part queries
7
+ into atomic, independently-retrievable sub-questions using spaCy's dependency parser.
8
+ """
9
+ def __init__(self, nlp: Optional[Any] = None) -> None:
10
+ """
11
+ Initialize the QueryDecomposer.
12
+
13
+ Args:
14
+ nlp: An optional pre-loaded spaCy pipeline. If None, 'en_core_web_sm'
15
+ will be loaded lazily on first use.
16
+ """
17
+ self._nlp = nlp
18
+
19
+ @property
20
+ def nlp(self) -> Any:
21
+ """Lazily load the spaCy pipeline."""
22
+ if self._nlp is None:
23
+ try:
24
+ self._nlp = spacy.load("en_core_web_sm")
25
+ except OSError as e:
26
+ raise ImportError(
27
+ "spaCy model 'en_core_web_sm' is not installed. "
28
+ "Please install it by running:\n"
29
+ " python -m spacy download en_core_web_sm"
30
+ ) from e
31
+ return self._nlp
32
+
33
+ def _has_subject_and_verb(self, tokens: List[Any]) -> bool:
34
+ """Check if a list of tokens contains at least one subject and one verb."""
35
+ has_subj = any(t.dep_ in {"nsubj", "nsubjpass", "csubj", "csubjpass", "expl"} for t in tokens)
36
+ has_verb = any(t.pos_ in {"VERB", "AUX"} for t in tokens)
37
+ return has_subj and has_verb
38
+
39
+ def _format_question(self, tokens: List[Any], original_ends_with_q: bool) -> str:
40
+ """Format a list of tokens into a clean, capitalized question sentence."""
41
+ start_idx = 0
42
+ while start_idx < len(tokens) and (
43
+ tokens[start_idx].pos_ in {"PUNCT", "CCONJ"} or
44
+ tokens[start_idx].text.lower() in {"and", "also", "but", "or", "plus", "as", "well"}
45
+ ):
46
+ start_idx += 1
47
+
48
+ end_idx = len(tokens)
49
+ while end_idx > start_idx and tokens[end_idx - 1].pos_ in {"PUNCT"}:
50
+ end_idx -= 1
51
+
52
+ clause_tokens = tokens[start_idx:end_idx]
53
+ if not clause_tokens:
54
+ return ""
55
+
56
+ # Construct the text respecting original whitespace
57
+ text = "".join(t.text + t.whitespace_ for t in clause_tokens).strip()
58
+ if not text:
59
+ return ""
60
+
61
+ # Capitalize the first letter
62
+ text = text[0].upper() + text[1:]
63
+
64
+ # Ensure correct sentence punctuation
65
+ if original_ends_with_q and not text[-1] in {".", "?", "!"}:
66
+ text += "?"
67
+ elif not text[-1] in {".", "?", "!"}:
68
+ text += "?"
69
+
70
+ return text
71
+
72
+ def _split_span(self, span: Any) -> List[List[Any]]:
73
+ """Recursively split a span of tokens using dependency relations and punctuation."""
74
+ tokens = list(span)
75
+
76
+ # 1. Split on semicolons
77
+ for i, token in enumerate(tokens):
78
+ if token.text == ";":
79
+ left = tokens[:i]
80
+ right = tokens[i+1:]
81
+ if self._has_subject_and_verb(left) and self._has_subject_and_verb(right):
82
+ return self._split_span(left) + self._split_span(right)
83
+
84
+ # 2. Split on coordinating conjunctions ("and", "also", "but", "or")
85
+ for i, token in enumerate(tokens):
86
+ if token.dep_ == "conj" and token.pos_ in {"VERB", "AUX"}:
87
+ conj_head = token.head
88
+ if conj_head in tokens:
89
+ # Find a coordinating conjunction associated with this conjunct
90
+ cc_tokens = [
91
+ t for t in tokens
92
+ if t.dep_ == "cc" and t.text.lower() in {"and", "also", "but", "or"}
93
+ and (t.head == token or t.head == conj_head)
94
+ ]
95
+ if cc_tokens:
96
+ cc_tok = cc_tokens[0]
97
+ conj_subtree = list(token.subtree)
98
+ right = [t for t in conj_subtree if t in tokens]
99
+ left = [t for t in tokens if t not in right]
100
+
101
+ # Remove the coordinating conjunction token from both parts
102
+ left = [t for t in left if t != cc_tok]
103
+ right = [t for t in right if t != cc_tok]
104
+
105
+ if self._has_subject_and_verb(left) and self._has_subject_and_verb(right):
106
+ return self._split_span(left) + self._split_span(right)
107
+
108
+ # 3. Split on commas separating multiple Wh-questions
109
+ for i, token in enumerate(tokens):
110
+ if token.text == ",":
111
+ left = tokens[:i]
112
+ right = tokens[i+1:]
113
+ if self._has_subject_and_verb(left) and self._has_subject_and_verb(right):
114
+ # Verify if both parts represent separate Wh-questions
115
+ wh_words = {"what", "who", "whom", "whose", "which", "where", "when", "why", "how"}
116
+ has_wh_left = any(t.tag_.startswith("W") or t.text.lower() in wh_words for t in left)
117
+ has_wh_right = any(t.tag_.startswith("W") or t.text.lower() in wh_words for t in right[:3])
118
+
119
+ if has_wh_left and has_wh_right:
120
+ return self._split_span(left) + self._split_span(right)
121
+
122
+ # 4. Split on "as well as" separating clauses
123
+ for i in range(len(tokens) - 2):
124
+ t1, t2, t3 = tokens[i], tokens[i+1], tokens[i+2]
125
+ if t1.text.lower() == "as" and t2.text.lower() == "well" and t3.text.lower() == "as":
126
+ left = tokens[:i]
127
+ right = tokens[i+3:]
128
+ if self._has_subject_and_verb(left) and self._has_subject_and_verb(right):
129
+ return self._split_span(left) + self._split_span(right)
130
+
131
+ return [tokens]
132
+
133
+ def decompose(self, query: str) -> List[str]:
134
+ """
135
+ Split a compound or multi-part query into 2-5 atomic sub-questions.
136
+
137
+ Args:
138
+ query: The input compound question string.
139
+
140
+ Returns:
141
+ A list of standalone sub-question strings. If no split is found,
142
+ returns the original query in a single-item list.
143
+ """
144
+ query = query.strip()
145
+ if not query:
146
+ return []
147
+
148
+ doc = self.nlp(query)
149
+ original_ends_with_q = query.endswith("?")
150
+
151
+ sub_questions = []
152
+ for sent in doc.sents:
153
+ fragments = self._split_span(sent)
154
+ for frag in fragments:
155
+ formatted = self._format_question(frag, original_ends_with_q)
156
+ if formatted:
157
+ sub_questions.append(formatted)
158
+
159
+ # Validate that we actually found a split and didn't exceed limits
160
+ if len(sub_questions) <= 1 or len(sub_questions) > 5:
161
+ return [query]
162
+
163
+ return sub_questions
164
+
165
+ def decompose_and_retrieve(self, query: str, retriever_fn: Callable[[str], List[Any]]) -> Dict[str, List[Any]]:
166
+ """
167
+ Decompose a query into sub-questions and retrieve document chunks for each.
168
+
169
+ Args:
170
+ query: The input compound question string.
171
+ retriever_fn: A callable function taking a query string and returning a list of retrieved chunks.
172
+
173
+ Returns:
174
+ A dictionary mapping each sub-question to its list of retrieved chunks.
175
+ """
176
+ sub_questions = self.decompose(query)
177
+ results = {}
178
+ for sub_q in sub_questions:
179
+ results[sub_q] = retriever_fn(sub_q)
180
+ return results
@@ -0,0 +1,3 @@
1
+ from .confidence import ConfidenceScorer
2
+
3
+ __all__ = ["ConfidenceScorer"]
@@ -0,0 +1,241 @@
1
+ import numpy as np
2
+ from typing import List, Dict, Any, Optional
3
+
4
+ def _softmax(x: np.ndarray) -> np.ndarray:
5
+ """Compute softmax values for each sets of scores in x in a numerically stable way."""
6
+ e_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
7
+ return e_x / e_x.sum(axis=-1, keepdims=True)
8
+
9
+ class ConfidenceScorer:
10
+ """
11
+ A scorer that evaluates the confidence of generated RAG answers against retrieved chunks
12
+ using a local NLI (Natural Language Inference) CrossEncoder model.
13
+ """
14
+ def __init__(self, model_name: str = "cross-encoder/nli-MiniLM2-L6-H768", threshold: float = 0.5) -> None:
15
+ """
16
+ Initialize the ConfidenceScorer.
17
+
18
+ Args:
19
+ model_name: The local/HuggingFace model name of the CrossEncoder NLI model.
20
+ threshold: Entailment probability threshold below which a claim is unsupported.
21
+ """
22
+ self.model_name = model_name
23
+ self.threshold = threshold
24
+ self._model = None
25
+ self._nlp = None
26
+
27
+ @property
28
+ def model(self) -> Any:
29
+ """Lazily load the sentence-transformers CrossEncoder model."""
30
+ if self._model is None:
31
+ from sentence_transformers import CrossEncoder
32
+ self._model = CrossEncoder(self.model_name)
33
+ return self._model
34
+
35
+ @property
36
+ def nlp(self) -> Any:
37
+ """Lazily load the spaCy pipeline."""
38
+ if self._nlp is None:
39
+ import spacy
40
+ try:
41
+ self._nlp = spacy.load("en_core_web_sm")
42
+ except OSError as e:
43
+ raise ImportError(
44
+ "spaCy model 'en_core_web_sm' is not installed. "
45
+ "Please install it by running:\n"
46
+ " python -m spacy download en_core_web_sm"
47
+ ) from e
48
+ return self._nlp
49
+
50
+ def score(self, answer: str, retrieved_chunks: List[str]) -> Dict[str, Any]:
51
+ """
52
+ Score a single answer against a list of retrieved chunks.
53
+
54
+ Args:
55
+ answer: The generated answer string.
56
+ retrieved_chunks: A list of context chunks retrieved for the query.
57
+
58
+ Returns:
59
+ A dictionary containing the score, verdict, unsupported claims, and reasoning.
60
+ """
61
+ answer = answer.strip()
62
+ if not answer:
63
+ return {
64
+ "score": 0,
65
+ "verdict": "hallucinated",
66
+ "unsupported_claims": [],
67
+ "reasoning": "Answer is empty."
68
+ }
69
+
70
+ # Segment answer into sentences
71
+ doc = self.nlp(answer)
72
+ sentences = [sent.text.strip() for sent in doc.sents if sent.text.strip()]
73
+ if not sentences:
74
+ return {
75
+ "score": 0,
76
+ "verdict": "hallucinated",
77
+ "unsupported_claims": [],
78
+ "reasoning": "No valid sentences found in answer."
79
+ }
80
+
81
+ if not retrieved_chunks:
82
+ return {
83
+ "score": 0,
84
+ "verdict": "hallucinated",
85
+ "unsupported_claims": sentences,
86
+ "reasoning": "No retrieved chunks provided."
87
+ }
88
+
89
+ # Prepare all premise-hypothesis pairs (chunk is premise, sentence is hypothesis)
90
+ pairs = [(chunk, sentence) for sentence in sentences for chunk in retrieved_chunks]
91
+
92
+ # Predict NLI probabilities
93
+ logits = self.model.predict(pairs)
94
+ probs = _softmax(logits)
95
+
96
+ # Entailment probability is at index 1 for cross-encoder/nli-deberta-v3-base
97
+ entailment_probs = probs[:, 1].reshape(len(sentences), len(retrieved_chunks))
98
+
99
+ # Max entailment score per sentence across chunks
100
+ max_entailment_scores = np.max(entailment_probs, axis=1)
101
+
102
+ # Evaluate support
103
+ unsupported_claims = []
104
+ supported_count = 0
105
+ for sentence, score in zip(sentences, max_entailment_scores):
106
+ if score < self.threshold:
107
+ unsupported_claims.append(sentence)
108
+ else:
109
+ supported_count += 1
110
+
111
+ # Aggregate to 0-100 score
112
+ aggregated_score = int(round(float(np.mean(max_entailment_scores)) * 100))
113
+
114
+ # Compute verdict
115
+ if supported_count == len(sentences):
116
+ verdict = "grounded"
117
+ elif supported_count == 0:
118
+ verdict = "hallucinated"
119
+ else:
120
+ verdict = "partially_grounded"
121
+
122
+ # Build explanation reasoning
123
+ reasoning = f"{supported_count} of {len(sentences)} sentences had entailment above threshold {self.threshold:.2f}."
124
+ if unsupported_claims:
125
+ unsupported_str = "; ".join(f"'{claim}'" for claim in unsupported_claims)
126
+ reasoning += f" Unsupported sentences: {unsupported_str}."
127
+
128
+ return {
129
+ "score": aggregated_score,
130
+ "verdict": verdict,
131
+ "unsupported_claims": unsupported_claims,
132
+ "reasoning": reasoning
133
+ }
134
+
135
+ def batch_score(self, answers: List[str], chunks_list: List[List[str]]) -> List[Dict[str, Any]]:
136
+ """
137
+ Score a batch of answers against their corresponding list of retrieved chunks
138
+ using a single model prediction pass for maximum efficiency.
139
+
140
+ Args:
141
+ answers: A list of generated answer strings.
142
+ chunks_list: A list of lists of retrieved chunks for each answer.
143
+
144
+ Returns:
145
+ A list of dictionary results, each containing score, verdict, unsupported claims, and reasoning.
146
+ """
147
+ all_pairs = []
148
+ qa_sentence_lists = []
149
+ pair_ranges = []
150
+
151
+ for ans, chunks in zip(answers, chunks_list):
152
+ ans = ans.strip()
153
+ if not ans:
154
+ qa_sentence_lists.append([])
155
+ pair_ranges.append((0, 0))
156
+ continue
157
+
158
+ doc = self.nlp(ans)
159
+ sentences = [sent.text.strip() for sent in doc.sents if sent.text.strip()]
160
+ qa_sentence_lists.append(sentences)
161
+
162
+ if not sentences or not chunks:
163
+ pair_ranges.append((0, 0))
164
+ continue
165
+
166
+ start_idx = len(all_pairs)
167
+ for sent in sentences:
168
+ for chunk in chunks:
169
+ all_pairs.append((chunk, sent))
170
+ end_idx = len(all_pairs)
171
+ pair_ranges.append((start_idx, end_idx))
172
+
173
+ # Predict all pairs in a single forward pass
174
+ if all_pairs:
175
+ logits = self.model.predict(all_pairs)
176
+ probs = _softmax(logits)
177
+ entailment_probs = probs[:, 1]
178
+ else:
179
+ entailment_probs = np.array([])
180
+
181
+ results = []
182
+ for i, (ans, chunks) in enumerate(zip(answers, chunks_list)):
183
+ sentences = qa_sentence_lists[i]
184
+ if not ans or not sentences:
185
+ results.append({
186
+ "score": 0,
187
+ "verdict": "hallucinated",
188
+ "unsupported_claims": [],
189
+ "reasoning": "Answer is empty or contains no valid sentences."
190
+ })
191
+ continue
192
+
193
+ if not chunks:
194
+ results.append({
195
+ "score": 0,
196
+ "verdict": "hallucinated",
197
+ "unsupported_claims": sentences,
198
+ "reasoning": "No retrieved chunks provided."
199
+ })
200
+ continue
201
+
202
+ start_idx, end_idx = pair_ranges[i]
203
+ qa_entailment = entailment_probs[start_idx:end_idx].reshape(len(sentences), len(chunks))
204
+
205
+ # Max entailment score per sentence across chunks
206
+ max_entailment_scores = np.max(qa_entailment, axis=1)
207
+
208
+ # Evaluate support
209
+ unsupported_claims = []
210
+ supported_count = 0
211
+ for sentence, score in zip(sentences, max_entailment_scores):
212
+ if score < self.threshold:
213
+ unsupported_claims.append(sentence)
214
+ else:
215
+ supported_count += 1
216
+
217
+ # Aggregate to 0-100 score
218
+ aggregated_score = int(round(float(np.mean(max_entailment_scores)) * 100))
219
+
220
+ # Compute verdict
221
+ if supported_count == len(sentences):
222
+ verdict = "grounded"
223
+ elif supported_count == 0:
224
+ verdict = "hallucinated"
225
+ else:
226
+ verdict = "partially_grounded"
227
+
228
+ # Build explanation reasoning
229
+ reasoning = f"{supported_count} of {len(sentences)} sentences had entailment above threshold {self.threshold:.2f}."
230
+ if unsupported_claims:
231
+ unsupported_str = "; ".join(f"'{claim}'" for claim in unsupported_claims)
232
+ reasoning += f" Unsupported sentences: {unsupported_str}."
233
+
234
+ results.append({
235
+ "score": aggregated_score,
236
+ "verdict": verdict,
237
+ "unsupported_claims": unsupported_claims,
238
+ "reasoning": reasoning
239
+ })
240
+
241
+ return results
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: ragkitframe
3
+ Version: 0.1.0
4
+ Summary: A modular, fully local reliability layer for RAG pipelines.
5
+ Author: Sindhuja Ramaraj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Sindhu06trs/ragkit
8
+ Project-URL: Repository, https://github.com/Sindhu06trs/ragkit
9
+ Keywords: rag,retrieval,llm,nlp,hallucination,local,offline
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: spacy>=3.0.0
17
+ Requires-Dist: sentence-transformers>=2.2.0
18
+ Requires-Dist: numpy
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
21
+ Requires-Dist: tabulate>=0.9.0; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # ragkitframe
25
+
26
+ A modular, pip-installable reliability layer for Retrieval-Augmented Generation (RAG) pipelines.
27
+
28
+ ## Why ragkit?
29
+ - **100% Fully Local**: Requires zero external API calls, zero hosted LLM inference, and runs completely offline.
30
+ - **Privacy-First**: No data leaves your machine; uses local NLP rules and lightweight, open-source sentence-transformers NLI models.
31
+ - **Framework-Agnostic**: Plugs directly into LangChain, LlamaIndex, or any custom pythonic RAG pipeline.
32
+ - **Import-Only-What-You-Need**: Extremely fast import times with lazy loading of all underlying model dependencies on first invocation.
33
+
34
+ ---
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ # Install the library in editable/dev mode or directly
40
+ pip install ragkitframe
41
+
42
+ # Download the required local English spaCy pipeline
43
+ python -m spacy download en_core_web_sm
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Quickstart Examples
49
+
50
+ ### 1. Query Decomposition
51
+ Split compound queries into atomic questions before retrieval.
52
+ ```python
53
+ from ragkit import QueryDecomposer
54
+
55
+ decomposer = QueryDecomposer()
56
+ sub_questions = decomposer.decompose("What is climate change and how does it affect oceans?")
57
+ print(sub_questions)
58
+ # Output: ['What is climate change?', 'How does it affect oceans?']
59
+ ```
60
+
61
+ ### 2. Confidence Scoring (Hallucination Detection)
62
+ Verify if the generated answer is supported by the retrieved document chunks.
63
+ ```python
64
+ from ragkit import ConfidenceScorer
65
+
66
+ scorer = ConfidenceScorer() # Uses tiny 100MB model by default
67
+ chunks = ["Photosynthesis uses sunlight to convert water and CO2 into oxygen and glucose."]
68
+ answer = "Plants convert carbon dioxide and water into glucose using sunlight. They also produce helium."
69
+
70
+ result = scorer.score(answer, chunks)
71
+ print(f"Score: {result['score']}/100 | Verdict: {result['verdict']}")
72
+ # Output: Score: 50/100 | Verdict: partially_grounded
73
+ print("Reasoning:", result["reasoning"])
74
+
75
+ # For better accuracy, use the larger model:
76
+ # scorer = ConfidenceScorer(model_name="cross-encoder/nli-deberta-v3-base")
77
+ ```
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ ragkit/__init__.py
6
+ ragkit/decompose/__init__.py
7
+ ragkit/decompose/decomposer.py
8
+ ragkit/score/__init__.py
9
+ ragkit/score/confidence.py
10
+ ragkitframe.egg-info/PKG-INFO
11
+ ragkitframe.egg-info/SOURCES.txt
12
+ ragkitframe.egg-info/dependency_links.txt
13
+ ragkitframe.egg-info/requires.txt
14
+ ragkitframe.egg-info/top_level.txt
15
+ tests/test_decompose.py
16
+ tests/test_score.py
@@ -0,0 +1,7 @@
1
+ spacy>=3.0.0
2
+ sentence-transformers>=2.2.0
3
+ numpy
4
+
5
+ [dev]
6
+ pytest>=7.0.0
7
+ tabulate>=0.9.0
@@ -0,0 +1 @@
1
+ ragkit
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from setuptools import setup
2
+
3
+ setup()
@@ -0,0 +1,56 @@
1
+ import pytest
2
+ from unittest.mock import patch
3
+ from ragkit import QueryDecomposer
4
+
5
+ def test_query_decomposer_simple():
6
+ decomposer = QueryDecomposer()
7
+ query = "What is the capital of France?"
8
+ res = decomposer.decompose(query)
9
+ assert res == [query]
10
+
11
+ def test_query_decomposer_compound():
12
+ decomposer = QueryDecomposer()
13
+ query = "What is climate change and how does it affect oceans?"
14
+ res = decomposer.decompose(query)
15
+ assert len(res) == 2
16
+ assert "What is climate change?" in res
17
+ assert "How does it affect oceans?" in res
18
+
19
+ def test_query_decomposer_multiple_wh():
20
+ decomposer = QueryDecomposer()
21
+ query = "What is the history of AI, and who invented it?"
22
+ res = decomposer.decompose(query)
23
+ assert len(res) == 2
24
+ assert "What is the history of AI?" in res
25
+ assert "Who invented it?" in res
26
+
27
+ def test_query_decomposer_semicolon():
28
+ decomposer = QueryDecomposer()
29
+ query = "Where is Mount Everest; how high is it?"
30
+ res = decomposer.decompose(query)
31
+ assert len(res) == 2
32
+ assert "Where is Mount Everest?" in res
33
+ assert "How high is it?" in res
34
+
35
+ def test_decompose_and_retrieve():
36
+ decomposer = QueryDecomposer()
37
+ query = "What is climate change and how does it affect oceans?"
38
+
39
+ called_queries = []
40
+ def mock_retriever(q):
41
+ called_queries.append(q)
42
+ return [f"chunk for {q}"]
43
+
44
+ retrieved = decomposer.decompose_and_retrieve(query, mock_retriever)
45
+ assert len(called_queries) == 2
46
+ assert "What is climate change?" in retrieved
47
+ assert "How does it affect oceans?" in retrieved
48
+ assert retrieved["What is climate change?"] == ["chunk for What is climate change?"]
49
+
50
+ def test_spacy_not_installed_error():
51
+ with patch("spacy.load") as mock_load:
52
+ mock_load.side_effect = OSError("Model not found")
53
+ decomposer = QueryDecomposer()
54
+ with pytest.raises(ImportError) as exc_info:
55
+ _ = decomposer.nlp
56
+ assert "spaCy model 'en_core_web_sm' is not installed" in str(exc_info.value)
@@ -0,0 +1,61 @@
1
+ import pytest
2
+ from ragkit import ConfidenceScorer
3
+
4
+ def test_confidence_scorer_grounded():
5
+ scorer = ConfidenceScorer()
6
+ chunks = ["The capital of France is Paris. It is known for Eiffel Tower."]
7
+ answer = "Paris is the capital of France. It features the Eiffel Tower."
8
+
9
+ res = scorer.score(answer, chunks)
10
+ assert res["score"] > 80
11
+ assert res["verdict"] == "grounded"
12
+ assert len(res["unsupported_claims"]) == 0
13
+ assert "2 of 2 sentences had entailment above threshold" in res["reasoning"]
14
+
15
+ def test_confidence_scorer_hallucinated():
16
+ scorer = ConfidenceScorer()
17
+ chunks = ["The capital of France is Paris."]
18
+ answer = "The capital of Italy is Rome."
19
+
20
+ res = scorer.score(answer, chunks)
21
+ assert res["score"] < 30
22
+ assert res["verdict"] == "hallucinated"
23
+ assert len(res["unsupported_claims"]) == 1
24
+ assert "0 of 1 sentences had entailment above threshold" in res["reasoning"]
25
+
26
+ def test_confidence_scorer_partial():
27
+ scorer = ConfidenceScorer()
28
+ chunks = ["The capital of France is Paris."]
29
+ answer = "Paris is France's capital. Berlin is Germany's capital."
30
+
31
+ res = scorer.score(answer, chunks)
32
+ assert res["verdict"] == "partially_grounded"
33
+ assert len(res["unsupported_claims"]) == 1
34
+ assert "Berlin is Germany's capital." in res["unsupported_claims"]
35
+ assert "1 of 2 sentences had entailment above threshold" in res["reasoning"]
36
+
37
+ def test_confidence_scorer_batch():
38
+ scorer = ConfidenceScorer()
39
+ chunks1 = ["The capital of France is Paris."]
40
+ answer1 = "Paris is the capital of France."
41
+
42
+ chunks2 = ["The capital of Germany is Berlin."]
43
+ answer2 = "The capital of Italy is Rome."
44
+
45
+ batch_res = scorer.batch_score([answer1, answer2], [chunks1, chunks2])
46
+ assert len(batch_res) == 2
47
+ assert batch_res[0]["verdict"] == "grounded"
48
+ assert batch_res[1]["verdict"] == "hallucinated"
49
+
50
+ def test_confidence_scorer_empty():
51
+ scorer = ConfidenceScorer()
52
+ res = scorer.score("", ["some chunk"])
53
+ assert res["score"] == 0
54
+ assert res["verdict"] == "hallucinated"
55
+ assert res["reasoning"] == "Answer is empty."
56
+
57
+ res = scorer.score("Some answer.", [])
58
+ assert res["score"] == 0
59
+ assert res["verdict"] == "hallucinated"
60
+ assert res["unsupported_claims"] == ["Some answer."]
61
+ assert res["reasoning"] == "No retrieved chunks provided."