arara-rag 0.4.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.
- arara_rag-0.4.0/LICENSE +15 -0
- arara_rag-0.4.0/PKG-INFO +228 -0
- arara_rag-0.4.0/README.md +196 -0
- arara_rag-0.4.0/arara_rag/__init__.py +36 -0
- arara_rag-0.4.0/arara_rag/__main__.py +3 -0
- arara_rag-0.4.0/arara_rag/chunk.py +177 -0
- arara_rag-0.4.0/arara_rag/cli.py +124 -0
- arara_rag-0.4.0/arara_rag/dense.py +142 -0
- arara_rag-0.4.0/arara_rag/fuse.py +42 -0
- arara_rag-0.4.0/arara_rag/lexical.py +238 -0
- arara_rag-0.4.0/arara_rag/pipeline.py +748 -0
- arara_rag-0.4.0/arara_rag/py.typed +0 -0
- arara_rag-0.4.0/arara_rag/store.py +655 -0
- arara_rag-0.4.0/arara_rag/text.py +64 -0
- arara_rag-0.4.0/arara_rag/types.py +41 -0
- arara_rag-0.4.0/arara_rag.egg-info/PKG-INFO +228 -0
- arara_rag-0.4.0/arara_rag.egg-info/SOURCES.txt +25 -0
- arara_rag-0.4.0/arara_rag.egg-info/dependency_links.txt +1 -0
- arara_rag-0.4.0/arara_rag.egg-info/requires.txt +18 -0
- arara_rag-0.4.0/arara_rag.egg-info/top_level.txt +1 -0
- arara_rag-0.4.0/pyproject.toml +52 -0
- arara_rag-0.4.0/setup.cfg +4 -0
- arara_rag-0.4.0/tests/test_arara.py +331 -0
- arara_rag-0.4.0/tests/test_metrics.py +88 -0
- arara_rag-0.4.0/tests/test_parallel.py +99 -0
- arara_rag-0.4.0/tests/test_space.py +60 -0
- arara_rag-0.4.0/tests/test_store.py +246 -0
arara_rag-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|
arara_rag-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arara-rag
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Portuguese-first, CPU-only retrieval: tinyzchunk chunking, static embeddings, BM25, rank fusion and CXM25 reranking. numpy only, out-of-core, with metadata filtering and CRUD.
|
|
5
|
+
Author: Carlo Moro
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/cnmoro/Arara-RAG
|
|
8
|
+
Project-URL: Repository, https://github.com/cnmoro/Arara-RAG
|
|
9
|
+
Keywords: rag,retrieval,portuguese,pt-br,embeddings,bm25,cpu,numpy,out-of-core
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: numpy>=1.23
|
|
17
|
+
Requires-Dist: tokenizers>=0.20
|
|
18
|
+
Requires-Dist: model2vec>=0.9
|
|
19
|
+
Requires-Dist: tinyzchunk>=0.3.3
|
|
20
|
+
Requires-Dist: cxm25>=0.1.0
|
|
21
|
+
Requires-Dist: threadpoolctl>=3.1
|
|
22
|
+
Provides-Extra: bench
|
|
23
|
+
Requires-Dist: datasets>=2.19; extra == "bench"
|
|
24
|
+
Requires-Dist: pyarrow; extra == "bench"
|
|
25
|
+
Requires-Dist: huggingface_hub>=0.23; extra == "bench"
|
|
26
|
+
Requires-Dist: matplotlib>=3.8; extra == "bench"
|
|
27
|
+
Provides-Extra: validate
|
|
28
|
+
Requires-Dist: pytrec_eval-terrier>=0.5.6; extra == "validate"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# arara-rag
|
|
34
|
+
|
|
35
|
+
**Portuguese-first retrieval that runs entirely on CPU.** Chunking, dense and
|
|
36
|
+
lexical retrieval, rank fusion and reranking — numpy only. No PyTorch, no ONNX
|
|
37
|
+
Runtime, no FAISS. The whole install is **200 MB**.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install arara-rag
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from arara_rag import Arara
|
|
45
|
+
|
|
46
|
+
arara = Arara(path="./indice") # out-of-core and persistent
|
|
47
|
+
arara.add_documents(
|
|
48
|
+
{"lei_1234": open("lei.txt").read()},
|
|
49
|
+
metadata={"ano": 2024, "tipo": "lei", "uf": "BR"},
|
|
50
|
+
)
|
|
51
|
+
hits = arara.search("qual a alíquota?", top_k=5, where={"ano": {"$gte": 2020}})
|
|
52
|
+
print(hits[0].doc_id, arara.resolve(hits[0])) # exact source span
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## What's in it
|
|
56
|
+
|
|
57
|
+
| Stage | Component | Size |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| Chunking | [`tinyzchunk`](https://github.com/cnmoro/tinyzchunk) — tokenizer-free, distilled from an LLM teacher | 2.1 MB |
|
|
60
|
+
| Dense | [`static-nomic-384-pten-v2`](https://huggingface.co/cnmoro/static-nomic-384-pten-v2) — Model2Vec static embeddings | 62 MB |
|
|
61
|
+
| Lexical | BM25 over a numpy inverted index | — |
|
|
62
|
+
| Rerank | [`CXM25`](https://github.com/cnmoro/CXM25) — PT-BR lexical scoring | bundled |
|
|
63
|
+
| Fusion | Reciprocal Rank Fusion | — |
|
|
64
|
+
|
|
65
|
+
An index can be **out-of-core**: vectors live in a memory-mapped file and
|
|
66
|
+
documents, metadata and offsets in SQLite, so the index is about **1 KB per
|
|
67
|
+
document** and cold pages can be evicted by the OS instead of being pinned on
|
|
68
|
+
the heap. Same API either way — `Arara()` keeps everything in memory.
|
|
69
|
+
|
|
70
|
+
## Speed and memory
|
|
71
|
+
|
|
72
|
+
One CPU core, no GPU. Measured end to end with `python -m bench.profile`.
|
|
73
|
+
|
|
74
|
+
| documents | index build | query p50 | query p95 | index size | peak RSS to serve |
|
|
75
|
+
|---|---|---|---|---|---|
|
|
76
|
+
| 1,000 | 3.1 s | **0.45 ms** | 0.54 ms | 1.8 MB | 472 MB |
|
|
77
|
+
| 10,000 | 8.8 s | **0.86 ms** | 6.8 ms | 18 MB | 481 MB |
|
|
78
|
+
| 50,000 | 33.8 s | **7.0 ms** | 8.0 ms | 91 MB | 553 MB |
|
|
79
|
+
|
|
80
|
+
- **~1,300–1,500 documents/second** to chunk, embed, tokenise and index —
|
|
81
|
+
chunking and embedding are per-document, so they run across processes.
|
|
82
|
+
A single process manages ~300/second.
|
|
83
|
+
- **~1.8 KB per document** of index: 1.5 KB of vectors plus BM25 postings.
|
|
84
|
+
- Query latency scales with corpus size because both retrievers score the whole
|
|
85
|
+
corpus per query — that is what makes the ranking exact rather than
|
|
86
|
+
approximate.
|
|
87
|
+
- Peak RSS is dominated by a **~470 MB fixed cost** (Python, numpy, the
|
|
88
|
+
embedding model and the tokenizer tables); the corpus adds ~1.7 KB per
|
|
89
|
+
document on top. Vectors are memory-mapped, so cold pages can be evicted.
|
|
90
|
+
|
|
91
|
+

|
|
92
|
+
|
|
93
|
+
## Retrieval quality
|
|
94
|
+
|
|
95
|
+
MTEB-BR, the Brazilian Portuguese benchmark with a
|
|
96
|
+
[public leaderboard](https://huggingface.co/spaces/MTEB-BR/leaderboard).
|
|
97
|
+
nDCG@10, fixed-window chunking. Metrics are computed by `bench/metrics.py`,
|
|
98
|
+
which `bench/validate_metrics.py` checks against `pytrec_eval` to **0.0e+00**.
|
|
99
|
+
|
|
100
|
+
| Task | docs | dense | lexical | hybrid |
|
|
101
|
+
|---|---|---|---|---|
|
|
102
|
+
| BRTaxQAR (capped) | 478 | 0.2934 | **0.4051** | 0.3486 |
|
|
103
|
+
| FaQuADIR | 244 | 0.7139 | **0.8961** | 0.8304 |
|
|
104
|
+
| FaqBacenRetrieval | 1,673 | 0.3745 | **0.4881** | 0.4526 |
|
|
105
|
+
| JurisTCU | 16,045 | 0.3887 | **0.5378** | 0.4890 |
|
|
106
|
+
| Quati | 50,000 | 0.3268 | **0.4067** | 0.4046 |
|
|
107
|
+
|
|
108
|
+
CXM25 reranking on top of the hybrid adds **+0.018 to +0.077 nDCG@10** across
|
|
109
|
+
these tasks for 1–6 ms per query (FaQuADIR: 0.8304 → **0.9078**,
|
|
110
|
+
BRTaxQAR full documents: 0.4801 → **0.5091**).
|
|
111
|
+
|
|
112
|
+
## Why chunking matters most
|
|
113
|
+
|
|
114
|
+
Legal documents in BR-TaxQA-R average 32,000 characters and reach 1.17M. MTEB-BR
|
|
115
|
+
truncates them at 32k because transformer encoders cannot fit more. arara
|
|
116
|
+
chunks, so it indexes the whole statute.
|
|
117
|
+
|
|
118
|
+
| Configuration | chunks | nDCG@10 | R@100 |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| capped at 32k, one vector per doc *(the leaderboard's setting)* | 478 | 0.1496 | 0.4351 |
|
|
121
|
+
| capped at 32k, fixed windows | 2,552 | 0.2934 | 0.6300 |
|
|
122
|
+
| capped at 32k, **tinyzchunk** | 23,319 | 0.3088 | 0.6225 |
|
|
123
|
+
| **full documents**, fixed windows | 6,439 | 0.4041 | 0.7497 |
|
|
124
|
+
| **full documents**, paragraph splits | 6,439 | 0.4041 | 0.7497 |
|
|
125
|
+
| **full documents**, tinyzchunk | 60,927 | 0.4287 | 0.7486 |
|
|
126
|
+
| **full documents**, tinyzchunk + BM25 | 60,927 | 0.4801 | 0.8496 |
|
|
127
|
+
| **full documents**, + CXM25 rerank | 60,927 | **0.5091** | 0.8102 |
|
|
128
|
+
|
|
129
|
+

|
|
130
|
+
|
|
131
|
+
## Against the leaderboard
|
|
132
|
+
|
|
133
|
+
**On FaQuADIR, arara's best configuration outranks all 96 models on the board**
|
|
134
|
+
— above `voyage-context-4`, `gemini-embedding-2` and `Qwen3-Embedding-8B` — on
|
|
135
|
+
one CPU core. On BR-TaxQA-R it beats 90 of 95.
|
|
136
|
+
|
|
137
|
+
The honest caveat: the leaderboard evaluates *embedding* models, and there is no
|
|
138
|
+
BM25 entry on it. arara's strongest modes are lexical, and lexical retrieval is
|
|
139
|
+
simply very good on short, high-overlap PT-BR documents — part of that gap is a
|
|
140
|
+
missing baseline on their side, not a transformer-killing dense model on ours.
|
|
141
|
+
|
|
142
|
+

|
|
143
|
+
|
|
144
|
+
## Reranking
|
|
145
|
+
|
|
146
|
+
MTEB-BR reranking hands you a fixed candidate list and scores only the order
|
|
147
|
+
(MAP@1000), so `identity` is the baseline the benchmark ships with.
|
|
148
|
+
|
|
149
|
+
| Task | identity | dense | lexical | hybrid | **CXM25** |
|
|
150
|
+
|---|---|---|---|---|---|
|
|
151
|
+
| QuatiReranking | 0.2839 | 0.2798 | 0.2939 | 0.3066 | **0.3100** |
|
|
152
|
+
| JurisTCUReranking | 0.4150 | 0.3609 | 0.4279 | 0.4129 | **0.4845** |
|
|
153
|
+
|
|
154
|
+
## Out-of-core, metadata, CRUD
|
|
155
|
+
|
|
156
|
+
An index is read far more than it is written, so deletes are tombstones and
|
|
157
|
+
freed slots are recycled on the next write.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
arara = Arara(path="./indice", max_chunk_chars=2000)
|
|
161
|
+
|
|
162
|
+
arara.add_documents(docs, metadata={"ano": 2024}) # insert / replace
|
|
163
|
+
arara.update_metadata("lei_1234", {"revisado": True}) # no re-embedding
|
|
164
|
+
arara.delete_document("lei_1234") # tombstone + slot reuse
|
|
165
|
+
arara.get_document("lei_1234") # (text, metadata)
|
|
166
|
+
arara.compact() # reclaim file space
|
|
167
|
+
|
|
168
|
+
arara.search(q, where={"tipo": {"$in": ["lei", "decreto"]}, "ano": {"$gte": 2020}})
|
|
169
|
+
arara.search(q, where={"$or": [{"uf": "SP"}, {"uf": "RJ"}]})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Supported per field: `$eq` (bare value), `$ne`, `$gt`, `$gte`, `$lt`, `$lte`,
|
|
173
|
+
`$in`, `$nin`, `$exists`, `$contains`, `$startswith`, `$endswith`; plus
|
|
174
|
+
top-level `$and` / `$or`. Field names are validated and values are bound as SQL
|
|
175
|
+
parameters, so a filter cannot inject SQL.
|
|
176
|
+
|
|
177
|
+
## Guarantees
|
|
178
|
+
|
|
179
|
+
Enforced by 88 tests, not asserted in prose:
|
|
180
|
+
|
|
181
|
+
- every chunk is an **exact substring** of the canonical document, ordered and
|
|
182
|
+
non-overlapping, with only whitespace between chunks — nothing is dropped;
|
|
183
|
+
- **no chunk exceeds `max_chunk_chars`**, including on a 24,000-character line;
|
|
184
|
+
- CRLF and LF inputs chunk **identically** and offsets still resolve;
|
|
185
|
+
- the in-memory and on-disk paths return **identical rankings**;
|
|
186
|
+
- importing the package never imports `torch` or `onnxruntime`.
|
|
187
|
+
|
|
188
|
+
## Reproduce
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
python -m venv .venv && .venv/bin/pip install -e ".[bench,validate,dev]"
|
|
192
|
+
python -m pytest tests/ # 88 tests
|
|
193
|
+
python bench/validate_metrics.py # metrics vs pytrec_eval
|
|
194
|
+
./bench/run_all.sh # every suite -> bench/results/
|
|
195
|
+
python -m bench.profile # speed and memory -> docs/scaling.png
|
|
196
|
+
python -m bench.charts # regenerate the figures
|
|
197
|
+
python -m bench.leaderboard # compare against MTEB-BR
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Layout
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
arara_rag/
|
|
204
|
+
chunk.py chunking and the losslessness contract
|
|
205
|
+
dense.py static encoder
|
|
206
|
+
lexical.py BM25 inverted index + CXM25 reranker
|
|
207
|
+
store.py memory-mapped vectors, SQLite catalog, filters
|
|
208
|
+
pipeline.py Arara: add / search / rerank / CRUD
|
|
209
|
+
bench/ task loaders, metrics, suites, profiling, charts
|
|
210
|
+
tests/ 88 contract and correctness tests
|
|
211
|
+
space/ Gradio demo
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Limitations
|
|
215
|
+
|
|
216
|
+
- **PT-BR and English.** The tokenizer, stemmer and stopwords are Portuguese.
|
|
217
|
+
- **The dense model is small** and static; lexical retrieval carries the stack
|
|
218
|
+
on short, high-overlap documents.
|
|
219
|
+
- **Per-chunk bookkeeping stays resident** (16 bytes/chunk); vectors, text and
|
|
220
|
+
metadata do not.
|
|
221
|
+
- **CXM25 reranking is ~71 µs/document**, so it runs over a candidate set.
|
|
222
|
+
- **Index build forks worker processes** to parallelise chunking and embedding.
|
|
223
|
+
Set `workers=1` where forking is unsafe or unavailable; the result is
|
|
224
|
+
byte-identical, only slower.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
Apache-2.0.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# arara-rag
|
|
2
|
+
|
|
3
|
+
**Portuguese-first retrieval that runs entirely on CPU.** Chunking, dense and
|
|
4
|
+
lexical retrieval, rank fusion and reranking — numpy only. No PyTorch, no ONNX
|
|
5
|
+
Runtime, no FAISS. The whole install is **200 MB**.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install arara-rag
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from arara_rag import Arara
|
|
13
|
+
|
|
14
|
+
arara = Arara(path="./indice") # out-of-core and persistent
|
|
15
|
+
arara.add_documents(
|
|
16
|
+
{"lei_1234": open("lei.txt").read()},
|
|
17
|
+
metadata={"ano": 2024, "tipo": "lei", "uf": "BR"},
|
|
18
|
+
)
|
|
19
|
+
hits = arara.search("qual a alíquota?", top_k=5, where={"ano": {"$gte": 2020}})
|
|
20
|
+
print(hits[0].doc_id, arara.resolve(hits[0])) # exact source span
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What's in it
|
|
24
|
+
|
|
25
|
+
| Stage | Component | Size |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| Chunking | [`tinyzchunk`](https://github.com/cnmoro/tinyzchunk) — tokenizer-free, distilled from an LLM teacher | 2.1 MB |
|
|
28
|
+
| Dense | [`static-nomic-384-pten-v2`](https://huggingface.co/cnmoro/static-nomic-384-pten-v2) — Model2Vec static embeddings | 62 MB |
|
|
29
|
+
| Lexical | BM25 over a numpy inverted index | — |
|
|
30
|
+
| Rerank | [`CXM25`](https://github.com/cnmoro/CXM25) — PT-BR lexical scoring | bundled |
|
|
31
|
+
| Fusion | Reciprocal Rank Fusion | — |
|
|
32
|
+
|
|
33
|
+
An index can be **out-of-core**: vectors live in a memory-mapped file and
|
|
34
|
+
documents, metadata and offsets in SQLite, so the index is about **1 KB per
|
|
35
|
+
document** and cold pages can be evicted by the OS instead of being pinned on
|
|
36
|
+
the heap. Same API either way — `Arara()` keeps everything in memory.
|
|
37
|
+
|
|
38
|
+
## Speed and memory
|
|
39
|
+
|
|
40
|
+
One CPU core, no GPU. Measured end to end with `python -m bench.profile`.
|
|
41
|
+
|
|
42
|
+
| documents | index build | query p50 | query p95 | index size | peak RSS to serve |
|
|
43
|
+
|---|---|---|---|---|---|
|
|
44
|
+
| 1,000 | 3.1 s | **0.45 ms** | 0.54 ms | 1.8 MB | 472 MB |
|
|
45
|
+
| 10,000 | 8.8 s | **0.86 ms** | 6.8 ms | 18 MB | 481 MB |
|
|
46
|
+
| 50,000 | 33.8 s | **7.0 ms** | 8.0 ms | 91 MB | 553 MB |
|
|
47
|
+
|
|
48
|
+
- **~1,300–1,500 documents/second** to chunk, embed, tokenise and index —
|
|
49
|
+
chunking and embedding are per-document, so they run across processes.
|
|
50
|
+
A single process manages ~300/second.
|
|
51
|
+
- **~1.8 KB per document** of index: 1.5 KB of vectors plus BM25 postings.
|
|
52
|
+
- Query latency scales with corpus size because both retrievers score the whole
|
|
53
|
+
corpus per query — that is what makes the ranking exact rather than
|
|
54
|
+
approximate.
|
|
55
|
+
- Peak RSS is dominated by a **~470 MB fixed cost** (Python, numpy, the
|
|
56
|
+
embedding model and the tokenizer tables); the corpus adds ~1.7 KB per
|
|
57
|
+
document on top. Vectors are memory-mapped, so cold pages can be evicted.
|
|
58
|
+
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
## Retrieval quality
|
|
62
|
+
|
|
63
|
+
MTEB-BR, the Brazilian Portuguese benchmark with a
|
|
64
|
+
[public leaderboard](https://huggingface.co/spaces/MTEB-BR/leaderboard).
|
|
65
|
+
nDCG@10, fixed-window chunking. Metrics are computed by `bench/metrics.py`,
|
|
66
|
+
which `bench/validate_metrics.py` checks against `pytrec_eval` to **0.0e+00**.
|
|
67
|
+
|
|
68
|
+
| Task | docs | dense | lexical | hybrid |
|
|
69
|
+
|---|---|---|---|---|
|
|
70
|
+
| BRTaxQAR (capped) | 478 | 0.2934 | **0.4051** | 0.3486 |
|
|
71
|
+
| FaQuADIR | 244 | 0.7139 | **0.8961** | 0.8304 |
|
|
72
|
+
| FaqBacenRetrieval | 1,673 | 0.3745 | **0.4881** | 0.4526 |
|
|
73
|
+
| JurisTCU | 16,045 | 0.3887 | **0.5378** | 0.4890 |
|
|
74
|
+
| Quati | 50,000 | 0.3268 | **0.4067** | 0.4046 |
|
|
75
|
+
|
|
76
|
+
CXM25 reranking on top of the hybrid adds **+0.018 to +0.077 nDCG@10** across
|
|
77
|
+
these tasks for 1–6 ms per query (FaQuADIR: 0.8304 → **0.9078**,
|
|
78
|
+
BRTaxQAR full documents: 0.4801 → **0.5091**).
|
|
79
|
+
|
|
80
|
+
## Why chunking matters most
|
|
81
|
+
|
|
82
|
+
Legal documents in BR-TaxQA-R average 32,000 characters and reach 1.17M. MTEB-BR
|
|
83
|
+
truncates them at 32k because transformer encoders cannot fit more. arara
|
|
84
|
+
chunks, so it indexes the whole statute.
|
|
85
|
+
|
|
86
|
+
| Configuration | chunks | nDCG@10 | R@100 |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| capped at 32k, one vector per doc *(the leaderboard's setting)* | 478 | 0.1496 | 0.4351 |
|
|
89
|
+
| capped at 32k, fixed windows | 2,552 | 0.2934 | 0.6300 |
|
|
90
|
+
| capped at 32k, **tinyzchunk** | 23,319 | 0.3088 | 0.6225 |
|
|
91
|
+
| **full documents**, fixed windows | 6,439 | 0.4041 | 0.7497 |
|
|
92
|
+
| **full documents**, paragraph splits | 6,439 | 0.4041 | 0.7497 |
|
|
93
|
+
| **full documents**, tinyzchunk | 60,927 | 0.4287 | 0.7486 |
|
|
94
|
+
| **full documents**, tinyzchunk + BM25 | 60,927 | 0.4801 | 0.8496 |
|
|
95
|
+
| **full documents**, + CXM25 rerank | 60,927 | **0.5091** | 0.8102 |
|
|
96
|
+
|
|
97
|
+

|
|
98
|
+
|
|
99
|
+
## Against the leaderboard
|
|
100
|
+
|
|
101
|
+
**On FaQuADIR, arara's best configuration outranks all 96 models on the board**
|
|
102
|
+
— above `voyage-context-4`, `gemini-embedding-2` and `Qwen3-Embedding-8B` — on
|
|
103
|
+
one CPU core. On BR-TaxQA-R it beats 90 of 95.
|
|
104
|
+
|
|
105
|
+
The honest caveat: the leaderboard evaluates *embedding* models, and there is no
|
|
106
|
+
BM25 entry on it. arara's strongest modes are lexical, and lexical retrieval is
|
|
107
|
+
simply very good on short, high-overlap PT-BR documents — part of that gap is a
|
|
108
|
+
missing baseline on their side, not a transformer-killing dense model on ours.
|
|
109
|
+
|
|
110
|
+

|
|
111
|
+
|
|
112
|
+
## Reranking
|
|
113
|
+
|
|
114
|
+
MTEB-BR reranking hands you a fixed candidate list and scores only the order
|
|
115
|
+
(MAP@1000), so `identity` is the baseline the benchmark ships with.
|
|
116
|
+
|
|
117
|
+
| Task | identity | dense | lexical | hybrid | **CXM25** |
|
|
118
|
+
|---|---|---|---|---|---|
|
|
119
|
+
| QuatiReranking | 0.2839 | 0.2798 | 0.2939 | 0.3066 | **0.3100** |
|
|
120
|
+
| JurisTCUReranking | 0.4150 | 0.3609 | 0.4279 | 0.4129 | **0.4845** |
|
|
121
|
+
|
|
122
|
+
## Out-of-core, metadata, CRUD
|
|
123
|
+
|
|
124
|
+
An index is read far more than it is written, so deletes are tombstones and
|
|
125
|
+
freed slots are recycled on the next write.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
arara = Arara(path="./indice", max_chunk_chars=2000)
|
|
129
|
+
|
|
130
|
+
arara.add_documents(docs, metadata={"ano": 2024}) # insert / replace
|
|
131
|
+
arara.update_metadata("lei_1234", {"revisado": True}) # no re-embedding
|
|
132
|
+
arara.delete_document("lei_1234") # tombstone + slot reuse
|
|
133
|
+
arara.get_document("lei_1234") # (text, metadata)
|
|
134
|
+
arara.compact() # reclaim file space
|
|
135
|
+
|
|
136
|
+
arara.search(q, where={"tipo": {"$in": ["lei", "decreto"]}, "ano": {"$gte": 2020}})
|
|
137
|
+
arara.search(q, where={"$or": [{"uf": "SP"}, {"uf": "RJ"}]})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Supported per field: `$eq` (bare value), `$ne`, `$gt`, `$gte`, `$lt`, `$lte`,
|
|
141
|
+
`$in`, `$nin`, `$exists`, `$contains`, `$startswith`, `$endswith`; plus
|
|
142
|
+
top-level `$and` / `$or`. Field names are validated and values are bound as SQL
|
|
143
|
+
parameters, so a filter cannot inject SQL.
|
|
144
|
+
|
|
145
|
+
## Guarantees
|
|
146
|
+
|
|
147
|
+
Enforced by 88 tests, not asserted in prose:
|
|
148
|
+
|
|
149
|
+
- every chunk is an **exact substring** of the canonical document, ordered and
|
|
150
|
+
non-overlapping, with only whitespace between chunks — nothing is dropped;
|
|
151
|
+
- **no chunk exceeds `max_chunk_chars`**, including on a 24,000-character line;
|
|
152
|
+
- CRLF and LF inputs chunk **identically** and offsets still resolve;
|
|
153
|
+
- the in-memory and on-disk paths return **identical rankings**;
|
|
154
|
+
- importing the package never imports `torch` or `onnxruntime`.
|
|
155
|
+
|
|
156
|
+
## Reproduce
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
python -m venv .venv && .venv/bin/pip install -e ".[bench,validate,dev]"
|
|
160
|
+
python -m pytest tests/ # 88 tests
|
|
161
|
+
python bench/validate_metrics.py # metrics vs pytrec_eval
|
|
162
|
+
./bench/run_all.sh # every suite -> bench/results/
|
|
163
|
+
python -m bench.profile # speed and memory -> docs/scaling.png
|
|
164
|
+
python -m bench.charts # regenerate the figures
|
|
165
|
+
python -m bench.leaderboard # compare against MTEB-BR
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Layout
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
arara_rag/
|
|
172
|
+
chunk.py chunking and the losslessness contract
|
|
173
|
+
dense.py static encoder
|
|
174
|
+
lexical.py BM25 inverted index + CXM25 reranker
|
|
175
|
+
store.py memory-mapped vectors, SQLite catalog, filters
|
|
176
|
+
pipeline.py Arara: add / search / rerank / CRUD
|
|
177
|
+
bench/ task loaders, metrics, suites, profiling, charts
|
|
178
|
+
tests/ 88 contract and correctness tests
|
|
179
|
+
space/ Gradio demo
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Limitations
|
|
183
|
+
|
|
184
|
+
- **PT-BR and English.** The tokenizer, stemmer and stopwords are Portuguese.
|
|
185
|
+
- **The dense model is small** and static; lexical retrieval carries the stack
|
|
186
|
+
on short, high-overlap documents.
|
|
187
|
+
- **Per-chunk bookkeeping stays resident** (16 bytes/chunk); vectors, text and
|
|
188
|
+
metadata do not.
|
|
189
|
+
- **CXM25 reranking is ~71 µs/document**, so it runs over a candidate set.
|
|
190
|
+
- **Index build forks worker processes** to parallelise chunking and embedding.
|
|
191
|
+
Set `workers=1` where forking is unsafe or unavailable; the result is
|
|
192
|
+
byte-identical, only slower.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
Apache-2.0.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""arara-rag -- a Portuguese-first, CPU-only RAG retrieval stack.
|
|
2
|
+
|
|
3
|
+
Everything runs on CPU with numpy. No PyTorch, no ONNX Runtime, no FAISS on the
|
|
4
|
+
query path.
|
|
5
|
+
|
|
6
|
+
>>> from arara_rag import Arara
|
|
7
|
+
>>> a = Arara(path="./index") # out-of-core, persistent
|
|
8
|
+
>>> a.add_documents({"doc": "texto ..."}, metadata={"ano": 2024})
|
|
9
|
+
>>> hits = a.search("pergunta", top_k=5, where={"ano": {"$gte": 2020}})
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .chunk import Chunker
|
|
13
|
+
from .dense import DEFAULT_DENSE_MODEL, DenseEncoder, DenseIndex
|
|
14
|
+
from .fuse import rank_from_scores, reciprocal_rank_fusion
|
|
15
|
+
from .lexical import BM25Index, CXM25Scorer
|
|
16
|
+
from .pipeline import Arara
|
|
17
|
+
from .text import Tokenizer
|
|
18
|
+
from .types import Chunk, Hit
|
|
19
|
+
|
|
20
|
+
__version__ = "0.4.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Arara",
|
|
24
|
+
"BM25Index",
|
|
25
|
+
"CXM25Scorer",
|
|
26
|
+
"Chunk",
|
|
27
|
+
"Chunker",
|
|
28
|
+
"DEFAULT_DENSE_MODEL",
|
|
29
|
+
"DenseEncoder",
|
|
30
|
+
"DenseIndex",
|
|
31
|
+
"Hit",
|
|
32
|
+
"Tokenizer",
|
|
33
|
+
"rank_from_scores",
|
|
34
|
+
"reciprocal_rank_fusion",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Chunking strategies.
|
|
2
|
+
|
|
3
|
+
``tinyzchunk`` is the default: a GPU-free, tokenizer-free chunker distilled
|
|
4
|
+
from an LLM teacher. It is what lets arara index the 32k-1.1M character legal
|
|
5
|
+
statutes that fixed-window embedders are forced to truncate.
|
|
6
|
+
|
|
7
|
+
The contract below is enforced here rather than assumed, because the upstream
|
|
8
|
+
chunker has three behaviours a pipeline cannot tolerate:
|
|
9
|
+
|
|
10
|
+
1. it strips the whitespace between structural units, so consecutive chunks are
|
|
11
|
+
not byte-adjacent;
|
|
12
|
+
2. it can return a chunk longer than ``max_chunk_chars`` on degenerate input
|
|
13
|
+
(a single very long line);
|
|
14
|
+
3. it leaves CRLF line endings in the returned text, so "chunks identically" is
|
|
15
|
+
only true of the boundaries, not of the strings.
|
|
16
|
+
|
|
17
|
+
arara therefore normalises line endings, splits oversized chunks at word
|
|
18
|
+
boundaries, and defines the guarantee it can actually uphold:
|
|
19
|
+
|
|
20
|
+
* every chunk is an exact substring of the **canonical** document
|
|
21
|
+
(line endings normalised to ``\\n``);
|
|
22
|
+
* chunks are ordered and non-overlapping;
|
|
23
|
+
* the gap between two consecutive chunks contains **only whitespace**, so no
|
|
24
|
+
non-whitespace character is ever dropped or duplicated;
|
|
25
|
+
* no chunk exceeds ``max_chunk_chars``.
|
|
26
|
+
|
|
27
|
+
``Arara.document_text(doc_id)`` returns the canonical text the offsets index
|
|
28
|
+
into.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from typing import Literal
|
|
34
|
+
|
|
35
|
+
from .types import Chunk
|
|
36
|
+
|
|
37
|
+
ChunkMode = Literal["tinyzchunk", "paragraph", "window", "document"]
|
|
38
|
+
|
|
39
|
+
# Legacy alias: "none" never meant "one chunk per document" -- it means "no
|
|
40
|
+
# structural chunker", i.e. fixed windows. Keeping the alias avoids silently
|
|
41
|
+
# changing behaviour for callers while the honest name is "window".
|
|
42
|
+
_ALIASES = {"none": "window"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canonicalize(text: str) -> str:
|
|
46
|
+
"""Normalise line endings so offsets are stable across platforms."""
|
|
47
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Chunker:
|
|
51
|
+
"""Splits documents into provably lossless spans."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
mode: ChunkMode | str = "tinyzchunk",
|
|
56
|
+
max_chunk_chars: int = 2500,
|
|
57
|
+
min_chunk_chars: int = 100,
|
|
58
|
+
enforce_max_chars: bool = True,
|
|
59
|
+
) -> None:
|
|
60
|
+
mode = _ALIASES.get(mode, mode)
|
|
61
|
+
if mode not in ("tinyzchunk", "paragraph", "window", "document"):
|
|
62
|
+
raise ValueError(f"unknown chunk mode: {mode!r}")
|
|
63
|
+
self.mode = mode
|
|
64
|
+
self.max_chunk_chars = max_chunk_chars
|
|
65
|
+
self.min_chunk_chars = min_chunk_chars
|
|
66
|
+
self.enforce_max_chars = enforce_max_chars
|
|
67
|
+
if mode == "tinyzchunk":
|
|
68
|
+
from tinyzchunk import Chunker as _TinyZChunker
|
|
69
|
+
|
|
70
|
+
self._impl = _TinyZChunker(
|
|
71
|
+
max_chunk_chars=max_chunk_chars,
|
|
72
|
+
min_chunk_chars=min_chunk_chars,
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
self._impl = None
|
|
76
|
+
|
|
77
|
+
# -- public API ---------------------------------------------------------
|
|
78
|
+
def split(self, doc_id: str, text: str) -> list[Chunk]:
|
|
79
|
+
"""Chunk ``text``; offsets index into ``canonicalize(text)``."""
|
|
80
|
+
canonical = canonicalize(text)
|
|
81
|
+
if not canonical:
|
|
82
|
+
return []
|
|
83
|
+
chunks: list[Chunk] = []
|
|
84
|
+
cursor = 0
|
|
85
|
+
for piece in self._pieces(canonical):
|
|
86
|
+
if not piece:
|
|
87
|
+
continue
|
|
88
|
+
start = canonical.find(piece, cursor)
|
|
89
|
+
if start < 0:
|
|
90
|
+
# Defensive: never emit a chunk whose offsets we cannot prove.
|
|
91
|
+
# Fall back to windowing the remainder rather than lying.
|
|
92
|
+
for win in self._window(canonical[cursor:]):
|
|
93
|
+
s = canonical.find(win, cursor)
|
|
94
|
+
if s < 0:
|
|
95
|
+
continue
|
|
96
|
+
chunks.append(
|
|
97
|
+
Chunk(
|
|
98
|
+
chunk_id=f"{doc_id}#{len(chunks)}",
|
|
99
|
+
doc_id=doc_id,
|
|
100
|
+
text=win,
|
|
101
|
+
start=s,
|
|
102
|
+
end=s + len(win),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
cursor = s + len(win)
|
|
106
|
+
continue
|
|
107
|
+
end = start + len(piece)
|
|
108
|
+
chunks.append(
|
|
109
|
+
Chunk(
|
|
110
|
+
chunk_id=f"{doc_id}#{len(chunks)}",
|
|
111
|
+
doc_id=doc_id,
|
|
112
|
+
text=piece,
|
|
113
|
+
start=start,
|
|
114
|
+
end=end,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
cursor = end
|
|
118
|
+
return chunks
|
|
119
|
+
|
|
120
|
+
# -- strategies ---------------------------------------------------------
|
|
121
|
+
def _pieces(self, text: str) -> list[str]:
|
|
122
|
+
if not text:
|
|
123
|
+
return []
|
|
124
|
+
if self.mode == "document":
|
|
125
|
+
# One vector per document, ceiling deliberately ignored. This is the
|
|
126
|
+
# operating point every fixed-window embedder is stuck at, and it is
|
|
127
|
+
# only sane for documents that fit the model's context.
|
|
128
|
+
stripped = text.strip()
|
|
129
|
+
return [stripped] if stripped else []
|
|
130
|
+
if self.mode == "tinyzchunk":
|
|
131
|
+
raw = self._impl.chunk(text)
|
|
132
|
+
raw = raw if raw else [text]
|
|
133
|
+
elif self.mode == "paragraph":
|
|
134
|
+
raw = self._paragraphs(text)
|
|
135
|
+
else: # window
|
|
136
|
+
raw = [text]
|
|
137
|
+
if not self.enforce_max_chars:
|
|
138
|
+
return [p for piece in raw for p in self._window(piece) if p]
|
|
139
|
+
pieces: list[str] = []
|
|
140
|
+
for piece in raw:
|
|
141
|
+
# Word-aware splitting is applied to every mode, including the
|
|
142
|
+
# window baseline, so that a cut never lands mid-word.
|
|
143
|
+
pieces.extend(self._split_oversized(piece))
|
|
144
|
+
return pieces
|
|
145
|
+
|
|
146
|
+
def _window(self, text: str) -> list[str]:
|
|
147
|
+
"""Hard character windows; the honest baseline every chunker beats."""
|
|
148
|
+
if len(text) <= self.max_chunk_chars:
|
|
149
|
+
return [text]
|
|
150
|
+
return [
|
|
151
|
+
text[i : i + self.max_chunk_chars]
|
|
152
|
+
for i in range(0, len(text), self.max_chunk_chars)
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
def _paragraphs(self, text: str) -> list[str]:
|
|
156
|
+
raw = [p for p in text.split("\n\n") if p.strip()]
|
|
157
|
+
return raw if raw else [text]
|
|
158
|
+
|
|
159
|
+
def _split_oversized(self, piece: str) -> list[str]:
|
|
160
|
+
"""Break a piece that exceeds the ceiling at the last word boundary."""
|
|
161
|
+
if len(piece) <= self.max_chunk_chars:
|
|
162
|
+
return [piece.strip()] if piece.strip() else []
|
|
163
|
+
out: list[str] = []
|
|
164
|
+
rest = piece
|
|
165
|
+
while len(rest) > self.max_chunk_chars:
|
|
166
|
+
window = rest[: self.max_chunk_chars]
|
|
167
|
+
cut = window.rfind(" ")
|
|
168
|
+
if cut < self.max_chunk_chars // 2:
|
|
169
|
+
cut = self.max_chunk_chars # unbreakable run: hard cut
|
|
170
|
+
head = rest[:cut].strip()
|
|
171
|
+
if head:
|
|
172
|
+
out.append(head)
|
|
173
|
+
rest = rest[cut:]
|
|
174
|
+
tail = rest.strip()
|
|
175
|
+
if tail:
|
|
176
|
+
out.append(tail)
|
|
177
|
+
return out
|