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.
Files changed (96) hide show
  1. {lodedb-0.1.2 → lodedb-0.2.0}/PKG-INFO +70 -1
  2. {lodedb-0.1.2 → lodedb-0.2.0}/README.md +69 -0
  3. {lodedb-0.1.2 → lodedb-0.2.0}/pyproject.toml +1 -1
  4. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/__init__.py +1 -1
  5. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/_commit_manifest.py +17 -2
  6. lodedb-0.2.0/src/lodedb/engine/_filter_plan.py +205 -0
  7. lodedb-0.2.0/src/lodedb/engine/_lexical.py +566 -0
  8. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/core.py +1194 -72
  9. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/index.py +171 -3
  10. lodedb-0.2.0/src/lodedb/engine/lexical_index_store.py +394 -0
  11. lodedb-0.2.0/src/lodedb/graph/__init__.py +13 -0
  12. lodedb-0.2.0/src/lodedb/graph/_store.py +355 -0
  13. lodedb-0.2.0/src/lodedb/graph/knowledge_graph.py +780 -0
  14. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/db.py +455 -23
  15. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/presets.py +31 -0
  16. {lodedb-0.1.2 → lodedb-0.2.0}/LICENSE +0 -0
  17. {lodedb-0.1.2 → lodedb-0.2.0}/NOTICE +0 -0
  18. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/__main__.py +0 -0
  19. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/config.py +0 -0
  20. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/__init__.py +0 -0
  21. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/_atomic_io.py +0 -0
  22. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/_filelock.py +0 -0
  23. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/_predicate.py +0 -0
  24. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/document_text_store.py +0 -0
  25. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/embedding_backends.py +0 -0
  26. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/gpu_turbovec.py +0 -0
  27. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/mps_turbovec.py +0 -0
  28. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/route_profiles.py +0 -0
  29. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/route_registry.py +0 -0
  30. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/runtime_policy.py +0 -0
  31. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/state_journal_store.py +0 -0
  32. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/turbovec_delta_store.py +0 -0
  33. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/turbovec_index.py +0 -0
  34. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/engine/turbovec_resident.py +0 -0
  35. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/__init__.py +0 -0
  36. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/backends.py +0 -0
  37. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/benchmark.py +0 -0
  38. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/cli.py +0 -0
  39. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/doctor.py +0 -0
  40. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/integrations/__init__.py +0 -0
  41. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/integrations/langchain.py +0 -0
  42. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/mcp_server.py +0 -0
  43. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/local/server.py +0 -0
  44. {lodedb-0.1.2 → lodedb-0.2.0}/src/lodedb/py.typed +0 -0
  45. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/Cargo.lock +0 -0
  46. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/Cargo.toml +0 -0
  47. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/Cargo.toml +0 -0
  48. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/README.md +0 -0
  49. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/build.rs +0 -0
  50. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/examples/dump_state.rs +0 -0
  51. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/examples/kernel_xtest.rs +0 -0
  52. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/codebook.rs +0 -0
  53. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/encode.rs +0 -0
  54. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/error.rs +0 -0
  55. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/id_map.rs +0 -0
  56. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/io.rs +0 -0
  57. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/lib.rs +0 -0
  58. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/pack.rs +0 -0
  59. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/rotation.rs +0 -0
  60. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/src/search.rs +0 -0
  61. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/codebook.rs +0 -0
  62. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/concurrent_search.rs +0 -0
  63. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/distortion.rs +0 -0
  64. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/encode.rs +0 -0
  65. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/encoded_rows.rs +0 -0
  66. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/filtering.rs +0 -0
  67. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/id_map.rs +0 -0
  68. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/input_validation.rs +0 -0
  69. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/io_versioning.rs +0 -0
  70. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/kernel_correctness.rs +0 -0
  71. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/lazy_init.rs +0 -0
  72. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/reconstruction.rs +0 -0
  73. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/rotation.rs +0 -0
  74. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/state_sequences.rs +0 -0
  75. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/swap_remove.rs +0 -0
  76. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec/tests/tqplus_calibration.rs +0 -0
  77. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/Cargo.toml +0 -0
  78. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/README.md +0 -0
  79. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/build.rs +0 -0
  80. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/__init__.py +0 -0
  81. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/_dedup.py +0 -0
  82. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/_persist.py +0 -0
  83. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/agno.py +0 -0
  84. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/haystack.py +0 -0
  85. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/langchain.py +0 -0
  86. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/python/turbovec/llama_index.py +0 -0
  87. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/src/lib.rs +0 -0
  88. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_agno.py +0 -0
  89. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_dedup.py +0 -0
  90. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_filtering.py +0 -0
  91. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_haystack.py +0 -0
  92. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_id_map.py +0 -0
  93. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_index.py +0 -0
  94. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_langchain.py +0 -0
  95. {lodedb-0.1.2 → lodedb-0.2.0}/third_party/turbovec/turbovec-python/tests/test_llama_index.py +0 -0
  96. {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.1.2
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.1.2"
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"
@@ -19,7 +19,7 @@ from lodedb.local import (
19
19
  from lodedb.local.cli import app, main
20
20
 
21
21
  # Keep in sync with `version` in pyproject.toml (the release workflow asserts they match).
22
- __version__ = "0.1.2"
22
+ __version__ = "0.2.0"
23
23
 
24
24
  __all__ = [
25
25
  "LOCAL_MODEL_PRESETS",
@@ -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. It is pinned by the root exactly like the JSON
182
- and TurboVec manifests, so raw text commits and rolls back atomically.
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