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.
- ragkitframe-0.1.0/LICENSE +21 -0
- ragkitframe-0.1.0/PKG-INFO +77 -0
- ragkitframe-0.1.0/README.md +54 -0
- ragkitframe-0.1.0/pyproject.toml +36 -0
- ragkitframe-0.1.0/ragkit/__init__.py +4 -0
- ragkitframe-0.1.0/ragkit/decompose/__init__.py +3 -0
- ragkitframe-0.1.0/ragkit/decompose/decomposer.py +180 -0
- ragkitframe-0.1.0/ragkit/score/__init__.py +3 -0
- ragkitframe-0.1.0/ragkit/score/confidence.py +241 -0
- ragkitframe-0.1.0/ragkitframe.egg-info/PKG-INFO +77 -0
- ragkitframe-0.1.0/ragkitframe.egg-info/SOURCES.txt +16 -0
- ragkitframe-0.1.0/ragkitframe.egg-info/dependency_links.txt +1 -0
- ragkitframe-0.1.0/ragkitframe.egg-info/requires.txt +7 -0
- ragkitframe-0.1.0/ragkitframe.egg-info/top_level.txt +1 -0
- ragkitframe-0.1.0/setup.cfg +4 -0
- ragkitframe-0.1.0/setup.py +3 -0
- ragkitframe-0.1.0/tests/test_decompose.py +56 -0
- ragkitframe-0.1.0/tests/test_score.py +61 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ragkit
|
|
@@ -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."
|