chopper-rag 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.
- chopper_rag/__init__.py +11 -0
- chopper_rag/core.py +221 -0
- chopper_rag/haystack.py +47 -0
- chopper_rag/langchain.py +44 -0
- chopper_rag/llama_index.py +57 -0
- chopper_rag-0.1.0.dist-info/METADATA +141 -0
- chopper_rag-0.1.0.dist-info/RECORD +10 -0
- chopper_rag-0.1.0.dist-info/WHEEL +5 -0
- chopper_rag-0.1.0.dist-info/licenses/LICENSE +21 -0
- chopper_rag-0.1.0.dist-info/top_level.txt +1 -0
chopper_rag/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""chopper-rag: the chopper context compressor (exact quotes cut out of retrieved snippets) for RAG pipelines.
|
|
2
|
+
|
|
3
|
+
Framework adapters live in submodules so that importing this package needs none of the frameworks:
|
|
4
|
+
chopper_rag.langchain.ChopperCompressor (langchain-core)
|
|
5
|
+
chopper_rag.llama_index.ChopperPostprocessor (llama-index-core)
|
|
6
|
+
chopper_rag.haystack.ChopperDocumentCompressor (haystack-ai)
|
|
7
|
+
"""
|
|
8
|
+
from .core import Chop, ChopResult, Chopper
|
|
9
|
+
|
|
10
|
+
__all__ = ["Chop", "ChopResult", "Chopper"]
|
|
11
|
+
__version__ = "0.1.0"
|
chopper_rag/core.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""The chopper: a fine-tuned mmBERT encoder that cuts exact quotes ("chops") out of retrieved passages.
|
|
2
|
+
|
|
3
|
+
Input to the model, one string of at most 8,192 tokens:
|
|
4
|
+
|
|
5
|
+
"Question: <question> [SEP] <passage 1> [SEP] ... [SEP] <passage N>"
|
|
6
|
+
|
|
7
|
+
Per token the KEEP head gives p(KEEP); the decision head on the first token gives SEARCH (0) / ANSWER (1).
|
|
8
|
+
Decoding (the one the published results were measured with): keep passage tokens with p > pred_thr, merge kept runs
|
|
9
|
+
separated by <= merge_gap tokens, drop runs shorter than min_run tokens, bridge the single character between two kept
|
|
10
|
+
tokens, then map each run back to character offsets of the ORIGINAL passage strings.
|
|
11
|
+
|
|
12
|
+
Passages that do not fit into one 8,192-token input are processed in several forward passes: passages are packed
|
|
13
|
+
greedily, in order, into groups that fit next to the question; a single passage longer than the whole budget is cut
|
|
14
|
+
into windows at token boundaries. Each group is chopped exactly like a single input; chop offsets always refer to the
|
|
15
|
+
original passage. The model was trained on inputs of up to ~20 passages of ~1,000 characters, so splitting changes
|
|
16
|
+
nothing for typical retriever outputs (16-20 passages fit in one pass).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import torch
|
|
26
|
+
|
|
27
|
+
DEFAULT_REPO = "wiiiktor/chopper_mmbert"
|
|
28
|
+
VARIANTS = {"base": "mmbert-base", "small": "mmbert-small"}
|
|
29
|
+
MAX_LEN = 8192
|
|
30
|
+
_SAFETY = 32 # tokens kept free when packing groups (tokenisation at segment borders may differ slightly)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Chop:
|
|
35
|
+
"""One exact quote: ``passages[passage_index][start:end] == text``."""
|
|
36
|
+
passage_index: int
|
|
37
|
+
start: int
|
|
38
|
+
end: int
|
|
39
|
+
text: str
|
|
40
|
+
score: float | None = None # mean p(KEEP) over the run's tokens
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ChopResult:
|
|
45
|
+
chops: list[Chop]
|
|
46
|
+
decision: str # "ANSWER" (the passages hold the answer) or "SEARCH" (search again)
|
|
47
|
+
p_answer: float # p(ANSWER); with several forward passes, the maximum over them
|
|
48
|
+
n_passes: int = 1 # forward passes used (> 1 when the input did not fit 8,192 tokens)
|
|
49
|
+
texts: list[str] = field(default_factory=list) # chop texts, in order
|
|
50
|
+
|
|
51
|
+
def __iter__(self):
|
|
52
|
+
return iter(self.chops)
|
|
53
|
+
|
|
54
|
+
def __len__(self):
|
|
55
|
+
return len(self.chops)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------ decoding (identical to the reference operator)
|
|
59
|
+
def _runs(m):
|
|
60
|
+
d = np.diff(np.concatenate([[0], m.astype(np.int8), [0]]))
|
|
61
|
+
return list(zip(np.where(d == 1)[0], np.where(d == -1)[0]))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def shape(q, gap=5, mn=4):
|
|
65
|
+
"""merge kept runs <= gap tokens apart, drop runs shorter than mn"""
|
|
66
|
+
q = q.copy()
|
|
67
|
+
rr = _runs(q)
|
|
68
|
+
for (a0, b0), (a1, b1) in zip(rr, rr[1:]):
|
|
69
|
+
if a1 - b0 <= gap:
|
|
70
|
+
q[b0:a1] = True
|
|
71
|
+
for a0, b0 in _runs(q):
|
|
72
|
+
if b0 - a0 < mn:
|
|
73
|
+
q[a0:b0] = False
|
|
74
|
+
return q
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def kept_text(s, offsets, q):
|
|
78
|
+
"""kept tokens -> contiguous character runs of the string s"""
|
|
79
|
+
cm = np.zeros(len(s) + 1, dtype=bool)
|
|
80
|
+
for (a, b), k in zip(offsets, q):
|
|
81
|
+
if k:
|
|
82
|
+
cm[a:b] = True
|
|
83
|
+
cm[1:-1] |= cm[:-2] & cm[2:] # bridge the space between two kept tokens
|
|
84
|
+
return [(int(a), int(b)) for a, b in _runs(cm)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fetch(repo, folder):
|
|
88
|
+
"""a local copy (repo is a directory holding mmbert-base/ ...) or a Hugging Face download"""
|
|
89
|
+
if os.path.isdir(os.path.join(repo, folder)):
|
|
90
|
+
return os.path.join(repo, folder)
|
|
91
|
+
from huggingface_hub import snapshot_download
|
|
92
|
+
return os.path.join(snapshot_download(repo, allow_patterns=[f"{folder}/*"]), folder)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Chopper:
|
|
96
|
+
"""Load with :meth:`from_pretrained`; call :meth:`chop` (list of :class:`Chop`) or :meth:`run` (+ decision)."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, tokenizer, encoder, heads, device, threshold=0.5, merge_gap=5, min_run=4, max_length=MAX_LEN):
|
|
99
|
+
self.tokenizer, self.encoder, self.device = tokenizer, encoder, device
|
|
100
|
+
self.heads = {k: v.to(device).float() for k, v in heads.items()}
|
|
101
|
+
self.threshold, self.merge_gap, self.min_run, self.max_length = threshold, merge_gap, min_run, max_length
|
|
102
|
+
self.sep = f" {tokenizer.sep_token} "
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_pretrained(cls, repo: str = DEFAULT_REPO, variant: str = "base", device: str = "auto",
|
|
106
|
+
threshold: float | None = None, dtype: torch.dtype | None = None) -> "Chopper":
|
|
107
|
+
"""repo: HF repo id or a local directory containing ``mmbert-base/`` / ``mmbert-small/``.
|
|
108
|
+
variant: "base" (0.31B, recommended) or "small" (0.14B). threshold: p(KEEP) cut-off (default from
|
|
109
|
+
chopper_config.json). dtype: default bfloat16 on CUDA, float32 on CPU."""
|
|
110
|
+
from safetensors.torch import load_file
|
|
111
|
+
from transformers import AutoModel, AutoTokenizer
|
|
112
|
+
if variant not in VARIANTS:
|
|
113
|
+
raise ValueError(f"variant must be one of {list(VARIANTS)}, got {variant!r}")
|
|
114
|
+
if device == "auto":
|
|
115
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
116
|
+
if dtype is None:
|
|
117
|
+
dtype = torch.bfloat16 if str(device).startswith("cuda") else torch.float32
|
|
118
|
+
d = _fetch(repo, VARIANTS[variant])
|
|
119
|
+
tok = AutoTokenizer.from_pretrained(f"{d}/encoder")
|
|
120
|
+
enc = AutoModel.from_pretrained(f"{d}/encoder", dtype=dtype).to(device).eval()
|
|
121
|
+
heads = load_file(f"{d}/heads.safetensors")
|
|
122
|
+
dec = json.load(open(f"{d}/chopper_config.json")).get("decode", {})
|
|
123
|
+
thr = dec.get("pred_thr", 0.5) if threshold is None else threshold
|
|
124
|
+
return cls(tok, enc, heads, device, threshold=thr, merge_gap=dec.get("merge_gap", 5),
|
|
125
|
+
min_run=dec.get("min_run", 4))
|
|
126
|
+
|
|
127
|
+
# -------------------------------------------------------------- public API
|
|
128
|
+
def chop(self, question: str, passages: list[str]) -> list[Chop]:
|
|
129
|
+
"""the chops (exact quotes) of ``passages`` for ``question``, in passage order"""
|
|
130
|
+
return self.run(question, passages).chops
|
|
131
|
+
|
|
132
|
+
def run(self, question: str, passages: list[str]) -> ChopResult:
|
|
133
|
+
"""chops plus the ANSWER / SEARCH decision"""
|
|
134
|
+
passages = [p if isinstance(p, str) else str(p) for p in passages]
|
|
135
|
+
head = f"Question: {question}"
|
|
136
|
+
segs = self._segments(head, passages)
|
|
137
|
+
chops, p_ans, n = [], 0.0, 0
|
|
138
|
+
for group in self._groups(head, segs):
|
|
139
|
+
c, pa = self._forward(head, group, passages)
|
|
140
|
+
chops += c; p_ans = max(p_ans, pa); n += 1
|
|
141
|
+
if n == 0: # no passages: nothing to keep, search
|
|
142
|
+
p_ans = self._forward(head, [], passages)[1]; n = 1
|
|
143
|
+
chops.sort(key=lambda c: (c.passage_index, c.start))
|
|
144
|
+
return ChopResult(chops, "ANSWER" if p_ans >= 0.5 else "SEARCH", p_ans, n, [c.text for c in chops])
|
|
145
|
+
|
|
146
|
+
__call__ = run
|
|
147
|
+
|
|
148
|
+
# -------------------------------------------------------------- internals
|
|
149
|
+
def _ntok(self, text):
|
|
150
|
+
return len(self.tokenizer(text, add_special_tokens=False)["input_ids"])
|
|
151
|
+
|
|
152
|
+
def _segments(self, head, passages):
|
|
153
|
+
"""(passage_index, char offset in the passage, text): stripped passages, too-long ones cut into windows"""
|
|
154
|
+
budget = self.max_length - self._ntok(head) - 2 - _SAFETY # [CLS]/[SEP] + one separator
|
|
155
|
+
if budget < 16:
|
|
156
|
+
raise ValueError("the question alone fills the 8,192-token input")
|
|
157
|
+
segs = []
|
|
158
|
+
for i, p in enumerate(passages):
|
|
159
|
+
s = p.strip()
|
|
160
|
+
if not s:
|
|
161
|
+
continue
|
|
162
|
+
lead = len(p) - len(p.lstrip())
|
|
163
|
+
e = self.tokenizer(s, add_special_tokens=False, return_offsets_mapping=True)
|
|
164
|
+
off = e["offset_mapping"]
|
|
165
|
+
if len(off) <= budget:
|
|
166
|
+
segs.append((i, lead, s, len(off)))
|
|
167
|
+
continue
|
|
168
|
+
for w in range(0, len(off), budget): # one very long passage: token windows
|
|
169
|
+
a, b = off[w][0], off[min(w + budget, len(off)) - 1][1]
|
|
170
|
+
t = s[a:b]
|
|
171
|
+
tl = len(t) - len(t.lstrip())
|
|
172
|
+
if t.strip():
|
|
173
|
+
segs.append((i, lead + a + tl, t.strip(), min(budget, len(off) - w)))
|
|
174
|
+
return segs
|
|
175
|
+
|
|
176
|
+
def _groups(self, head, segs):
|
|
177
|
+
"""greedy, in order: as many segments per forward pass as fit into max_length"""
|
|
178
|
+
limit = self.max_length - self._ntok(head) - 2 - _SAFETY
|
|
179
|
+
group, used = [], 0
|
|
180
|
+
for sg in segs:
|
|
181
|
+
cost = sg[3] + 2 # the passage + its " [SEP] "
|
|
182
|
+
if group and used + cost > limit:
|
|
183
|
+
yield group; group, used = [], 0
|
|
184
|
+
group.append(sg); used += cost
|
|
185
|
+
if group:
|
|
186
|
+
yield group
|
|
187
|
+
|
|
188
|
+
@torch.no_grad()
|
|
189
|
+
def _forward(self, head, group, passages):
|
|
190
|
+
s, spans = head, []
|
|
191
|
+
for (i, off, text, _) in group:
|
|
192
|
+
s += self.sep
|
|
193
|
+
spans.append((len(s), len(s) + len(text), i, off))
|
|
194
|
+
s += text
|
|
195
|
+
e = self.tokenizer(s, return_offsets_mapping=True, truncation=True, max_length=self.max_length,
|
|
196
|
+
return_tensors="pt")
|
|
197
|
+
if len(group) > 1 and e["input_ids"].shape[1] >= self.max_length: # estimate was off: split the group
|
|
198
|
+
m = len(group) // 2
|
|
199
|
+
c1, p1 = self._forward(head, group[:m], passages)
|
|
200
|
+
c2, p2 = self._forward(head, group[m:], passages)
|
|
201
|
+
return c1 + c2, max(p1, p2)
|
|
202
|
+
off = e.pop("offset_mapping")[0].tolist()
|
|
203
|
+
h = self.encoder(**{k: v.to(self.device) for k, v in e.items()}).last_hidden_state[0].float()
|
|
204
|
+
p = torch.sigmoid(h @ self.heads["keep.weight"].T + self.heads["keep.bias"])[:, 0].cpu().numpy()
|
|
205
|
+
p_ans = float(torch.softmax(h[0] @ self.heads["decision.weight"].T + self.heads["decision.bias"], -1)[1])
|
|
206
|
+
body = np.array([a >= len(head) and b > a for a, b in off])
|
|
207
|
+
q = shape((p > self.threshold) & body, self.merge_gap, self.min_run) & body
|
|
208
|
+
out = []
|
|
209
|
+
for a, b in kept_text(s, off, q):
|
|
210
|
+
for (sa, sb, i, poff) in spans: # a run may cross a separator: cut per passage
|
|
211
|
+
x, y = max(a, sa), min(b, sb)
|
|
212
|
+
if y <= x:
|
|
213
|
+
continue
|
|
214
|
+
t = s[x:y]
|
|
215
|
+
x += len(t) - len(t.lstrip()); y -= len(t) - len(t.rstrip())
|
|
216
|
+
if y - x <= 2:
|
|
217
|
+
continue
|
|
218
|
+
ts = [k for k, (u, v) in enumerate(off) if q[k] and u < y and v > x]
|
|
219
|
+
start, end = poff + (x - sa), poff + (y - sa)
|
|
220
|
+
out.append(Chop(i, start, end, passages[i][start:end], float(p[ts].mean()) if ts else None))
|
|
221
|
+
return out, p_ans
|
chopper_rag/haystack.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Haystack 2.x / 3.x: ``ChopperDocumentCompressor`` component, placed after a retriever or ranker."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from haystack import Document, component, default_from_dict, default_to_dict
|
|
7
|
+
|
|
8
|
+
from .core import DEFAULT_REPO, Chopper
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@component
|
|
12
|
+
class ChopperDocumentCompressor:
|
|
13
|
+
"""Replaces the documents by one Document per chop (the exact quote). ``meta`` = the source document's meta +
|
|
14
|
+
``chop_start``/``chop_end`` (character offsets in the source ``content``), ``source_index``, ``source_id`` and
|
|
15
|
+
``chop_score``; ``score`` = the source document's score. Outputs ``documents`` and ``decision``
|
|
16
|
+
("ANSWER" or "SEARCH": whether the documents hold the answer)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, repo: str = DEFAULT_REPO, variant: str = "base", device: str = "auto",
|
|
19
|
+
threshold: float | None = None) -> None:
|
|
20
|
+
self.repo, self.variant, self.device, self.threshold = repo, variant, device, threshold
|
|
21
|
+
self._chopper: Chopper | None = None
|
|
22
|
+
|
|
23
|
+
def warm_up(self) -> None:
|
|
24
|
+
if self._chopper is None:
|
|
25
|
+
self._chopper = Chopper.from_pretrained(self.repo, variant=self.variant, device=self.device,
|
|
26
|
+
threshold=self.threshold)
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
return default_to_dict(self, repo=self.repo, variant=self.variant, device=self.device,
|
|
30
|
+
threshold=self.threshold)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, data: dict[str, Any]) -> "ChopperDocumentCompressor":
|
|
34
|
+
return default_from_dict(cls, data)
|
|
35
|
+
|
|
36
|
+
@component.output_types(documents=list[Document], decision=str)
|
|
37
|
+
def run(self, documents: list[Document], query: str) -> dict[str, Any]:
|
|
38
|
+
self.warm_up()
|
|
39
|
+
if not documents:
|
|
40
|
+
return {"documents": [], "decision": "SEARCH"}
|
|
41
|
+
res = self._chopper.run(query, [d.content or "" for d in documents])
|
|
42
|
+
out = [Document(content=c.text, score=documents[c.passage_index].score,
|
|
43
|
+
meta={**documents[c.passage_index].meta, "chop_start": c.start, "chop_end": c.end,
|
|
44
|
+
"source_index": c.passage_index, "source_id": documents[c.passage_index].id,
|
|
45
|
+
"chop_score": c.score})
|
|
46
|
+
for c in res.chops]
|
|
47
|
+
return {"documents": out, "decision": res.decision}
|
chopper_rag/langchain.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""LangChain: ``ChopperCompressor`` (a ``BaseDocumentCompressor``), e.g. inside a ``ContextualCompressionRetriever``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from langchain_core.callbacks import Callbacks
|
|
8
|
+
from langchain_core.documents import BaseDocumentCompressor, Document
|
|
9
|
+
from pydantic import ConfigDict
|
|
10
|
+
|
|
11
|
+
from .core import DEFAULT_REPO, Chopper
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChopperCompressor(BaseDocumentCompressor):
|
|
15
|
+
"""Returns one Document per chop: ``page_content`` = the exact quote, ``metadata`` = the source document's
|
|
16
|
+
metadata + ``chop_start``/``chop_end`` (character offsets in the source ``page_content``), ``source_index``
|
|
17
|
+
(position of the source in the input list) and ``chop_score`` (mean p(KEEP)).
|
|
18
|
+
The ANSWER / SEARCH decision of the last call is in ``last_decision``."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
21
|
+
|
|
22
|
+
chopper: Any = None
|
|
23
|
+
last_decision: str | None = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_pretrained(cls, repo: str = DEFAULT_REPO, variant: str = "base", device: str = "auto",
|
|
27
|
+
threshold: float | None = None) -> "ChopperCompressor":
|
|
28
|
+
return cls(chopper=Chopper.from_pretrained(repo, variant=variant, device=device, threshold=threshold))
|
|
29
|
+
|
|
30
|
+
def model_post_init(self, __context: Any) -> None:
|
|
31
|
+
if self.chopper is None:
|
|
32
|
+
self.chopper = Chopper.from_pretrained()
|
|
33
|
+
|
|
34
|
+
def compress_documents(self, documents: Sequence[Document], query: str,
|
|
35
|
+
callbacks: Callbacks | None = None) -> Sequence[Document]:
|
|
36
|
+
documents = list(documents)
|
|
37
|
+
if not documents:
|
|
38
|
+
return []
|
|
39
|
+
res = self.chopper.run(query, [d.page_content for d in documents])
|
|
40
|
+
self.last_decision = res.decision
|
|
41
|
+
return [Document(page_content=c.text,
|
|
42
|
+
metadata={**documents[c.passage_index].metadata, "chop_start": c.start, "chop_end": c.end,
|
|
43
|
+
"source_index": c.passage_index, "chop_score": c.score})
|
|
44
|
+
for c in res.chops]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""LlamaIndex: ``ChopperPostprocessor`` (a ``BaseNodePostprocessor``), e.g. ``node_postprocessors=[...]`` of a
|
|
2
|
+
query engine."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, List, Optional
|
|
6
|
+
|
|
7
|
+
from llama_index.core.bridge.pydantic import Field, PrivateAttr
|
|
8
|
+
from llama_index.core.postprocessor.types import BaseNodePostprocessor
|
|
9
|
+
from llama_index.core.schema import MetadataMode, NodeRelationship, NodeWithScore, QueryBundle, TextNode
|
|
10
|
+
|
|
11
|
+
from .core import DEFAULT_REPO, Chopper
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChopperPostprocessor(BaseNodePostprocessor):
|
|
15
|
+
"""Replaces the retrieved nodes by one TextNode per chop (the exact quote). Metadata = the source node's
|
|
16
|
+
metadata + ``chop_start``/``chop_end`` (character offsets in the source node's text), ``source_index`` and
|
|
17
|
+
``chop_score``; the SOURCE relationship points to the source node; the score is the source node's score.
|
|
18
|
+
The ANSWER / SEARCH decision of the last call is in ``last_decision``."""
|
|
19
|
+
|
|
20
|
+
repo: str = Field(default=DEFAULT_REPO, description="HF repo id or local directory of the chopper weights")
|
|
21
|
+
variant: str = Field(default="base", description="'base' (0.31B, recommended) or 'small' (0.14B)")
|
|
22
|
+
device: str = Field(default="auto")
|
|
23
|
+
threshold: Optional[float] = Field(default=None, description="p(KEEP) cut-off; default from the model config")
|
|
24
|
+
last_decision: Optional[str] = None
|
|
25
|
+
_chopper: Any = PrivateAttr(default=None)
|
|
26
|
+
|
|
27
|
+
def __init__(self, chopper: Optional[Chopper] = None, **kwargs: Any) -> None:
|
|
28
|
+
super().__init__(**kwargs)
|
|
29
|
+
self._chopper = chopper or Chopper.from_pretrained(self.repo, variant=self.variant, device=self.device,
|
|
30
|
+
threshold=self.threshold)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def class_name(cls) -> str:
|
|
34
|
+
return "ChopperPostprocessor"
|
|
35
|
+
|
|
36
|
+
def _postprocess_nodes(self, nodes: List[NodeWithScore],
|
|
37
|
+
query_bundle: Optional[QueryBundle] = None) -> List[NodeWithScore]:
|
|
38
|
+
if query_bundle is None:
|
|
39
|
+
raise ValueError("ChopperPostprocessor needs the query (query_bundle or query_str)")
|
|
40
|
+
if not nodes:
|
|
41
|
+
return []
|
|
42
|
+
texts = [n.node.get_content(metadata_mode=MetadataMode.NONE) for n in nodes]
|
|
43
|
+
res = self._chopper.run(query_bundle.query_str, texts)
|
|
44
|
+
self.last_decision = res.decision
|
|
45
|
+
out = []
|
|
46
|
+
for c in res.chops:
|
|
47
|
+
src = nodes[c.passage_index]
|
|
48
|
+
node = TextNode(
|
|
49
|
+
text=c.text,
|
|
50
|
+
metadata={**src.node.metadata, "chop_start": c.start, "chop_end": c.end,
|
|
51
|
+
"source_index": c.passage_index, "chop_score": c.score},
|
|
52
|
+
excluded_embed_metadata_keys=list(src.node.excluded_embed_metadata_keys),
|
|
53
|
+
excluded_llm_metadata_keys=list(src.node.excluded_llm_metadata_keys),
|
|
54
|
+
relationships={NodeRelationship.SOURCE: src.node.as_related_node_info()},
|
|
55
|
+
)
|
|
56
|
+
out.append(NodeWithScore(node=node, score=src.score))
|
|
57
|
+
return out
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chopper-rag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The chopper context compressor for RAG: exact quotes cut out of retrieved snippets, for LangChain, LlamaIndex and Haystack
|
|
5
|
+
Author: wiiiktor
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Model, https://huggingface.co/wiiiktor/chopper_mmbert
|
|
8
|
+
Project-URL: Source, https://github.com/wiiiktor/chopper_demo
|
|
9
|
+
Keywords: rag,context-compression,langchain,llama-index,haystack,mmbert
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: torch>=2.1
|
|
16
|
+
Requires-Dist: transformers>=4.48
|
|
17
|
+
Requires-Dist: safetensors
|
|
18
|
+
Requires-Dist: huggingface_hub
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Provides-Extra: langchain
|
|
21
|
+
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
22
|
+
Provides-Extra: llama-index
|
|
23
|
+
Requires-Dist: llama-index-core>=0.12; extra == "llama-index"
|
|
24
|
+
Provides-Extra: haystack
|
|
25
|
+
Requires-Dist: haystack-ai>=2.0; extra == "haystack"
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: langchain-core>=0.3; extra == "all"
|
|
28
|
+
Requires-Dist: llama-index-core>=0.12; extra == "all"
|
|
29
|
+
Requires-Dist: haystack-ai>=2.0; extra == "all"
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: pytest; extra == "test"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# chopper-rag
|
|
35
|
+
|
|
36
|
+
The **chopper** as a drop-in context compressor for LangChain, LlamaIndex and Haystack.
|
|
37
|
+
|
|
38
|
+
The chopper is a fine-tuned mmBERT encoder. It reads the question and the snippets your retriever returned, and cuts out
|
|
39
|
+
**chops**: exact quotes cut out of retrieved snippets, never rewritten. A chop is typically 3-5% of the retrieved
|
|
40
|
+
text. Every chop records where it came from (`passage[start:end] == text`), and the chops work with any answering model.
|
|
41
|
+
It also gives one **ANSWER / SEARCH** decision: whether the snippets seem to contain the answer, or you should search again.
|
|
42
|
+
|
|
43
|
+
- Model: [huggingface.co/wiiiktor/chopper_mmbert](https://huggingface.co/wiiiktor/chopper_mmbert): `base` (0.31B, recommended) and `small` (0.14B)
|
|
44
|
+
- Code and demo: [github.com/wiiiktor/chopper_demo](https://github.com/wiiiktor/chopper_demo)
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install chopper-rag # core only (torch, transformers)
|
|
50
|
+
pip install "chopper-rag[langchain]" # or [llama-index], [haystack], [all]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Core
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from chopper_rag import Chopper
|
|
57
|
+
|
|
58
|
+
chopper = Chopper.from_pretrained(variant="base") # device="auto", threshold from the model config
|
|
59
|
+
result = chopper.run("When was the Eiffel Tower completed?", passages)
|
|
60
|
+
for c in result.chops: # Chop(passage_index, start, end, text, score)
|
|
61
|
+
assert passages[c.passage_index][c.start:c.end] == c.text
|
|
62
|
+
print(result.decision) # "ANSWER" or "SEARCH"
|
|
63
|
+
chops = chopper.chop(question, passages) # just the list of Chop
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## LangChain
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from langchain_classic.retrievers import ContextualCompressionRetriever # langchain<1.0: langchain.retrievers
|
|
70
|
+
from chopper_rag.langchain import ChopperCompressor
|
|
71
|
+
|
|
72
|
+
compressor = ChopperCompressor.from_pretrained(variant="base")
|
|
73
|
+
retriever = ContextualCompressionRetriever(base_compressor=compressor, base_retriever=vectorstore.as_retriever(search_kwargs={"k": 16}))
|
|
74
|
+
docs = retriever.invoke("When was the Eiffel Tower completed?")
|
|
75
|
+
for d in docs: # one Document per chop
|
|
76
|
+
print(d.page_content, d.metadata["source_index"], d.metadata["chop_start"], d.metadata["chop_end"])
|
|
77
|
+
# or directly: compressor.compress_documents(documents, query)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## LlamaIndex
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from chopper_rag.llama_index import ChopperPostprocessor
|
|
84
|
+
|
|
85
|
+
chopper = ChopperPostprocessor(variant="base")
|
|
86
|
+
query_engine = index.as_query_engine(similarity_top_k=16, node_postprocessors=[chopper])
|
|
87
|
+
response = query_engine.query("When was the Eiffel Tower completed?")
|
|
88
|
+
for n in response.source_nodes: # one TextNode per chop, SOURCE relationship -> the retrieved node
|
|
89
|
+
print(n.node.text, n.node.metadata["chop_start"], n.node.metadata["chop_end"])
|
|
90
|
+
# or directly: chopper.postprocess_nodes(nodes, query_str="...")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Haystack (2.x and later)
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from haystack import Pipeline
|
|
97
|
+
from chopper_rag.haystack import ChopperDocumentCompressor
|
|
98
|
+
|
|
99
|
+
pipe = Pipeline()
|
|
100
|
+
pipe.add_component("retriever", retriever) # any retriever, top_k=16
|
|
101
|
+
pipe.add_component("chopper", ChopperDocumentCompressor(variant="base"))
|
|
102
|
+
pipe.connect("retriever.documents", "chopper.documents")
|
|
103
|
+
out = pipe.run({"retriever": {"query": q}, "chopper": {"query": q}})
|
|
104
|
+
print([d.content for d in out["chopper"]["documents"]], out["chopper"]["decision"])
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Output metadata (all three frameworks)
|
|
108
|
+
|
|
109
|
+
One output document / node per chop, in passage order. Metadata = the source's metadata plus:
|
|
110
|
+
|
|
111
|
+
| key | meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `chop_start`, `chop_end` | character offsets of the chop in the source text |
|
|
114
|
+
| `source_index` | position of the source in the input list |
|
|
115
|
+
| `chop_score` | mean p(KEEP) over the chop's tokens |
|
|
116
|
+
| `source_id` | (Haystack only) id of the source Document |
|
|
117
|
+
|
|
118
|
+
The score of each output is the score of its source. The last ANSWER / SEARCH decision is in `last_decision`
|
|
119
|
+
(LangChain, LlamaIndex) or in the `decision` output (Haystack). A source with no chops produces no output; if nothing
|
|
120
|
+
is kept, the result is empty (usually together with `SEARCH`).
|
|
121
|
+
|
|
122
|
+
## Details
|
|
123
|
+
|
|
124
|
+
- Input: `Question: <q> [SEP] <passage 1> [SEP] ... [SEP] <passage N>`, up to 8,192 tokens. Decoding: keep tokens
|
|
125
|
+
with p(KEEP) > 0.5, merge runs at most 5 tokens apart, drop runs shorter than 4 tokens (from `chopper_config.json`;
|
|
126
|
+
`threshold=` overrides the cut-off: lower keeps more).
|
|
127
|
+
- Inputs longer than 8,192 tokens are processed in several passes: passages are packed in order into groups that fit
|
|
128
|
+
next to the question, and a single passage longer than the budget is cut into token windows. Offsets always refer
|
|
129
|
+
to the original passage. The model was trained on ~20 passages of ~1,000 characters, which fit one pass.
|
|
130
|
+
- Leading/trailing whitespace of a passage is ignored; offsets still refer to the unstripped text.
|
|
131
|
+
- Device: `"auto"` = CUDA if available (bfloat16), else CPU (float32). Pass `repo=` a local directory holding
|
|
132
|
+
`mmbert-base/` / `mmbert-small/` to run offline.
|
|
133
|
+
|
|
134
|
+
## Tests
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install -e ".[all,test]"
|
|
138
|
+
CUDA_VISIBLE_DEVICES="" pytest tests/ # uses mmbert-small; CHOPPER_VARIANT=base for the base model
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
License: MIT.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
chopper_rag/__init__.py,sha256=wThJNRAW0nFb152LTxCoJQLoQa3aLILtrgwJG3h3Vxo,544
|
|
2
|
+
chopper_rag/core.py,sha256=kl-8x-Fk9-VHzvXO9CpboxxwCaPhjW7uTnpLw5xNBN0,10702
|
|
3
|
+
chopper_rag/haystack.py,sha256=heoj4u4waTiJpYv5jvPalv0UKqotYKclWwHxcMrIHT4,2375
|
|
4
|
+
chopper_rag/langchain.py,sha256=WKsSaoo0kfyvNiclmrJDj2Dx1PODmLw9cv4QmVmM8is,2070
|
|
5
|
+
chopper_rag/llama_index.py,sha256=SowzmbpgUQ5j_BKIlBm2-rv8D3S42txO_Nx87D_2OoM,3007
|
|
6
|
+
chopper_rag-0.1.0.dist-info/licenses/LICENSE,sha256=xNNl68cH3htTFL1IWaVjDE_BAnzqep_vNkaVJImRB-A,1065
|
|
7
|
+
chopper_rag-0.1.0.dist-info/METADATA,sha256=htMDHZA07-F1GVqP8_kOkDLA7hea5cmKZfHzvFH4iz0,6387
|
|
8
|
+
chopper_rag-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
9
|
+
chopper_rag-0.1.0.dist-info/top_level.txt,sha256=gtROUkgtMmiwqkAHozguhJEm-e2WRxbsMZbnFTiTco4,12
|
|
10
|
+
chopper_rag-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wiiiktor
|
|
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 @@
|
|
|
1
|
+
chopper_rag
|