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.
@@ -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)