cbrkit.indexable.lancedb
LanceDB storage backend.
1"""LanceDB storage backend.""" 2 3from collections.abc import Collection 4from dataclasses import dataclass, field 5from typing import Any, Literal, cast, override 6 7import lancedb as ldb 8import numpy as np 9from lancedb.index import FTS, BTree 10 11from ..helpers import get_logger 12from ..typing import BatchConversionFunc, Casebase, IndexableFunc, NumpyArray 13from ._common import RowCodec, _normalize_patch_keys, _sql_in_clause, make_codec 14 15logger = get_logger(__name__) 16 17 18@dataclass(slots=True) 19class lancedb[K: int | str, V = str](IndexableFunc[Casebase[K, V], Collection[K]]): 20 """LanceDB storage backend. 21 22 Manages an embedded LanceDB database on disk. Supports dense 23 (vector), sparse (FTS/BM25), and hybrid index types which 24 control what data is stored and what indices are built. 25 26 Warning: 27 Persisted vectors are tied to the 28 :paramref:`conversion_func` used when they were written. 29 Reopening a table backed by a different embedding model 30 silently returns wrong results when the new model has the 31 same dimension, and raises on `INSERT` when it does not — 32 :meth:`put_index` only re-embeds entries whose text 33 changed. Drop the table (or use a fresh `table_name`) 34 when changing models. 35 36 Args: 37 uri: Path to the LanceDB database directory. 38 table_name: Table name within the database. 39 index_type: Determines what data is stored and which 40 indices are created. `"dense"` stores embeddings, 41 `"sparse"` builds an FTS index, `"hybrid"` does 42 both. 43 conversion_func: Embedding function. Required for 44 `"dense"` and `"hybrid"` index types. 45 key_column: Column name for case keys. 46 value_column: Column holding the embeddable text. With the 47 default ``V = str`` the casebase value *is* this column; 48 with a *model* it names the model field to embed. 49 vector_column: Column name for dense embedding vectors. 50 model: A dataclass or pydantic :class:`~pydantic.BaseModel` 51 describing rows richer than plain text. When set, ``V`` is 52 the model type: every field becomes a stored column, 53 ``value_column`` names the embeddable field, and reads 54 reconstruct model instances. This replaces any side-channel 55 metadata — extra columns ride on the typed value itself. 56 """ 57 58 uri: str 59 table_name: str 60 index_type: Literal["dense", "sparse", "hybrid"] = "dense" 61 conversion_func: BatchConversionFunc[str, NumpyArray] | None = None 62 key_column: str = "key" 63 value_column: str = "value" 64 vector_column: str = "vector" 65 model: type[V] | None = None 66 _db: ldb.DBConnection = field(init=False, repr=False) 67 _table: ldb.Table | None = field(default=None, init=False, repr=False) 68 _codec: RowCodec[V] = field(init=False, repr=False) 69 70 def __post_init__(self) -> None: 71 if self.index_type in ("dense", "hybrid") and self.conversion_func is None: 72 raise ValueError( 73 f"conversion_func is required for index_type={self.index_type!r}" 74 ) 75 76 self._codec = make_codec( 77 self.model, self.value_column, key_column=self.key_column 78 ) 79 self._db = ldb.connect(self.uri) 80 81 if self.table_name in self._db.list_tables().tables: 82 self._table = self._db.open_table(self.table_name) 83 84 @override 85 def has_index(self) -> bool: 86 """Return whether a table exists in the database.""" 87 return self._table is not None 88 89 def search_limit(self) -> int | None: 90 """Return the total number of rows, or `None` when empty.""" 91 if self._table is None: 92 return None 93 94 return self._table.count_rows() 95 96 def _build_rows(self, casebase: Casebase[K, V]) -> list[dict[str, Any]]: 97 """Build row dicts for LanceDB from a casebase.""" 98 codec = self._codec 99 keys = list(casebase.keys()) 100 rows = [codec.encode(value) for value in casebase.values()] 101 102 if self.index_type != "sparse": 103 assert self.conversion_func is not None 104 texts = [row[self.value_column] for row in rows] 105 for row, vec in zip(rows, self.conversion_func(texts), strict=True): 106 row[self.vector_column] = np.asarray(vec).tolist() 107 108 for row, key in zip(rows, keys, strict=True): 109 row[self.key_column] = key 110 111 return rows 112 113 def _setup_indices(self, table: ldb.Table) -> None: 114 """Create scalar and optional FTS indices on a table.""" 115 table.create_index(self.key_column, config=BTree()) 116 117 if self.index_type in ("sparse", "hybrid"): 118 table.create_index(self.value_column, config=FTS()) 119 120 @property 121 @override 122 def index(self) -> Casebase[K, V]: 123 """Return the indexed casebase from the LanceDB table.""" 124 if self._table is None: 125 return {} 126 codec = self._codec 127 needed = codec.columns 128 table = self._table.to_arrow() 129 keys = table.column(self.key_column).to_pylist() 130 payloads = {c: table.column(c).to_pylist() for c in needed} 131 return { 132 key: codec.decode({c: payloads[c][i] for c in needed}) 133 for i, key in enumerate(keys) 134 } 135 136 @override 137 def put_index(self, data: Casebase[K, V]) -> None: 138 """Replace the LanceDB table contents with *data*.""" 139 if self._table is None: 140 if not data: 141 return 142 143 rows = self._build_rows(data) 144 self._table = self._db.create_table( 145 self.table_name, 146 rows, 147 mode="overwrite", 148 ) 149 self._setup_indices(self._table) 150 return 151 152 if not data: 153 self._table.delete("true") 154 return 155 156 rows = self._build_rows(data) 157 ( 158 self._table.merge_insert(self.key_column) 159 .when_matched_update_all() 160 .when_not_matched_insert_all() 161 .when_not_matched_by_source_delete() 162 .execute(rows) 163 ) 164 165 @override 166 def upsert_index(self, data: Casebase[K, V]) -> None: 167 """Insert or replace rows in the LanceDB table. 168 169 If no table exists yet, delegates to :meth:`put_index`. 170 """ 171 if self._table is None: 172 self.put_index(data) 173 return 174 175 if not data: 176 return 177 178 rows = self._build_rows(data) 179 ( 180 self._table.merge_insert(self.key_column) 181 .when_matched_update_all() 182 .when_not_matched_insert_all() 183 .execute(rows) 184 ) 185 186 @override 187 def delete_index( 188 self, 189 data: Collection[K], 190 ) -> None: 191 """Delete rows from the LanceDB table by key.""" 192 if self._table is None or not data: 193 return 194 195 self._table.delete(_sql_in_clause(self.key_column, data)) 196 197 @override 198 def patch_index( 199 self, 200 upsert: Casebase[K, V] | None = None, 201 delete: Collection[K] | None = None, 202 ) -> None: 203 """Apply inserts, replacements, and deletes as one LanceDB mutation.""" 204 normalized = _normalize_patch_keys(upsert, delete) 205 206 if normalized is None: 207 return 208 209 _, delete_keys = normalized 210 211 if self._table is None: 212 if upsert: 213 self.put_index(upsert) 214 return 215 216 if not upsert: 217 self.delete_index(delete_keys) 218 return 219 220 rows = self._build_rows(upsert) 221 operation = ( 222 self._table.merge_insert(self.key_column) 223 .when_matched_update_all() 224 .when_not_matched_insert_all() 225 ) 226 227 if delete_keys: 228 operation = operation.when_not_matched_by_source_delete( 229 _sql_in_clause(self.key_column, delete_keys) 230 ) 231 232 operation.execute(rows) 233 234 def keys_where(self, where: str | None = None) -> list[K]: 235 """Return keys matching a native LanceDB predicate.""" 236 if self._table is None: 237 return [] 238 239 query = self._table.search().select([self.key_column]) 240 241 if where is not None: 242 query = query.where(where) 243 244 table = query.to_arrow() 245 return cast(list[K], table.column(self.key_column).to_pylist()) 246 247 def delete_where( 248 self, 249 where: str, 250 ) -> list[K]: 251 """Delete rows matching a native LanceDB predicate and return their keys.""" 252 if self._table is None: 253 return [] 254 255 keys = self.keys_where(where) 256 257 if not keys: 258 return [] 259 260 self._table.delete(where) 261 return keys 262 263 def replace_where( 264 self, 265 where: str, 266 data: Casebase[K, V], 267 ) -> list[K]: 268 """Replace rows matching a native LanceDB predicate with *data*.""" 269 if self._table is None: 270 self.put_index(data) 271 return [] 272 273 keys = self.keys_where(where) 274 275 if not data: 276 if keys: 277 self._table.delete(where) 278 return keys 279 280 rows = self._build_rows(data) 281 ( 282 self._table.merge_insert(self.key_column) 283 .when_matched_update_all() 284 .when_not_matched_insert_all() 285 .when_not_matched_by_source_delete(where) 286 .execute(rows) 287 ) 288 return keys 289 290 291__all__ = ["lancedb"]
19@dataclass(slots=True) 20class lancedb[K: int | str, V = str](IndexableFunc[Casebase[K, V], Collection[K]]): 21 """LanceDB storage backend. 22 23 Manages an embedded LanceDB database on disk. Supports dense 24 (vector), sparse (FTS/BM25), and hybrid index types which 25 control what data is stored and what indices are built. 26 27 Warning: 28 Persisted vectors are tied to the 29 :paramref:`conversion_func` used when they were written. 30 Reopening a table backed by a different embedding model 31 silently returns wrong results when the new model has the 32 same dimension, and raises on `INSERT` when it does not — 33 :meth:`put_index` only re-embeds entries whose text 34 changed. Drop the table (or use a fresh `table_name`) 35 when changing models. 36 37 Args: 38 uri: Path to the LanceDB database directory. 39 table_name: Table name within the database. 40 index_type: Determines what data is stored and which 41 indices are created. `"dense"` stores embeddings, 42 `"sparse"` builds an FTS index, `"hybrid"` does 43 both. 44 conversion_func: Embedding function. Required for 45 `"dense"` and `"hybrid"` index types. 46 key_column: Column name for case keys. 47 value_column: Column holding the embeddable text. With the 48 default ``V = str`` the casebase value *is* this column; 49 with a *model* it names the model field to embed. 50 vector_column: Column name for dense embedding vectors. 51 model: A dataclass or pydantic :class:`~pydantic.BaseModel` 52 describing rows richer than plain text. When set, ``V`` is 53 the model type: every field becomes a stored column, 54 ``value_column`` names the embeddable field, and reads 55 reconstruct model instances. This replaces any side-channel 56 metadata — extra columns ride on the typed value itself. 57 """ 58 59 uri: str 60 table_name: str 61 index_type: Literal["dense", "sparse", "hybrid"] = "dense" 62 conversion_func: BatchConversionFunc[str, NumpyArray] | None = None 63 key_column: str = "key" 64 value_column: str = "value" 65 vector_column: str = "vector" 66 model: type[V] | None = None 67 _db: ldb.DBConnection = field(init=False, repr=False) 68 _table: ldb.Table | None = field(default=None, init=False, repr=False) 69 _codec: RowCodec[V] = field(init=False, repr=False) 70 71 def __post_init__(self) -> None: 72 if self.index_type in ("dense", "hybrid") and self.conversion_func is None: 73 raise ValueError( 74 f"conversion_func is required for index_type={self.index_type!r}" 75 ) 76 77 self._codec = make_codec( 78 self.model, self.value_column, key_column=self.key_column 79 ) 80 self._db = ldb.connect(self.uri) 81 82 if self.table_name in self._db.list_tables().tables: 83 self._table = self._db.open_table(self.table_name) 84 85 @override 86 def has_index(self) -> bool: 87 """Return whether a table exists in the database.""" 88 return self._table is not None 89 90 def search_limit(self) -> int | None: 91 """Return the total number of rows, or `None` when empty.""" 92 if self._table is None: 93 return None 94 95 return self._table.count_rows() 96 97 def _build_rows(self, casebase: Casebase[K, V]) -> list[dict[str, Any]]: 98 """Build row dicts for LanceDB from a casebase.""" 99 codec = self._codec 100 keys = list(casebase.keys()) 101 rows = [codec.encode(value) for value in casebase.values()] 102 103 if self.index_type != "sparse": 104 assert self.conversion_func is not None 105 texts = [row[self.value_column] for row in rows] 106 for row, vec in zip(rows, self.conversion_func(texts), strict=True): 107 row[self.vector_column] = np.asarray(vec).tolist() 108 109 for row, key in zip(rows, keys, strict=True): 110 row[self.key_column] = key 111 112 return rows 113 114 def _setup_indices(self, table: ldb.Table) -> None: 115 """Create scalar and optional FTS indices on a table.""" 116 table.create_index(self.key_column, config=BTree()) 117 118 if self.index_type in ("sparse", "hybrid"): 119 table.create_index(self.value_column, config=FTS()) 120 121 @property 122 @override 123 def index(self) -> Casebase[K, V]: 124 """Return the indexed casebase from the LanceDB table.""" 125 if self._table is None: 126 return {} 127 codec = self._codec 128 needed = codec.columns 129 table = self._table.to_arrow() 130 keys = table.column(self.key_column).to_pylist() 131 payloads = {c: table.column(c).to_pylist() for c in needed} 132 return { 133 key: codec.decode({c: payloads[c][i] for c in needed}) 134 for i, key in enumerate(keys) 135 } 136 137 @override 138 def put_index(self, data: Casebase[K, V]) -> None: 139 """Replace the LanceDB table contents with *data*.""" 140 if self._table is None: 141 if not data: 142 return 143 144 rows = self._build_rows(data) 145 self._table = self._db.create_table( 146 self.table_name, 147 rows, 148 mode="overwrite", 149 ) 150 self._setup_indices(self._table) 151 return 152 153 if not data: 154 self._table.delete("true") 155 return 156 157 rows = self._build_rows(data) 158 ( 159 self._table.merge_insert(self.key_column) 160 .when_matched_update_all() 161 .when_not_matched_insert_all() 162 .when_not_matched_by_source_delete() 163 .execute(rows) 164 ) 165 166 @override 167 def upsert_index(self, data: Casebase[K, V]) -> None: 168 """Insert or replace rows in the LanceDB table. 169 170 If no table exists yet, delegates to :meth:`put_index`. 171 """ 172 if self._table is None: 173 self.put_index(data) 174 return 175 176 if not data: 177 return 178 179 rows = self._build_rows(data) 180 ( 181 self._table.merge_insert(self.key_column) 182 .when_matched_update_all() 183 .when_not_matched_insert_all() 184 .execute(rows) 185 ) 186 187 @override 188 def delete_index( 189 self, 190 data: Collection[K], 191 ) -> None: 192 """Delete rows from the LanceDB table by key.""" 193 if self._table is None or not data: 194 return 195 196 self._table.delete(_sql_in_clause(self.key_column, data)) 197 198 @override 199 def patch_index( 200 self, 201 upsert: Casebase[K, V] | None = None, 202 delete: Collection[K] | None = None, 203 ) -> None: 204 """Apply inserts, replacements, and deletes as one LanceDB mutation.""" 205 normalized = _normalize_patch_keys(upsert, delete) 206 207 if normalized is None: 208 return 209 210 _, delete_keys = normalized 211 212 if self._table is None: 213 if upsert: 214 self.put_index(upsert) 215 return 216 217 if not upsert: 218 self.delete_index(delete_keys) 219 return 220 221 rows = self._build_rows(upsert) 222 operation = ( 223 self._table.merge_insert(self.key_column) 224 .when_matched_update_all() 225 .when_not_matched_insert_all() 226 ) 227 228 if delete_keys: 229 operation = operation.when_not_matched_by_source_delete( 230 _sql_in_clause(self.key_column, delete_keys) 231 ) 232 233 operation.execute(rows) 234 235 def keys_where(self, where: str | None = None) -> list[K]: 236 """Return keys matching a native LanceDB predicate.""" 237 if self._table is None: 238 return [] 239 240 query = self._table.search().select([self.key_column]) 241 242 if where is not None: 243 query = query.where(where) 244 245 table = query.to_arrow() 246 return cast(list[K], table.column(self.key_column).to_pylist()) 247 248 def delete_where( 249 self, 250 where: str, 251 ) -> list[K]: 252 """Delete rows matching a native LanceDB predicate and return their keys.""" 253 if self._table is None: 254 return [] 255 256 keys = self.keys_where(where) 257 258 if not keys: 259 return [] 260 261 self._table.delete(where) 262 return keys 263 264 def replace_where( 265 self, 266 where: str, 267 data: Casebase[K, V], 268 ) -> list[K]: 269 """Replace rows matching a native LanceDB predicate with *data*.""" 270 if self._table is None: 271 self.put_index(data) 272 return [] 273 274 keys = self.keys_where(where) 275 276 if not data: 277 if keys: 278 self._table.delete(where) 279 return keys 280 281 rows = self._build_rows(data) 282 ( 283 self._table.merge_insert(self.key_column) 284 .when_matched_update_all() 285 .when_not_matched_insert_all() 286 .when_not_matched_by_source_delete(where) 287 .execute(rows) 288 ) 289 return keys
LanceDB storage backend.
Manages an embedded LanceDB database on disk. Supports dense (vector), sparse (FTS/BM25), and hybrid index types which control what data is stored and what indices are built.
Warning:
Persisted vectors are tied to the :paramref:
conversion_funcused when they were written. Reopening a table backed by a different embedding model silently returns wrong results when the new model has the same dimension, and raises onINSERTwhen it does not —put_index()only re-embeds entries whose text changed. Drop the table (or use a freshtable_name) when changing models.
Arguments:
- uri: Path to the LanceDB database directory.
- table_name: Table name within the database.
- index_type: Determines what data is stored and which
indices are created.
"dense"stores embeddings,"sparse"builds an FTS index,"hybrid"does both. - conversion_func: Embedding function. Required for
"dense"and"hybrid"index types. - key_column: Column name for case keys.
- value_column: Column holding the embeddable text. With the
default
V = strthe casebase value is this column; with a model it names the model field to embed. - vector_column: Column name for dense embedding vectors.
- model: A dataclass or pydantic
~pydantic.BaseModeldescribing rows richer than plain text. When set,Vis the model type: every field becomes a stored column,value_columnnames the embeddable field, and reads reconstruct model instances. This replaces any side-channel metadata — extra columns ride on the typed value itself.
85 @override 86 def has_index(self) -> bool: 87 """Return whether a table exists in the database.""" 88 return self._table is not None
Return whether a table exists in the database.
90 def search_limit(self) -> int | None: 91 """Return the total number of rows, or `None` when empty.""" 92 if self._table is None: 93 return None 94 95 return self._table.count_rows()
Return the total number of rows, or None when empty.
121 @property 122 @override 123 def index(self) -> Casebase[K, V]: 124 """Return the indexed casebase from the LanceDB table.""" 125 if self._table is None: 126 return {} 127 codec = self._codec 128 needed = codec.columns 129 table = self._table.to_arrow() 130 keys = table.column(self.key_column).to_pylist() 131 payloads = {c: table.column(c).to_pylist() for c in needed} 132 return { 133 key: codec.decode({c: payloads[c][i] for c in needed}) 134 for i, key in enumerate(keys) 135 }
Return the indexed casebase from the LanceDB table.
137 @override 138 def put_index(self, data: Casebase[K, V]) -> None: 139 """Replace the LanceDB table contents with *data*.""" 140 if self._table is None: 141 if not data: 142 return 143 144 rows = self._build_rows(data) 145 self._table = self._db.create_table( 146 self.table_name, 147 rows, 148 mode="overwrite", 149 ) 150 self._setup_indices(self._table) 151 return 152 153 if not data: 154 self._table.delete("true") 155 return 156 157 rows = self._build_rows(data) 158 ( 159 self._table.merge_insert(self.key_column) 160 .when_matched_update_all() 161 .when_not_matched_insert_all() 162 .when_not_matched_by_source_delete() 163 .execute(rows) 164 )
Replace the LanceDB table contents with data.
166 @override 167 def upsert_index(self, data: Casebase[K, V]) -> None: 168 """Insert or replace rows in the LanceDB table. 169 170 If no table exists yet, delegates to :meth:`put_index`. 171 """ 172 if self._table is None: 173 self.put_index(data) 174 return 175 176 if not data: 177 return 178 179 rows = self._build_rows(data) 180 ( 181 self._table.merge_insert(self.key_column) 182 .when_matched_update_all() 183 .when_not_matched_insert_all() 184 .execute(rows) 185 )
Insert or replace rows in the LanceDB table.
If no table exists yet, delegates to put_index().
187 @override 188 def delete_index( 189 self, 190 data: Collection[K], 191 ) -> None: 192 """Delete rows from the LanceDB table by key.""" 193 if self._table is None or not data: 194 return 195 196 self._table.delete(_sql_in_clause(self.key_column, data))
Delete rows from the LanceDB table by key.
198 @override 199 def patch_index( 200 self, 201 upsert: Casebase[K, V] | None = None, 202 delete: Collection[K] | None = None, 203 ) -> None: 204 """Apply inserts, replacements, and deletes as one LanceDB mutation.""" 205 normalized = _normalize_patch_keys(upsert, delete) 206 207 if normalized is None: 208 return 209 210 _, delete_keys = normalized 211 212 if self._table is None: 213 if upsert: 214 self.put_index(upsert) 215 return 216 217 if not upsert: 218 self.delete_index(delete_keys) 219 return 220 221 rows = self._build_rows(upsert) 222 operation = ( 223 self._table.merge_insert(self.key_column) 224 .when_matched_update_all() 225 .when_not_matched_insert_all() 226 ) 227 228 if delete_keys: 229 operation = operation.when_not_matched_by_source_delete( 230 _sql_in_clause(self.key_column, delete_keys) 231 ) 232 233 operation.execute(rows)
Apply inserts, replacements, and deletes as one LanceDB mutation.
235 def keys_where(self, where: str | None = None) -> list[K]: 236 """Return keys matching a native LanceDB predicate.""" 237 if self._table is None: 238 return [] 239 240 query = self._table.search().select([self.key_column]) 241 242 if where is not None: 243 query = query.where(where) 244 245 table = query.to_arrow() 246 return cast(list[K], table.column(self.key_column).to_pylist())
Return keys matching a native LanceDB predicate.
248 def delete_where( 249 self, 250 where: str, 251 ) -> list[K]: 252 """Delete rows matching a native LanceDB predicate and return their keys.""" 253 if self._table is None: 254 return [] 255 256 keys = self.keys_where(where) 257 258 if not keys: 259 return [] 260 261 self._table.delete(where) 262 return keys
Delete rows matching a native LanceDB predicate and return their keys.
264 def replace_where( 265 self, 266 where: str, 267 data: Casebase[K, V], 268 ) -> list[K]: 269 """Replace rows matching a native LanceDB predicate with *data*.""" 270 if self._table is None: 271 self.put_index(data) 272 return [] 273 274 keys = self.keys_where(where) 275 276 if not data: 277 if keys: 278 self._table.delete(where) 279 return keys 280 281 rows = self._build_rows(data) 282 ( 283 self._table.merge_insert(self.key_column) 284 .when_matched_update_all() 285 .when_not_matched_insert_all() 286 .when_not_matched_by_source_delete(where) 287 .execute(rows) 288 ) 289 return keys
Replace rows matching a native LanceDB predicate with data.