chunkguard 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.
chunkguard/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """PIC document chunking for RAG: text in, chunks out, with any LLM and embedding model."""
2
+
3
+ from chunkguard.pic import SUMMARY_PROMPT, AsyncPICChunker, Chunk, PICChunker
4
+ from chunkguard.sentences import split_sentences
5
+
6
+ __all__ = [
7
+ "SUMMARY_PROMPT",
8
+ "AsyncPICChunker",
9
+ "Chunk",
10
+ "PICChunker",
11
+ "async_chunker_from_env",
12
+ "chunker_from_env",
13
+ "split_sentences",
14
+ ]
15
+
16
+
17
+ def __getattr__(name: str):
18
+ # The .env helpers need the optional litellm extra; import them only on use.
19
+ if name in ("chunker_from_env", "async_chunker_from_env"):
20
+ from chunkguard import backend
21
+
22
+ return getattr(backend, name)
23
+ raise AttributeError(f"module 'chunkguard' has no attribute {name!r}")
chunkguard/backend.py ADDED
@@ -0,0 +1,155 @@
1
+ """Any LLM and embedding provider via LiteLLM, configured from .env.
2
+
3
+ Needs the extra: pip install "chunkguard[litellm]". Models are LiteLLM strings
4
+ such as "openai/gpt-4.1-mini", "anthropic/claude-sonnet-5-5", "gemini/gemini-2.5-flash",
5
+ "azure/<deployment>", "bedrock/...", "ollama/llama3.1" or "mistral/mistral-embed".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ try:
13
+ import litellm
14
+ from pydantic_settings import BaseSettings, SettingsConfigDict
15
+ except ImportError as error:
16
+ raise ImportError(
17
+ 'chunker_from_env needs the litellm extra: pip install "chunkguard[litellm]"'
18
+ ) from error
19
+
20
+ from chunkguard.pic import AsyncComplete, AsyncEmbed, AsyncPICChunker, Complete, Embed, PICChunker
21
+
22
+ # Most providers accept far more, but Cohere caps a request at 96 inputs.
23
+ DEFAULT_EMBED_BATCH_SIZE = 64
24
+ # Keeps a request well below the common 300k-token limit, even for dense scripts.
25
+ EMBED_BATCH_CHARS = 400_000
26
+ # Only the embedding input is cut; the chunk text always keeps the whole sentence.
27
+ EMBED_INPUT_MAX_CHARS = 8_000
28
+
29
+
30
+ class Settings(BaseSettings):
31
+ model_config = SettingsConfigDict(
32
+ env_prefix="CHUNKGUARD_", env_file=".env", env_ignore_empty=True, extra="ignore"
33
+ )
34
+
35
+ # Unset keys fall back to the provider's own variables (OPENAI_API_KEY, AWS_*, ...).
36
+ llm_model: str
37
+ llm_api_key: str | None = None
38
+ llm_api_base: str | None = None
39
+ llm_api_version: str | None = None
40
+
41
+ embedding_model: str
42
+ embedding_api_key: str | None = None
43
+ embedding_api_base: str | None = None
44
+ embedding_api_version: str | None = None
45
+ embedding_batch_size: int = DEFAULT_EMBED_BATCH_SIZE
46
+
47
+ # Per request. LiteLLM's own defaults are 600 s and no retries.
48
+ request_timeout: float = 120
49
+ max_retries: int = 2
50
+
51
+ max_chunk_chars: int = 2000 # 0 = no limit
52
+ min_chunk_chars: int = 200 # 0 = no merging
53
+ summary_max_chars: int = 100_000 # 0 = send the whole document
54
+
55
+
56
+ def _connection(
57
+ api_key: str | None, api_base: str | None, api_version: str | None, s: Settings
58
+ ) -> dict:
59
+ options = {"api_key": api_key, "api_base": api_base, "api_version": api_version}
60
+ return {key: value for key, value in options.items() if value is not None} | {
61
+ "timeout": s.request_timeout,
62
+ "num_retries": s.max_retries,
63
+ }
64
+
65
+
66
+ def _batches(texts: list[str], size: int) -> list[list[str]]:
67
+ batches: list[list[str]] = []
68
+ chars = 0
69
+ for text in (t[:EMBED_INPUT_MAX_CHARS] for t in texts):
70
+ if not batches or len(batches[-1]) == size or chars + len(text) > EMBED_BATCH_CHARS:
71
+ batches.append([])
72
+ chars = 0
73
+ batches[-1].append(text)
74
+ chars += len(text)
75
+ return batches
76
+
77
+
78
+ def _vectors(response: Any) -> list[list[float]]:
79
+ items = sorted(response.data, key=lambda item: item["index"])
80
+ return [item["embedding"] for item in items]
81
+
82
+
83
+ def _messages(prompt: str) -> list[dict[str, str]]:
84
+ return [{"role": "user", "content": prompt}]
85
+
86
+
87
+ def litellm_complete(model: str, **connection: Any) -> Complete:
88
+ def complete(prompt: str) -> str:
89
+ response = litellm.completion(model=model, messages=_messages(prompt), **connection)
90
+ return response.choices[0].message.content or ""
91
+
92
+ return complete
93
+
94
+
95
+ def litellm_embed(
96
+ model: str, batch_size: int = DEFAULT_EMBED_BATCH_SIZE, **connection: Any
97
+ ) -> Embed:
98
+ def embed(texts: list[str]) -> list[list[float]]:
99
+ vectors: list[list[float]] = []
100
+ for batch in _batches(texts, batch_size):
101
+ vectors.extend(_vectors(litellm.embedding(model=model, input=batch, **connection)))
102
+ return vectors
103
+
104
+ return embed
105
+
106
+
107
+ def async_litellm_complete(model: str, **connection: Any) -> AsyncComplete:
108
+ async def complete(prompt: str) -> str:
109
+ response = await litellm.acompletion(model=model, messages=_messages(prompt), **connection)
110
+ return response.choices[0].message.content or ""
111
+
112
+ return complete
113
+
114
+
115
+ def async_litellm_embed(
116
+ model: str, batch_size: int = DEFAULT_EMBED_BATCH_SIZE, **connection: Any
117
+ ) -> AsyncEmbed:
118
+ async def embed(texts: list[str]) -> list[list[float]]:
119
+ vectors: list[list[float]] = []
120
+ for batch in _batches(texts, batch_size):
121
+ response = await litellm.aembedding(model=model, input=batch, **connection)
122
+ vectors.extend(_vectors(response))
123
+ return vectors
124
+
125
+ return embed
126
+
127
+
128
+ def _parts(env_file: str) -> tuple[Settings, dict, dict, dict]:
129
+ s = Settings(_env_file=env_file)
130
+ llm = _connection(s.llm_api_key, s.llm_api_base, s.llm_api_version, s)
131
+ embedder = _connection(s.embedding_api_key, s.embedding_api_base, s.embedding_api_version, s)
132
+ options = {
133
+ "max_chars": s.max_chunk_chars or None,
134
+ "min_chars": s.min_chunk_chars,
135
+ "summary_max_chars": s.summary_max_chars or None,
136
+ }
137
+ return s, llm, embedder, options
138
+
139
+
140
+ def chunker_from_env(env_file: str = ".env") -> PICChunker:
141
+ s, llm, embedder, options = _parts(env_file)
142
+ return PICChunker(
143
+ litellm_complete(s.llm_model, **llm),
144
+ litellm_embed(s.embedding_model, s.embedding_batch_size, **embedder),
145
+ **options,
146
+ )
147
+
148
+
149
+ def async_chunker_from_env(env_file: str = ".env") -> AsyncPICChunker:
150
+ s, llm, embedder, options = _parts(env_file)
151
+ return AsyncPICChunker(
152
+ async_litellm_complete(s.llm_model, **llm),
153
+ async_litellm_embed(s.embedding_model, s.embedding_batch_size, **embedder),
154
+ **options,
155
+ )
chunkguard/pic.py ADDED
@@ -0,0 +1,261 @@
1
+ """PIC: Pseudo-Instruction for document Chunking (Wang et al., Findings of ACL 2025).
2
+
3
+ An LLM summary of the document stands in for the unknown user queries. Every
4
+ sentence is compared with that summary; the mean similarity is the threshold.
5
+ Runs of neighbouring sentences on the same side of the threshold form a chunk,
6
+ so boundaries sit exactly where the text turns towards or away from the topic.
7
+
8
+ Splitting long and merging short chunks is not part of the paper; both can be
9
+ switched off (max_chars=None, min_chars=0).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from collections.abc import Awaitable, Callable
16
+ from statistics import fmean
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from chunkguard.sentences import split_sentences
21
+
22
+ Span = tuple[int, int]
23
+ # (text, language) -> sentence spans, see chunkguard.sentences
24
+ SentenceSplitter = Callable[[str, str], list[Span]]
25
+ # Any LLM: prompt -> completion text
26
+ Complete = Callable[[str], str]
27
+ AsyncComplete = Callable[[str], Awaitable[str]]
28
+ # Any embedding model: texts -> one vector per text, same order
29
+ Embed = Callable[[list[str]], list[list[float]]]
30
+ AsyncEmbed = Callable[[list[str]], Awaitable[list[list[float]]]]
31
+
32
+ # Summarizer prompt from the PIC paper (Appendix C), plus the output language.
33
+ SUMMARY_PROMPT = (
34
+ "Read this passage:\n{text}\n"
35
+ "Write a summary for this passage. Your summary should be concise and informative. "
36
+ "Give the summary directly, without any other text. "
37
+ "Write it in the language of the passage (language code: {language})."
38
+ )
39
+
40
+ # A sentence range [first, end) within the document.
41
+ Group = tuple[int, int]
42
+
43
+ # Pieces of a long document the summarizer sees, spread evenly over the text.
44
+ _EXCERPT_PIECES = 8
45
+
46
+
47
+ class Chunk(BaseModel):
48
+ doc_id: str
49
+ index: int
50
+ text: str
51
+ start: int # character offset in the input text, inclusive
52
+ end: int # character offset in the input text, exclusive
53
+ sentence_start: int # inclusive
54
+ sentence_end: int # exclusive
55
+ # Sentences at or above the document mean make up at least half the text.
56
+ # Before merging every chunk is uniform; a merge may absorb a short opposite run.
57
+ relevant: bool
58
+ similarity: float # mean cosine similarity of the chunk's sentences to the summary
59
+ summary: str
60
+
61
+
62
+ class _Chunker:
63
+ def __init__(
64
+ self,
65
+ *,
66
+ max_chars: int | None = 2000,
67
+ min_chars: int = 200,
68
+ summary_max_chars: int | None = 100_000,
69
+ summary_prompt: str = SUMMARY_PROMPT,
70
+ sentence_splitter: SentenceSplitter = split_sentences,
71
+ ) -> None:
72
+ if max_chars is not None and max_chars < 1:
73
+ raise ValueError("max_chars must be positive or None")
74
+ if min_chars < 0 or (max_chars is not None and min_chars > max_chars):
75
+ raise ValueError("min_chars must be between 0 and max_chars")
76
+ if summary_max_chars is not None and summary_max_chars < _EXCERPT_PIECES:
77
+ raise ValueError(f"summary_max_chars must be at least {_EXCERPT_PIECES} or None")
78
+ self.max_chars = max_chars
79
+ self.min_chars = min_chars
80
+ self.summary_max_chars = summary_max_chars
81
+ self.summary_prompt = summary_prompt
82
+ self.sentence_splitter = sentence_splitter
83
+
84
+ def _prompt(self, text: str, language: str) -> str:
85
+ excerpt = _excerpt(text, self.summary_max_chars)
86
+ return self.summary_prompt.format(text=excerpt, language=language)
87
+
88
+ def _chunks(
89
+ self,
90
+ doc_id: str,
91
+ text: str,
92
+ spans: list[Span],
93
+ summary: str,
94
+ vectors: list[list[float]],
95
+ ) -> list[Chunk]:
96
+ if not summary:
97
+ raise ValueError(f"LLM returned an empty summary for {doc_id!r}")
98
+ if len(vectors) != len(spans) + 1:
99
+ raise ValueError(f"expected {len(spans) + 1} embeddings, got {len(vectors)}")
100
+
101
+ reference, *sentence_vectors = vectors
102
+ similarities = [_cosine(reference, vector) for vector in sentence_vectors]
103
+ threshold = fmean(similarities)
104
+ max_chars = math.inf if self.max_chars is None else self.max_chars
105
+
106
+ groups = _runs([s >= threshold for s in similarities])
107
+ groups = [p for g in groups for p in _split_long(g, spans, max_chars)]
108
+ groups = _merge_short(groups, spans, similarities, self.min_chars, max_chars)
109
+
110
+ chunks = []
111
+ for index, (first, end) in enumerate(groups):
112
+ start, stop = spans[first][0], spans[end - 1][1]
113
+ sizes = [e - s for s, e in spans[first:end]]
114
+ relevant_chars = sum(
115
+ n for n, sim in zip(sizes, similarities[first:end], strict=True) if sim >= threshold
116
+ )
117
+ chunks.append(
118
+ Chunk(
119
+ doc_id=doc_id,
120
+ index=index,
121
+ text=text[start:stop],
122
+ start=start,
123
+ end=stop,
124
+ sentence_start=first,
125
+ sentence_end=end,
126
+ relevant=2 * relevant_chars >= sum(sizes),
127
+ similarity=fmean(similarities[first:end]),
128
+ summary=summary,
129
+ )
130
+ )
131
+ return chunks
132
+
133
+
134
+ class PICChunker(_Chunker):
135
+ """Text in, chunks out. Swappable with any splitter of the same signature.
136
+
137
+ Costs one LLM call and one embed call (for all sentences) per document.
138
+ """
139
+
140
+ def __init__(self, complete: Complete, embed: Embed, **options) -> None:
141
+ super().__init__(**options)
142
+ self.complete = complete
143
+ self.embed = embed
144
+
145
+ def split(self, doc_id: str, text: str, language: str) -> list[Chunk]:
146
+ spans = self.sentence_splitter(text, language)
147
+ if not spans:
148
+ return []
149
+ summary = self.complete(self._prompt(text, language)).strip()
150
+ vectors = self.embed([summary, *(text[s:e] for s, e in spans)])
151
+ return self._chunks(doc_id, text, spans, summary, vectors)
152
+
153
+
154
+ class AsyncPICChunker(_Chunker):
155
+ """PICChunker with async complete/embed for asyncio indexing pipelines."""
156
+
157
+ def __init__(self, complete: AsyncComplete, embed: AsyncEmbed, **options) -> None:
158
+ super().__init__(**options)
159
+ self.complete = complete
160
+ self.embed = embed
161
+
162
+ async def split(self, doc_id: str, text: str, language: str) -> list[Chunk]:
163
+ spans = self.sentence_splitter(text, language)
164
+ if not spans:
165
+ return []
166
+ summary = (await self.complete(self._prompt(text, language))).strip()
167
+ vectors = await self.embed([summary, *(text[s:e] for s, e in spans)])
168
+ return self._chunks(doc_id, text, spans, summary, vectors)
169
+
170
+
171
+ def _excerpt(text: str, limit: int | None) -> str:
172
+ """Shorten text to about limit characters, taking evenly spread pieces.
173
+
174
+ Keeps the summary within the LLM's context window while every part of the
175
+ document still contributes to it.
176
+ """
177
+ if limit is None or len(text) <= limit:
178
+ return text
179
+ size = limit // _EXCERPT_PIECES
180
+ step = (len(text) - size) / (_EXCERPT_PIECES - 1)
181
+ return "\n[…]\n".join(
182
+ text[round(i * step) : round(i * step) + size] for i in range(_EXCERPT_PIECES)
183
+ )
184
+
185
+
186
+ def _cosine(a: list[float], b: list[float]) -> float:
187
+ norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
188
+ return sum(x * y for x, y in zip(a, b, strict=True)) / norm if norm else 0.0
189
+
190
+
191
+ def _runs(flags: list[bool]) -> list[Group]:
192
+ """Split the sentence sequence wherever the flag flips."""
193
+ groups, first = [], 0
194
+ for i in range(1, len(flags)):
195
+ if flags[i] != flags[i - 1]:
196
+ groups.append((first, i))
197
+ first = i
198
+ groups.append((first, len(flags)))
199
+ return groups
200
+
201
+
202
+ def _length(spans: list[Span], group: Group) -> int:
203
+ first, end = group
204
+ return spans[end - 1][1] - spans[first][0]
205
+
206
+
207
+ def _split_long(group: Group, spans: list[Span], max_chars: float) -> list[Group]:
208
+ """Split at sentence boundaries into near-equal pieces of at most max_chars.
209
+
210
+ A single sentence longer than max_chars stays whole.
211
+ """
212
+ total = _length(spans, group)
213
+ if total <= max_chars:
214
+ return [group]
215
+ target = total / math.ceil(total / max_chars)
216
+ first, end = group
217
+ pieces, start = [], first
218
+ for i in range(first + 1, end):
219
+ without, with_next = _length(spans, (start, i)), _length(spans, (start, i + 1))
220
+ if with_next > max_chars or with_next - target > target - without:
221
+ pieces.append((start, i))
222
+ start = i
223
+ pieces.append((start, end))
224
+ return pieces
225
+
226
+
227
+ def _merge_short(
228
+ groups: list[Group],
229
+ spans: list[Span],
230
+ similarities: list[float],
231
+ min_chars: int,
232
+ max_chars: float,
233
+ ) -> list[Group]:
234
+ """Merge chunks below min_chars into the neighbour with the closest mean similarity.
235
+
236
+ max_chars wins over min_chars: a merge that would exceed it is skipped.
237
+ """
238
+ groups = list(groups)
239
+
240
+ def score(group: Group) -> float:
241
+ return fmean(similarities[group[0] : group[1]])
242
+
243
+ while True:
244
+ for k in sorted(range(len(groups)), key=lambda k: _length(spans, groups[k])):
245
+ if _length(spans, groups[k]) >= min_chars:
246
+ return groups
247
+ # Merging with neighbour k-1 or k+1 means merging the pair starting at lo.
248
+ pairs = [
249
+ lo
250
+ for lo in (k - 1, k)
251
+ if 0 <= lo < len(groups) - 1
252
+ and _length(spans, (groups[lo][0], groups[lo + 1][1])) <= max_chars
253
+ ]
254
+ if pairs:
255
+ lo = min(
256
+ pairs, key=lambda lo: abs(score(groups[2 * lo + 1 - k]) - score(groups[k]))
257
+ )
258
+ groups[lo : lo + 2] = [(groups[lo][0], groups[lo + 1][1])]
259
+ break
260
+ else:
261
+ return groups
chunkguard/py.typed ADDED
File without changes
@@ -0,0 +1,61 @@
1
+ """Sentence splitting that returns character spans, so no text is lost.
2
+
3
+ Boundaries come from sentencex (Wikimedia), which carries abbreviation and
4
+ punctuation rules for many languages and falls back to related languages for
5
+ the rest. On top of that, every line break ends a sentence, so headings and
6
+ list items stand alone.
7
+ """
8
+
9
+ import re
10
+
11
+ import sentencex
12
+
13
+ _SHORT_NUMBER = re.compile(r"(?:^|(?<=\s))\d{1,3}\.$")
14
+ # Characters looked at around a boundary; slicing the whole text per boundary is quadratic.
15
+ _WINDOW = 16
16
+
17
+
18
+ def split_sentences(text: str, language: str) -> list[tuple[int, int]]:
19
+ """Return the (start, end) character spans of the sentences in ``text``.
20
+
21
+ ``language`` is a BCP 47 code such as "en" or "pt-BR"; only the primary
22
+ subtag is used. Spans exclude surrounding whitespace, so ``text[start:end]``
23
+ is the sentence, and whitespace-only pieces are dropped.
24
+ """
25
+ primary = language.split("-")[0].lower()
26
+ cuts = {i + 1 for i, char in enumerate(text) if char == "\n"}
27
+ for boundary in sentencex.get_sentence_boundaries(primary, text):
28
+ end = boundary["end_index"]
29
+ if not _continues_lowercase(text, end) and not _ends_with_short_number(text, end):
30
+ cuts.add(end)
31
+
32
+ spans: list[tuple[int, int]] = []
33
+ start = 0
34
+ for cut in [*sorted(cuts), len(text)]:
35
+ piece = text[start:cut]
36
+ left = len(piece) - len(piece.lstrip())
37
+ right = len(piece.rstrip())
38
+ if right > left:
39
+ spans.append((start + left, start + right))
40
+ start = cut
41
+ return spans
42
+
43
+
44
+ def _continues_lowercase(text: str, position: int) -> bool:
45
+ """True if the same line goes on with a lowercase letter, i.e. after an abbreviation.
46
+
47
+ sentencex misses this for some abbreviations and for non-ASCII letters such
48
+ as "ü". Merging two sentences costs less than cutting one in half.
49
+ """
50
+ rest = text[position : position + _WINDOW].lstrip(" \t")
51
+ return bool(rest) and rest[0].islower()
52
+
53
+
54
+ def _ends_with_short_number(text: str, position: int) -> bool:
55
+ """True if the piece ends in a number of up to three digits plus a period.
56
+
57
+ That is an ordinal in German, Hungarian, Danish, Czech and others ("im 18.
58
+ Jahrhundert"); sentencex only knows the case before month names. The cost
59
+ is a merged sentence in the rare English "We sold 3. Then ..." case.
60
+ """
61
+ return _SHORT_NUMBER.search(text[max(0, position - _WINDOW) : position].rstrip()) is not None
@@ -0,0 +1,254 @@
1
+ Metadata-Version: 2.5
2
+ Name: chunkguard
3
+ Version: 0.1.0
4
+ Summary: PIC document chunking for RAG with any LLM and embedding model
5
+ Project-URL: Homepage, https://github.com/zakariaefakhri/chunkguard
6
+ Project-URL: Issues, https://github.com/zakariaefakhri/chunkguard/issues
7
+ Project-URL: Paper, https://aclanthology.org/2025.findings-acl.422/
8
+ Author: Zakariae Fakhri
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: chunking,embeddings,llm,rag,retrieval,text-splitter
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Text Processing :: Linguistic
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: pydantic>=2.0
24
+ Requires-Dist: sentencex>=1.0.30
25
+ Provides-Extra: litellm
26
+ Requires-Dist: litellm>=1.83.0; extra == 'litellm'
27
+ Requires-Dist: pydantic-settings>=2.2; extra == 'litellm'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # chunkguard
31
+
32
+ [![CI](https://github.com/zakariaefakhri/chunkguard/actions/workflows/ci.yml/badge.svg)](https://github.com/zakariaefakhri/chunkguard/actions/workflows/ci.yml)
33
+
34
+ Document chunking for RAG with **PIC** (Pseudo-Instruction for document Chunking), with any
35
+ LLM and any embedding model. From
36
+ [*Document Segmentation Matters for Retrieval-Augmented Generation*](https://aclanthology.org/2025.findings-acl.422/)
37
+ (Wang et al., Findings of ACL 2025). This is an independent implementation, not the authors' code.
38
+
39
+ When you build a knowledge base, you don't know the questions users will ask. PIC stands in
40
+ for them with an LLM summary of the document. Every sentence is embedded and compared with
41
+ the summary; the threshold is the mean similarity. Neighbouring sentences at or above it
42
+ form a chunk of core content, neighbouring sentences below it a chunk of side details. So
43
+ chunk boundaries sit exactly where the text turns towards or away from its topic, and every
44
+ sentence is judged against the whole document rather than just its neighbour, as in
45
+ semantic chunking.
46
+
47
+ Order is kept, no sentence is cut, nothing is dropped. Cost per document: one LLM call and
48
+ one embedding pass over its sentences. No training.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pip install chunkguard # core: bring your own model functions (Pydantic + sentencex)
54
+ pip install "chunkguard[litellm]" # plus .env configuration for 100+ providers via LiteLLM
55
+ ```
56
+
57
+ ## Choose your models
58
+
59
+ chunkguard needs two things, and you pick both: an LLM that writes one summary per document,
60
+ and an embedding model. They can come from different providers.
61
+
62
+ **Option 1: plain functions.** `PICChunker` takes any `complete(prompt) -> str` and any
63
+ `embed(texts) -> list[vector]`. No provider SDK is involved:
64
+
65
+ ```python
66
+ from chunkguard import PICChunker
67
+ from sentence_transformers import SentenceTransformer # local embeddings, as an example
68
+ import anthropic # any LLM, as an example
69
+
70
+ claude = anthropic.Anthropic()
71
+ encoder = SentenceTransformer("BAAI/bge-m3")
72
+
73
+
74
+ def complete(prompt: str) -> str:
75
+ message = claude.messages.create(
76
+ model="claude-sonnet-5-5", max_tokens=512, messages=[{"role": "user", "content": prompt}]
77
+ )
78
+ return message.content[0].text
79
+
80
+
81
+ chunker = PICChunker(complete=complete, embed=lambda texts: encoder.encode(texts).tolist())
82
+ chunks = chunker.split(doc_id="report-7", text=cleaned_text, language="en")
83
+ ```
84
+
85
+ **Option 2: `.env` with LiteLLM.** Install the extra, copy [`.env.example`](.env.example) to
86
+ `.env` and set the two models as [LiteLLM strings](https://docs.litellm.ai/docs/providers):
87
+
88
+ ```bash
89
+ CHUNKGUARD_LLM_MODEL=anthropic/claude-sonnet-5-5
90
+ CHUNKGUARD_EMBEDDING_MODEL=cohere/embed-multilingual-v3.0
91
+ ```
92
+
93
+ Put keys in `.env` as `CHUNKGUARD_LLM_API_KEY` / `CHUNKGUARD_EMBEDDING_API_KEY`. If those are
94
+ unset, LiteLLM uses the provider's usual variables (`ANTHROPIC_API_KEY`, `COHERE_API_KEY`,
95
+ `AWS_*`, ...) from the process environment. Azure, Bedrock, Vertex, Ollama, vLLM and any
96
+ OpenAI-compatible server work the same way. Then:
97
+
98
+ ```python
99
+ from chunkguard import chunker_from_env
100
+
101
+ chunker = chunker_from_env()
102
+ chunks = chunker.split(doc_id="report-7", text=cleaned_text, language="en")
103
+
104
+ for chunk in chunks:
105
+ print(chunk.index, chunk.relevant, round(chunk.similarity, 3), chunk.text[:60])
106
+ ```
107
+
108
+ `split` takes plain text that extraction has already cleaned (line breaks inside sentences
109
+ joined). Each `Chunk` carries:
110
+
111
+ | Field | Meaning |
112
+ |---|---|
113
+ | `doc_id`, `index` | Document and position of the chunk in it |
114
+ | `text` | Exact slice `input[start:end]` |
115
+ | `start`, `end` | Character offsets into the input (end exclusive) |
116
+ | `sentence_start`, `sentence_end` | Sentence range (end exclusive) |
117
+ | `relevant` | Core content (`True`) or side details (`False`) |
118
+ | `similarity` | Mean cosine similarity of the chunk's sentences to the summary |
119
+ | `summary` | The document summary, for use as metadata in the index |
120
+
121
+ `Chunk` is a Pydantic model; `chunk.model_dump()` gives a dict for your vector store.
122
+
123
+ ### Async
124
+
125
+ ```python
126
+ from chunkguard import async_chunker_from_env
127
+
128
+ chunker = async_chunker_from_env()
129
+ chunks = await chunker.split("report-7", cleaned_text, "en")
130
+ ```
131
+
132
+ `AsyncPICChunker` takes async `complete` and `embed` functions in the same way.
133
+
134
+ ### Your own summary prompt
135
+
136
+ The default is the paper's prompt (`chunkguard.SUMMARY_PROMPT`) plus an instruction to answer
137
+ in the document's language. Pass `summary_prompt` with `{text}` and `{language}` placeholders
138
+ to change it.
139
+
140
+ ### Your own sentence splitter
141
+
142
+ Sentences come from [sentencex](https://github.com/wikimedia/sentencex) (see [Limits](#limits)).
143
+ To use another splitter, pass a function
144
+ that returns `(start, end)` character spans. For example, NLTK's Punkt, which the paper uses
145
+ for sentence splitting:
146
+
147
+ ```python
148
+ from nltk.tokenize.punkt import PunktSentenceTokenizer
149
+
150
+ punkt = PunktSentenceTokenizer()
151
+
152
+
153
+ def nltk_sentences(text, language):
154
+ return list(punkt.span_tokenize(text))
155
+
156
+
157
+ chunker = PICChunker(complete, embed, sentence_splitter=nltk_sentences)
158
+ ```
159
+
160
+ ## How it works
161
+
162
+ 1. **Sentences.** Split into sentences with sentencex, keeping character offsets. Every line
163
+ break also ends a sentence, so headings and list items stand alone.
164
+ 2. **Summary.** One LLM call with the summarizer prompt from the paper (Appendix C), plus an
165
+ instruction to answer in the document's language.
166
+ 3. **Similarity.** Embed the summary and every sentence; cosine similarity of each sentence
167
+ to the summary.
168
+ 4. **Threshold.** The mean of those similarities. A sentence is relevant at or above it.
169
+ 5. **Grouping.** Each run of neighbouring sentences with the same flag becomes one chunk.
170
+
171
+ Steps 1 to 5 are the method as published. chunkguard adds three things for production use;
172
+ set `max_chars=None, min_chars=0, summary_max_chars=None` to switch them off:
173
+
174
+ 6. **Long chunks** (over `max_chars`) are split at sentence boundaries into near-equal pieces.
175
+ 7. **Short chunks** (under `min_chars`) merge into the neighbour with the closest mean
176
+ similarity, unless that would exceed `max_chars`. A merged chunk is `relevant` when its
177
+ relevant sentences make up at least half of its text.
178
+ 8. **Long documents** reach the summarizer as eight evenly spread excerpts totalling
179
+ `summary_max_chars`, so the call fits the context window and every part of the document
180
+ still shapes the summary.
181
+
182
+ ## Configuration
183
+
184
+ With plain functions, pass the options to `PICChunker` (`max_chars`, `min_chars`,
185
+ `summary_max_chars`, `summary_prompt`, `sentence_splitter`). With the LiteLLM extra, they come
186
+ from environment variables or `.env`, prefixed `CHUNKGUARD_`:
187
+
188
+ | Variable | Default | Meaning |
189
+ |---|---|---|
190
+ | `LLM_MODEL` | required | LiteLLM model string, e.g. `gemini/gemini-2.5-flash` |
191
+ | `LLM_API_KEY` | provider variable | API key |
192
+ | `LLM_API_BASE` | – | Endpoint, e.g. Azure resource, Ollama or any OpenAI-compatible server |
193
+ | `LLM_API_VERSION` | – | Azure only |
194
+ | `EMBEDDING_MODEL`, `EMBEDDING_API_*` | | Same settings for the embedding model |
195
+ | `EMBEDDING_BATCH_SIZE` | `64` | Texts per embedding request |
196
+ | `REQUEST_TIMEOUT` | `120` | Seconds per request |
197
+ | `MAX_RETRIES` | `2` | Retries per request on provider errors |
198
+ | `MAX_CHUNK_CHARS` | `2000` | Split longer chunks; `0` = no limit |
199
+ | `MIN_CHUNK_CHARS` | `200` | Merge shorter chunks; `0` = no merging |
200
+ | `SUMMARY_MAX_CHARS` | `100000` | Excerpt longer documents for the summary; `0` = send all |
201
+
202
+ Embedding requests stay below 400,000 characters each. A sentence longer than 8,000
203
+ characters is truncated for embedding only; its chunk keeps the full text.
204
+
205
+ LiteLLM versions 1.82.7 and 1.82.8 were compromised on PyPI in March 2026; the extra requires
206
+ 1.83.0 or later.
207
+
208
+ ## Limits
209
+
210
+ - **Sentence splitting is rule-based.** sentencex carries punctuation and abbreviation rules
211
+ for many languages and falls back to related ones. chunkguard adds two language-neutral
212
+ rules: no cut before a lowercase letter, and no cut after a number of up to three digits
213
+ ("im 18. Jahrhundert"), which merges the rare English "We sold 3. Then ..." into one
214
+ sentence. Some boundaries are still missed (a sentence ending in "etc."), and languages
215
+ without sentence punctuation (Thai) are not split. Pass your own splitter if that matters.
216
+ - **Sizes are in characters, not tokens.** Pick `MAX_CHUNK_CHARS` with your embedding model's
217
+ token limit in mind.
218
+ - **The relevance flag is relative to the document.** Nearly every document has relevant and
219
+ irrelevant chunks, however on-topic it is overall. Use it to weight or filter retrieval,
220
+ not as an absolute quality score.
221
+ - **Retrieval gains are the paper's results** on English Wikipedia and open-domain QA. This
222
+ repository does not reproduce them.
223
+
224
+ ## Development
225
+
226
+ ```bash
227
+ uv sync
228
+ uv run pytest
229
+ uv run ruff check . && uv run ruff format --check .
230
+ ```
231
+
232
+ Releases: bump `version` in `pyproject.toml`, then push a matching tag (`v0.1.0`). The
233
+ release workflow publishes to PyPI via trusted publishing.
234
+
235
+ ## Citation
236
+
237
+ If you use PIC in research, cite the paper:
238
+
239
+ ```bibtex
240
+ @inproceedings{wang-etal-2025-document,
241
+ title = {Document Segmentation Matters for Retrieval-Augmented Generation},
242
+ author = {Wang, Zhitong and Gao, Cheng and Xiao, Chaojun and Huang, Yufei and Si, Shuzheng and
243
+ Luo, Kangyang and Bai, Yuzhuo and Li, Wenhao and Duan, Tangjian and Lv, Chuancheng and
244
+ Lu, Guoshan and Chen, Gang and Qi, Fanchao and Sun, Maosong},
245
+ booktitle = {Findings of the Association for Computational Linguistics: ACL 2025},
246
+ pages = {8063--8075},
247
+ year = {2025},
248
+ url = {https://aclanthology.org/2025.findings-acl.422/}
249
+ }
250
+ ```
251
+
252
+ ## License
253
+
254
+ [MIT](LICENSE)
@@ -0,0 +1,9 @@
1
+ chunkguard/__init__.py,sha256=bh7xWGcRwkmf9mW0R7_3F-Ko0c9wPz4Kfpc_JQcOTcs,715
2
+ chunkguard/backend.py,sha256=begTsJSEz8wf_SisXe2LRS2ey7R2tzUCE1z4V7rlEyE,5471
3
+ chunkguard/pic.py,sha256=iFgG_D2pXcoKtWLEMFjaFSAHV85a4woWCLLha9P4gAQ,9796
4
+ chunkguard/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ chunkguard/sentences.py,sha256=sDzjevqo3JhhGIq-micaxoGc1m03Vs0fubCjgUdtPok,2460
6
+ chunkguard-0.1.0.dist-info/METADATA,sha256=eNCS2pEidmxDA9qCTpNLDWjZ8N4-37Qdxf49RstuMPk,10899
7
+ chunkguard-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ chunkguard-0.1.0.dist-info/licenses/LICENSE,sha256=N8zGq3bv7D1IYT9PAbCzVgjR1VVpzmChWVkV3QDojb4,1072
9
+ chunkguard-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zakariae Fakhri
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.