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

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_func used 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 on INSERT when it does not — put_index() only re-embeds entries whose text changed. Drop the table (or use a fresh table_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 = str the 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.BaseModel describing rows richer than plain text. When set, V is the model type: every field becomes a stored column, value_column names the embeddable field, and reads reconstruct model instances. This replaces any side-channel metadata — extra columns ride on the typed value itself.
lancedb( uri: str, table_name: str, index_type: Literal['dense', 'sparse', 'hybrid'] = 'dense', conversion_func: Optional[cbrkit.typing.BatchConversionFunc[str, NumpyArray]] = None, key_column: str = 'key', value_column: str = 'value', vector_column: str = 'vector', model: type[V] | None = None)
uri: str
table_name: str
index_type: Literal['dense', 'sparse', 'hybrid']
conversion_func: Optional[cbrkit.typing.BatchConversionFunc[str, NumpyArray]]
key_column: str
value_column: str
vector_column: str
model: type[V] | None
@override
def has_index(self) -> bool:
84    @override
85    def has_index(self) -> bool:
86        """Return whether a table exists in the database."""
87        return self._table is not None

Return whether a table exists in the database.

def search_limit(self) -> int | None:
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()

Return the total number of rows, or None when empty.

index: Casebase[K, V]
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        }

Return the indexed casebase from the LanceDB table.

@override
def put_index(self, data: Casebase[K, V]) -> None:
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        )

Replace the LanceDB table contents with data.

@override
def upsert_index(self, data: Casebase[K, V]) -> None:
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        )

Insert or replace rows in the LanceDB table.

If no table exists yet, delegates to put_index().

@override
def delete_index(self, data: Collection[K]) -> None:
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))

Delete rows from the LanceDB table by key.

@override
def patch_index( self, upsert: Casebase[K, V] | None = None, delete: Collection[K] | None = None) -> None:
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)

Apply inserts, replacements, and deletes as one LanceDB mutation.

def keys_where(self, where: str | None = None) -> list[K]:
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())

Return keys matching a native LanceDB predicate.

def delete_where(self, where: str) -> list[K]:
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

Delete rows matching a native LanceDB predicate and return their keys.

def replace_where(self, where: str, data: Casebase[K, V]) -> list[K]:
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

Replace rows matching a native LanceDB predicate with data.