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.
- chunkguard-0.1.0/.env.example +40 -0
- chunkguard-0.1.0/.github/workflows/ci.yml +31 -0
- chunkguard-0.1.0/.github/workflows/release.yml +21 -0
- chunkguard-0.1.0/.gitignore +7 -0
- chunkguard-0.1.0/LICENSE +21 -0
- chunkguard-0.1.0/PKG-INFO +254 -0
- chunkguard-0.1.0/README.md +225 -0
- chunkguard-0.1.0/pyproject.toml +50 -0
- chunkguard-0.1.0/src/chunkguard/__init__.py +23 -0
- chunkguard-0.1.0/src/chunkguard/backend.py +155 -0
- chunkguard-0.1.0/src/chunkguard/pic.py +261 -0
- chunkguard-0.1.0/src/chunkguard/py.typed +0 -0
- chunkguard-0.1.0/src/chunkguard/sentences.py +61 -0
- chunkguard-0.1.0/tests/test_backend.py +212 -0
- chunkguard-0.1.0/tests/test_pic.py +224 -0
- chunkguard-0.1.0/tests/test_sentences.py +78 -0
- chunkguard-0.1.0/uv.lock +2373 -0
|
@@ -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
|
chunkguard-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](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
|
+
[](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}")
|