lodedb 0.1.2__tar.gz → 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.
- {lodedb-0.1.2 → lodedb-0.2.0}/PKG-INFO +70 -1
- {lodedb-0.1.2 → lodedb-0.2.0}/README.md +69 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/pyproject.toml +1 -1
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/__init__.py +1 -1
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/_commit_manifest.py +17 -2
- lodedb-0.2.0/src/lodedb/engine/_filter_plan.py +205 -0
- lodedb-0.2.0/src/lodedb/engine/_lexical.py +566 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/core.py +1194 -72
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/index.py +171 -3
- lodedb-0.2.0/src/lodedb/engine/lexical_index_store.py +394 -0
- lodedb-0.2.0/src/lodedb/graph/__init__.py +13 -0
- lodedb-0.2.0/src/lodedb/graph/_store.py +355 -0
- lodedb-0.2.0/src/lodedb/graph/knowledge_graph.py +780 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/db.py +455 -23
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/presets.py +31 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/LICENSE +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/NOTICE +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/__main__.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/config.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/__init__.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/_atomic_io.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/_filelock.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/_predicate.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/document_text_store.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/embedding_backends.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/gpu_turbovec.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/mps_turbovec.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/route_profiles.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/route_registry.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/runtime_policy.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/state_journal_store.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/turbovec_delta_store.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/turbovec_index.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/turbovec_resident.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/__init__.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/backends.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/benchmark.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/cli.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/doctor.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/integrations/__init__.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/integrations/langchain.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/mcp_server.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/server.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/py.typed +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/Cargo.lock +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/Cargo.toml +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/Cargo.toml +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/README.md +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/build.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/examples/dump_state.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/examples/kernel_xtest.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/codebook.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/encode.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/error.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/id_map.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/io.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/lib.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/pack.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/rotation.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/search.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/codebook.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/concurrent_search.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/distortion.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/encode.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/encoded_rows.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/filtering.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/id_map.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/input_validation.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/io_versioning.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/kernel_correctness.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/lazy_init.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/reconstruction.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/rotation.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/state_sequences.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/swap_remove.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/tqplus_calibration.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/Cargo.toml +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/README.md +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/build.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/__init__.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/_dedup.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/_persist.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/agno.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/haystack.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/langchain.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/llama_index.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/src/lib.rs +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_agno.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_dedup.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_filtering.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_haystack.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_id_map.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_index.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_langchain.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_llama_index.py +0 -0
- {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lodedb
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Classifier: Development Status :: 4 - Beta
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -121,6 +121,9 @@ for hits in db.search_many(["fox", "dog"], k=5): # batched; the GPU can serve
|
|
|
121
121
|
db.search("fox", k=5, filter={"topic": "animals"}) # bare scalar = exact
|
|
122
122
|
db.search("fox", k=5, filter={"$or": [{"topic": "animals"}, {"year": {"$gte": 2020}}]})
|
|
123
123
|
|
|
124
|
+
# hybrid search: vector recall plus exact lexical matches the embedding misses
|
|
125
|
+
db.search("E1234", k=5, mode="hybrid") # surfaces error codes, serials, dates in the body
|
|
126
|
+
|
|
124
127
|
db.get(fox) # -> "the quick brown fox jumps" (text retained by default)
|
|
125
128
|
db.persist() # durable .tvim/.tvd/.jsd snapshot; replays on reopen
|
|
126
129
|
```
|
|
@@ -139,6 +142,68 @@ reader.search("fox", k=5) # reads a committed snapshot
|
|
|
139
142
|
reader.add("nope") # raises ReadOnlyError
|
|
140
143
|
```
|
|
141
144
|
|
|
145
|
+
## Hybrid search
|
|
146
|
+
|
|
147
|
+
Vector search alone misses exact tokens the embedding does not capture: error codes
|
|
148
|
+
(`E1234`), serial numbers (`ABC-123`), dates (`2024-01-15`). Pass `mode="hybrid"` to run a
|
|
149
|
+
lexical BM25 ranker alongside the vector scan and fuse the two ranked lists with Reciprocal
|
|
150
|
+
Rank Fusion. The lexical ranker matches those tokens exactly, so a document whose body carries
|
|
151
|
+
the code is recovered even when the embedding ranks it nowhere near the top.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
db.add("the turbine tripped and reported fault code E1234 overnight", metadata={"unit": "t3"})
|
|
155
|
+
|
|
156
|
+
db.search("E1234", k=5) # mode="vector" (default): may miss the exact code
|
|
157
|
+
db.search("E1234", k=5, mode="hybrid") # BM25 + RRF: surfaces it in the top-k
|
|
158
|
+
db.search("E1234", k=5, mode="lexical") # BM25 ranking alone, no vector scan
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Prerequisites
|
|
162
|
+
|
|
163
|
+
`mode="hybrid"` and `mode="lexical"` build a BM25 index over your text, so they need a text
|
|
164
|
+
source enabled when you open the database. `mode="vector"` (the default) needs nothing.
|
|
165
|
+
|
|
166
|
+
| Mode | Enable | Source of the BM25 index |
|
|
167
|
+
| --- | --- | --- |
|
|
168
|
+
| `"vector"` (default) | nothing | not used |
|
|
169
|
+
| `"hybrid"`, `"lexical"` | `store_text=True` (on by default) | rebuilt in memory from the retained raw text |
|
|
170
|
+
| `"hybrid"`, `"lexical"` | `index_text=True` | a durable on-disk postings store, no raw text required |
|
|
171
|
+
|
|
172
|
+
Either source is enough and you can enable both. `store_text=True` is the default, so hybrid
|
|
173
|
+
works out of the box. With neither source enabled, a hybrid or lexical query raises a clear,
|
|
174
|
+
actionable error rather than silently degrading.
|
|
175
|
+
|
|
176
|
+
### How it works
|
|
177
|
+
|
|
178
|
+
A `filter` constrains both rankers, so `mode="hybrid"` with a filter returns the true top-k of
|
|
179
|
+
the matching subset. The vector half of a hybrid query runs on the same scan as `mode="vector"`,
|
|
180
|
+
including the GPU-resident batch scan that serves `search_many`; only the BM25 ranking and the
|
|
181
|
+
fusion run on the CPU, and the vector kernel and on-disk format are untouched. The serving BM25
|
|
182
|
+
index lives in memory and is maintained incrementally: a small mutation folds just the changed
|
|
183
|
+
chunks into the existing index, so a single `add` never forces a full re-tokenization.
|
|
184
|
+
|
|
185
|
+
### Durable lexical index (`index_text=True`)
|
|
186
|
+
|
|
187
|
+
By default the BM25 index is rebuilt from the retained raw text, so it needs `store_text=True`
|
|
188
|
+
and is re-tokenized on the first hybrid query after opening. Pass `index_text=True` to persist
|
|
189
|
+
it instead: each document's per-chunk terms are captured at `add` time into a dedicated `.tvlex`
|
|
190
|
+
sidecar (a base plus a `.lxd` delta journal, committed O(changed) per write), so hybrid and
|
|
191
|
+
lexical search survive a reopen rebuilt straight from the persisted terms, with no
|
|
192
|
+
re-tokenization and without retaining the raw text at all. The `.tvlex` sidecar holds
|
|
193
|
+
payload-derived terms only and, like the raw-text sidecar, never reaches the redacted artifacts
|
|
194
|
+
or telemetry. The tokenizer lowercases and splits on punctuation but keeps code-like tokens
|
|
195
|
+
whole, so `ABC-123` and `2024-01-15` stay findable as single tokens. Reopen with the same
|
|
196
|
+
`index_text` value you wrote with.
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
db = LodeDB(path="./data", index_text=True, store_text=False) # durable lexical index, no raw text
|
|
200
|
+
db.add("the turbine tripped and reported fault code E1234 overnight")
|
|
201
|
+
db.close()
|
|
202
|
+
|
|
203
|
+
reopened = LodeDB(path="./data", index_text=True, store_text=False)
|
|
204
|
+
reopened.search("E1234", k=5, mode="hybrid") # works after reopen, rebuilt from persisted terms
|
|
205
|
+
```
|
|
206
|
+
|
|
142
207
|
## GPU-resident index
|
|
143
208
|
|
|
144
209
|
With the `[gpu]` extra on a CUDA host, LodeDB reconstructs the compact index into an fp16
|
|
@@ -255,6 +320,10 @@ lodedb benchmark # local, metrics-only benchmark
|
|
|
255
320
|
faster on the measured M1, so the MPS scan stays off by default until newer Apple GPUs are
|
|
256
321
|
re-measured.
|
|
257
322
|
- **Single queries run on the CPU**; the GPU serves batched `search_many`.
|
|
323
|
+
- **Hybrid search needs a lexical source and serves from memory.** `mode="hybrid"`/`"lexical"`
|
|
324
|
+
need either `store_text=True` (the index built from raw text) or `index_text=True` (a durable
|
|
325
|
+
`.tvlex` postings store that survives reopens without raw text). The serving index is held in
|
|
326
|
+
memory and maintained incrementally across mutations.
|
|
258
327
|
- **Single writer per path.** One writer at a time (many concurrent readers), with no live
|
|
259
328
|
cross-process refresh, on local filesystems only. See
|
|
260
329
|
[Concurrency & durability](#concurrency--durability).
|
|
@@ -87,6 +87,9 @@ for hits in db.search_many(["fox", "dog"], k=5): # batched; the GPU can serve
|
|
|
87
87
|
db.search("fox", k=5, filter={"topic": "animals"}) # bare scalar = exact
|
|
88
88
|
db.search("fox", k=5, filter={"$or": [{"topic": "animals"}, {"year": {"$gte": 2020}}]})
|
|
89
89
|
|
|
90
|
+
# hybrid search: vector recall plus exact lexical matches the embedding misses
|
|
91
|
+
db.search("E1234", k=5, mode="hybrid") # surfaces error codes, serials, dates in the body
|
|
92
|
+
|
|
90
93
|
db.get(fox) # -> "the quick brown fox jumps" (text retained by default)
|
|
91
94
|
db.persist() # durable .tvim/.tvd/.jsd snapshot; replays on reopen
|
|
92
95
|
```
|
|
@@ -105,6 +108,68 @@ reader.search("fox", k=5) # reads a committed snapshot
|
|
|
105
108
|
reader.add("nope") # raises ReadOnlyError
|
|
106
109
|
```
|
|
107
110
|
|
|
111
|
+
## Hybrid search
|
|
112
|
+
|
|
113
|
+
Vector search alone misses exact tokens the embedding does not capture: error codes
|
|
114
|
+
(`E1234`), serial numbers (`ABC-123`), dates (`2024-01-15`). Pass `mode="hybrid"` to run a
|
|
115
|
+
lexical BM25 ranker alongside the vector scan and fuse the two ranked lists with Reciprocal
|
|
116
|
+
Rank Fusion. The lexical ranker matches those tokens exactly, so a document whose body carries
|
|
117
|
+
the code is recovered even when the embedding ranks it nowhere near the top.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
db.add("the turbine tripped and reported fault code E1234 overnight", metadata={"unit": "t3"})
|
|
121
|
+
|
|
122
|
+
db.search("E1234", k=5) # mode="vector" (default): may miss the exact code
|
|
123
|
+
db.search("E1234", k=5, mode="hybrid") # BM25 + RRF: surfaces it in the top-k
|
|
124
|
+
db.search("E1234", k=5, mode="lexical") # BM25 ranking alone, no vector scan
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Prerequisites
|
|
128
|
+
|
|
129
|
+
`mode="hybrid"` and `mode="lexical"` build a BM25 index over your text, so they need a text
|
|
130
|
+
source enabled when you open the database. `mode="vector"` (the default) needs nothing.
|
|
131
|
+
|
|
132
|
+
| Mode | Enable | Source of the BM25 index |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `"vector"` (default) | nothing | not used |
|
|
135
|
+
| `"hybrid"`, `"lexical"` | `store_text=True` (on by default) | rebuilt in memory from the retained raw text |
|
|
136
|
+
| `"hybrid"`, `"lexical"` | `index_text=True` | a durable on-disk postings store, no raw text required |
|
|
137
|
+
|
|
138
|
+
Either source is enough and you can enable both. `store_text=True` is the default, so hybrid
|
|
139
|
+
works out of the box. With neither source enabled, a hybrid or lexical query raises a clear,
|
|
140
|
+
actionable error rather than silently degrading.
|
|
141
|
+
|
|
142
|
+
### How it works
|
|
143
|
+
|
|
144
|
+
A `filter` constrains both rankers, so `mode="hybrid"` with a filter returns the true top-k of
|
|
145
|
+
the matching subset. The vector half of a hybrid query runs on the same scan as `mode="vector"`,
|
|
146
|
+
including the GPU-resident batch scan that serves `search_many`; only the BM25 ranking and the
|
|
147
|
+
fusion run on the CPU, and the vector kernel and on-disk format are untouched. The serving BM25
|
|
148
|
+
index lives in memory and is maintained incrementally: a small mutation folds just the changed
|
|
149
|
+
chunks into the existing index, so a single `add` never forces a full re-tokenization.
|
|
150
|
+
|
|
151
|
+
### Durable lexical index (`index_text=True`)
|
|
152
|
+
|
|
153
|
+
By default the BM25 index is rebuilt from the retained raw text, so it needs `store_text=True`
|
|
154
|
+
and is re-tokenized on the first hybrid query after opening. Pass `index_text=True` to persist
|
|
155
|
+
it instead: each document's per-chunk terms are captured at `add` time into a dedicated `.tvlex`
|
|
156
|
+
sidecar (a base plus a `.lxd` delta journal, committed O(changed) per write), so hybrid and
|
|
157
|
+
lexical search survive a reopen rebuilt straight from the persisted terms, with no
|
|
158
|
+
re-tokenization and without retaining the raw text at all. The `.tvlex` sidecar holds
|
|
159
|
+
payload-derived terms only and, like the raw-text sidecar, never reaches the redacted artifacts
|
|
160
|
+
or telemetry. The tokenizer lowercases and splits on punctuation but keeps code-like tokens
|
|
161
|
+
whole, so `ABC-123` and `2024-01-15` stay findable as single tokens. Reopen with the same
|
|
162
|
+
`index_text` value you wrote with.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
db = LodeDB(path="./data", index_text=True, store_text=False) # durable lexical index, no raw text
|
|
166
|
+
db.add("the turbine tripped and reported fault code E1234 overnight")
|
|
167
|
+
db.close()
|
|
168
|
+
|
|
169
|
+
reopened = LodeDB(path="./data", index_text=True, store_text=False)
|
|
170
|
+
reopened.search("E1234", k=5, mode="hybrid") # works after reopen, rebuilt from persisted terms
|
|
171
|
+
```
|
|
172
|
+
|
|
108
173
|
## GPU-resident index
|
|
109
174
|
|
|
110
175
|
With the `[gpu]` extra on a CUDA host, LodeDB reconstructs the compact index into an fp16
|
|
@@ -221,6 +286,10 @@ lodedb benchmark # local, metrics-only benchmark
|
|
|
221
286
|
faster on the measured M1, so the MPS scan stays off by default until newer Apple GPUs are
|
|
222
287
|
re-measured.
|
|
223
288
|
- **Single queries run on the CPU**; the GPU serves batched `search_many`.
|
|
289
|
+
- **Hybrid search needs a lexical source and serves from memory.** `mode="hybrid"`/`"lexical"`
|
|
290
|
+
need either `store_text=True` (the index built from raw text) or `index_text=True` (a durable
|
|
291
|
+
`.tvlex` postings store that survives reopens without raw text). The serving index is held in
|
|
292
|
+
memory and maintained incrementally across mutations.
|
|
224
293
|
- **Single writer per path.** One writer at a time (many concurrent readers), with no live
|
|
225
294
|
cross-process refresh, on local filesystems only. See
|
|
226
295
|
[Concurrency & durability](#concurrency--durability).
|
|
@@ -5,7 +5,7 @@ build-backend = "maturin"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lodedb"
|
|
7
7
|
# Static (maturin reads it here); keep in sync with src/lodedb/__init__.py __version__.
|
|
8
|
-
version = "0.
|
|
8
|
+
version = "0.2.0"
|
|
9
9
|
description = "Local-first, privacy-by-default embedded vector database. Your data never leaves your machine."
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
requires-python = ">=3.11"
|
|
@@ -17,6 +17,8 @@ at which a full base was last written):
|
|
|
17
17
|
<key>.gen/g<epoch>.tvim.tvim-delta/ its ``.tvd`` vector journal
|
|
18
18
|
<key>.gen/g<epoch>.tvtext opt-in raw-text base (full id->text map)
|
|
19
19
|
<key>.gen/g<epoch>.tvtext.tvtext-delta/ its ``.txd`` raw-text journal
|
|
20
|
+
<key>.gen/g<epoch>.tvlex opt-in lexical-index base (id->token lists)
|
|
21
|
+
<key>.gen/g<epoch>.tvlex.tvlex-delta/ its ``.lxd`` lexical-index journal
|
|
20
22
|
|
|
21
23
|
and a single top-level pointer:
|
|
22
24
|
|
|
@@ -89,6 +91,12 @@ def base_tvtext_path(persistence_dir: str | Path, index_key: str, epoch: int) ->
|
|
|
89
91
|
return generation_dir(persistence_dir, index_key) / f"g{int(epoch)}.tvtext"
|
|
90
92
|
|
|
91
93
|
|
|
94
|
+
def base_tvlex_path(persistence_dir: str | Path, index_key: str, epoch: int) -> Path:
|
|
95
|
+
"""Returns the lexical-index base path for one index key and base epoch."""
|
|
96
|
+
|
|
97
|
+
return generation_dir(persistence_dir, index_key) / f"g{int(epoch)}.tvlex"
|
|
98
|
+
|
|
99
|
+
|
|
92
100
|
def is_commit_manifest_name(name: str) -> bool:
|
|
93
101
|
"""Returns whether a top-level file name is a root commit manifest."""
|
|
94
102
|
|
|
@@ -173,13 +181,19 @@ def build_commit_body(
|
|
|
173
181
|
tvim_manifest: dict[str, Any] | None,
|
|
174
182
|
tvim_present: bool,
|
|
175
183
|
tvtext_manifest: dict[str, Any] | None,
|
|
184
|
+
tvlex_manifest: dict[str, Any] | None = None,
|
|
176
185
|
) -> dict[str, Any]:
|
|
177
186
|
"""Assembles a root-manifest body capturing one consistent committed generation.
|
|
178
187
|
|
|
179
188
|
``tvtext_manifest`` is the raw-text journal manifest (base + delta segments)
|
|
180
189
|
for this committed generation, or ``None`` when raw-text storage is disabled
|
|
181
|
-
or the index holds no text.
|
|
182
|
-
|
|
190
|
+
or the index holds no text. ``tvlex_manifest`` is the analogous lexical-index
|
|
191
|
+
(BM25 postings) journal manifest, or ``None`` when lexical-index persistence
|
|
192
|
+
is disabled or the index holds no tokens. Both are pinned by the root exactly
|
|
193
|
+
like the JSON and TurboVec manifests, so the text sidecar and the lexical
|
|
194
|
+
sidecar commit and roll back atomically with the generation. ``tvlex`` is an
|
|
195
|
+
optional key read via ``.get`` (like ``tvtext``), so an older reader that
|
|
196
|
+
does not know it ignores it.
|
|
183
197
|
"""
|
|
184
198
|
|
|
185
199
|
return {
|
|
@@ -192,6 +206,7 @@ def build_commit_body(
|
|
|
192
206
|
"tvim": tvim_manifest,
|
|
193
207
|
"tvim_present": bool(tvim_present),
|
|
194
208
|
"tvtext": tvtext_manifest,
|
|
209
|
+
"tvlex": tvlex_manifest,
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Set-based resolution of a validated metadata filter to a document-id set.
|
|
2
|
+
|
|
3
|
+
Given a generation's in-memory per-field value indexes, this resolves a
|
|
4
|
+
*validated* metadata filter to the matching document-id set using set operations
|
|
5
|
+
and bisect, instead of the O(corpus) per-document compiled-matcher scan that
|
|
6
|
+
``LodeEngine._scan_filter_allowlist`` runs. Comparison semantics mirror
|
|
7
|
+
``_predicate`` exactly: this module reuses its number parsing and ordered
|
|
8
|
+
comparators, so an index-resolved set is identical to the compiled-matcher
|
|
9
|
+
result. (That identity is asserted in tests against the reference scan, which is
|
|
10
|
+
retained as the oracle.)
|
|
11
|
+
|
|
12
|
+
Stdlib-only; it must not import ``core``. ``_MetadataPostingIndex`` uses it for
|
|
13
|
+
both filtered search (the document set is expanded to chunk ids) and filtered
|
|
14
|
+
enumeration / count (the document set is used directly).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import bisect
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from lodedb.engine._predicate import _ORDERED_CMP, _as_number
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FieldIndex:
|
|
27
|
+
"""Per-key value index for one metadata field within a generation.
|
|
28
|
+
|
|
29
|
+
Holds the docs carrying the key, a value -> docs map, and the field's numeric
|
|
30
|
+
values sorted for bisect (with the non-numeric values kept aside so a numeric
|
|
31
|
+
range query does not have to scan every distinct value).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("docs", "value_docs", "_num_keys", "_num_values", "_nonnumeric_values")
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.docs: set[str] = set()
|
|
38
|
+
self.value_docs: dict[str, set[str]] = {}
|
|
39
|
+
self._num_keys: list[float] = []
|
|
40
|
+
self._num_values: list[str] = []
|
|
41
|
+
self._nonnumeric_values: list[str] = []
|
|
42
|
+
|
|
43
|
+
def finalize(self) -> None:
|
|
44
|
+
"""Partitions distinct values into a sorted numeric array + the rest."""
|
|
45
|
+
|
|
46
|
+
numeric: list[tuple[float, str]] = []
|
|
47
|
+
nonnumeric: list[str] = []
|
|
48
|
+
for value in self.value_docs:
|
|
49
|
+
number = _as_number(value)
|
|
50
|
+
if number is None:
|
|
51
|
+
nonnumeric.append(value)
|
|
52
|
+
else:
|
|
53
|
+
numeric.append((number, value))
|
|
54
|
+
numeric.sort()
|
|
55
|
+
self._num_keys = [number for number, _ in numeric]
|
|
56
|
+
self._num_values = [value for _, value in numeric]
|
|
57
|
+
self._nonnumeric_values = nonnumeric
|
|
58
|
+
|
|
59
|
+
def numeric_values_satisfying(self, op: str, operand_number: float) -> list[str]:
|
|
60
|
+
"""Returns value strings whose numeric value satisfies ``op operand`` (bisect)."""
|
|
61
|
+
|
|
62
|
+
keys = self._num_keys
|
|
63
|
+
if op == "$gt":
|
|
64
|
+
return self._num_values[bisect.bisect_right(keys, operand_number):]
|
|
65
|
+
if op == "$gte":
|
|
66
|
+
return self._num_values[bisect.bisect_left(keys, operand_number):]
|
|
67
|
+
if op == "$lt":
|
|
68
|
+
return self._num_values[: bisect.bisect_left(keys, operand_number)]
|
|
69
|
+
return self._num_values[: bisect.bisect_right(keys, operand_number)] # $lte
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_EMPTY_FIELD = FieldIndex()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_field_indexes(
|
|
76
|
+
document_metadata: Mapping[str, Mapping[str, str]],
|
|
77
|
+
) -> tuple[dict[str, FieldIndex], set[str]]:
|
|
78
|
+
"""Builds per-field value indexes and the full document-id set for a generation."""
|
|
79
|
+
|
|
80
|
+
fields: dict[str, FieldIndex] = {}
|
|
81
|
+
all_docs: set[str] = set()
|
|
82
|
+
for document_id, metadata in document_metadata.items():
|
|
83
|
+
doc = str(document_id)
|
|
84
|
+
all_docs.add(doc)
|
|
85
|
+
for key, value in metadata.items():
|
|
86
|
+
field = fields.get(str(key))
|
|
87
|
+
if field is None:
|
|
88
|
+
field = FieldIndex()
|
|
89
|
+
fields[str(key)] = field
|
|
90
|
+
field.docs.add(doc)
|
|
91
|
+
field.value_docs.setdefault(str(value), set()).add(doc)
|
|
92
|
+
for field in fields.values():
|
|
93
|
+
field.finalize()
|
|
94
|
+
return fields, all_docs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resolve(
|
|
98
|
+
metadata_filter: Mapping[str, Any],
|
|
99
|
+
fields: Mapping[str, FieldIndex],
|
|
100
|
+
all_docs: set[str],
|
|
101
|
+
) -> set[str]:
|
|
102
|
+
"""Resolves a validated metadata filter to the matching document-id set.
|
|
103
|
+
|
|
104
|
+
Entries of a node are AND-ed (Mongo semantics, matching ``_predicate``);
|
|
105
|
+
``$and``/``$or``/``$not`` recurse. Returns a fresh set the caller may mutate.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
result: set[str] | None = None
|
|
109
|
+
for key, spec in metadata_filter.items():
|
|
110
|
+
if key == "$and":
|
|
111
|
+
docs = _resolve_and(spec, fields, all_docs)
|
|
112
|
+
elif key == "$or":
|
|
113
|
+
docs = set()
|
|
114
|
+
for sub in spec:
|
|
115
|
+
docs |= resolve(sub, fields, all_docs)
|
|
116
|
+
elif key == "$not":
|
|
117
|
+
docs = all_docs - resolve(spec, fields, all_docs)
|
|
118
|
+
else:
|
|
119
|
+
docs = _resolve_field(key, spec, fields, all_docs)
|
|
120
|
+
result = docs if result is None else (result & docs)
|
|
121
|
+
if not result:
|
|
122
|
+
return set()
|
|
123
|
+
return set(all_docs) if result is None else result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_and(
|
|
127
|
+
subs: list[Mapping[str, Any]],
|
|
128
|
+
fields: Mapping[str, FieldIndex],
|
|
129
|
+
all_docs: set[str],
|
|
130
|
+
) -> set[str]:
|
|
131
|
+
result: set[str] | None = None
|
|
132
|
+
for sub in subs:
|
|
133
|
+
docs = resolve(sub, fields, all_docs)
|
|
134
|
+
result = docs if result is None else (result & docs)
|
|
135
|
+
if not result:
|
|
136
|
+
return set()
|
|
137
|
+
return set(all_docs) if result is None else result
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_field(
|
|
141
|
+
field: str,
|
|
142
|
+
spec: Any,
|
|
143
|
+
fields: Mapping[str, FieldIndex],
|
|
144
|
+
all_docs: set[str],
|
|
145
|
+
) -> set[str]:
|
|
146
|
+
index = fields.get(field, _EMPTY_FIELD)
|
|
147
|
+
if not isinstance(spec, Mapping):
|
|
148
|
+
# Bare scalar is exact-match sugar for $eq.
|
|
149
|
+
return set(index.value_docs.get(str(spec), ()))
|
|
150
|
+
result: set[str] | None = None
|
|
151
|
+
for op, operand in spec.items():
|
|
152
|
+
docs = _resolve_operator(op, operand, index, all_docs)
|
|
153
|
+
result = docs if result is None else (result & docs)
|
|
154
|
+
if not result:
|
|
155
|
+
return set()
|
|
156
|
+
return set(all_docs) if result is None else result
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_operator(
|
|
160
|
+
op: str,
|
|
161
|
+
operand: Any,
|
|
162
|
+
index: FieldIndex,
|
|
163
|
+
all_docs: set[str],
|
|
164
|
+
) -> set[str]:
|
|
165
|
+
if op == "$eq":
|
|
166
|
+
return set(index.value_docs.get(str(operand), ()))
|
|
167
|
+
if op == "$ne":
|
|
168
|
+
# A missing key satisfies $ne (its absent value is never equal to operand).
|
|
169
|
+
return all_docs - index.value_docs.get(str(operand), set())
|
|
170
|
+
if op == "$in":
|
|
171
|
+
docs: set[str] = set()
|
|
172
|
+
for value in operand:
|
|
173
|
+
docs |= index.value_docs.get(str(value), set())
|
|
174
|
+
return docs
|
|
175
|
+
if op == "$nin":
|
|
176
|
+
excluded: set[str] = set()
|
|
177
|
+
for value in operand:
|
|
178
|
+
excluded |= index.value_docs.get(str(value), set())
|
|
179
|
+
return all_docs - excluded
|
|
180
|
+
if op == "$exists":
|
|
181
|
+
return set(index.docs) if operand else (all_docs - index.docs)
|
|
182
|
+
# Ordered ($gt/$gte/$lt/$lte): a missing key never satisfies an ordered op.
|
|
183
|
+
return _resolve_ordered(op, str(operand), index)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _resolve_ordered(op: str, operand: str, index: FieldIndex) -> set[str]:
|
|
187
|
+
compare = _ORDERED_CMP[op]
|
|
188
|
+
operand_number = _as_number(operand)
|
|
189
|
+
docs: set[str] = set()
|
|
190
|
+
if operand_number is None:
|
|
191
|
+
# Non-numeric operand: every comparison is lexicographic (matching
|
|
192
|
+
# _predicate), so compare each stored value string to the operand.
|
|
193
|
+
for value, value_docs in index.value_docs.items():
|
|
194
|
+
if compare(value, operand):
|
|
195
|
+
docs |= value_docs
|
|
196
|
+
return docs
|
|
197
|
+
# Numeric operand: numeric-parseable stored values compare numerically
|
|
198
|
+
# (resolved by bisect); non-numeric stored values compare lexicographically
|
|
199
|
+
# against the operand string (kept to the small non-numeric set).
|
|
200
|
+
for value in index.numeric_values_satisfying(op, operand_number):
|
|
201
|
+
docs |= index.value_docs[value]
|
|
202
|
+
for value in index._nonnumeric_values:
|
|
203
|
+
if compare(value, operand):
|
|
204
|
+
docs |= index.value_docs[value]
|
|
205
|
+
return docs
|