chunkguard 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # Used by chunker_from_env(), which needs: pip install "chunkguard[litellm]"
2
+ # Models are LiteLLM strings "<provider>/<model>", see https://docs.litellm.ai/docs/providers
3
+ # LLM and embedding model are chosen independently, from any provider.
4
+
5
+ CHUNKGUARD_LLM_MODEL=openai/gpt-4.1-mini
6
+ CHUNKGUARD_EMBEDDING_MODEL=openai/text-embedding-3-small
7
+
8
+ # Optional. Unset values fall back to the provider's own variables
9
+ # (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, AWS_*, ...) in the process environment.
10
+ CHUNKGUARD_LLM_API_KEY=
11
+ CHUNKGUARD_LLM_API_BASE=
12
+ CHUNKGUARD_LLM_API_VERSION=
13
+ CHUNKGUARD_EMBEDDING_API_KEY=
14
+ CHUNKGUARD_EMBEDDING_API_BASE=
15
+ CHUNKGUARD_EMBEDDING_API_VERSION=
16
+
17
+ # Other setups, for example:
18
+ # CHUNKGUARD_LLM_MODEL=anthropic/claude-sonnet-5-5
19
+ # CHUNKGUARD_LLM_MODEL=gemini/gemini-2.5-flash
20
+ # CHUNKGUARD_LLM_MODEL=azure/<deployment> + API_BASE and API_VERSION
21
+ # CHUNKGUARD_LLM_MODEL=ollama/llama3.1 (local, API_BASE=http://localhost:11434)
22
+ # CHUNKGUARD_EMBEDDING_MODEL=cohere/embed-multilingual-v3.0
23
+ # CHUNKGUARD_EMBEDDING_MODEL=mistral/mistral-embed
24
+ # CHUNKGUARD_EMBEDDING_MODEL=openai/<model> + API_BASE for any OpenAI-compatible server
25
+
26
+ # Texts per embedding request; lower it if your provider rejects large batches.
27
+ CHUNKGUARD_EMBEDDING_BATCH_SIZE=64
28
+
29
+ # Per request, for LLM and embedding calls alike.
30
+ CHUNKGUARD_REQUEST_TIMEOUT=120
31
+ CHUNKGUARD_MAX_RETRIES=2
32
+
33
+ # Chunk size limits in characters; 0 switches a limit off (the paper uses none).
34
+ # Sentences are never cut, so a single sentence longer than the maximum stays one chunk.
35
+ CHUNKGUARD_MAX_CHUNK_CHARS=2000
36
+ CHUNKGUARD_MIN_CHUNK_CHARS=200
37
+
38
+ # Longer documents reach the LLM as evenly spread excerpts of this total size;
39
+ # 0 sends the whole document.
40
+ CHUNKGUARD_SUMMARY_MAX_CHARS=100000
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python: ["3.11", "3.12", "3.13"]
15
+ resolution: [highest]
16
+ include:
17
+ # Proves the lower bounds in pyproject.toml still hold.
18
+ - python: "3.11"
19
+ resolution: lowest-direct
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v6
23
+ - run: uv run --python ${{ matrix.python }} --resolution ${{ matrix.resolution }} pytest -q
24
+
25
+ lint:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: astral-sh/setup-uv@v6
30
+ - run: uv run ruff check .
31
+ - run: uv run ruff format --check .
@@ -0,0 +1,21 @@
1
+ name: Release
2
+
3
+ # Push a tag like v0.1.0 to publish that version to PyPI.
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write # PyPI trusted publishing, no token needed
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v6
17
+ - name: Check tag matches package version
18
+ run: test "${GITHUB_REF_NAME#v}" = "$(uv version --short)"
19
+ - run: uv run pytest -q
20
+ - run: uv build
21
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .env*
2
+ !.env.example
3
+ .venv/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ dist/
@@ -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.
@@ -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,225 @@
1
+ # chunkguard
2
+
3
+ [![CI](https://github.com/zakariaefakhri/chunkguard/actions/workflows/ci.yml/badge.svg)](https://github.com/zakariaefakhri/chunkguard/actions/workflows/ci.yml)
4
+
5
+ Document chunking for RAG with **PIC** (Pseudo-Instruction for document Chunking), with any
6
+ LLM and any embedding model. From
7
+ [*Document Segmentation Matters for Retrieval-Augmented Generation*](https://aclanthology.org/2025.findings-acl.422/)
8
+ (Wang et al., Findings of ACL 2025). This is an independent implementation, not the authors' code.
9
+
10
+ When you build a knowledge base, you don't know the questions users will ask. PIC stands in
11
+ for them with an LLM summary of the document. Every sentence is embedded and compared with
12
+ the summary; the threshold is the mean similarity. Neighbouring sentences at or above it
13
+ form a chunk of core content, neighbouring sentences below it a chunk of side details. So
14
+ chunk boundaries sit exactly where the text turns towards or away from its topic, and every
15
+ sentence is judged against the whole document rather than just its neighbour, as in
16
+ semantic chunking.
17
+
18
+ Order is kept, no sentence is cut, nothing is dropped. Cost per document: one LLM call and
19
+ one embedding pass over its sentences. No training.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install chunkguard # core: bring your own model functions (Pydantic + sentencex)
25
+ pip install "chunkguard[litellm]" # plus .env configuration for 100+ providers via LiteLLM
26
+ ```
27
+
28
+ ## Choose your models
29
+
30
+ chunkguard needs two things, and you pick both: an LLM that writes one summary per document,
31
+ and an embedding model. They can come from different providers.
32
+
33
+ **Option 1: plain functions.** `PICChunker` takes any `complete(prompt) -> str` and any
34
+ `embed(texts) -> list[vector]`. No provider SDK is involved:
35
+
36
+ ```python
37
+ from chunkguard import PICChunker
38
+ from sentence_transformers import SentenceTransformer # local embeddings, as an example
39
+ import anthropic # any LLM, as an example
40
+
41
+ claude = anthropic.Anthropic()
42
+ encoder = SentenceTransformer("BAAI/bge-m3")
43
+
44
+
45
+ def complete(prompt: str) -> str:
46
+ message = claude.messages.create(
47
+ model="claude-sonnet-5-5", max_tokens=512, messages=[{"role": "user", "content": prompt}]
48
+ )
49
+ return message.content[0].text
50
+
51
+
52
+ chunker = PICChunker(complete=complete, embed=lambda texts: encoder.encode(texts).tolist())
53
+ chunks = chunker.split(doc_id="report-7", text=cleaned_text, language="en")
54
+ ```
55
+
56
+ **Option 2: `.env` with LiteLLM.** Install the extra, copy [`.env.example`](.env.example) to
57
+ `.env` and set the two models as [LiteLLM strings](https://docs.litellm.ai/docs/providers):
58
+
59
+ ```bash
60
+ CHUNKGUARD_LLM_MODEL=anthropic/claude-sonnet-5-5
61
+ CHUNKGUARD_EMBEDDING_MODEL=cohere/embed-multilingual-v3.0
62
+ ```
63
+
64
+ Put keys in `.env` as `CHUNKGUARD_LLM_API_KEY` / `CHUNKGUARD_EMBEDDING_API_KEY`. If those are
65
+ unset, LiteLLM uses the provider's usual variables (`ANTHROPIC_API_KEY`, `COHERE_API_KEY`,
66
+ `AWS_*`, ...) from the process environment. Azure, Bedrock, Vertex, Ollama, vLLM and any
67
+ OpenAI-compatible server work the same way. Then:
68
+
69
+ ```python
70
+ from chunkguard import chunker_from_env
71
+
72
+ chunker = chunker_from_env()
73
+ chunks = chunker.split(doc_id="report-7", text=cleaned_text, language="en")
74
+
75
+ for chunk in chunks:
76
+ print(chunk.index, chunk.relevant, round(chunk.similarity, 3), chunk.text[:60])
77
+ ```
78
+
79
+ `split` takes plain text that extraction has already cleaned (line breaks inside sentences
80
+ joined). Each `Chunk` carries:
81
+
82
+ | Field | Meaning |
83
+ |---|---|
84
+ | `doc_id`, `index` | Document and position of the chunk in it |
85
+ | `text` | Exact slice `input[start:end]` |
86
+ | `start`, `end` | Character offsets into the input (end exclusive) |
87
+ | `sentence_start`, `sentence_end` | Sentence range (end exclusive) |
88
+ | `relevant` | Core content (`True`) or side details (`False`) |
89
+ | `similarity` | Mean cosine similarity of the chunk's sentences to the summary |
90
+ | `summary` | The document summary, for use as metadata in the index |
91
+
92
+ `Chunk` is a Pydantic model; `chunk.model_dump()` gives a dict for your vector store.
93
+
94
+ ### Async
95
+
96
+ ```python
97
+ from chunkguard import async_chunker_from_env
98
+
99
+ chunker = async_chunker_from_env()
100
+ chunks = await chunker.split("report-7", cleaned_text, "en")
101
+ ```
102
+
103
+ `AsyncPICChunker` takes async `complete` and `embed` functions in the same way.
104
+
105
+ ### Your own summary prompt
106
+
107
+ The default is the paper's prompt (`chunkguard.SUMMARY_PROMPT`) plus an instruction to answer
108
+ in the document's language. Pass `summary_prompt` with `{text}` and `{language}` placeholders
109
+ to change it.
110
+
111
+ ### Your own sentence splitter
112
+
113
+ Sentences come from [sentencex](https://github.com/wikimedia/sentencex) (see [Limits](#limits)).
114
+ To use another splitter, pass a function
115
+ that returns `(start, end)` character spans. For example, NLTK's Punkt, which the paper uses
116
+ for sentence splitting:
117
+
118
+ ```python
119
+ from nltk.tokenize.punkt import PunktSentenceTokenizer
120
+
121
+ punkt = PunktSentenceTokenizer()
122
+
123
+
124
+ def nltk_sentences(text, language):
125
+ return list(punkt.span_tokenize(text))
126
+
127
+
128
+ chunker = PICChunker(complete, embed, sentence_splitter=nltk_sentences)
129
+ ```
130
+
131
+ ## How it works
132
+
133
+ 1. **Sentences.** Split into sentences with sentencex, keeping character offsets. Every line
134
+ break also ends a sentence, so headings and list items stand alone.
135
+ 2. **Summary.** One LLM call with the summarizer prompt from the paper (Appendix C), plus an
136
+ instruction to answer in the document's language.
137
+ 3. **Similarity.** Embed the summary and every sentence; cosine similarity of each sentence
138
+ to the summary.
139
+ 4. **Threshold.** The mean of those similarities. A sentence is relevant at or above it.
140
+ 5. **Grouping.** Each run of neighbouring sentences with the same flag becomes one chunk.
141
+
142
+ Steps 1 to 5 are the method as published. chunkguard adds three things for production use;
143
+ set `max_chars=None, min_chars=0, summary_max_chars=None` to switch them off:
144
+
145
+ 6. **Long chunks** (over `max_chars`) are split at sentence boundaries into near-equal pieces.
146
+ 7. **Short chunks** (under `min_chars`) merge into the neighbour with the closest mean
147
+ similarity, unless that would exceed `max_chars`. A merged chunk is `relevant` when its
148
+ relevant sentences make up at least half of its text.
149
+ 8. **Long documents** reach the summarizer as eight evenly spread excerpts totalling
150
+ `summary_max_chars`, so the call fits the context window and every part of the document
151
+ still shapes the summary.
152
+
153
+ ## Configuration
154
+
155
+ With plain functions, pass the options to `PICChunker` (`max_chars`, `min_chars`,
156
+ `summary_max_chars`, `summary_prompt`, `sentence_splitter`). With the LiteLLM extra, they come
157
+ from environment variables or `.env`, prefixed `CHUNKGUARD_`:
158
+
159
+ | Variable | Default | Meaning |
160
+ |---|---|---|
161
+ | `LLM_MODEL` | required | LiteLLM model string, e.g. `gemini/gemini-2.5-flash` |
162
+ | `LLM_API_KEY` | provider variable | API key |
163
+ | `LLM_API_BASE` | – | Endpoint, e.g. Azure resource, Ollama or any OpenAI-compatible server |
164
+ | `LLM_API_VERSION` | – | Azure only |
165
+ | `EMBEDDING_MODEL`, `EMBEDDING_API_*` | | Same settings for the embedding model |
166
+ | `EMBEDDING_BATCH_SIZE` | `64` | Texts per embedding request |
167
+ | `REQUEST_TIMEOUT` | `120` | Seconds per request |
168
+ | `MAX_RETRIES` | `2` | Retries per request on provider errors |
169
+ | `MAX_CHUNK_CHARS` | `2000` | Split longer chunks; `0` = no limit |
170
+ | `MIN_CHUNK_CHARS` | `200` | Merge shorter chunks; `0` = no merging |
171
+ | `SUMMARY_MAX_CHARS` | `100000` | Excerpt longer documents for the summary; `0` = send all |
172
+
173
+ Embedding requests stay below 400,000 characters each. A sentence longer than 8,000
174
+ characters is truncated for embedding only; its chunk keeps the full text.
175
+
176
+ LiteLLM versions 1.82.7 and 1.82.8 were compromised on PyPI in March 2026; the extra requires
177
+ 1.83.0 or later.
178
+
179
+ ## Limits
180
+
181
+ - **Sentence splitting is rule-based.** sentencex carries punctuation and abbreviation rules
182
+ for many languages and falls back to related ones. chunkguard adds two language-neutral
183
+ rules: no cut before a lowercase letter, and no cut after a number of up to three digits
184
+ ("im 18. Jahrhundert"), which merges the rare English "We sold 3. Then ..." into one
185
+ sentence. Some boundaries are still missed (a sentence ending in "etc."), and languages
186
+ without sentence punctuation (Thai) are not split. Pass your own splitter if that matters.
187
+ - **Sizes are in characters, not tokens.** Pick `MAX_CHUNK_CHARS` with your embedding model's
188
+ token limit in mind.
189
+ - **The relevance flag is relative to the document.** Nearly every document has relevant and
190
+ irrelevant chunks, however on-topic it is overall. Use it to weight or filter retrieval,
191
+ not as an absolute quality score.
192
+ - **Retrieval gains are the paper's results** on English Wikipedia and open-domain QA. This
193
+ repository does not reproduce them.
194
+
195
+ ## Development
196
+
197
+ ```bash
198
+ uv sync
199
+ uv run pytest
200
+ uv run ruff check . && uv run ruff format --check .
201
+ ```
202
+
203
+ Releases: bump `version` in `pyproject.toml`, then push a matching tag (`v0.1.0`). The
204
+ release workflow publishes to PyPI via trusted publishing.
205
+
206
+ ## Citation
207
+
208
+ If you use PIC in research, cite the paper:
209
+
210
+ ```bibtex
211
+ @inproceedings{wang-etal-2025-document,
212
+ title = {Document Segmentation Matters for Retrieval-Augmented Generation},
213
+ author = {Wang, Zhitong and Gao, Cheng and Xiao, Chaojun and Huang, Yufei and Si, Shuzheng and
214
+ Luo, Kangyang and Bai, Yuzhuo and Li, Wenhao and Duan, Tangjian and Lv, Chuancheng and
215
+ Lu, Guoshan and Chen, Gang and Qi, Fanchao and Sun, Maosong},
216
+ booktitle = {Findings of the Association for Computational Linguistics: ACL 2025},
217
+ pages = {8063--8075},
218
+ year = {2025},
219
+ url = {https://aclanthology.org/2025.findings-acl.422/}
220
+ }
221
+ ```
222
+
223
+ ## License
224
+
225
+ [MIT](LICENSE)
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "chunkguard"
3
+ version = "0.1.0"
4
+ description = "PIC document chunking for RAG with any LLM and embedding model"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Zakariae Fakhri" }]
9
+ requires-python = ">=3.11"
10
+ keywords = ["rag", "chunking", "text-splitter", "retrieval", "llm", "embeddings"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ "Topic :: Text Processing :: Linguistic",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = ["pydantic>=2.0", "sentencex>=1.0.30"]
24
+
25
+ [project.optional-dependencies]
26
+ # .env configuration for any provider. 1.82.7/1.82.8 were compromised on PyPI.
27
+ litellm = ["litellm>=1.83.0", "pydantic-settings>=2.2"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/zakariaefakhri/chunkguard"
31
+ Issues = "https://github.com/zakariaefakhri/chunkguard/issues"
32
+ Paper = "https://aclanthology.org/2025.findings-acl.422/"
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8", "ruff>=0.6", "litellm>=1.83.0", "pydantic-settings>=2.2"]
36
+
37
+ [build-system]
38
+ requires = ["hatchling>=1.27"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py311"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "I", "UP", "B"]
47
+
48
+ [tool.pytest.ini_options]
49
+ # LiteLLM's own types trigger this Pydantic warning on import.
50
+ filterwarnings = ["ignore:.*ReadOnly.*:UserWarning"]
@@ -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}")