ragmill 0.2.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.
- ragmill-0.2.0/.github/workflows/ci.yml +34 -0
- ragmill-0.2.0/.github/workflows/publish.yml +33 -0
- ragmill-0.2.0/.gitignore +7 -0
- ragmill-0.2.0/CHANGELOG.md +21 -0
- ragmill-0.2.0/GUIDE.md +376 -0
- ragmill-0.2.0/LICENSE +21 -0
- ragmill-0.2.0/PKG-INFO +135 -0
- ragmill-0.2.0/README.md +87 -0
- ragmill-0.2.0/pyproject.toml +60 -0
- ragmill-0.2.0/src/ragmill/__init__.py +3 -0
- ragmill-0.2.0/src/ragmill/embeddings.py +95 -0
- ragmill-0.2.0/src/ragmill/engine.py +152 -0
- ragmill-0.2.0/src/ragmill/parsers.py +33 -0
- ragmill-0.2.0/src/ragmill/sync.py +74 -0
- ragmill-0.2.0/src/ragmill/vector_store.py +163 -0
- ragmill-0.2.0/tests/conftest.py +26 -0
- ragmill-0.2.0/tests/test_chunking.py +45 -0
- ragmill-0.2.0/tests/test_embeddings.py +42 -0
- ragmill-0.2.0/tests/test_ingestion.py +51 -0
- ragmill-0.2.0/tests/test_sync.py +80 -0
- ragmill-0.2.0/tests/test_vector_store.py +162 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Cache embedding model download
|
|
25
|
+
uses: actions/cache@v4
|
|
26
|
+
with:
|
|
27
|
+
path: ~/.cache/ragmill/models
|
|
28
|
+
key: ragmill-embedding-model-v1
|
|
29
|
+
|
|
30
|
+
- name: Install package with dev extras
|
|
31
|
+
run: pip install -e ".[dev]"
|
|
32
|
+
|
|
33
|
+
- name: Run tests
|
|
34
|
+
run: pytest tests/ -v
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch: {}
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build-and-publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
environment: pypi
|
|
12
|
+
permissions:
|
|
13
|
+
id-token: write # required for PyPI trusted publishing
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Set up Python
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
|
|
23
|
+
- name: Install build tooling
|
|
24
|
+
run: pip install build twine
|
|
25
|
+
|
|
26
|
+
- name: Build sdist and wheel
|
|
27
|
+
run: python -m build
|
|
28
|
+
|
|
29
|
+
- name: Validate package metadata
|
|
30
|
+
run: twine check dist/*
|
|
31
|
+
|
|
32
|
+
- name: Publish to PyPI
|
|
33
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
ragmill-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [0.2.0]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- `EmbeddingModel` (`ragmill.embeddings`) — local ONNX-based sentence embeddings via a quantized MiniLM model, downloaded once and cached offline.
|
|
10
|
+
- `VectorStore` (`ragmill.vector_store`) — SQLite-backed storage with brute-force cosine similarity search, plus filtering by `filename`, `source_file`, `modified_after`, `modified_before`.
|
|
11
|
+
- `sync_directory` (`ragmill.sync`) — incremental sync between a folder and a `VectorStore`: skips unchanged files (content-hash based), replaces chunks for changed files, removes chunks for deleted files.
|
|
12
|
+
- `modified_at` is now captured during ingestion and threaded through chunk metadata.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Project renamed to **RAGMill**. The original name, `nexus-flow`, was already taken on PyPI by an unrelated package; the next candidate, `nexusflow`, was rejected by PyPI for being confusingly similar to it. Package name is now `ragmill`, and the import path changed accordingly: `import ragmill` (previously `import nexus_flow`). The main class was renamed `NexusEngine` → `RAGEngine` to match.
|
|
16
|
+
|
|
17
|
+
## [0.1.0]
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `RAGEngine` — directory ingestion (`.txt`, `.md`, `.log`, `.rst`, `.pdf` via `pypdf`, `.docx` via `python-docx`) and semantic chunking with paragraph/sentence-boundary splitting and configurable overlap.
|
|
21
|
+
- Optional extras (`pdf`, `docx`, `all`, `dev`) so the core package has zero hard dependencies.
|
ragmill-0.2.0/GUIDE.md
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
# How RAGMill Turns a Folder Into Chunks
|
|
2
|
+
|
|
3
|
+
A line-by-line trace of what actually happens inside `src/ragmill/` when you call `engine.execute_pipeline()` — using real files and real output, not made-up examples.
|
|
4
|
+
|
|
5
|
+
Covers all four stages that are actually built: **ingestion**, **chunking**, **embedding**, and **vector storage**.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Project structure
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
ragmill/
|
|
13
|
+
├── pyproject.toml
|
|
14
|
+
├── README.md
|
|
15
|
+
├── GUIDE.md (this file)
|
|
16
|
+
├── src/ragmill/
|
|
17
|
+
│ ├── __init__.py exports RAGEngine only — see note below
|
|
18
|
+
│ ├── engine.py Stage 01 + 02 + 03: RAGEngine (ingestion, chunking, assembly)
|
|
19
|
+
│ ├── parsers.py PDF/DOCX text extractors used by engine.py
|
|
20
|
+
│ ├── embeddings.py Stage 04: EmbeddingModel
|
|
21
|
+
│ ├── vector_store.py Stage 05: VectorStore
|
|
22
|
+
│ └── sync.py Stage 06: sync_directory (incremental updates)
|
|
23
|
+
└── tests/
|
|
24
|
+
├── test_ingestion.py
|
|
25
|
+
├── test_chunking.py
|
|
26
|
+
├── test_embeddings.py
|
|
27
|
+
├── test_vector_store.py
|
|
28
|
+
└── test_sync.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> **Why `__init__.py` only exports `RAGEngine`.** `EmbeddingModel` and `VectorStore` live in submodules you import explicitly (`from ragmill.embeddings import EmbeddingModel`), not off the top-level package. If `ragmill/__init__.py` imported them eagerly, `import ragmill` would immediately require `numpy`/`onnxruntime`/`tokenizers` to be installed — breaking the zero-dependency promise for anyone who only wants ingestion + chunking.
|
|
32
|
+
|
|
33
|
+
## Setup
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python3.12 -m venv .venv && source .venv/bin/activate
|
|
37
|
+
pip install -e ".[dev]"
|
|
38
|
+
pytest tests/ -v
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
> **Use Python 3.12, not the newest interpreter on your machine.** `onnxruntime` (required for the embeddings stage) doesn't ship wheels for very recent Python releases right away — building this project on Python 3.14 fails with `No matching distribution found for onnxruntime`. Python 3.9–3.12 all work; 3.12 is what this repo's `.venv` is actually built on.
|
|
42
|
+
|
|
43
|
+
### Install matrix
|
|
44
|
+
|
|
45
|
+
| Extra | Adds | When you need it |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| *(none)* | nothing | `.txt` / `.md` / `.log` / `.rst` only, zero dependencies |
|
|
48
|
+
| `pdf` | `pypdf` | reading `.pdf` files |
|
|
49
|
+
| `docx` | `python-docx` | reading `.docx` files |
|
|
50
|
+
| `embeddings` | `onnxruntime`, `numpy`, `tokenizers` | Stage 04 + 05 (embedding, vector search) |
|
|
51
|
+
| `all` | all of the above | everything — this is what the examples below assume |
|
|
52
|
+
| `dev` | all of the above + `pytest`, `black`, `mypy`, `reportlab` | running the test suite |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## 00 — The example we'll follow
|
|
57
|
+
|
|
58
|
+
Everything below traces what happens to one folder, `research_notes/`, which has one file of each supported type:
|
|
59
|
+
|
|
60
|
+
| File | What it is | Read via |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `intro.md` | Markdown notes with headings and paragraphs | read directly |
|
|
63
|
+
| `raw_dump.txt` | Unformatted plain-text field notes | read directly |
|
|
64
|
+
| `q3_report.pdf` | A two-line PDF report | `pypdf` |
|
|
65
|
+
| `meeting_minutes.docx` | A two-paragraph Word document | `python-docx` |
|
|
66
|
+
|
|
67
|
+
Four different file formats, one consistent goal: turn each into a plain string so the rest of the pipeline never has to know or care what format it came from.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 01 — Ingestion
|
|
72
|
+
|
|
73
|
+
### Walking the folder, one file at a time
|
|
74
|
+
|
|
75
|
+
`stream_directory()` uses Python's `os.walk` to recurse through every subfolder. For each file, it checks the extension against a dispatch table and decides how to read it — then **yields** the result immediately rather than collecting everything into a list first.
|
|
76
|
+
|
|
77
|
+
> **Why a generator?** If you point this at a directory with 10,000 files, a plain `return list_of_everything` would hold every file's full text in memory simultaneously. `yield` hands off one file at a time, so memory use stays flat no matter how large the folder is.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# src/ragmill/engine.py — stream_directory()
|
|
81
|
+
for root, _, files in os.walk(directory_path):
|
|
82
|
+
for file in files:
|
|
83
|
+
extension = os.path.splitext(file)[1].lower()
|
|
84
|
+
if extension not in PLAIN_TEXT_EXTENSIONS + PDF_EXTENSIONS + DOCX_EXTENSIONS:
|
|
85
|
+
continue # unsupported file, skip silently
|
|
86
|
+
|
|
87
|
+
content = self._extract_content(full_path, extension)
|
|
88
|
+
yield {"source_path": ..., "filename": file, "raw_content": content.strip()}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The actual reading is dispatched by extension. Plain text formats are read directly; PDF and DOCX are handed off to dedicated parser functions:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# src/ragmill/engine.py — _extract_content()
|
|
95
|
+
if extension in PLAIN_TEXT_EXTENSIONS: # .txt .md .log .rst
|
|
96
|
+
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
97
|
+
return f.read()
|
|
98
|
+
if extension in PDF_EXTENSIONS:
|
|
99
|
+
return extract_pdf_text(full_path)
|
|
100
|
+
if extension in DOCX_EXTENSIONS:
|
|
101
|
+
return extract_docx_text(full_path)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Here's what each of the four example files actually produces at this stage — this is real output from running the code, not a mock-up:
|
|
105
|
+
|
|
106
|
+
| File | `raw_content` (first chars) |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `intro.md` | `"# Q3 Research Notes\n\nWe spent the quarter…"` |
|
|
109
|
+
| `raw_dump.txt` | `"raw dump\nunstructured field notes go here…"` |
|
|
110
|
+
| `q3_report.pdf` | `"Q3 Vendor Comparison Report\nLangChain median…"` |
|
|
111
|
+
| `meeting_minutes.docx` | `"Meeting Minutes: Ingestion Bakeoff\n\nDecision…"` |
|
|
112
|
+
|
|
113
|
+
Notice the PDF and DOCX text arrives looking just like the plain-text files — page breaks and paragraph breaks collapsed into plain `\n` characters. That normalization is the entire point of this stage: whatever comes out, the chunker downstream treats identically.
|
|
114
|
+
|
|
115
|
+
> **Why the imports are hidden inside the functions.** `parsers.py` only imports `pypdf` and `python-docx` *inside* the extractor functions, not at the top of the file. That means `import ragmill` never touches those libraries — someone who only needs `.txt`/`.md` support can install the core package with zero dependencies. If a PDF/DOCX extra isn't installed, calling that function raises a clear `ImportError` telling you exactly which extra to install — caught by the `try/except` in `stream_directory`, so one bad or unsupported file doesn't kill the whole run.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 02 — Semantic Chunking
|
|
120
|
+
|
|
121
|
+
### Splitting text without breaking sentences
|
|
122
|
+
|
|
123
|
+
`semantic_chunking()` takes one file's full text and turns it into a list of chunks no longer than `chunk_size` characters. The rule it follows: **never cut in the middle of a sentence if a cleaner boundary is available.**
|
|
124
|
+
|
|
125
|
+
It works in two passes:
|
|
126
|
+
|
|
127
|
+
1. **Split on blank lines.** `re.split(r'\n\s*\n', text)` breaks the text into paragraphs. Each paragraph gets appended to a running buffer until adding the next one would exceed `chunk_size` — at that point the buffer is closed off as a finished chunk.
|
|
128
|
+
2. **Fallback to sentences.** If a single paragraph is already longer than `chunk_size` on its own (common with dense PDF text with no paragraph breaks), it's split again on sentence-ending punctuation and rebuilt sentence-by-sentence instead.
|
|
129
|
+
|
|
130
|
+
### The overlap mechanic, traced for real
|
|
131
|
+
|
|
132
|
+
Every time a chunk closes, the *last `overlap` characters* of it are carried forward as the start of the next chunk. This is what lets a chunk "remember" what came right before it. Below is the actual output of `chunk_size=60, overlap=15` on this real sentence:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
"RAGMill crawls the folder using os.walk. It never loads
|
|
136
|
+
more than one file into memory at a time. Then it splits
|
|
137
|
+
text into overlapping chunks."
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
| Chunk | Content | Len | Carried to next chunk |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| 0 | `RAGMill crawls the folder using os.walk.` | 40 | `" using os.walk."` |
|
|
143
|
+
| 1 | `` `using os.walk.` `` It never loads more than one file into memory at a time. | 71 | `"mory at a time."` |
|
|
144
|
+
| 2 | `` `mory at a time.` `` Then it splits text into overlapping chunks. | 60 | — (last chunk) |
|
|
145
|
+
|
|
146
|
+
> **An honest edge case worth knowing.** Look closely at chunk 2's overlap: `"mory at a time."` — that's a raw character slice of the previous chunk's last 15 characters, and it happens to land mid-word, inside `"memory"`. The overlap is character-based, not word-aware. It still does its job (carrying real context forward), but it isn't going to respect word boundaries the way the paragraph/sentence splitting does.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# src/ragmill/engine.py — semantic_chunking(), the overlap line
|
|
150
|
+
overlap_prefix = current_buffer[-self.overlap:] if len(current_buffer) >= self.overlap else current_buffer
|
|
151
|
+
current_buffer = f"{overlap_prefix} {sentence}".strip() if self.overlap > 0 else sentence
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 03 — Pipeline Assembly
|
|
157
|
+
|
|
158
|
+
### Tying ingestion and chunking together
|
|
159
|
+
|
|
160
|
+
`execute_pipeline()` is the orchestrator — it's the only method most callers actually need. For every file `stream_directory` yields, it runs `semantic_chunking` on that file's text, then wraps every resulting chunk in a metadata envelope:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
# src/ragmill/engine.py — execute_pipeline()
|
|
164
|
+
for file_manifest in self.stream_directory(directory_path):
|
|
165
|
+
text_chunks = self.semantic_chunking(file_manifest["raw_content"])
|
|
166
|
+
for index, chunk in enumerate(text_chunks):
|
|
167
|
+
pipeline_payloads.append({
|
|
168
|
+
"metadata": {
|
|
169
|
+
"source_file": file_manifest["source_path"],
|
|
170
|
+
"filename": file_manifest["filename"],
|
|
171
|
+
"chunk_index": index,
|
|
172
|
+
"character_length": len(chunk)
|
|
173
|
+
},
|
|
174
|
+
"content": chunk
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
So the final shape of every item in the returned list — regardless of whether it started life as a `.txt`, `.pdf`, or `.docx` file — is identical:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"metadata": {
|
|
183
|
+
"source_file": "/…/research_notes/meeting_minutes.docx",
|
|
184
|
+
"filename": "meeting_minutes.docx",
|
|
185
|
+
"chunk_index": 0,
|
|
186
|
+
"character_length": 96
|
|
187
|
+
},
|
|
188
|
+
"content": "Meeting Minutes: Ingestion Bakeoff\n\nDecision: ship pypdf and python-docx as optional extras…"
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## 04 — Embeddings
|
|
195
|
+
|
|
196
|
+
### Turning chunks into vectors, fully offline after the first run
|
|
197
|
+
|
|
198
|
+
`EmbeddingModel` wraps a small quantized ONNX model (`Xenova/all-MiniLM-L6-v2`, ~23MB) plus its tokenizer. On first use it downloads both files to `~/.cache/ragmill/models/` — every call after that runs with zero network access.
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
# src/ragmill/embeddings.py — EmbeddingModel.embed()
|
|
202
|
+
encodings = self.tokenizer.encode_batch(texts)
|
|
203
|
+
input_ids = np.array([e.ids for e in encodings], dtype=np.int64)
|
|
204
|
+
attention_mask = np.array([e.attention_mask for e in encodings], dtype=np.int64)
|
|
205
|
+
|
|
206
|
+
outputs = self.session.run(None, {
|
|
207
|
+
"input_ids": input_ids,
|
|
208
|
+
"attention_mask": attention_mask,
|
|
209
|
+
"token_type_ids": np.zeros_like(input_ids),
|
|
210
|
+
})
|
|
211
|
+
token_embeddings = outputs[0] # (batch, seq_len, 384) — one vector per token
|
|
212
|
+
|
|
213
|
+
# mean-pool token vectors into one vector per sentence, ignoring padding
|
|
214
|
+
mask = attention_mask[:, :, np.newaxis].astype(np.float32)
|
|
215
|
+
pooled = (token_embeddings * mask).sum(axis=1) / np.clip(mask.sum(axis=1), 1e-9, None)
|
|
216
|
+
|
|
217
|
+
# L2-normalize so cosine similarity becomes a plain dot product later
|
|
218
|
+
return pooled / np.clip(np.linalg.norm(pooled, axis=1, keepdims=True), 1e-9, None)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The ONNX model outputs one 384-dimensional vector *per token*, not per sentence — mean-pooling across the real tokens (via the attention mask, so padding doesn't skew the average) is what collapses a whole sentence down to one vector. Real output, run on the four `research_notes/` chunks:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
model.embed([chunk["content"] for chunk in chunks]).shape
|
|
225
|
+
# (4, 384)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
> **Why L2-normalize here instead of at search time?** Once every vector has length 1, cosine similarity between two vectors is just their dot product — no division needed. Normalizing once at embedding time means every future search is a single matrix multiply instead of a similarity formula.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 05 — Vector Storage
|
|
233
|
+
|
|
234
|
+
### A SQLite table plus a matrix multiply
|
|
235
|
+
|
|
236
|
+
`VectorStore` is deliberately not a specialized vector database — it's a SQLite table (`source_file`, `filename`, `chunk_index`, `content`, `embedding` as a raw float32 blob) plus a brute-force similarity scan. At the scale this library targets — one local folder's worth of documents, not a billion-row index — loading every embedding into memory and scoring them in one matrix multiply is fast enough and avoids pulling in FAISS or a native SQLite extension.
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
# src/ragmill/vector_store.py — VectorStore.search()
|
|
240
|
+
vectors = np.stack([np.frombuffer(row[4], dtype=np.float32) for row in rows])
|
|
241
|
+
scores = vectors @ query_vector # pre-normalized vectors -> dot product == cosine similarity
|
|
242
|
+
top_indices = np.argsort(-scores)[:top_k]
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Real trace: after embedding and storing all four `research_notes/` chunks, querying `"How fast is RAGMill compared to other tools?"` returns:
|
|
246
|
+
|
|
247
|
+
| Score | File | Content |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| 0.315 | `q3_report.pdf` | "Q3 Vendor Comparison Report\nLangChain median parse time: 180ms per fil…" |
|
|
250
|
+
| 0.161 | `intro.md` | "# Q3 Research Notes\n\nWe spent the quarter benchmarking local ingestion…" |
|
|
251
|
+
| 0.130 | `meeting_minutes.docx` | "Meeting Minutes: Ingestion Bakeoff\n\nDecision: ship pypdf and python-do…" |
|
|
252
|
+
|
|
253
|
+
The PDF chunk that actually mentions parse speed comes out on top, without keyword matching — the search never sees the literal word "fast." That's the entire payoff of the embedding stage: it ranks by meaning, not by shared words.
|
|
254
|
+
|
|
255
|
+
### Narrowing a search before it scores anything
|
|
256
|
+
|
|
257
|
+
`search()` also accepts `filename`, `source_file`, `modified_after`, and `modified_before`. These become a SQL `WHERE` clause that runs *before* the similarity scan — so filtering to one file isn't just "hide results after the fact," it's fewer rows loaded into the matrix multiply in the first place:
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
# src/ragmill/vector_store.py — VectorStore.search()
|
|
261
|
+
if filename is not None:
|
|
262
|
+
clauses.append("filename = ?")
|
|
263
|
+
params.append(filename)
|
|
264
|
+
...
|
|
265
|
+
query = "SELECT source_file, filename, chunk_index, content, embedding FROM chunks"
|
|
266
|
+
if clauses:
|
|
267
|
+
query += " WHERE " + " AND ".join(clauses)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Real trace, filtering the same query down to one file:
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
store.search(query_vector, top_k=5, filename="q3_report.pdf")
|
|
274
|
+
# -> ["q3_report.pdf"] (1 result, only that file)
|
|
275
|
+
store.search(query_vector, top_k=5)
|
|
276
|
+
# -> ["q3_report.pdf", "meeting_minutes.docx", "raw_dump.txt", "intro.md"]
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## 06 — Incremental Sync
|
|
282
|
+
|
|
283
|
+
### Not re-embedding the whole folder every time
|
|
284
|
+
|
|
285
|
+
`execute_pipeline()` has no memory of previous runs — call it twice and you chunk and embed everything twice. `sync_directory()` fixes that by tracking one SHA-256 hash per file (in the `file_state` table) and comparing it against what's already stored:
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
# src/ragmill/sync.py — sync_directory()
|
|
289
|
+
content_hash = _hash_content(file_manifest["raw_content"])
|
|
290
|
+
existing_state = store.get_file_state(source_file)
|
|
291
|
+
|
|
292
|
+
if existing_state is not None and existing_state["content_hash"] == content_hash:
|
|
293
|
+
skipped += 1
|
|
294
|
+
continue # unchanged — never touches semantic_chunking() or model.embed()
|
|
295
|
+
|
|
296
|
+
...
|
|
297
|
+
store.delete_by_source(source_file) # drop old chunks first, so an update doesn't leave stale ones behind
|
|
298
|
+
vectors = model.embed([p["content"] for p in payloads])
|
|
299
|
+
store.add(payloads, vectors)
|
|
300
|
+
store.upsert_file_state(source_file, content_hash, file_manifest["modified_at"])
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
After every file is walked, anything left in `file_state` that wasn't seen this run gets removed — that's how a file deleted from disk gets its chunks deleted from the store too:
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
deleted = store.delete_missing_sources(seen_sources)
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Real trace on `research_notes/` (4 files):
|
|
310
|
+
|
|
311
|
+
| Call | Result |
|
|
312
|
+
|---|---|
|
|
313
|
+
| 1st `sync_directory()` | `{"added": 4, "updated": 0, "skipped": 0, "deleted": 0}` |
|
|
314
|
+
| 2nd `sync_directory()`, nothing changed | `{"added": 0, "updated": 0, "skipped": 4, "deleted": 0}` |
|
|
315
|
+
| edit one file's content, sync again | `{"added": 0, "updated": 1, "skipped": 3, "deleted": 0}` |
|
|
316
|
+
| delete one file, sync again | `{"added": 0, "updated": 0, "skipped": 2, "deleted": 1}` |
|
|
317
|
+
|
|
318
|
+
> **Why the hash is on the file's raw content, not the file's mtime.** Touching a file (e.g. `git checkout`) can bump its modification time without changing a single character inside it. Hashing `raw_content` means a file only counts as "changed" if its actual text changed — `modified_at` is still captured and stored (useful for search filtering, see above), but it isn't what drives the skip/update decision.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Try it yourself
|
|
323
|
+
|
|
324
|
+
Install with the extras you need, then run the full loop — ingest, chunk, embed, store, search:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
pip install -e ".[all]" # core + pdf + docx + embeddings
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
from ragmill import RAGEngine
|
|
332
|
+
from ragmill.embeddings import EmbeddingModel
|
|
333
|
+
from ragmill.vector_store import VectorStore
|
|
334
|
+
|
|
335
|
+
chunks = RAGEngine(chunk_size=500, overlap=50).execute_pipeline("./research_notes")
|
|
336
|
+
|
|
337
|
+
model = EmbeddingModel() # downloads the model once, caches it after
|
|
338
|
+
vectors = model.embed([c["content"] for c in chunks])
|
|
339
|
+
|
|
340
|
+
store = VectorStore("research_notes.db")
|
|
341
|
+
store.add(chunks, vectors)
|
|
342
|
+
|
|
343
|
+
query_vector = model.embed(["how fast is this compared to other tools?"])[0]
|
|
344
|
+
for result in store.search(query_vector, top_k=3):
|
|
345
|
+
print(round(result["score"], 3), result["metadata"]["filename"], "->", result["content"][:70])
|
|
346
|
+
|
|
347
|
+
# run again later — unchanged files are skipped, not re-embedded
|
|
348
|
+
from ragmill.sync import sync_directory
|
|
349
|
+
print(sync_directory("./research_notes", RAGEngine(chunk_size=500, overlap=50), model, store))
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## How this is verified
|
|
355
|
+
|
|
356
|
+
Every claim above has a corresponding test — 31 in total, all passing:
|
|
357
|
+
|
|
358
|
+
| Stage | Test file | What it actually checks |
|
|
359
|
+
|---|---|---|
|
|
360
|
+
| 01 Ingestion | `tests/test_ingestion.py` | all four formats parse correctly; unsupported extensions are skipped; a missing directory raises `FileNotFoundError` |
|
|
361
|
+
| 02 Chunking | `tests/test_chunking.py` | paragraph/sentence boundary splitting, the overlap carry-forward, and the invalid-config `ValueError` |
|
|
362
|
+
| 04 Embeddings | `tests/test_embeddings.py` | output shape is `(n, 384)`, vectors are unit-normalized, and semantically related sentences score higher than unrelated ones — skips itself if the model can't download (no network) |
|
|
363
|
+
| 05 Vector storage | `tests/test_vector_store.py` | nearest-vector ranking, `filename`/`source_file`/`modified_at` filtering, `file_state` roundtrips, `delete_by_source`, `delete_missing_sources`, and data surviving a close/reopen |
|
|
364
|
+
| 06 Incremental sync | `tests/test_sync.py` | first sync adds everything; a no-op second sync skips everything; editing a file's content triggers an update (no duplicate rows); deleting a file removes its chunks on the next sync |
|
|
365
|
+
|
|
366
|
+
Run them yourself with `pytest tests/ -v` after the setup steps above.
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## What's still not built
|
|
371
|
+
|
|
372
|
+
- **No approximate nearest-neighbor index.** The brute-force scan in `VectorStore.search()` is O(n) per query — fine for thousands of chunks, not for millions. Scaling past that would mean swapping in something like `sqlite-vec` or FAISS behind the same `search()` interface.
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
*Traced from `src/ragmill/engine.py`, `src/ragmill/parsers.py`, `src/ragmill/embeddings.py`, `src/ragmill/vector_store.py`, and `src/ragmill/sync.py`. Every code sample and table above is real output — nothing here was invented for illustration.*
|
ragmill-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdullah Bin Aqeel
|
|
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.
|
ragmill-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ragmill
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A lightweight, zero-config local pipeline engine for AI data ingestion, semantic chunking, embeddings, and vector search.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Abdullahbinaqeel/RAGMill
|
|
6
|
+
Project-URL: Repository, https://github.com/Abdullahbinaqeel/RAGMill
|
|
7
|
+
Project-URL: Issues, https://github.com/Abdullahbinaqeel/RAGMill/issues
|
|
8
|
+
Author-email: Abdullah Bin Aqeel <abdulbinaqeel@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: data-pipeline,llm-ingestion,onnx,rag,semantic-chunking,vector-embeddings,vector-search
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Provides-Extra: all
|
|
24
|
+
Requires-Dist: numpy>=1.22.0; extra == 'all'
|
|
25
|
+
Requires-Dist: onnxruntime>=1.14.0; extra == 'all'
|
|
26
|
+
Requires-Dist: pypdf>=4.0; extra == 'all'
|
|
27
|
+
Requires-Dist: python-docx>=1.0; extra == 'all'
|
|
28
|
+
Requires-Dist: tokenizers>=0.15.0; extra == 'all'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: black>=23.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: numpy>=1.22.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: onnxruntime>=1.14.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pypdf>=4.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: python-docx>=1.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: reportlab>=4.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: tokenizers>=0.15.0; extra == 'dev'
|
|
39
|
+
Provides-Extra: docx
|
|
40
|
+
Requires-Dist: python-docx>=1.0; extra == 'docx'
|
|
41
|
+
Provides-Extra: embeddings
|
|
42
|
+
Requires-Dist: numpy>=1.22.0; extra == 'embeddings'
|
|
43
|
+
Requires-Dist: onnxruntime>=1.14.0; extra == 'embeddings'
|
|
44
|
+
Requires-Dist: tokenizers>=0.15.0; extra == 'embeddings'
|
|
45
|
+
Provides-Extra: pdf
|
|
46
|
+
Requires-Dist: pypdf>=4.0; extra == 'pdf'
|
|
47
|
+
Description-Content-Type: text/markdown
|
|
48
|
+
|
|
49
|
+
# RAGMill
|
|
50
|
+
|
|
51
|
+
[](https://pypi.org/project/ragmill/)
|
|
52
|
+
[](https://github.com/Abdullahbinaqeel/RAGMill/actions/workflows/ci.yml)
|
|
53
|
+
[](LICENSE)
|
|
54
|
+
[](https://pypi.org/project/ragmill/)
|
|
55
|
+
|
|
56
|
+
A lightweight, zero-config local pipeline engine for AI data ingestion, semantic chunking, embeddings, and vector search.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install ragmill[all] # includes PDF + DOCX + embeddings support
|
|
62
|
+
# or
|
|
63
|
+
pip install ragmill # core only (txt/md), zero dependencies
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Developing locally instead? Clone the repo and use an editable install:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install -e ".[dev]"
|
|
70
|
+
pytest tests/ -v
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
### Ingest + chunk
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from ragmill import RAGEngine
|
|
79
|
+
|
|
80
|
+
engine = RAGEngine(chunk_size=500, overlap=50)
|
|
81
|
+
chunks = engine.execute_pipeline("./my_documents")
|
|
82
|
+
|
|
83
|
+
for chunk in chunks:
|
|
84
|
+
print(chunk["metadata"]["filename"], chunk["content"][:80])
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Supports `.txt`, `.md`, `.log`, `.rst`, `.pdf`, and `.docx` out of the box.
|
|
88
|
+
|
|
89
|
+
### Embed + search locally
|
|
90
|
+
|
|
91
|
+
Requires the `embeddings` extra (`pip install -e ".[embeddings]"`). The model
|
|
92
|
+
(a quantized MiniLM ONNX export, ~23MB) downloads once to
|
|
93
|
+
`~/.cache/ragmill/models` and runs fully offline after that.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from ragmill import RAGEngine
|
|
97
|
+
from ragmill.embeddings import EmbeddingModel
|
|
98
|
+
from ragmill.vector_store import VectorStore
|
|
99
|
+
|
|
100
|
+
chunks = RAGEngine().execute_pipeline("./my_documents")
|
|
101
|
+
|
|
102
|
+
model = EmbeddingModel()
|
|
103
|
+
vectors = model.embed([c["content"] for c in chunks])
|
|
104
|
+
|
|
105
|
+
store = VectorStore("my_documents.db") # or VectorStore() for in-memory
|
|
106
|
+
store.add(chunks, vectors)
|
|
107
|
+
|
|
108
|
+
query_vector = model.embed(["how does the overlap window work?"])[0]
|
|
109
|
+
for result in store.search(query_vector, top_k=3):
|
|
110
|
+
print(round(result["score"], 3), result["metadata"]["filename"], "->", result["content"][:80])
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Filter a search to a specific file or a time window:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
store.search(query_vector, top_k=3, filename="report.pdf")
|
|
117
|
+
store.search(query_vector, top_k=3, modified_after=1704067200.0) # since 2024-01-01
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Keep a store in sync with a folder
|
|
121
|
+
|
|
122
|
+
Re-embedding every file on every run is wasteful once a folder is large.
|
|
123
|
+
`sync_directory` tracks a content hash per file and only touches what
|
|
124
|
+
actually changed:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from ragmill.sync import sync_directory
|
|
128
|
+
|
|
129
|
+
result = sync_directory("./my_documents", engine, model, store)
|
|
130
|
+
print(result) # {"added": 2, "updated": 1, "skipped": 40, "deleted": 1}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Unchanged files are skipped without re-embedding. A changed file has its old
|
|
134
|
+
chunks replaced with new ones. A file removed from disk has its chunks
|
|
135
|
+
removed from the store on the next sync.
|