statcatlib 0.8.0__py3-none-any.whl
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.
- statcatlib/__init__.py +187 -0
- statcatlib/bundle.py +565 -0
- statcatlib/cli/__init__.py +546 -0
- statcatlib/expr.py +167 -0
- statcatlib/model/__init__.py +56 -0
- statcatlib/model/agents.py +32 -0
- statcatlib/model/catalog.py +71 -0
- statcatlib/model/classification.py +19 -0
- statcatlib/model/dataset.py +198 -0
- statcatlib/model/distribution.py +50 -0
- statcatlib/model/licence.py +18 -0
- statcatlib/model/quality.py +31 -0
- statcatlib/model/skos.py +141 -0
- statcatlib/model/spatial.py +19 -0
- statcatlib/model/temporal.py +33 -0
- statcatlib/model/types.py +5 -0
- statcatlib/py.typed +0 -0
- statcatlib/query.py +924 -0
- statcatlib/sources/__init__.py +13 -0
- statcatlib/sources/ckan.py +490 -0
- statcatlib/sources/sdmx.py +321 -0
- statcatlib/store/__init__.py +38 -0
- statcatlib/store/lance.py +1494 -0
- statcatlib/store/row.py +30 -0
- statcatlib/store/schema.py +403 -0
- statcatlib/store/scopes.py +1419 -0
- statcatlib/store/sentinel.py +99 -0
- statcatlib/store/store.py +6836 -0
- statcatlib/store/xhtml.py +206 -0
- statcatlib/urn.py +108 -0
- statcatlib-0.8.0.dist-info/METADATA +429 -0
- statcatlib-0.8.0.dist-info/RECORD +35 -0
- statcatlib-0.8.0.dist-info/WHEEL +4 -0
- statcatlib-0.8.0.dist-info/entry_points.txt +3 -0
- statcatlib-0.8.0.dist-info/licenses/LICENSE +180 -0
|
@@ -0,0 +1,1494 @@
|
|
|
1
|
+
"""LanceDB vector + full-text index for statcatlib search.
|
|
2
|
+
|
|
3
|
+
Isolated from the core store so that lancedb is never imported unless an
|
|
4
|
+
``embedding_model`` is passed to ``Catalog``, or ``search()`` is called
|
|
5
|
+
on a folder-mode catalog. Core statcatlib has no dependency on lancedb.
|
|
6
|
+
|
|
7
|
+
Each language gets its own table, holding one row per entity in that
|
|
8
|
+
language, with a text column (for BM25 full-text search via Tantivy) and
|
|
9
|
+
an optional embedding column (for semantic nearest-neighbour search).
|
|
10
|
+
Tables are generation-suffixed (``search_fr__000003``) and reached
|
|
11
|
+
through a manifest, so a rebuild can write a whole new generation before
|
|
12
|
+
committing to any of it.
|
|
13
|
+
|
|
14
|
+
The split is what lets each language carry its own tokenizer: Tantivy
|
|
15
|
+
takes one stemmer and one stopword list per index, so a shared table
|
|
16
|
+
meant one shared configuration, and LanceDB's default is English. French
|
|
17
|
+
text was being stemmed with English rules (#101). Separate tables also
|
|
18
|
+
give each language exact BM25 statistics — pooled into one table, ``N``
|
|
19
|
+
and ``avgdl`` spanned every language at once.
|
|
20
|
+
|
|
21
|
+
`LanceIndex` routes by language and is the only thing callers
|
|
22
|
+
touch; `_LanguageIndex` is one language's table.
|
|
23
|
+
|
|
24
|
+
Search modes
|
|
25
|
+
------------
|
|
26
|
+
- **BM25 only** — no embedding model required; ``search()`` uses Tantivy FTS
|
|
27
|
+
- **Semantic** — requires ``embedding_model``; pure vector nearest-neighbour
|
|
28
|
+
- **Hybrid** — requires ``embedding_model``; RRF merge of BM25 + vector
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import contextlib
|
|
34
|
+
import json
|
|
35
|
+
import warnings
|
|
36
|
+
from collections.abc import Iterable, Mapping
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import TYPE_CHECKING, NotRequired, TypedDict
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
|
|
43
|
+
import lancedb
|
|
44
|
+
import lancedb.table
|
|
45
|
+
import pyarrow as pa
|
|
46
|
+
from lancedb.query import BooleanQuery, MatchQuery
|
|
47
|
+
|
|
48
|
+
__all__: list[str] = ["LanceIndex", "make_schema"]
|
|
49
|
+
|
|
50
|
+
#: Legacy single-table name. Catalogs built before per-language tables
|
|
51
|
+
#: (#101) have exactly this one table, holding every language under a
|
|
52
|
+
#: single English-stemmed FTS index. Read-only fallback; ``build_index``
|
|
53
|
+
#: replaces it with the per-language layout.
|
|
54
|
+
_TABLE = "search"
|
|
55
|
+
|
|
56
|
+
#: Prefix for per-language tables. The full name carries the language and
|
|
57
|
+
#: the generation that wrote it — ``search_fr__000003`` — so a rebuild's
|
|
58
|
+
#: tables can sit alongside the ones still serving queries.
|
|
59
|
+
_TABLE_PREFIX = "search_"
|
|
60
|
+
|
|
61
|
+
#: Manifest naming the live table for each language. It is the single
|
|
62
|
+
#: source of truth for what a query reads, which is what makes a rebuild
|
|
63
|
+
#: atomic without ``rename_table`` — LanceDB OSS raises
|
|
64
|
+
#: ``NotImplementedError`` for that. A rebuild writes new
|
|
65
|
+
#: generation-suffixed tables, then replaces this file in a single atomic
|
|
66
|
+
#: move; until that lands, every query still reads the previous
|
|
67
|
+
#: generation. A crash therefore leaves the old index whole and serving,
|
|
68
|
+
#: with the half-built tables merely orphaned.
|
|
69
|
+
_MANIFEST_FILE = "index.json"
|
|
70
|
+
_MANIFEST_VERSION = 2
|
|
71
|
+
|
|
72
|
+
#: Rows *carrying a vector* that a language's table needs before an ANN
|
|
73
|
+
#: index is worth building. IVF-PQ needs this many to train at all, and
|
|
74
|
+
#: trains poorly not far above it; under the threshold a brute-force scan
|
|
75
|
+
#: is both faster and exact. Now that each language has its own table the
|
|
76
|
+
#: bar applies per language, so a small language is left to scan rather
|
|
77
|
+
#: than given a badly-trained index.
|
|
78
|
+
#:
|
|
79
|
+
#: Counted over embedded rows rather than all rows on purpose (#102). A
|
|
80
|
+
#: table mixing embedded rows with ``fts_only`` ones is mostly NULL in the
|
|
81
|
+
#: embedding column — 95% of it, on a large catalog whose big codelists
|
|
82
|
+
#: are ``fts_only`` — and IVF
|
|
83
|
+
#: trains its centroids over those NULLs too. The result is one degenerate
|
|
84
|
+
#: cluster holding almost all the mass, with the few real vectors
|
|
85
|
+
#: scattered across partitions the probe never reaches: stored vectors
|
|
86
|
+
#: perfectly correct, approximate retrieval near-random. Counting rows
|
|
87
|
+
#: would clear the bar on exactly the tables that must not be indexed.
|
|
88
|
+
_ANN_MIN_VECTORS = 256
|
|
89
|
+
|
|
90
|
+
#: Vector index LanceDB builds for us. IVF_FLAT stores each vector whole
|
|
91
|
+
#: inside its partition; IVF_PQ — LanceDB's default, and what statcatlib built
|
|
92
|
+
#: until #102 — stores a product-quantised approximation instead.
|
|
93
|
+
#:
|
|
94
|
+
#: Measured on clustered 384-d vectors, recall@10 against exact cosine
|
|
95
|
+
#: over the same stored vectors:
|
|
96
|
+
#:
|
|
97
|
+
#: IVF_PQ 20% (58% with refine_factor=10; nprobes did not move it)
|
|
98
|
+
#: IVF_FLAT 100%
|
|
99
|
+
#:
|
|
100
|
+
#: 20% is the bug in #102: the vectors were right and retrieval threw
|
|
101
|
+
#: them away. Quantisation cannot preserve the margin this corpus runs
|
|
102
|
+
#: on — e5 puts ~0.001 cosine between the right answer and an unrelated
|
|
103
|
+
#: one — and nprobes leaving recall unchanged is what identifies the loss
|
|
104
|
+
#: as quantisation rather than probing.
|
|
105
|
+
#:
|
|
106
|
+
#: IVF_FLAT is also *faster* here, because reading a whole vector beats
|
|
107
|
+
#: reconstructing it — several times faster than flat-scan at every
|
|
108
|
+
#: size measured. It trains at any size, where IVF_PQ needs 256 vectors.
|
|
109
|
+
#: The larger index on disk is the whole trade, and it buys exactness.
|
|
110
|
+
_ANN_INDEX_TYPE = "IVF_FLAT"
|
|
111
|
+
|
|
112
|
+
#: BCP-47 primary subtag → Tantivy Snowball stemmer name. Exactly the set
|
|
113
|
+
#: LanceDB accepts; anything outside it raises rather than falling back,
|
|
114
|
+
#: so the mapping is a whitelist and not a best guess. Notably absent:
|
|
115
|
+
#: Chinese, Japanese, Korean, Catalan — LanceDB rejects all four.
|
|
116
|
+
_SNOWBALL_LANGUAGE: dict[str, str] = {
|
|
117
|
+
"ar": "Arabic",
|
|
118
|
+
"da": "Danish",
|
|
119
|
+
"nl": "Dutch",
|
|
120
|
+
"en": "English",
|
|
121
|
+
"fi": "Finnish",
|
|
122
|
+
"fr": "French",
|
|
123
|
+
"de": "German",
|
|
124
|
+
"el": "Greek",
|
|
125
|
+
"hu": "Hungarian",
|
|
126
|
+
"it": "Italian",
|
|
127
|
+
"no": "Norwegian",
|
|
128
|
+
"pt": "Portuguese",
|
|
129
|
+
"ro": "Romanian",
|
|
130
|
+
"ru": "Russian",
|
|
131
|
+
"es": "Spanish",
|
|
132
|
+
"sv": "Swedish",
|
|
133
|
+
"ta": "Tamil",
|
|
134
|
+
"tr": "Turkish",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _as_int(value: object, default: int = 0) -> int:
|
|
139
|
+
"""Narrow a manifest value to an `int`, or *default*."""
|
|
140
|
+
if isinstance(value, bool): # bool is an int; a flag is not a count
|
|
141
|
+
return default
|
|
142
|
+
return value if isinstance(value, int) else default
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _as_opt_str(value: object) -> str | None:
|
|
146
|
+
"""Narrow a manifest value to a `str`, or ``None``."""
|
|
147
|
+
return value if isinstance(value, str) else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _as_layout(entry: object) -> LanguageLayout | None:
|
|
151
|
+
"""Narrow one manifest ``languages`` entry, or ``None`` if it is not one.
|
|
152
|
+
|
|
153
|
+
Returns ``None`` for the v1 shape, where the entry was the table name as a
|
|
154
|
+
bare string rather than a mapping — the caller handles that case, since a
|
|
155
|
+
v1 entry still names a usable table even though it records nothing else.
|
|
156
|
+
"""
|
|
157
|
+
if not isinstance(entry, Mapping):
|
|
158
|
+
return None
|
|
159
|
+
table = _as_opt_str(entry.get("table"))
|
|
160
|
+
if table is None:
|
|
161
|
+
return None
|
|
162
|
+
return {
|
|
163
|
+
"table": table,
|
|
164
|
+
"rows": _as_int(entry.get("rows")),
|
|
165
|
+
"embedded": _as_int(entry.get("embedded")),
|
|
166
|
+
"ann": _as_opt_str(entry.get("ann")),
|
|
167
|
+
"stemmer": _as_opt_str(entry.get("stemmer")),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _statcatlib_version() -> str:
|
|
172
|
+
"""Return the running statcatlib version, or ``"unknown"``.
|
|
173
|
+
|
|
174
|
+
Defined here rather than imported from ``store``: ``store`` imports
|
|
175
|
+
this module, so the dependency only runs one way.
|
|
176
|
+
"""
|
|
177
|
+
import importlib.metadata # noqa: PLC0415
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
return importlib.metadata.version("statcatlib")
|
|
181
|
+
except importlib.metadata.PackageNotFoundError:
|
|
182
|
+
return "unknown"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _language_slug(lang: str) -> str:
|
|
186
|
+
"""Return the table-name suffix for *lang* (``"fr-CA"`` → ``"fr_ca"``).
|
|
187
|
+
|
|
188
|
+
Region subtags are preserved: ``fr-CA`` and ``fr-FR`` are different
|
|
189
|
+
corpora with different vocabularies, even though they stem alike, and
|
|
190
|
+
collapsing them would re-pool the document frequencies that separate
|
|
191
|
+
tables exist to keep apart.
|
|
192
|
+
"""
|
|
193
|
+
return lang.strip().lower().replace("-", "_")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _stemmer_for(lang: str) -> str | None:
|
|
197
|
+
"""Return the Snowball stemmer name for *lang*, or ``None``.
|
|
198
|
+
|
|
199
|
+
Falls back on the primary subtag, so ``fr-CA`` stems as French. Returns
|
|
200
|
+
``None`` for any language LanceDB has no stemmer for — the caller then
|
|
201
|
+
indexes it unstemmed, which is merely weaker. The one thing never done
|
|
202
|
+
is substituting a *different* language's stemmer: applying English
|
|
203
|
+
rules to French text is the defect #101 exists to fix, and silently
|
|
204
|
+
doing it to a third language would be the same bug wearing a hat.
|
|
205
|
+
"""
|
|
206
|
+
return _SNOWBALL_LANGUAGE.get(_language_slug(lang).split("_")[0])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
_EMBEDDING_COLUMN = "embedding"
|
|
210
|
+
|
|
211
|
+
#: Columns every row carries, whatever entity kind it describes.
|
|
212
|
+
#: ``text`` is the FTS target; ``lang`` is its BCP-47 language.
|
|
213
|
+
_BASE_COLUMNS: tuple[tuple[str, str], ...] = (
|
|
214
|
+
("id", "string"),
|
|
215
|
+
("entity_type", "string"),
|
|
216
|
+
("lang", "string"),
|
|
217
|
+
("text", "string"),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
#: Entity-specific columns. Every row carries every one of these — the
|
|
221
|
+
#: collectors pad with empty values (``_EMPTY_CODE_FIELDS`` and friends in
|
|
222
|
+
#: ``store.py``) so a single table can hold all four entity kinds.
|
|
223
|
+
_EXTENSION_COLUMNS: tuple[tuple[str, str], ...] = (
|
|
224
|
+
# code rows
|
|
225
|
+
("dimension_id", "string"),
|
|
226
|
+
("dimension_label", "string"),
|
|
227
|
+
("code_id", "string"),
|
|
228
|
+
("code_label", "string"),
|
|
229
|
+
("code_urn", "string"),
|
|
230
|
+
("codelist_urn", "string"),
|
|
231
|
+
("parent_id", "string"),
|
|
232
|
+
("depth", "int64"),
|
|
233
|
+
# concept rows
|
|
234
|
+
("concept_urn", "string"),
|
|
235
|
+
("scheme_urn", "string"),
|
|
236
|
+
("concept_id", "string"),
|
|
237
|
+
# reference-metadata rows
|
|
238
|
+
("set_urn", "string"),
|
|
239
|
+
("attribute_path", "string"),
|
|
240
|
+
("section_title", "string"),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class LanguageLayout(TypedDict):
|
|
245
|
+
"""What one language's table was built with, as `Catalog.index_layout` reports it.
|
|
246
|
+
|
|
247
|
+
Public: this is the shape callers get back, and it is the thing to check
|
|
248
|
+
after a build — "was French actually stemmed as French?" is answerable from
|
|
249
|
+
it without opening the index.
|
|
250
|
+
|
|
251
|
+
`stemmer` is ``None`` for a language LanceDB has no Snowball stemmer for
|
|
252
|
+
(Chinese, Japanese, Korean, Catalan), which is indexed unstemmed rather
|
|
253
|
+
than with another language's rules. `ann` is ``None`` when no vector index
|
|
254
|
+
was built. `rows` and `embedded` differ by an order of magnitude on a
|
|
255
|
+
catalog mixing embedded and ``fts_only`` codelists, which is what makes
|
|
256
|
+
"why is vector search scanning?" answerable here.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
table: str
|
|
260
|
+
rows: int
|
|
261
|
+
embedded: int
|
|
262
|
+
ann: str | None
|
|
263
|
+
stemmer: str | None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class _Manifest(TypedDict):
|
|
267
|
+
"""``index.json``, as written.
|
|
268
|
+
|
|
269
|
+
A `TypedDict` on the write side only, for the same reason as
|
|
270
|
+
`store._EmbeddingsSidecar`: here the shape is guaranteed because this
|
|
271
|
+
module builds it. `_read_manifest` returns `dict[str, object]` — that
|
|
272
|
+
value came off disk and may have been written by an older statcatlib, so
|
|
273
|
+
claiming this shape of it would be an assertion rather than a fact.
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
version: int
|
|
277
|
+
written_by: str
|
|
278
|
+
generation: int
|
|
279
|
+
languages: dict[str, LanguageLayout]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class SearchRow(TypedDict):
|
|
283
|
+
"""One row of the search corpus, as written to a language table.
|
|
284
|
+
|
|
285
|
+
Mirrors `_BASE_COLUMNS` + `_EXTENSION_COLUMNS` + `_EMBEDDING_COLUMN`
|
|
286
|
+
above, and `test_search_row_matches_schema` asserts the two agree — a
|
|
287
|
+
duplicated column list that can drift is worse than none.
|
|
288
|
+
|
|
289
|
+
The four base keys are required; every extension key belongs to one row
|
|
290
|
+
kind (code, concept, reference-metadata) and is `NotRequired`. That is
|
|
291
|
+
what makes a typo worth catching here: the Arrow schema is declared, so a
|
|
292
|
+
misspelled key does not raise — it writes nothing and leaves the real
|
|
293
|
+
column null, which is exactly the failure `make_schema` exists to prevent
|
|
294
|
+
for `embedding`.
|
|
295
|
+
|
|
296
|
+
Collectors still pad the keys they do not own with ``""`` / ``0`` rather
|
|
297
|
+
than omitting them. `_to_arrow` would turn an absent key into NULL and
|
|
298
|
+
says so, so the padding is belt-and-braces — but the two are not the same
|
|
299
|
+
stored value, and anything filtering on ``code_id = ''`` would notice.
|
|
300
|
+
Dropping it is a data change, not a typing one.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
id: str
|
|
304
|
+
entity_type: str
|
|
305
|
+
lang: str
|
|
306
|
+
text: str
|
|
307
|
+
# code rows
|
|
308
|
+
dimension_id: NotRequired[str | None]
|
|
309
|
+
dimension_label: NotRequired[str | None]
|
|
310
|
+
code_id: NotRequired[str | None]
|
|
311
|
+
code_label: NotRequired[str | None]
|
|
312
|
+
code_urn: NotRequired[str | None]
|
|
313
|
+
codelist_urn: NotRequired[str | None]
|
|
314
|
+
parent_id: NotRequired[str | None]
|
|
315
|
+
depth: NotRequired[int | None]
|
|
316
|
+
# concept rows
|
|
317
|
+
concept_urn: NotRequired[str | None]
|
|
318
|
+
scheme_urn: NotRequired[str | None]
|
|
319
|
+
concept_id: NotRequired[str | None]
|
|
320
|
+
# reference-metadata rows
|
|
321
|
+
set_urn: NotRequired[str | None]
|
|
322
|
+
attribute_path: NotRequired[str | None]
|
|
323
|
+
section_title: NotRequired[str | None]
|
|
324
|
+
# present only when an embedding model is configured
|
|
325
|
+
embedding: NotRequired[list[float] | None]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class Bm25HitRow(SearchRow):
|
|
329
|
+
"""A `SearchRow` as BM25 returns it — with its relevance score.
|
|
330
|
+
|
|
331
|
+
``_score`` is *required*, not optional: LanceDB always appends it to a
|
|
332
|
+
full-text result. Modelling it as `NotRequired` and reading it with `[]`
|
|
333
|
+
is what the checker refused, correctly — the honest fix is two result
|
|
334
|
+
types rather than one with two maybe-columns, since a query knows which
|
|
335
|
+
of the two it ran.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
_score: float
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class VectorHitRow(SearchRow):
|
|
342
|
+
"""A `SearchRow` as vector search returns it — with its distance.
|
|
343
|
+
|
|
344
|
+
Lower is nearer, which is why `_dedupe_by_id` keeps the *minimum* here
|
|
345
|
+
and the maximum for `Bm25HitRow`.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
_distance: float
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def make_schema(*, embedding_dim: int | None) -> pa.Schema:
|
|
352
|
+
"""Return the explicit PyArrow schema for the search table.
|
|
353
|
+
|
|
354
|
+
Declared rather than inferred from the row dicts. Inference is right
|
|
355
|
+
often enough to be misleading: PyArrow scans the whole batch, so row
|
|
356
|
+
order genuinely does not matter, but a batch in which *every* row
|
|
357
|
+
carries ``embedding=None`` — ``embed_codes=False``, or every codelist
|
|
358
|
+
marked ``fts_only`` — infers a null-typed column, and the ANN index
|
|
359
|
+
then silently never builds. Declaring the schema also guarantees the
|
|
360
|
+
per-language tables of #101 share one column set instead of each
|
|
361
|
+
inferring its own from whatever rows that language happened to get.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
embedding_dim: Width of the embedding vector, or ``None`` when no
|
|
365
|
+
embedding model is configured — in which case the table has
|
|
366
|
+
no ``embedding`` column at all, exactly as before.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
The PyArrow schema every language table is created with. Declared
|
|
370
|
+
rather than inferred so an all-null column keeps its type.
|
|
371
|
+
"""
|
|
372
|
+
import pyarrow as pa # noqa: PLC0415
|
|
373
|
+
|
|
374
|
+
types: dict[str, pa.DataType] = {"string": pa.string(), "int64": pa.int64()}
|
|
375
|
+
fields = [pa.field(name, types[kind]) for name, kind in (*_BASE_COLUMNS, *_EXTENSION_COLUMNS)]
|
|
376
|
+
if embedding_dim is not None:
|
|
377
|
+
# Nullable on purpose: rows opted out of embedding (embed_codes=False,
|
|
378
|
+
# or an fts_only codelist policy) carry NULL here alongside embedded
|
|
379
|
+
# rows in the same table.
|
|
380
|
+
fields.append(pa.field(_EMBEDDING_COLUMN, pa.list_(pa.float32(), embedding_dim), nullable=True))
|
|
381
|
+
return pa.schema(fields)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _embedding_dim_from_rows(rows: list[SearchRow]) -> int | None:
|
|
385
|
+
"""Return the width of the first populated embedding, or ``None``.
|
|
386
|
+
|
|
387
|
+
A fallback for callers that cannot supply the width themselves. The
|
|
388
|
+
authoritative source is the embedding model — see ``add_bulk``'s
|
|
389
|
+
``embedding_dim``. Deriving it from data cannot distinguish "no model
|
|
390
|
+
configured" from "a model is configured but every row opted out of
|
|
391
|
+
embedding", and the latter needs a typed column.
|
|
392
|
+
"""
|
|
393
|
+
for row in rows:
|
|
394
|
+
vec = row.get(_EMBEDDING_COLUMN)
|
|
395
|
+
if vec is not None:
|
|
396
|
+
return len(vec)
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _to_arrow(rows: list[SearchRow], *, schema: pa.Schema | None = None) -> pa.Table:
|
|
401
|
+
"""Materialize *rows* as a PyArrow table under the declared schema.
|
|
402
|
+
|
|
403
|
+
Keys absent from a row become NULL; keys absent from the schema are
|
|
404
|
+
dropped. Both are intentional — the collectors pad rows to the union
|
|
405
|
+
schema, and this is the backstop if one ever misses a column.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
rows: Row dicts to materialize, one per (entity, language).
|
|
409
|
+
schema: Bind to this schema instead of deriving one. Appending to
|
|
410
|
+
an existing table must reuse *that table's* schema: a batch
|
|
411
|
+
of rows that all happen to carry ``embedding=None`` would
|
|
412
|
+
otherwise derive a schema with no embedding column and fail
|
|
413
|
+
to line up with the table it is being added to.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
A PyArrow table holding *rows* under `make_schema`'s schema.
|
|
417
|
+
"""
|
|
418
|
+
import pyarrow as pa # noqa: PLC0415
|
|
419
|
+
|
|
420
|
+
if schema is None:
|
|
421
|
+
schema = make_schema(embedding_dim=_embedding_dim_from_rows(rows))
|
|
422
|
+
return pa.Table.from_pylist(rows, schema=schema)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# Stopwords stripped at query time before terms are joined with AND/OR.
|
|
426
|
+
# Kept per language: applied as one merged set, a French stopword that is
|
|
427
|
+
# an English content word gets silently dropped from an English query —
|
|
428
|
+
# "son", "ton", "sur" and "pas" are all in the French list.
|
|
429
|
+
_STOPWORDS_EN: frozenset[str] = frozenset(
|
|
430
|
+
{
|
|
431
|
+
# English (standard Lucene/Tantivy set)
|
|
432
|
+
"a",
|
|
433
|
+
"an",
|
|
434
|
+
"and",
|
|
435
|
+
"are",
|
|
436
|
+
"as",
|
|
437
|
+
"at",
|
|
438
|
+
"be",
|
|
439
|
+
"but",
|
|
440
|
+
"by",
|
|
441
|
+
"for",
|
|
442
|
+
"if",
|
|
443
|
+
"in",
|
|
444
|
+
"into",
|
|
445
|
+
"is",
|
|
446
|
+
"it",
|
|
447
|
+
"no",
|
|
448
|
+
"not",
|
|
449
|
+
"of",
|
|
450
|
+
"on",
|
|
451
|
+
"or",
|
|
452
|
+
"such",
|
|
453
|
+
"that",
|
|
454
|
+
"the",
|
|
455
|
+
"their",
|
|
456
|
+
"then",
|
|
457
|
+
"there",
|
|
458
|
+
"these",
|
|
459
|
+
"they",
|
|
460
|
+
"this",
|
|
461
|
+
"to",
|
|
462
|
+
"was",
|
|
463
|
+
"will",
|
|
464
|
+
"with",
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
_STOPWORDS_FR: frozenset[str] = frozenset(
|
|
469
|
+
{
|
|
470
|
+
"au",
|
|
471
|
+
"aux",
|
|
472
|
+
"avec",
|
|
473
|
+
"ce",
|
|
474
|
+
"ces",
|
|
475
|
+
"dans",
|
|
476
|
+
"de",
|
|
477
|
+
"des",
|
|
478
|
+
"du",
|
|
479
|
+
"elle",
|
|
480
|
+
"en",
|
|
481
|
+
"et",
|
|
482
|
+
"eux",
|
|
483
|
+
"il",
|
|
484
|
+
"je",
|
|
485
|
+
"la",
|
|
486
|
+
"le",
|
|
487
|
+
"les",
|
|
488
|
+
"leur",
|
|
489
|
+
"lui",
|
|
490
|
+
"ma",
|
|
491
|
+
"mais",
|
|
492
|
+
"me",
|
|
493
|
+
"même",
|
|
494
|
+
"mes",
|
|
495
|
+
"mon",
|
|
496
|
+
"ne",
|
|
497
|
+
"nos",
|
|
498
|
+
"notre",
|
|
499
|
+
"nous",
|
|
500
|
+
"ou",
|
|
501
|
+
"par",
|
|
502
|
+
"pas",
|
|
503
|
+
"pour",
|
|
504
|
+
"qu",
|
|
505
|
+
"que",
|
|
506
|
+
"qui",
|
|
507
|
+
"sa",
|
|
508
|
+
"se",
|
|
509
|
+
"ses",
|
|
510
|
+
"son",
|
|
511
|
+
"sur",
|
|
512
|
+
"ta",
|
|
513
|
+
"te",
|
|
514
|
+
"tes",
|
|
515
|
+
"toi",
|
|
516
|
+
"ton",
|
|
517
|
+
"tu",
|
|
518
|
+
"un",
|
|
519
|
+
"une",
|
|
520
|
+
"vos",
|
|
521
|
+
"votre",
|
|
522
|
+
"vous",
|
|
523
|
+
}
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
_STOPWORDS_BY_LANG: dict[str, frozenset[str]] = {
|
|
527
|
+
"en": _STOPWORDS_EN,
|
|
528
|
+
"fr": _STOPWORDS_FR,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#: Union of every language's stopwords. The fallback when the query's
|
|
532
|
+
#: language is unknown, which is the old behaviour for every language.
|
|
533
|
+
_STOPWORDS: frozenset[str] = _STOPWORDS_EN | _STOPWORDS_FR
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _stopwords_for(lang: str | None) -> frozenset[str]:
|
|
537
|
+
"""Return the stopword set for *lang*, falling back to the union."""
|
|
538
|
+
if lang is None:
|
|
539
|
+
return _STOPWORDS
|
|
540
|
+
return _STOPWORDS_BY_LANG.get(_language_slug(lang).split("_")[0], _STOPWORDS)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _strip_stopwords(text: str, lang: str | None = None) -> str:
|
|
544
|
+
"""Strip *lang*'s stopwords from *text*.
|
|
545
|
+
|
|
546
|
+
Returns the original text unchanged when every term is a stopword.
|
|
547
|
+
Tantivy removes stopwords inside the index too, in the language that
|
|
548
|
+
index was built for; this is the query-side half, and it has to agree
|
|
549
|
+
with it. Passing the language matters: the merged set strips French
|
|
550
|
+
"son" / "ton" / "sur" / "pas" out of English queries, where they are
|
|
551
|
+
ordinary content words.
|
|
552
|
+
"""
|
|
553
|
+
stopwords = _stopwords_for(lang)
|
|
554
|
+
terms = [t for t in text.split() if t.lower() not in stopwords]
|
|
555
|
+
if not terms:
|
|
556
|
+
return text
|
|
557
|
+
return " ".join(terms)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _dedupe_by_id(
|
|
561
|
+
hits: Iterable[tuple[str, float]],
|
|
562
|
+
*,
|
|
563
|
+
keep: str,
|
|
564
|
+
) -> list[tuple[str, float]]:
|
|
565
|
+
"""Collapse repeated ``id`` rows, keeping the best score per id.
|
|
566
|
+
|
|
567
|
+
Preserves the order of *first* occurrence of each id, since LanceDB
|
|
568
|
+
returns rows in rank order; ``keep`` controls which score replaces a
|
|
569
|
+
later duplicate (``"max"`` for BM25 relevance, ``"min"`` for vector
|
|
570
|
+
distance).
|
|
571
|
+
"""
|
|
572
|
+
best: dict[str, float] = {}
|
|
573
|
+
order: list[str] = []
|
|
574
|
+
for ident, score in hits:
|
|
575
|
+
if ident in best:
|
|
576
|
+
if (keep == "max" and score > best[ident]) or (keep == "min" and score < best[ident]):
|
|
577
|
+
best[ident] = score
|
|
578
|
+
else:
|
|
579
|
+
best[ident] = score
|
|
580
|
+
order.append(ident)
|
|
581
|
+
return [(ident, best[ident]) for ident in order]
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _entity_type_clause(entity_type: str | Sequence[str] | None) -> str:
|
|
585
|
+
"""Build a SQL fragment constraining ``entity_type``, or empty string."""
|
|
586
|
+
if entity_type is None:
|
|
587
|
+
return ""
|
|
588
|
+
if isinstance(entity_type, str):
|
|
589
|
+
return f"entity_type = '{entity_type}'"
|
|
590
|
+
values = ", ".join(f"'{v}'" for v in entity_type)
|
|
591
|
+
return f"entity_type IN ({values})"
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _in_clause(column: str, values: Sequence[str] | None) -> str:
|
|
595
|
+
"""Build an ``IN`` fragment for *column*, or empty string when unset."""
|
|
596
|
+
if not values:
|
|
597
|
+
return ""
|
|
598
|
+
rendered = ", ".join(f"'{v}'" for v in values)
|
|
599
|
+
return f"{column} IN ({rendered})"
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _prepare_query(text: str, operator: str, lang: str | None = None) -> str:
|
|
603
|
+
"""Strip stopwords and join remaining terms with the given operator.
|
|
604
|
+
|
|
605
|
+
Returns the original text unchanged for single-term queries or when
|
|
606
|
+
all terms are stopwords. Used internally and exposed for testing.
|
|
607
|
+
``lang`` selects the stopword set; omitting it uses the union of all
|
|
608
|
+
of them.
|
|
609
|
+
"""
|
|
610
|
+
terms = [t for t in text.split() if t.lower() not in _stopwords_for(lang)]
|
|
611
|
+
if len(terms) <= 1:
|
|
612
|
+
return text
|
|
613
|
+
if operator == "and":
|
|
614
|
+
return " AND ".join(terms)
|
|
615
|
+
return " ".join(terms)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _build_match_query(
|
|
619
|
+
text: str, *, operator: str, fuzziness: int, lang: str | None = None
|
|
620
|
+
) -> MatchQuery | BooleanQuery:
|
|
621
|
+
"""Construct an FTS query that always includes exact matches.
|
|
622
|
+
|
|
623
|
+
Tantivy's fuzzy query (used by LanceDB's `MatchQuery`) has
|
|
624
|
+
a quirk: when ``fuzziness > 0`` is set on a query term of length
|
|
625
|
+
>= 8 chars, the underlying Levenshtein automaton excludes the
|
|
626
|
+
exact term itself from the result set. So a user typing
|
|
627
|
+
``search("inflation", fuzzy=1)`` would silently get zero hits
|
|
628
|
+
even though "inflation" is in the index.
|
|
629
|
+
|
|
630
|
+
Workaround: when ``fuzziness > 0``, wrap an exact ``MatchQuery``
|
|
631
|
+
and the fuzzy ``MatchQuery`` in a ``BooleanQuery`` joined with
|
|
632
|
+
``SHOULD`` — both paths contribute, so exact hits always come
|
|
633
|
+
back regardless of how Tantivy's fuzzy automaton behaves.
|
|
634
|
+
"""
|
|
635
|
+
from lancedb.query import ( # noqa: PLC0415
|
|
636
|
+
BooleanQuery,
|
|
637
|
+
FullTextOperator,
|
|
638
|
+
MatchQuery,
|
|
639
|
+
Occur,
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
fts_op = FullTextOperator.AND if operator == "and" else FullTextOperator.OR
|
|
643
|
+
cleaned = _strip_stopwords(text, lang)
|
|
644
|
+
exact = MatchQuery(
|
|
645
|
+
query=cleaned,
|
|
646
|
+
column="text",
|
|
647
|
+
operator=fts_op,
|
|
648
|
+
boost=1.0,
|
|
649
|
+
fuzziness=0,
|
|
650
|
+
max_expansions=50,
|
|
651
|
+
prefix_length=0,
|
|
652
|
+
document_granularity=None,
|
|
653
|
+
)
|
|
654
|
+
if fuzziness == 0:
|
|
655
|
+
return exact
|
|
656
|
+
fuzzy = MatchQuery(
|
|
657
|
+
query=cleaned,
|
|
658
|
+
column="text",
|
|
659
|
+
operator=fts_op,
|
|
660
|
+
boost=1.0,
|
|
661
|
+
fuzziness=fuzziness,
|
|
662
|
+
max_expansions=50,
|
|
663
|
+
prefix_length=0,
|
|
664
|
+
document_granularity=None,
|
|
665
|
+
)
|
|
666
|
+
return BooleanQuery([(Occur.SHOULD, exact), (Occur.SHOULD, fuzzy)])
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _ann_is_quantised(tbl: lancedb.table.Table | None) -> bool:
|
|
670
|
+
"""True when *tbl* carries a vector index that stores approximations.
|
|
671
|
+
|
|
672
|
+
Read from the table rather than the manifest. The manifest only began
|
|
673
|
+
recording ``ann`` in version 2, so every index predating #102 is
|
|
674
|
+
indistinguishable there from one that never had a vector index at all
|
|
675
|
+
— and those two cases want opposite answers. ``list_indices()``
|
|
676
|
+
separates them exactly, at no extra cost: the table is already open.
|
|
677
|
+
|
|
678
|
+
False when there is no vector index, which covers both a BM25-only
|
|
679
|
+
catalog and a language under ``_ANN_MIN_VECTORS``. The latter is
|
|
680
|
+
brute-force scanned and therefore *exact* — the last thing to warn
|
|
681
|
+
about, and the one case the manifest would misread as stale.
|
|
682
|
+
"""
|
|
683
|
+
if tbl is None:
|
|
684
|
+
return False
|
|
685
|
+
try:
|
|
686
|
+
indices = tbl.list_indices()
|
|
687
|
+
except Exception: # noqa: BLE001
|
|
688
|
+
return False
|
|
689
|
+
for ix in indices:
|
|
690
|
+
if list(ix.columns) == [_EMBEDDING_COLUMN]:
|
|
691
|
+
# Matched on the absence of FLAT rather than the presence of
|
|
692
|
+
# PQ: IVF_PQ is merely the one statcatlib used to build. IVF_SQ,
|
|
693
|
+
# IVF_RQ and the quantised HNSW variants are lossy too, and a
|
|
694
|
+
# future default should not slip past this check by not being
|
|
695
|
+
# spelled "PQ".
|
|
696
|
+
return "FLAT" not in ix.index_type.upper()
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class _LanguageIndex:
|
|
701
|
+
"""One language's slice of the search corpus, in its own Lance table.
|
|
702
|
+
|
|
703
|
+
Per-language tables exist so each can carry its own Tantivy tokenizer
|
|
704
|
+
— French text stemmed with French rules, not English ones (#101). The
|
|
705
|
+
separation also gives each language exact BM25 document frequencies:
|
|
706
|
+
with every language in one table, `N` and `avgdl` pooled across all of
|
|
707
|
+
them, so a term common in English looked rarer than it was inside the
|
|
708
|
+
French corpus.
|
|
709
|
+
|
|
710
|
+
Because a table holds exactly one language, none of the search methods
|
|
711
|
+
filter on ``lang`` — membership is the filter. The exception is a
|
|
712
|
+
legacy single-table catalog (``filter_lang=True``), where this wraps
|
|
713
|
+
the shared table and must still narrow by language.
|
|
714
|
+
"""
|
|
715
|
+
|
|
716
|
+
def __init__(self, db: lancedb.DBConnection, lang: str, name: str, *, filter_lang: bool = False) -> None:
|
|
717
|
+
self._db = db
|
|
718
|
+
self._lang = lang
|
|
719
|
+
self._name = name
|
|
720
|
+
self._filter_lang = filter_lang
|
|
721
|
+
self._tbl: lancedb.table.Table | None = None
|
|
722
|
+
self._has_fts = False
|
|
723
|
+
self._ann_index_type: str | None = None
|
|
724
|
+
if name in db.list_tables().tables:
|
|
725
|
+
self._tbl = db.open_table(name)
|
|
726
|
+
|
|
727
|
+
@property
|
|
728
|
+
def table(self) -> lancedb.table.Table | None:
|
|
729
|
+
return self._tbl
|
|
730
|
+
|
|
731
|
+
@property
|
|
732
|
+
def name(self) -> str:
|
|
733
|
+
return self._name
|
|
734
|
+
|
|
735
|
+
def ann_is_quantised(self) -> bool:
|
|
736
|
+
"""True when this table carries a *lossy* vector index (#103).
|
|
737
|
+
|
|
738
|
+
See `_ann_is_quantised`; this is the per-language view of it.
|
|
739
|
+
|
|
740
|
+
Read from the table rather than the manifest. The manifest only
|
|
741
|
+
began recording ``ann`` in version 2, so every index that predates
|
|
742
|
+
the fix is indistinguishable there from one that never had a
|
|
743
|
+
vector index at all — and those two cases want opposite answers.
|
|
744
|
+
``list_indices()`` distinguishes them exactly, at no extra cost:
|
|
745
|
+
the table is already open.
|
|
746
|
+
|
|
747
|
+
False when there is no vector index, which covers both a
|
|
748
|
+
BM25-only catalog and a language under ``_ANN_MIN_VECTORS``. The
|
|
749
|
+
latter is brute-force scanned and therefore *exact* — the last
|
|
750
|
+
thing to warn about.
|
|
751
|
+
"""
|
|
752
|
+
return _ann_is_quantised(self._tbl)
|
|
753
|
+
|
|
754
|
+
@property
|
|
755
|
+
def ann_index_type(self) -> str | None:
|
|
756
|
+
"""Vector index built for this language, or ``None`` if it scans.
|
|
757
|
+
|
|
758
|
+
Set by `build_index`, so it reflects this process's build
|
|
759
|
+
rather than what may sit on disk from an earlier one.
|
|
760
|
+
"""
|
|
761
|
+
return self._ann_index_type
|
|
762
|
+
|
|
763
|
+
def count_rows(self) -> int:
|
|
764
|
+
return 0 if self._tbl is None else self._tbl.count_rows()
|
|
765
|
+
|
|
766
|
+
def count_embedded(self) -> int:
|
|
767
|
+
"""Return how many of this table's rows actually carry a vector.
|
|
768
|
+
|
|
769
|
+
The number that governs whether an ANN index is worth building —
|
|
770
|
+
the index only ever contains these rows, however many others sit
|
|
771
|
+
beside them (#102).
|
|
772
|
+
|
|
773
|
+
Returns 0 when the table has no ``embedding`` column at all
|
|
774
|
+
(a BM25-only catalog): there is nothing to index, and pushing the
|
|
775
|
+
filter down would raise rather than return zero, since Lance
|
|
776
|
+
rejects a predicate naming an absent column.
|
|
777
|
+
"""
|
|
778
|
+
if self._tbl is None or _EMBEDDING_COLUMN not in self._tbl.schema.names:
|
|
779
|
+
return 0
|
|
780
|
+
return self._tbl.count_rows(filter=f"{_EMBEDDING_COLUMN} IS NOT NULL")
|
|
781
|
+
|
|
782
|
+
def drop(self) -> None:
|
|
783
|
+
"""Drop this language's table, ignoring a table that isn't there."""
|
|
784
|
+
with contextlib.suppress(Exception):
|
|
785
|
+
self._db.drop_table(self._name)
|
|
786
|
+
self._tbl = None
|
|
787
|
+
|
|
788
|
+
# -- writes --------------------------------------------------------
|
|
789
|
+
|
|
790
|
+
def create(self, rows: list[SearchRow], embedding_dim: int | None) -> None:
|
|
791
|
+
"""Replace this language's table with *rows*."""
|
|
792
|
+
if self._tbl is not None:
|
|
793
|
+
self._db.drop_table(self._name)
|
|
794
|
+
schema = make_schema(embedding_dim=embedding_dim or _embedding_dim_from_rows(rows))
|
|
795
|
+
# NULL embeddings are deliberate (fts_only codelists, embed_codes=False)
|
|
796
|
+
# and the schema declares the column nullable; lancedb >= 0.36 counts
|
|
797
|
+
# a NULL as a wrong-width vector and rejects it under the default
|
|
798
|
+
# on_bad_vectors="error". "null" keeps them NULL (#125).
|
|
799
|
+
self._tbl = self._db.create_table(self._name, data=_to_arrow(rows, schema=schema), on_bad_vectors="null")
|
|
800
|
+
self._has_fts = False
|
|
801
|
+
|
|
802
|
+
def upsert(self, rows: list[SearchRow], embedding_dim: int | None) -> None:
|
|
803
|
+
"""Delete-then-add *rows* by ``id``, creating the table if needed."""
|
|
804
|
+
if self._tbl is None:
|
|
805
|
+
self.create(rows, embedding_dim)
|
|
806
|
+
return
|
|
807
|
+
for row in rows:
|
|
808
|
+
with contextlib.suppress(Exception):
|
|
809
|
+
self._tbl.delete(f"id = '{row['id']}'")
|
|
810
|
+
# on_bad_vectors="null": see create() (#125).
|
|
811
|
+
self._tbl.add(_to_arrow(rows, schema=self._tbl.schema), on_bad_vectors="null")
|
|
812
|
+
self._has_fts = False
|
|
813
|
+
|
|
814
|
+
# -- indexing ------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
def build_fts_index(self) -> None:
|
|
817
|
+
"""Create or replace this language's Tantivy FTS index.
|
|
818
|
+
|
|
819
|
+
``ascii_folding`` makes BM25 accent-insensitive (a query for
|
|
820
|
+
"Montreal" matches indexed "Montréal"); the tokenizer is applied
|
|
821
|
+
symmetrically at index and query time, so query folding is
|
|
822
|
+
automatic. All three of stemmer, stopwords and folding are pinned
|
|
823
|
+
explicitly rather than left to LanceDB's defaults — the defaults
|
|
824
|
+
are ``language='English'`` with ``stem=True``, which is precisely
|
|
825
|
+
how French text came to be stemmed as English.
|
|
826
|
+
"""
|
|
827
|
+
if self._tbl is None:
|
|
828
|
+
return
|
|
829
|
+
from lancedb.index import FTS # noqa: PLC0415
|
|
830
|
+
|
|
831
|
+
stemmer = _stemmer_for(self._lang)
|
|
832
|
+
with contextlib.suppress(Exception):
|
|
833
|
+
self._tbl.create_index(
|
|
834
|
+
"text",
|
|
835
|
+
replace=True,
|
|
836
|
+
config=FTS(
|
|
837
|
+
language=stemmer or "English",
|
|
838
|
+
stem=stemmer is not None,
|
|
839
|
+
remove_stop_words=stemmer is not None,
|
|
840
|
+
ascii_folding=True,
|
|
841
|
+
),
|
|
842
|
+
)
|
|
843
|
+
self._has_fts = True
|
|
844
|
+
|
|
845
|
+
def build_index(self, *, ann_min_vectors: int = _ANN_MIN_VECTORS) -> None:
|
|
846
|
+
"""Build this language's FTS index, and its ANN index if big enough.
|
|
847
|
+
|
|
848
|
+
"Big enough" is measured in *embedded* rows, not table rows: the
|
|
849
|
+
index is trained on the embedding column, so rows that carry no
|
|
850
|
+
vector are training noise rather than training data (#102).
|
|
851
|
+
Below the bar the language is left to brute-force scan, which at
|
|
852
|
+
that size is both faster and exact.
|
|
853
|
+
"""
|
|
854
|
+
if self._tbl is None:
|
|
855
|
+
return
|
|
856
|
+
self.build_fts_index()
|
|
857
|
+
if self.count_embedded() >= ann_min_vectors:
|
|
858
|
+
# The column must be the embeddings column ("embedding"), not
|
|
859
|
+
# LanceDB's conventional "vector"; a wrong name raises and, under
|
|
860
|
+
# the suppress, silently leaves the table with no ANN index,
|
|
861
|
+
# forcing every semantic query to do a brute-force scan.
|
|
862
|
+
# IvfFlat is what _ANN_INDEX_TYPE records in the manifest.
|
|
863
|
+
from lancedb.index import IvfFlat # noqa: PLC0415
|
|
864
|
+
|
|
865
|
+
with contextlib.suppress(Exception):
|
|
866
|
+
self._tbl.create_index(_EMBEDDING_COLUMN, replace=True, config=IvfFlat(distance_type="cosine"))
|
|
867
|
+
self._ann_index_type = _ANN_INDEX_TYPE
|
|
868
|
+
|
|
869
|
+
# -- reads ---------------------------------------------------------
|
|
870
|
+
|
|
871
|
+
def _where(self, *extra: str) -> str:
|
|
872
|
+
parts = [p for p in extra if p]
|
|
873
|
+
if self._filter_lang:
|
|
874
|
+
parts.insert(0, f"lang = '{self._lang}'")
|
|
875
|
+
return " AND ".join(parts)
|
|
876
|
+
|
|
877
|
+
def bm25(
|
|
878
|
+
self,
|
|
879
|
+
text: str,
|
|
880
|
+
*,
|
|
881
|
+
columns: Sequence[str] | None,
|
|
882
|
+
limit: int,
|
|
883
|
+
operator: str,
|
|
884
|
+
fuzziness: int,
|
|
885
|
+
filters: Sequence[str],
|
|
886
|
+
) -> list[Bm25HitRow]:
|
|
887
|
+
"""Run a BM25 query, returning raw result rows.
|
|
888
|
+
|
|
889
|
+
The `# pyright: ignore[reportReturnType]` below is this module's only
|
|
890
|
+
suppression, and is the shape sdmxlib's CLAUDE.md prescribes for a
|
|
891
|
+
gradual-typing boundary: the one unsafe step, kept to the accessor
|
|
892
|
+
that performs it. `to_list()` is declared `List[dict[Unknown, Unknown]]`
|
|
893
|
+
— LanceDB hands back rows of the schema *this module declared* and
|
|
894
|
+
says nothing about their shape. Validating 19 keys per row on every
|
|
895
|
+
search to recover what `make_schema` already guarantees would cost
|
|
896
|
+
more than it proves.
|
|
897
|
+
|
|
898
|
+
Not `# type: ignore`: that form is unattributable, so neither checker
|
|
899
|
+
can tell a dead comment from one carrying the other's error, and
|
|
900
|
+
`reportUnnecessaryTypeIgnoreComment` stops being trustworthy.
|
|
901
|
+
"""
|
|
902
|
+
if self._tbl is None:
|
|
903
|
+
return []
|
|
904
|
+
if not self._has_fts:
|
|
905
|
+
self.build_fts_index()
|
|
906
|
+
query = _build_match_query(text, operator=operator, fuzziness=fuzziness, lang=self._lang)
|
|
907
|
+
select_cols = ["id", "_score"] if columns is None else list(dict.fromkeys([*columns, "_score"]))
|
|
908
|
+
try:
|
|
909
|
+
search = self._tbl.search(query, query_type="fts")
|
|
910
|
+
where = self._where(*filters)
|
|
911
|
+
if where:
|
|
912
|
+
search = search.where(where, prefilter=True)
|
|
913
|
+
return search.limit(limit).select(select_cols).to_list() # pyright: ignore[reportReturnType] # pyrefly: ignore[bad-return]
|
|
914
|
+
except Exception: # noqa: BLE001
|
|
915
|
+
return []
|
|
916
|
+
|
|
917
|
+
def vector(
|
|
918
|
+
self,
|
|
919
|
+
embedding: list[float],
|
|
920
|
+
*,
|
|
921
|
+
columns: Sequence[str] | None,
|
|
922
|
+
limit: int,
|
|
923
|
+
filters: Sequence[str],
|
|
924
|
+
) -> list[VectorHitRow]:
|
|
925
|
+
"""Run a vector nearest-neighbour query, returning raw result rows."""
|
|
926
|
+
if self._tbl is None:
|
|
927
|
+
return []
|
|
928
|
+
select_cols = ["id", "_distance"] if columns is None else list(dict.fromkeys([*columns, "_distance"]))
|
|
929
|
+
try:
|
|
930
|
+
search = self._tbl.search(embedding)
|
|
931
|
+
where = self._where(*filters)
|
|
932
|
+
if where:
|
|
933
|
+
search = search.where(where, prefilter=True)
|
|
934
|
+
return search.limit(limit).select(select_cols).to_list() # pyright: ignore[reportReturnType] # pyrefly: ignore[bad-return]
|
|
935
|
+
except Exception: # noqa: BLE001
|
|
936
|
+
return []
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
class LanceIndex:
|
|
940
|
+
"""Router over one `_LanguageIndex` per language.
|
|
941
|
+
|
|
942
|
+
Which table a language lives in is an index concern, so it stays
|
|
943
|
+
behind this façade: callers keep passing ``lang=`` to the same
|
|
944
|
+
methods and never name a table.
|
|
945
|
+
|
|
946
|
+
Schema (identical in every language's table)
|
|
947
|
+
-------------------------------------------
|
|
948
|
+
id str FK to ``dataset.identifier``
|
|
949
|
+
entity_type str dataset kind (``"open"``, ``"aggregate"``, …)
|
|
950
|
+
lang str BCP-47 language code (``"en"``, ``"fr"``, …)
|
|
951
|
+
text str concatenated title + description + keywords
|
|
952
|
+
embedding list[float]|None vector (None when no embedding model)
|
|
953
|
+
|
|
954
|
+
``lang`` is retained on the row even though the table already implies
|
|
955
|
+
it: hit construction reads it back, and it keeps a table
|
|
956
|
+
self-describing if one is ever inspected on its own.
|
|
957
|
+
"""
|
|
958
|
+
|
|
959
|
+
def __init__(self, path: str | Path) -> None:
|
|
960
|
+
import lancedb # noqa: PLC0415
|
|
961
|
+
|
|
962
|
+
self._path = Path(path)
|
|
963
|
+
self._db: lancedb.DBConnection = lancedb.connect(str(path))
|
|
964
|
+
self._langs: dict[str, _LanguageIndex] = {}
|
|
965
|
+
self._legacy: lancedb.table.Table | None = None
|
|
966
|
+
self._generation = 0
|
|
967
|
+
|
|
968
|
+
names = set(self._db.list_tables().tables)
|
|
969
|
+
manifest = self._read_manifest()
|
|
970
|
+
if manifest is not None:
|
|
971
|
+
self._generation = _as_int(manifest.get("generation"))
|
|
972
|
+
languages = manifest.get("languages")
|
|
973
|
+
entries = languages.items() if isinstance(languages, Mapping) else ()
|
|
974
|
+
for lang, entry in entries:
|
|
975
|
+
layout = _as_layout(entry)
|
|
976
|
+
# v1 wrote the table name directly; v2 wraps it in a mapping.
|
|
977
|
+
table = layout["table"] if layout is not None else _as_opt_str(entry)
|
|
978
|
+
if table is not None and table in names:
|
|
979
|
+
self._langs[str(lang)] = _LanguageIndex(self._db, str(lang), table)
|
|
980
|
+
if not self._langs and _TABLE in names:
|
|
981
|
+
# Pre-#101 catalog: one table, every language, one English
|
|
982
|
+
# stemmer. Served read-only so an existing bundle keeps
|
|
983
|
+
# working; build_index() replaces it with per-language tables.
|
|
984
|
+
self._legacy = self._db.open_table(_TABLE)
|
|
985
|
+
self._warn_if_stale()
|
|
986
|
+
|
|
987
|
+
def _warn_if_stale(self) -> None:
|
|
988
|
+
"""Warn once if this index is out of date, for any reason.
|
|
989
|
+
|
|
990
|
+
One warning rather than one per defect, because there is one
|
|
991
|
+
remedy: ``build_index()`` fixes every item below in a single
|
|
992
|
+
rebuild, and a caller who reads two warnings still does the same
|
|
993
|
+
thing. It also keeps the two from being triaged separately when
|
|
994
|
+
they are usually the same neglected bundle.
|
|
995
|
+
|
|
996
|
+
The two conditions genuinely co-occur. A pre-#101 bundle predates
|
|
997
|
+
#102 as well, so its single legacy table is stemmed as English
|
|
998
|
+
*and* carries an IVF_PQ vector index; warning about the stemming
|
|
999
|
+
alone would send someone to rebuild while leaving them unaware
|
|
1000
|
+
their vector search was also degraded.
|
|
1001
|
+
"""
|
|
1002
|
+
reasons: list[str] = []
|
|
1003
|
+
refs: list[str] = []
|
|
1004
|
+
|
|
1005
|
+
if self._legacy is not None:
|
|
1006
|
+
reasons.append(
|
|
1007
|
+
"it predates per-language tables, so every language is stemmed "
|
|
1008
|
+
"as English and non-English queries match fewer documents than "
|
|
1009
|
+
"they should"
|
|
1010
|
+
)
|
|
1011
|
+
refs.append("#101")
|
|
1012
|
+
|
|
1013
|
+
# Both the legacy table and the per-language ones can carry a
|
|
1014
|
+
# pre-#102 vector index, and exactly one of the two is populated.
|
|
1015
|
+
stale_ann = sorted(lang for lang, index in self._langs.items() if index.ann_is_quantised())
|
|
1016
|
+
if _ann_is_quantised(self._legacy):
|
|
1017
|
+
stale_ann = ["the legacy table"]
|
|
1018
|
+
if stale_ann:
|
|
1019
|
+
reasons.append(
|
|
1020
|
+
f"its vector index for {', '.join(stale_ann)} is quantised, so "
|
|
1021
|
+
"vector and hybrid search return roughly four fifths of the "
|
|
1022
|
+
"correct nearest neighbours, though the stored vectors "
|
|
1023
|
+
"themselves are correct"
|
|
1024
|
+
)
|
|
1025
|
+
refs.append("#102")
|
|
1026
|
+
|
|
1027
|
+
if not reasons:
|
|
1028
|
+
return
|
|
1029
|
+
detail = reasons[0] if len(reasons) == 1 else "; ".join(f"({i}) {r}" for i, r in enumerate(reasons, 1))
|
|
1030
|
+
warnings.warn(
|
|
1031
|
+
f"search index is out of date: {detail}. Rebuild with build_index() to fix. (statcatlib {', '.join(refs)})",
|
|
1032
|
+
stacklevel=3,
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
# -- manifest ------------------------------------------------------
|
|
1036
|
+
|
|
1037
|
+
def _read_manifest(self) -> dict[str, object] | None:
|
|
1038
|
+
"""Return the parsed manifest, or ``None`` when absent or corrupt.
|
|
1039
|
+
|
|
1040
|
+
A manifest that cannot be parsed is treated as missing rather than
|
|
1041
|
+
as an error: the tables are still on disk, and refusing to open
|
|
1042
|
+
would strand a catalog over a file the next rebuild rewrites.
|
|
1043
|
+
"""
|
|
1044
|
+
try:
|
|
1045
|
+
data = json.loads((self._path / _MANIFEST_FILE).read_text())
|
|
1046
|
+
except (OSError, ValueError):
|
|
1047
|
+
return None
|
|
1048
|
+
return data if isinstance(data, dict) else None
|
|
1049
|
+
|
|
1050
|
+
def _write_manifest(self, indexes: dict[str, _LanguageIndex], generation: int) -> None:
|
|
1051
|
+
"""Atomically point the index at *indexes*.
|
|
1052
|
+
|
|
1053
|
+
Written to a temporary file and moved into place, which is atomic
|
|
1054
|
+
on POSIX and Windows alike. This single move is what commits a
|
|
1055
|
+
rebuild.
|
|
1056
|
+
|
|
1057
|
+
Each entry records the stemmer the language was indexed with and
|
|
1058
|
+
its row count, so "which languages does this index cover, and was
|
|
1059
|
+
French actually stemmed as French?" is answerable by reading one
|
|
1060
|
+
file — including for BM25-only catalogs, which write no
|
|
1061
|
+
``embeddings.json`` at all.
|
|
1062
|
+
"""
|
|
1063
|
+
payload: _Manifest = {
|
|
1064
|
+
# A reader meeting a manifest version above its own knows the
|
|
1065
|
+
# index was written by a newer statcatlib and can say so. It does
|
|
1066
|
+
# not help statcatlib older than the manifest itself — those look
|
|
1067
|
+
# for a table named "search", find none, and return no hits
|
|
1068
|
+
# silently — which is why deploying the library ahead of any
|
|
1069
|
+
# bundle built with it is a hard ordering constraint, not a
|
|
1070
|
+
# preference.
|
|
1071
|
+
"version": _MANIFEST_VERSION,
|
|
1072
|
+
"written_by": _statcatlib_version(),
|
|
1073
|
+
"generation": generation,
|
|
1074
|
+
"languages": {
|
|
1075
|
+
lang: {
|
|
1076
|
+
"table": index.name,
|
|
1077
|
+
"rows": index.count_rows(),
|
|
1078
|
+
# Embedded rows are what the vector index actually
|
|
1079
|
+
# contains, and on a catalog mixing embedded and
|
|
1080
|
+
# fts_only codelists the two differ by an order of
|
|
1081
|
+
# magnitude. Recording both makes "why is vector
|
|
1082
|
+
# search scanning?" answerable from the manifest
|
|
1083
|
+
# alone (#102).
|
|
1084
|
+
"embedded": index.count_embedded(),
|
|
1085
|
+
"ann": index.ann_index_type,
|
|
1086
|
+
"stemmer": _stemmer_for(lang),
|
|
1087
|
+
}
|
|
1088
|
+
for lang, index in sorted(indexes.items())
|
|
1089
|
+
},
|
|
1090
|
+
}
|
|
1091
|
+
self._path.mkdir(parents=True, exist_ok=True)
|
|
1092
|
+
tmp = self._path / f"{_MANIFEST_FILE}.tmp"
|
|
1093
|
+
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True))
|
|
1094
|
+
# Path.replace is os.replace: atomic on POSIX and Windows alike.
|
|
1095
|
+
tmp.replace(self._path / _MANIFEST_FILE)
|
|
1096
|
+
|
|
1097
|
+
# -- language routing ----------------------------------------------
|
|
1098
|
+
|
|
1099
|
+
def _for(self, lang: str) -> _LanguageIndex | None:
|
|
1100
|
+
"""Return the index serving *lang*, or ``None`` if nothing does."""
|
|
1101
|
+
slug = _language_slug(lang)
|
|
1102
|
+
if slug in self._langs:
|
|
1103
|
+
return self._langs[slug]
|
|
1104
|
+
if self._legacy is not None:
|
|
1105
|
+
return _LanguageIndex(self._db, slug, _TABLE, filter_lang=True)
|
|
1106
|
+
return None
|
|
1107
|
+
|
|
1108
|
+
@property
|
|
1109
|
+
def languages(self) -> list[str]:
|
|
1110
|
+
"""Languages with their own table, sorted."""
|
|
1111
|
+
return sorted(self._langs)
|
|
1112
|
+
|
|
1113
|
+
def table_for(self, lang: str = "en") -> lancedb.table.Table | None:
|
|
1114
|
+
"""Return the Lance table serving *lang*, or ``None``.
|
|
1115
|
+
|
|
1116
|
+
The escape hatch for inspecting a single language's corpus
|
|
1117
|
+
directly — index introspection and tests. Callers doing ordinary
|
|
1118
|
+
search should go through the search methods, which route by
|
|
1119
|
+
language themselves.
|
|
1120
|
+
"""
|
|
1121
|
+
index = self._for(lang)
|
|
1122
|
+
return None if index is None else index.table
|
|
1123
|
+
|
|
1124
|
+
def describe(self) -> dict[str, LanguageLayout]:
|
|
1125
|
+
"""Return ``{lang: {table, rows, stemmer}}`` for the live index.
|
|
1126
|
+
|
|
1127
|
+
What each language was actually indexed with, read back from the
|
|
1128
|
+
manifest — the thing to check when a query in one language
|
|
1129
|
+
behaves unlike the same query in another. A ``stemmer`` of
|
|
1130
|
+
``None`` means that language is indexed unstemmed because
|
|
1131
|
+
LanceDB has no Snowball stemmer for it.
|
|
1132
|
+
"""
|
|
1133
|
+
manifest = self._read_manifest()
|
|
1134
|
+
if manifest is None:
|
|
1135
|
+
return {}
|
|
1136
|
+
languages = manifest.get("languages")
|
|
1137
|
+
if not isinstance(languages, Mapping):
|
|
1138
|
+
return {}
|
|
1139
|
+
out: dict[str, LanguageLayout] = {}
|
|
1140
|
+
for lang, entry in languages.items():
|
|
1141
|
+
layout = _as_layout(entry)
|
|
1142
|
+
if layout is not None:
|
|
1143
|
+
out[str(lang)] = layout
|
|
1144
|
+
return out
|
|
1145
|
+
|
|
1146
|
+
def count_rows(self) -> int:
|
|
1147
|
+
"""Total rows across every language."""
|
|
1148
|
+
if self._legacy is not None and not self._langs:
|
|
1149
|
+
return self._legacy.count_rows()
|
|
1150
|
+
return sum(li.count_rows() for li in self._langs.values())
|
|
1151
|
+
|
|
1152
|
+
# -- writes --------------------------------------------------------
|
|
1153
|
+
|
|
1154
|
+
@staticmethod
|
|
1155
|
+
def _by_language(rows: list[SearchRow]) -> dict[str, list[SearchRow]]:
|
|
1156
|
+
grouped: dict[str, list[SearchRow]] = {}
|
|
1157
|
+
for row in rows:
|
|
1158
|
+
grouped.setdefault(_language_slug(row.get("lang", "") or ""), []).append(row)
|
|
1159
|
+
return grouped
|
|
1160
|
+
|
|
1161
|
+
def add(self, rows: list[SearchRow], *, embedding_dim: int | None = None) -> None:
|
|
1162
|
+
"""Upsert rows into their languages' tables, creating any that are new.
|
|
1163
|
+
|
|
1164
|
+
Args:
|
|
1165
|
+
rows: Row dicts, each carrying its own ``lang``.
|
|
1166
|
+
embedding_dim: See `add_bulk`.
|
|
1167
|
+
"""
|
|
1168
|
+
if not rows:
|
|
1169
|
+
return
|
|
1170
|
+
added_language = False
|
|
1171
|
+
for lang, lang_rows in self._by_language(rows).items():
|
|
1172
|
+
index = self._langs.get(lang)
|
|
1173
|
+
if index is None:
|
|
1174
|
+
# A language seen for the first time needs a table *and* a
|
|
1175
|
+
# manifest entry — an unreferenced table is invisible on
|
|
1176
|
+
# the next open, so writing one without the other would
|
|
1177
|
+
# silently lose these rows.
|
|
1178
|
+
index = _LanguageIndex(self._db, lang, f"{_TABLE_PREFIX}{lang}__{self._generation:06d}")
|
|
1179
|
+
self._langs[lang] = index
|
|
1180
|
+
added_language = True
|
|
1181
|
+
index.upsert(lang_rows, embedding_dim)
|
|
1182
|
+
if added_language:
|
|
1183
|
+
self._write_manifest(self._langs, self._generation)
|
|
1184
|
+
|
|
1185
|
+
def add_bulk(self, rows: list[SearchRow], *, embedding_dim: int | None = None) -> None:
|
|
1186
|
+
"""Bulk-load rows, replacing every language's table.
|
|
1187
|
+
|
|
1188
|
+
More efficient than per-row ``add()`` for full rebuilds — avoids
|
|
1189
|
+
O(n) deletes and LanceDB fragment accumulation.
|
|
1190
|
+
|
|
1191
|
+
Languages absent from *rows* have their tables dropped, so a
|
|
1192
|
+
rebuild after a language is removed from the catalog does not
|
|
1193
|
+
leave a stale corpus behind to be searched.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
rows: Row dicts to load, replacing the index's contents.
|
|
1197
|
+
embedding_dim: Width of the embedding vectors, taken from the
|
|
1198
|
+
embedding model. Supply it whenever a model is configured:
|
|
1199
|
+
deriving the width from the rows cannot tell "no model" from
|
|
1200
|
+
"a model, but every row opted out of embedding"
|
|
1201
|
+
(``embed_codes=False``, or every codelist marked
|
|
1202
|
+
``fts_only``). That case would otherwise produce a
|
|
1203
|
+
null-typed embedding column, and the ANN index would
|
|
1204
|
+
silently never build. ``None`` falls back to scanning the
|
|
1205
|
+
rows, which is correct whenever at least one is embedded.
|
|
1206
|
+
|
|
1207
|
+
Raises:
|
|
1208
|
+
Exception: Whatever the underlying build raised. Every table
|
|
1209
|
+
created so far in this rebuild is dropped before the
|
|
1210
|
+
exception propagates, so a failed rebuild leaves the previous
|
|
1211
|
+
generation whole rather than a half-written one.
|
|
1212
|
+
"""
|
|
1213
|
+
if not rows:
|
|
1214
|
+
return
|
|
1215
|
+
grouped = self._by_language(rows)
|
|
1216
|
+
previous = {lang: index.name for lang, index in self._langs.items()}
|
|
1217
|
+
generation = self._generation + 1
|
|
1218
|
+
|
|
1219
|
+
# Build the whole new generation before committing to any of it.
|
|
1220
|
+
# Until the manifest is replaced, every query still reads the old
|
|
1221
|
+
# tables, so a failure here leaves the previous index whole rather
|
|
1222
|
+
# than half-rebuilt. LanceDB OSS has no rename_table, so the
|
|
1223
|
+
# generation suffix is what lets new tables coexist with the ones
|
|
1224
|
+
# still serving.
|
|
1225
|
+
built: dict[str, _LanguageIndex] = {}
|
|
1226
|
+
try:
|
|
1227
|
+
for lang, lang_rows in grouped.items():
|
|
1228
|
+
index = _LanguageIndex(self._db, lang, f"{_TABLE_PREFIX}{lang}__{generation:06d}")
|
|
1229
|
+
index.create(lang_rows, embedding_dim)
|
|
1230
|
+
built[lang] = index
|
|
1231
|
+
except Exception:
|
|
1232
|
+
for index in built.values():
|
|
1233
|
+
index.drop()
|
|
1234
|
+
raise
|
|
1235
|
+
|
|
1236
|
+
self._write_manifest(built, generation)
|
|
1237
|
+
|
|
1238
|
+
# Past this point the rebuild has committed; everything below is
|
|
1239
|
+
# reclaiming space and must not be able to undo it.
|
|
1240
|
+
self._langs = built
|
|
1241
|
+
self._generation = generation
|
|
1242
|
+
for name in previous.values():
|
|
1243
|
+
with contextlib.suppress(Exception):
|
|
1244
|
+
self._db.drop_table(name)
|
|
1245
|
+
# A rebuild supersedes any pre-#101 table; drop it so the two
|
|
1246
|
+
# layouts can never both answer queries.
|
|
1247
|
+
if self._legacy is not None:
|
|
1248
|
+
with contextlib.suppress(Exception):
|
|
1249
|
+
self._db.drop_table(_TABLE)
|
|
1250
|
+
self._legacy = None
|
|
1251
|
+
|
|
1252
|
+
def discard_orphans(self) -> int:
|
|
1253
|
+
"""Drop language tables no live manifest points at.
|
|
1254
|
+
|
|
1255
|
+
A crash between building a generation and committing it leaves
|
|
1256
|
+
those tables on disk. They are inert — nothing routes to them —
|
|
1257
|
+
but they hold space. Returns the number dropped.
|
|
1258
|
+
"""
|
|
1259
|
+
live = {index.name for index in self._langs.values()}
|
|
1260
|
+
names = [n for n in self._db.list_tables().tables if n.startswith(_TABLE_PREFIX) and n not in live]
|
|
1261
|
+
for name in names:
|
|
1262
|
+
with contextlib.suppress(Exception):
|
|
1263
|
+
self._db.drop_table(name)
|
|
1264
|
+
return len(names)
|
|
1265
|
+
|
|
1266
|
+
# -- full-text (BM25) search ---------------------------------------
|
|
1267
|
+
|
|
1268
|
+
def build_fts_index(self) -> None:
|
|
1269
|
+
"""Create or replace every language's FTS index."""
|
|
1270
|
+
for index in self._langs.values():
|
|
1271
|
+
index.build_fts_index()
|
|
1272
|
+
|
|
1273
|
+
def bm25_search(
|
|
1274
|
+
self,
|
|
1275
|
+
text: str,
|
|
1276
|
+
*,
|
|
1277
|
+
lang: str = "en",
|
|
1278
|
+
limit: int = 100,
|
|
1279
|
+
operator: str = "or",
|
|
1280
|
+
fuzziness: int = 0,
|
|
1281
|
+
entity_type: str | Sequence[str] | None = None,
|
|
1282
|
+
) -> list[tuple[str, float]]:
|
|
1283
|
+
"""Return ``[(identifier, bm25_score), …]`` ordered by BM25 relevance.
|
|
1284
|
+
|
|
1285
|
+
Builds the FTS index lazily on first call if not already present.
|
|
1286
|
+
Returns an empty list when no table serves *lang*.
|
|
1287
|
+
|
|
1288
|
+
Results are deduplicated by ``id`` (best score per id wins) so a
|
|
1289
|
+
single dataset never appears more than once in the ranked output.
|
|
1290
|
+
|
|
1291
|
+
Args:
|
|
1292
|
+
text: Query text, tokenised and stemmed with *lang*'s rules.
|
|
1293
|
+
lang: Language whose per-language table — and therefore whose stemmer — answers the
|
|
1294
|
+
query. Table membership *is* the language filter; see `LanceIndex`.
|
|
1295
|
+
limit: Maximum rows to return after dedup.
|
|
1296
|
+
operator: How to join multi-term queries after stopword removal. ``"or"`` (default) matches any
|
|
1297
|
+
term; ``"and"`` requires all terms.
|
|
1298
|
+
fuzziness: Levenshtein edit distance for fuzzy matching (0, 1, or 2). ``0`` (default) requires
|
|
1299
|
+
exact term matches.
|
|
1300
|
+
entity_type: Restrict matches to one or more ``entity_type`` values (e.g. ``"open"`` or
|
|
1301
|
+
``("open", "aggregate")``). When ``None`` (default), all row kinds are eligible — including
|
|
1302
|
+
code rows.
|
|
1303
|
+
|
|
1304
|
+
Returns:
|
|
1305
|
+
``[(identifier, score), …]`` ordered by descending BM25 score,
|
|
1306
|
+
at most *limit* long. Empty when no table serves *lang*.
|
|
1307
|
+
"""
|
|
1308
|
+
index = self._for(lang)
|
|
1309
|
+
if index is None:
|
|
1310
|
+
return []
|
|
1311
|
+
# Oversample to leave headroom for dedup; cap to keep the query bounded.
|
|
1312
|
+
oversample = min(limit * 4, 1000) if entity_type is None else limit * 2
|
|
1313
|
+
rows = index.bm25(
|
|
1314
|
+
text,
|
|
1315
|
+
columns=None,
|
|
1316
|
+
limit=oversample,
|
|
1317
|
+
operator=operator,
|
|
1318
|
+
fuzziness=fuzziness,
|
|
1319
|
+
filters=[_entity_type_clause(entity_type)],
|
|
1320
|
+
)
|
|
1321
|
+
return _dedupe_by_id(((r["id"], r["_score"]) for r in rows), keep="max")[:limit]
|
|
1322
|
+
|
|
1323
|
+
def bm25_search_rows(
|
|
1324
|
+
self,
|
|
1325
|
+
text: str,
|
|
1326
|
+
*,
|
|
1327
|
+
columns: Sequence[str],
|
|
1328
|
+
lang: str = "en",
|
|
1329
|
+
limit: int = 100,
|
|
1330
|
+
operator: str = "or",
|
|
1331
|
+
fuzziness: int = 0,
|
|
1332
|
+
entity_type: str | Sequence[str] | None = None,
|
|
1333
|
+
id_filter: Sequence[str] | None = None,
|
|
1334
|
+
codelist_urn_filter: Sequence[str] | None = None,
|
|
1335
|
+
scheme_urn_filter: Sequence[str] | None = None,
|
|
1336
|
+
set_urn_filter: Sequence[str] | None = None,
|
|
1337
|
+
) -> list[Bm25HitRow]:
|
|
1338
|
+
"""Return full BM25 result rows ordered by relevance.
|
|
1339
|
+
|
|
1340
|
+
Like `bm25_search` but yields the requested *columns*
|
|
1341
|
+
alongside the score (``_score`` is always included). Used by
|
|
1342
|
+
``Catalog.search_codes`` to return structured code-row data
|
|
1343
|
+
without a second SQL round-trip.
|
|
1344
|
+
|
|
1345
|
+
Args:
|
|
1346
|
+
text: Query text, tokenised and stemmed with *lang*'s rules.
|
|
1347
|
+
columns: Lance columns to project into each result row, in addition to the score. Always
|
|
1348
|
+
includes ``"_score"``.
|
|
1349
|
+
lang: Language whose per-language table — and therefore whose stemmer — answers the
|
|
1350
|
+
query. Table membership *is* the language filter; see `LanceIndex`.
|
|
1351
|
+
limit: Maximum rows to return after dedup.
|
|
1352
|
+
operator: How to join multi-term queries after stopword removal. ``"or"`` (default) matches
|
|
1353
|
+
any term; ``"and"`` requires all terms.
|
|
1354
|
+
fuzziness: Levenshtein edit distance for fuzzy matching (0, 1, or 2). ``0`` (default) requires
|
|
1355
|
+
exact term matches.
|
|
1356
|
+
entity_type: Restrict matches to one or more ``entity_type`` values. ``None`` (default) leaves
|
|
1357
|
+
every row kind eligible.
|
|
1358
|
+
id_filter: Optional whitelist of ``id`` values to restrict matches to — used to scope code-level
|
|
1359
|
+
search to a specific dataset set.
|
|
1360
|
+
codelist_urn_filter: Restrict matches to rows whose ``codelist_urn`` is in this set — the
|
|
1361
|
+
primitive that lets ``cat.codes.find(..., codelist_urn=GEO)`` scope a code search to one
|
|
1362
|
+
codelist.
|
|
1363
|
+
scheme_urn_filter: Restrict matches to rows whose ``scheme_urn`` is in this set — the concept-
|
|
1364
|
+
row analogue of ``codelist_urn_filter``.
|
|
1365
|
+
set_urn_filter: Restrict matches to rows whose ``set_urn`` is in this set — the
|
|
1366
|
+
reference-metadata analogue of ``codelist_urn_filter``.
|
|
1367
|
+
|
|
1368
|
+
Returns:
|
|
1369
|
+
One dict per hit, carrying the requested *columns* plus
|
|
1370
|
+
``_score``. Empty when no table serves *lang*.
|
|
1371
|
+
"""
|
|
1372
|
+
index = self._for(lang)
|
|
1373
|
+
if index is None:
|
|
1374
|
+
return []
|
|
1375
|
+
return index.bm25(
|
|
1376
|
+
text,
|
|
1377
|
+
columns=columns,
|
|
1378
|
+
limit=limit,
|
|
1379
|
+
operator=operator,
|
|
1380
|
+
fuzziness=fuzziness,
|
|
1381
|
+
filters=[
|
|
1382
|
+
_entity_type_clause(entity_type),
|
|
1383
|
+
_in_clause("id", id_filter),
|
|
1384
|
+
_in_clause("codelist_urn", codelist_urn_filter),
|
|
1385
|
+
_in_clause("scheme_urn", scheme_urn_filter),
|
|
1386
|
+
_in_clause("set_urn", set_urn_filter),
|
|
1387
|
+
],
|
|
1388
|
+
)
|
|
1389
|
+
|
|
1390
|
+
# -- semantic (vector) search --------------------------------------
|
|
1391
|
+
|
|
1392
|
+
def search(
|
|
1393
|
+
self,
|
|
1394
|
+
embedding: list[float],
|
|
1395
|
+
*,
|
|
1396
|
+
lang: str = "en",
|
|
1397
|
+
limit: int = 100,
|
|
1398
|
+
entity_type: str | Sequence[str] | None = None,
|
|
1399
|
+
) -> list[tuple[str, float]]:
|
|
1400
|
+
"""Return ``[(identifier, distance), …]`` ordered by vector distance.
|
|
1401
|
+
|
|
1402
|
+
Returns an empty list when no table serves *lang*.
|
|
1403
|
+
|
|
1404
|
+
Results are deduplicated by ``id`` (best — i.e. lowest — distance
|
|
1405
|
+
per id wins) so a single dataset never appears more than once in
|
|
1406
|
+
the ranked output.
|
|
1407
|
+
|
|
1408
|
+
Args:
|
|
1409
|
+
embedding: Query vector, from the same model the index was built
|
|
1410
|
+
with. A dimension mismatch is a build-time error, not one
|
|
1411
|
+
caught here.
|
|
1412
|
+
lang: Language whose per-language table — and therefore whose stemmer — answers the
|
|
1413
|
+
query. Table membership *is* the language filter; see `LanceIndex`.
|
|
1414
|
+
limit: Maximum rows to return after dedup.
|
|
1415
|
+
entity_type: Restrict matches to one or more ``entity_type`` values (e.g. ``"open"`` or
|
|
1416
|
+
``("open", "aggregate")``). When ``None`` (default), all row kinds are eligible — including
|
|
1417
|
+
code rows.
|
|
1418
|
+
|
|
1419
|
+
Returns:
|
|
1420
|
+
``[(identifier, distance), …]`` ordered by ascending distance,
|
|
1421
|
+
at most *limit* long. Empty when no table serves *lang*.
|
|
1422
|
+
"""
|
|
1423
|
+
index = self._for(lang)
|
|
1424
|
+
if index is None:
|
|
1425
|
+
return []
|
|
1426
|
+
# Oversample to leave headroom for dedup; cap to keep the query bounded.
|
|
1427
|
+
oversample = min(limit * 4, 1000) if entity_type is None else limit * 2
|
|
1428
|
+
rows = index.vector(
|
|
1429
|
+
embedding,
|
|
1430
|
+
columns=None,
|
|
1431
|
+
limit=oversample,
|
|
1432
|
+
filters=[_entity_type_clause(entity_type)],
|
|
1433
|
+
)
|
|
1434
|
+
return _dedupe_by_id(((r["id"], r["_distance"]) for r in rows), keep="min")[:limit]
|
|
1435
|
+
|
|
1436
|
+
def search_rows(
|
|
1437
|
+
self,
|
|
1438
|
+
embedding: list[float],
|
|
1439
|
+
*,
|
|
1440
|
+
columns: Sequence[str],
|
|
1441
|
+
lang: str = "en",
|
|
1442
|
+
limit: int = 100,
|
|
1443
|
+
entity_type: str | Sequence[str] | None = None,
|
|
1444
|
+
id_filter: Sequence[str] | None = None,
|
|
1445
|
+
codelist_urn_filter: Sequence[str] | None = None,
|
|
1446
|
+
scheme_urn_filter: Sequence[str] | None = None,
|
|
1447
|
+
set_urn_filter: Sequence[str] | None = None,
|
|
1448
|
+
) -> list[VectorHitRow]:
|
|
1449
|
+
"""Vector nearest-neighbour search returning full rows.
|
|
1450
|
+
|
|
1451
|
+
Like `search` but yields the requested *columns* alongside
|
|
1452
|
+
``_distance`` instead of collapsing to ``(id, distance)`` pairs.
|
|
1453
|
+
Used by code-level semantic search where each code row is
|
|
1454
|
+
distinct even when several share the same dataset ``id`` — the
|
|
1455
|
+
id-dedup that `search` applies would lose all but one
|
|
1456
|
+
code per dataset.
|
|
1457
|
+
|
|
1458
|
+
Returns an empty list when no table serves *lang*.
|
|
1459
|
+
"""
|
|
1460
|
+
index = self._for(lang)
|
|
1461
|
+
if index is None:
|
|
1462
|
+
return []
|
|
1463
|
+
return index.vector(
|
|
1464
|
+
embedding,
|
|
1465
|
+
columns=columns,
|
|
1466
|
+
limit=limit,
|
|
1467
|
+
filters=[
|
|
1468
|
+
_entity_type_clause(entity_type),
|
|
1469
|
+
_in_clause("id", id_filter),
|
|
1470
|
+
_in_clause("codelist_urn", codelist_urn_filter),
|
|
1471
|
+
_in_clause("scheme_urn", scheme_urn_filter),
|
|
1472
|
+
_in_clause("set_urn", set_urn_filter),
|
|
1473
|
+
],
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
# -- index management ----------------------------------------------
|
|
1477
|
+
|
|
1478
|
+
def build_index(self) -> None:
|
|
1479
|
+
"""Build every language's FTS index, and its ANN index if big enough.
|
|
1480
|
+
|
|
1481
|
+
The ANN index requires ``_ANN_MIN_VECTORS`` *embedded* rows —
|
|
1482
|
+
rows without a vector are not in the index and so do not count
|
|
1483
|
+
toward being worth building one (#102). The threshold applies per
|
|
1484
|
+
language now that each has its own table, so a language with too
|
|
1485
|
+
few vectors is left to brute-force scan, which at that size costs
|
|
1486
|
+
little.
|
|
1487
|
+
|
|
1488
|
+
The manifest is rewritten afterwards: which vector index each
|
|
1489
|
+
language ended up with is only known once the build has run, and
|
|
1490
|
+
``add_bulk`` wrote its entry before that.
|
|
1491
|
+
"""
|
|
1492
|
+
for index in self._langs.values():
|
|
1493
|
+
index.build_index()
|
|
1494
|
+
self._write_manifest(self._langs, self._generation)
|