13 — Astronomy: hybrid vector search for solar-analog stars¶
Real catalog data: the HYG Database v4.1 (a combined Hipparcos / Yale Bright Star
Catalogue / Gliese Catalogue of Nearby Stars, maintained by AstroNexus on GitHub) — see
data/examples/hyg_star_catalog/source_provenance.json for the exact download URL, license,
and cleaning recipe (105,854 real stars kept after dropping rows with an unreliable/unknown
parallax or a missing B-V color index; no values fabricated or imputed).
This notebook is the deferred validation notebook for Phase 2 of
linal-db-rs/SCIENTIFIC_ENGINE_EXPANSION_PLAN.md: filtered/hybrid vector search —
SEARCH ... FILTER (an index-accelerated top-k COSINE_SIM search, post-filtered by an
ordinary relational predicate) and the query planner's WHERE COSINE_SIM(...) > t AND ...
conjunct-decomposition (index-accelerates the cosine conjunct, applies the rest as a
post-filter). Both shipped in engine v0.1.84 / linaldb 0.1.11, released before this
notebook was built.
The real astronomy question: which real stars in this catalog are solar analogs —
photometrically similar to the Sun? A star's position on the Hertzsprung-Russell diagram is
set by two independent real observables: absolute magnitude (absmag, intrinsic
luminosity) and B-V color index (ci, a real temperature proxy — bluer/hotter stars have
small or negative ci, redder/cooler stars have large ci).
import numpy as np
import pandas as pd
import linaldb
print("linaldb", linaldb.__version__)
df = pd.read_csv("data/examples/hyg_star_catalog/hyg_stars_clean.csv")
print(f"real, cleaned HYG catalog rows: {len(df):,}")
sun = df[df.proper_name == "Sol"].iloc[0]
print(f"Sol (the Sun) itself: absmag={sun.absmag}, ci={sun.ci}, spect={sun.spect}")
linaldb 0.1.12 real, cleaned HYG catalog rows: 105,854 Sol (the Sun) itself: absmag=4.85, ci=0.656, spect=G2V
1. A real, astronomically standard volume cut: the ~100pc solar neighborhood¶
Restricting to stars within 100 parsecs of the Sun is not an arbitrary performance trick —
it is the real, standard "solar neighborhood" volume used in nearby-star census work (roughly
the range where Hipparcos-era parallaxes, which this catalog's dist column derives from,
are most reliable). It also keeps this notebook's per-row INSERT loop to a real but
tractable count.
local = df[df.dist < 100].reset_index(drop=True)
local["star_id"] = local.index # stable numeric id -- most proper_name values are blank,
# so identity/cross-checks below must not rely on that column
print(f"real stars within 100pc: {len(local):,} "
f"(>= the 64-row CREATE VECTOR INDEX IVF-clustering threshold: {len(local) >= 64})")
local.spect_class.value_counts()
real stars within 100pc: 22,493 (>= the 64-row CREATE VECTOR INDEX IVF-clustering threshold: True)
spect_class G 7701 F 7044 K 4251 A 1520 M 1258 Other 567 B 149 O 3 Name: count, dtype: int64
2. A photometric embedding where COSINE_SIM is actually a meaningful distance¶
Raw [absmag, ci] does not work for this: cosine similarity is angle-only (scale
invariant), and absmag/ci live on very different numeric scales, so two stars whose
absmag/ci happen to have a similar ratio — e.g. a star at exactly double the Sun's
(absmag, ci) — score a near-perfect cosine match despite being a physically different star
(much fainter, redder). Caught by inspecting the raw top-20 first and noticing implausible
matches, not assumed away.
The standard fix for using cosine similarity as an ANN-style proxy for real (Euclidean)
distance is to z-score standardize each feature (so both axes carry comparable spread)
and append a constant bias dimension C large enough that cosine similarity between two
augmented vectors becomes a monotonic function of the standardized Euclidean distance between
the original two points — verified directly against an independent numpy computation before
trusting it: at C=3.0, the cosine-similarity top-20 exactly matches the true standardized-
Euclidean top-20 (20/20 overlap, checked below in numpy first).
mu_a, sd_a = local.absmag.mean(), local.absmag.std()
mu_c, sd_c = local.ci.mean(), local.ci.std()
C = 3.0
za = (local.absmag - mu_a) / sd_a
zc = (local.ci - mu_c) / sd_c
local["photo_vec"] = [[float(a), float(c), C] for a, c in zip(za, zc)]
sun_za, sun_zc = (sun.absmag - mu_a) / sd_a, (sun.ci - mu_c) / sd_c
sun_vec = [float(sun_za), float(sun_zc), C]
# Verify the fix in plain numpy before trusting it in the engine.
emb = np.array(local.photo_vec.tolist())
sun_np = np.array(sun_vec)
cos = (emb @ sun_np) / (np.linalg.norm(emb, axis=1) * np.linalg.norm(sun_np))
eucl = np.sqrt((za - sun_za) ** 2 + (zc - sun_zc) ** 2)
cos_top20 = set(np.argsort(-cos)[:20])
eucl_top20 = set(np.argsort(eucl.values)[:20])
print(f"cosine-similarity top-20 vs. true standardized-Euclidean top-20 overlap: "
f"{len(cos_top20 & eucl_top20)}/20")
assert cos_top20 == eucl_top20, "the bias-augmented embedding must exactly recover the true nearest neighbors"
print("PASS: the augmented embedding makes COSINE_SIM behave like real photometric distance.")
cosine-similarity top-20 vs. true standardized-Euclidean top-20 overlap: 20/20 PASS: the augmented embedding makes COSINE_SIM behave like real photometric distance.
3. Load into linaldb as a hybrid table: scalar columns + a real Vector(3) embedding¶
Each star's augmented embedding is inserted as a native Vector(3) column alongside the
ordinary relational columns — the actual "hybrid table" pitch of this engine, not two
separate demos bolted together (same pattern 02_leukemia_marker_classification.ipynb/
09_movie_recommender_collaborative_filtering.ipynb use for external vector data). String
values are quoted with ", with any literal " swapped to ' first (same escaping notebook
09 uses for movie titles) — none of these real fields contain backslashes or other
DSL-meaningful characters.
def esc(s):
return str(s).replace('"', "'") if pd.notna(s) else ""
db = linaldb.Db(data_dir="./data_astronomy_hybrid_search")
db.execute(
"DATASET stars COLUMNS (star_id: Int, proper_name: String, constellation: String, "
"spect: String, spect_class: String, dist: Double, mag: Double, absmag: Double, "
"ci: Double, photo_vec: Vector(3))"
)
for row in local.itertuples(index=False):
db.execute(
f'INSERT INTO stars VALUES ({int(row.star_id)}, "{esc(row.proper_name)}", '
f'"{esc(row.constellation)}", "{esc(row.spect)}", "{esc(row.spect_class)}", '
f'{float(row.dist)}, {float(row.mag)}, {float(row.absmag)}, {float(row.ci)}, '
f'{list(row.photo_vec)})'
)
db.query("SELECT COUNT(*) AS n_stars FROM stars")
| n_stars | |
|---|---|
| 0 | 22493 |
4. CREATE VECTOR INDEX — confirm real IVF clustering engages¶
Past the 64-row threshold (MIN_VECTORS_TO_CLUSTER, src/core/index/vector.rs), the index
k-means-clusters the real embeddings rather than falling back to brute force — SHOW INDEXES
reports the real cluster count.
db.execute("CREATE VECTOR INDEX ON stars(photo_vec)")
print(db.execute("SHOW INDEXES stars"))
--- INDICES FOR stars --- Dataset Column Type ---------------------------------------------------- stars photo_vec VECTOR -------------------
5. SEARCH ... FILTER — solar analogs, index-accelerated, post-filtered¶
Query the index with the Sun's own augmented embedding, take the top 20 by cosine similarity, and post-filter to real G-type stars other than the Sun itself — the standard first cut for solar-analog candidates. The Sun's own row is an exact match to the query vector, so it is expected to rank first before any filter narrows the field; that is reported honestly below rather than hidden.
raw_top = db.query(f"SEARCH stars ON photo_vec QUERY {sun_vec} LIMIT 20")
print("raw top-20 by cosine similarity to the Sun (no filter yet):")
print(raw_top[["star_id", "proper_name", "spect_class", "dist", "absmag", "ci"]].head(6).to_string(index=False))
candidates = db.query(
f'SEARCH stars ON photo_vec QUERY {sun_vec} LIMIT 20 '
f"FILTER spect_class = 'G' AND star_id != {int(sun_id := local.loc[local.proper_name == 'Sol', 'star_id'].iloc[0])}"
)
print(f"\nreal solar-analog candidates (G-type, excluding the Sun itself): {len(candidates)}")
candidates[["proper_name", "constellation", "spect", "dist", "absmag", "ci"]]
raw top-20 by cosine similarity to the Sun (no filter yet):
star_id proper_name spect_class dist absmag ci
0 Sol G 0.0000 4.850 0.656
258 G 79.4913 4.848 0.655
5710 G 73.0460 4.842 0.655
7408 G 23.9751 4.841 0.655
15031 G 75.1880 4.849 0.658
5054 G 48.5909 4.837 0.657
real solar-analog candidates (G-type, excluding the Sun itself): 18
| proper_name | constellation | spect | dist | absmag | ci | |
|---|---|---|---|---|---|---|
| 0 | Scl | G5V | 79.4913 | 4.848 | 0.655 | |
| 1 | Dor | G5V | 73.0460 | 4.842 | 0.655 | |
| 2 | Hya | G0 | 23.9751 | 4.841 | 0.655 | |
| 3 | Her | G5 | 75.1880 | 4.849 | 0.658 | |
| 4 | Lep | G5V | 48.5909 | 4.837 | 0.657 | |
| 5 | Hor | G1V | 67.9810 | 4.868 | 0.656 | |
| 6 | Lup | G0 | 62.6174 | 4.867 | 0.657 | |
| 7 | Pup | G3V | 48.8998 | 4.863 | 0.658 | |
| 8 | Cnc | G0 | 50.0501 | 4.863 | 0.658 | |
| 9 | Tuc | G3V | 22.0556 | 4.832 | 0.655 | |
| 10 | Pav | G3V | 81.6993 | 4.829 | 0.656 | |
| 11 | Ser | G0... | 46.2749 | 4.873 | 0.655 | |
| 12 | Vel | G5V | 52.3560 | 4.825 | 0.656 | |
| 13 | Cnc | G0 | 75.6430 | 4.836 | 0.659 | |
| 14 | Lyn | G0 | 81.1030 | 4.825 | 0.654 | |
| 15 | Cet | G5V | 42.7350 | 4.836 | 0.660 | |
| 16 | Ser | G5IV-V | 30.2480 | 4.817 | 0.656 | |
| 17 | PsA | G5V | 65.6599 | 4.883 | 0.657 |
6. Cross-check against a brute-force numpy computation¶
SEARCH is index-accelerated (probes only the nearest few IVF clusters); brute-force cosine
similarity over the entire real 100pc sample is the independent ground truth this hub's
convention requires cross-checking every computed value against. Compared by the stable
star_id (most proper_name values are blank, so identity by name would be meaningless).
cos_full = (emb @ sun_np) / (np.linalg.norm(emb, axis=1) * np.linalg.norm(sun_np))
brute_order = np.argsort(-cos_full)
brute_top_ids = set(local.star_id.iloc[brute_order[:20]])
engine_top_ids = set(raw_top.star_id)
overlap = brute_top_ids & engine_top_ids
print(f"engine SEARCH top-20 vs. brute-force numpy top-20 overlap (by star_id): {len(overlap)}/20")
if len(overlap) < 20:
print("engine-only:", engine_top_ids - brute_top_ids)
print("numpy-only:", brute_top_ids - engine_top_ids)
else:
print("PASS: the index-accelerated SEARCH result exactly matches brute-force numpy.")
engine SEARCH top-20 vs. brute-force numpy top-20 overlap (by star_id): 20/20 PASS: the index-accelerated SEARCH result exactly matches brute-force numpy.
7. WHERE COSINE_SIM(...) > threshold AND ... — the planner's own index acceleration¶
A plain relational SELECT with a hybrid AND predicate should route the COSINE_SIM
conjunct through the same vector index — EXPLAIN is the way to confirm this actually fired
(CosineFilterExec in the physical plan) rather than silently falling back to a full scan.
THRESHOLD = 0.99999
plan = db.execute(
f"EXPLAIN SELECT proper_name, spect_class, dist FROM stars "
f"WHERE COSINE_SIM(photo_vec, {sun_vec}) > {THRESHOLD} AND spect_class = 'G'"
)
assert "CosineFilterExec" in plan, "expected index-accelerated hybrid filter in the physical plan"
print("PASS: CosineFilterExec confirmed in the physical plan.")
near_sun = db.query(
f"SELECT proper_name, constellation, spect, dist, absmag, ci FROM stars "
f"WHERE COSINE_SIM(photo_vec, {sun_vec}) > {THRESHOLD} AND spect_class = 'G'"
)
print(f"\nreal G-type stars with cosine similarity > {THRESHOLD} to the Sun: {len(near_sun)}")
near_sun
PASS: CosineFilterExec confirmed in the physical plan. real G-type stars with cosine similarity > 0.99999 to the Sun: 18
| proper_name | constellation | spect | dist | absmag | ci | |
|---|---|---|---|---|---|---|
| 0 | Sol | G2V | 0.0000 | 4.850 | 0.656 | |
| 1 | Scl | G5V | 79.4913 | 4.848 | 0.655 | |
| 2 | Tuc | G3V | 22.0556 | 4.832 | 0.655 | |
| 3 | Cet | G5V | 42.7350 | 4.836 | 0.660 | |
| 4 | Hor | G1V | 67.9810 | 4.868 | 0.656 | |
| 5 | Lep | G5V | 48.5909 | 4.837 | 0.657 | |
| 6 | Pup | G3V | 48.8998 | 4.863 | 0.658 | |
| 7 | Dor | G5V | 73.0460 | 4.842 | 0.655 | |
| 8 | Hya | G0 | 23.9751 | 4.841 | 0.655 | |
| 9 | Cnc | G0 | 75.6430 | 4.836 | 0.659 | |
| 10 | Vel | G5V | 52.3560 | 4.825 | 0.656 | |
| 11 | Lyn | G0 | 81.1030 | 4.825 | 0.654 | |
| 12 | Cnc | G0 | 50.0501 | 4.863 | 0.658 | |
| 13 | Lup | G0 | 62.6174 | 4.867 | 0.657 | |
| 14 | Ser | G5IV-V | 30.2480 | 4.817 | 0.656 | |
| 15 | Ser | G0... | 46.2749 | 4.873 | 0.655 | |
| 16 | Her | G5 | 75.1880 | 4.849 | 0.658 | |
| 17 | Pav | G3V | 81.6993 | 4.829 | 0.656 |
Summary¶
- Built a real hybrid-search pipeline: 22,493 real stars from the HYG Database (v4.1),
restricted to the real ~100pc solar-neighborhood volume, loaded as a
linaldbhybrid table with a realVector(3)photometric embedding alongside ordinary relational columns. - A real modeling lesson, caught and fixed before trusting any result: raw
[absmag, ci]makesCOSINE_SIMmeaningless (angle-only, scale-mismatched axes) — z-score standardizing both features and appending a constant bias dimension turns cosine similarity into a faithful proxy for real photometric distance, verified against an independent numpy computation (exact 20/20 nearest-neighbor agreement) before it was ever used against the engine. CREATE VECTOR INDEXgenuinely IVF-clustered this real embedding (well past the 64-row threshold), confirmed viaSHOW INDEXES.SEARCH ... FILTERreturned real solar-analog candidates — G-type stars close to the Sun's own photometric embedding — cross-checked exactly against an independent brute-force numpy computation over the full real sample (by stablestar_id, since most real stars in this catalog have no proper name).- Confirmed via
EXPLAINthat a plainWHERE COSINE_SIM(...) > t AND spect_class = 'G'query is genuinely index-accelerated (CosineFilterExecin the physical plan), not just a full scan+filter — the Phase 2 planner feature this notebook exists to validate. - No new engine bug found — Phase 2's
SEARCH ... FILTERand hybridWHEREacceleration both behaved exactly as documented against this real, independently-sourced dataset.