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.
@@ -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
@@ -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}
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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