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.
@@ -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.
@@ -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
+ ![Speed and memory](docs/scaling.png)
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
+ ![Chunking a legal corpus beats truncating it by 3.4×](docs/ablation.png)
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
+ ![arara against the MTEB-BR leaderboard](docs/leaderboard.png)
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
+ ![Speed and memory](docs/scaling.png)
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
+ ![Chunking a legal corpus beats truncating it by 3.4×](docs/ablation.png)
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
+ ![arara against the MTEB-BR leaderboard](docs/leaderboard.png)
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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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