How search works¶
statcatlib searches its catalog with two techniques: BM25 keyword matching and vector similarity over embeddings. Hybrid search runs both and merges their rankings with Reciprocal Rank Fusion. This page explains each technique, how the index is built, and how search combines with structured filters. The calls themselves are documented in Catalog API.
Search runs on an EditionView and needs the statcatlib[search] extra. Each
edition has its own search index. wc.seal() builds it from the edition's
rows, so every sealed edition can be searched. A working copy's view,
wc.view(), searches the index its last wc.build_index() built, or before
that the index it carried from the previous edition; a product's first working
copy returns empty results until wc.build_index() runs.
What is indexed¶
build_index() writes one search row per entity per language, into the
edition's search index, a LanceDB directory:
| Entity kind | Row text | Searched by |
|---|---|---|
| Dataset | Title, description, keywords, theme labels and dimension names | view.datasets.find, view.search, query(...).search() |
| Concept | The concept's name and its definition text | view.concepts.find, view.search |
| Code | "{dataset title} \| {dimension}: {code label}", with SKOS altLabel synonyms and statcat:searchContext text |
view.codes.find, view.search |
| Reference metadata | Sections of methodology and quality text, in chunks | view.reference_metadata.find, view.search with kinds=("reference_metadata",) |
Theme labels and dimension names make a dataset findable by what it is about and how it is broken down, which its title often leaves out.
Codes are the largest kind, so build_index() limits them. It skips codelists
with more than max_codes_per_codelist codes (500 by default), skips codes
carrying a statcat:searchExclude annotation, and indexes the codelists named
in a CodelistIndexPolicy(mode="fts_only") for keyword search only, with no
embeddings. Short proper-noun labels, such as place names, match better by
keyword than by vector similarity, and skipping their embeddings makes a large
geography codelist index in seconds.
One table per language¶
Each language has its own table, with its own full-text index:
- Each table uses its language's stemmer. French text is stemmed with French rules, so a query for "canadien" matches "canadiennes"; English text with English rules. A language LanceDB has no Snowball stemmer for, such as Chinese, Japanese, Korean or Catalan, is indexed unstemmed rather than with another language's rules.
- BM25 statistics are exact per language. BM25 weighs a term by how rare it is. Pooled into one table, a term common in French looked rare beside the English documents, and ranked too high.
- The table is the language filter. A query reads only the table for its
lang, which defaults to"en". A French query needslang="fr".
EditionView.index_layout reports each language's table, row count and stemmer:
print(view.index_layout)
# {"en": {"table": "search_en__000001", "rows": 946, "stemmer": "English", ...},
# "fr": {"table": "search_fr__000001", "rows": 1173, "stemmer": "French", ...}}
BM25: keyword matching¶
BM25 is the standard keyword-relevance formula. A document ranks higher when:
- it contains the query's rarer terms ("GDP" carries more weight than "data");
- it repeats them, with diminishing returns for each repetition;
- it is shorter, so the terms make up more of it.
statcatlib runs BM25 with Tantivy, the full-text engine inside LanceDB. Both the index and the query are accent-folded, so "Montreal" matches "Montréal". Common words are dropped from the query in its own language's list, so the French "sur" is removed from French queries and kept in English ones.
| Option | Effect |
|---|---|
operator="or" (default) |
A document matches if it contains any query term. |
operator="and" |
A document must contain every query term. |
fuzzy=1 or fuzzy=2 |
Terms match within that many edits, so "consumr" finds "consumer". |
BM25 is best at exact words, codes, names and technical vocabulary. It misses synonyms and paraphrases: "joblessness" does not match "unemployment".
Vectors: matching by meaning¶
An embedding model turns a text into a vector, and texts with similar meanings get nearby vectors whatever their wording. Vector search embeds the query and returns the rows whose vectors are nearest to it.
statcatlib has no built-in model. Pass any model with an encode method, such
as a sentence-transformers model, to wc.build_index(), and to
product.view() to search a sealed edition:
from sentence_transformers import SentenceTransformer
import statcatlib as sc
model = SentenceTransformer("intfloat/multilingual-e5-small")
with sc.Catalog.create("my-catalog", exist_ok=True) as cat:
with cat.product("14100287").working_copy() as wc:
... # ingest into wc
wc.build_index(model)
hits = wc.view().datasets.find("people moving between provinces", mode="vector")
wc.seal() # rebuilds the index with the same model
with sc.Catalog.open("my-catalog") as cat:
view = cat.product("14100287").view(embedding_model=model)
hits = view.datasets.find("people moving between provinces", mode="vector")
build_index() records the model's name and its query and document prefixes
in the index's embeddings.json, and product.view() restores the prefixes.
Models such as the e5 family expect one ("query: ", "passage: ") on each
text.
A language's table gets an approximate nearest-neighbour index (IVF_FLAT)
once it holds 256 rows with vectors. A smaller table is scanned in full, which
at that size is faster and exact. IVF_FLAT keeps each vector whole, so
approximate search returns the same neighbours as an exact scan. Indexes built
before pinaxlib 5.24.0 (pinaxlib is statcatlib's former name) used a quantised
index that misses many of them, and product.view() warns when it finds one.
Vector search is best at synonyms, paraphrases and vague queries. It misses exact codes and rare names, which carry little meaning to a model.
Each language's vectors live in that language's table. With a multilingual
model, an English query can still find French datasets: search the French
table with it, using lang="fr".
Hybrid: Reciprocal Rank Fusion¶
mode="hybrid" runs BM25 and vector search over the same table and merges the
two rankings with Reciprocal Rank Fusion. Each result scores the sum, over the
rankings it appears in, of
where rank is its position in that ranking (1 for the top) and k is 60 by
default (rrf_k).
Fusion uses ranks only, so it needs no way to compare a BM25 score with a
vector distance, and no weight between the two. A result near the top of both
rankings outscores one that leads a single ranking. Each hit's source says
which evidence found it: "bm25", "vector", or "hybrid" for both.
for hit in view.datasets.find("labour market monthly data", mode="hybrid", limit=20).items:
print(hit.score, hit.source, hit.payload.identifier)
view.search() uses the same fusion a second way: it runs BM25 over every
entity kind it is given and merges their rankings into one list.
Search with filters¶
query(...).search() and query(...).hybrid_search() combine search with the
relational and structural filters of a QueryBuilder:
import statcatlib.query as q
results = (
view.query(sc.AggregateDataset)
.filter(q.has_code("GEO", "ON"), status="current")
.hybrid_search("unemployment by region")
.limit(20)
.execute()
)
The filters run first, in SQL, and the search ranks only the datasets that pass
them, so a narrow filter with a broad query still finds every match.
search() takes the top 200 BM25 matches among those datasets, and
hybrid_search() fuses the top 200 from each technique. A page that reaches
further, such as .offset(240).limit(20), raises the count to offset + limit.
total counts the matches among those candidates.
The filtered identifiers reach LanceDB as an id IN (...) prefilter. When the
filters keep every dataset of the queried kind, as query(sc.AggregateDataset)
with no .filter() does, statcatlib sends no list. On a 50,000-dataset index a
list of all 50,000 identifiers made a BM25 query take 140 ms, against 2 ms
without one; a list of 1,000 took 6 ms.
Both methods take lang, which names the table to search and defaults to
"en". A French query with filters needs lang="fr":
Keeping the index current¶
A working copy's index is a snapshot of its rows when wc.build_index() ran.
A dataset added afterwards is not searchable on wc.view() until the next
build. A rebuild writes a complete new set of tables, then switches to them in
one step, so a failed rebuild leaves the previous index serving.
wc.seal() rebuilds the index from the edition's rows, with the arguments of
the last wc.build_index() call, or the defaults when there was none, so a
sealed edition's index holds exactly the sealed rows. statcat build-index
CATALOG PRODUCT builds the index of a product's working copy from the command
line and leaves the working copy unsealed.
build_index() also records the annotations it read (statcat:searchExclude,
statcat:searchContext, skos:altLabel). When an overlay changes them in a
working copy afterwards, resuming the working copy warns that the index is out
of date until the next build. Opening an edition never rebuilds its index,
because a sealed edition does not change.
The format of the index directory is specified in the Catalog Format.