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 +23 -0
- chunkguard/backend.py +155 -0
- chunkguard/pic.py +261 -0
- chunkguard/py.typed +0 -0
- chunkguard/sentences.py +61 -0
- chunkguard-0.1.0.dist-info/METADATA +254 -0
- chunkguard-0.1.0.dist-info/RECORD +9 -0
- chunkguard-0.1.0.dist-info/WHEEL +4 -0
- chunkguard-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
chunkguard/sentences.py
ADDED
|
@@ -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
|
+
[](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,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.
|