python-lucide 0.3.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (24) hide show
  1. {python_lucide-0.3.0 → python_lucide-0.5.0}/PKG-INFO +57 -1
  2. {python_lucide-0.3.0 → python_lucide-0.5.0}/README.md +55 -0
  3. {python_lucide-0.3.0 → python_lucide-0.5.0}/pyproject.toml +3 -2
  4. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/build_clusters.py +88 -51
  5. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/build_search.py +51 -28
  6. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/cli.py +146 -31
  7. python_lucide-0.5.0/src/lucide/config.py +100 -0
  8. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/data/lucide-icon-clusters.json +2 -2
  9. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/search.py +92 -29
  10. python_lucide-0.5.0/tests/build_clusters_test.py +107 -0
  11. {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/build_search_test.py +48 -10
  12. {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/cli_test.py +132 -15
  13. python_lucide-0.5.0/tests/conftest.py +24 -0
  14. {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/search_test.py +93 -14
  15. python_lucide-0.3.0/src/lucide/config.py +0 -30
  16. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/__init__.py +0 -0
  17. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/core.py +0 -0
  18. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/data/__init__.py +0 -0
  19. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/data/gemini-icon-descriptions.jsonl +0 -0
  20. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/data/lucide-icons.db +0 -0
  21. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/db.py +0 -0
  22. {python_lucide-0.3.0 → python_lucide-0.5.0}/src/lucide/dev_utils.py +0 -0
  23. {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/__init__.py +0 -0
  24. {python_lucide-0.3.0 → python_lucide-0.5.0}/tests/core_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-lucide
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: A Python package for working with Lucide icons
5
5
  Keywords: lucide,icons,svg,sqlite
6
6
  Author: Mike Macpherson
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Dist: cairosvg>=2.7.0 ; extra == 'search'
18
19
  Requires-Dist: fastembed>=0.4.0 ; extra == 'search'
19
20
  Requires-Python: >=3.10
20
21
  Project-URL: Bug Tracker, https://github.com/mmacpherson/python-lucide/issues
@@ -43,6 +44,7 @@ in your Python projects, with no javascript in sight.
43
44
 
44
45
  ## Features
45
46
  - 🎨 **Access 1600+ Lucide icons** directly from Python
47
+ - 🔍 **Semantic search** - find icons by meaning, in the terminal or [in the browser](https://mmacpherson.github.io/python-lucide/)
46
48
  - 🛠 **Customize icons** with classes, sizes, colors, and other SVG attributes
47
49
  - 🚀 **Framework-friendly** with examples for FastHTML, Flask, Django, and more
48
50
  - 📦 **Lightweight** with minimal dependencies
@@ -83,6 +85,60 @@ lucide_icon("user", fill="currentColor") # Inherit color from CSS
83
85
  lucide_icon("user", stroke="#ff6b6b") # Hex colors work too
84
86
  ```
85
87
 
88
+ ## Semantic Search
89
+
90
+ Find icons by describing what you mean, not what they're called — in English
91
+ or [50+ languages](https://mmacpherson.github.io/python-lucide/):
92
+
93
+ <p align="center">
94
+ <a href="https://mmacpherson.github.io/python-lucide/">
95
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-english.png" width="49%" alt="Searching &quot;celebrate a big achievement&quot; returns trophy, award, medal, party-popper">
96
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-multilingual.png" width="49%" alt="Searching in Japanese with the multilingual model returns key, user-key, lock icons">
97
+ </a>
98
+ </p>
99
+
100
+ ```bash
101
+ # One-off, no install (requires uv)
102
+ uvx --from "python-lucide[search]" lucide search "waiting for a download"
103
+
104
+ # Or install the search extra
105
+ pip install "python-lucide[search]"
106
+ lucide search "celebrate a big achievement"
107
+ ```
108
+
109
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-cli.png" width="360" alt="Terminal output of lucide search: each result icon renders inline next to its name and match score">
110
+
111
+ In terminals supporting the Kitty graphics protocol (kitty, Ghostty,
112
+ WezTerm), the icons render inline as above — inside tmux too, with
113
+ `tmux set -g allow-passthrough on`. Elsewhere you get the plain text
114
+ list. (Rendering uses the cairo system library; if it's missing, the
115
+ CLI prints a hint.)
116
+
117
+ Icons are matched against AI-generated descriptions using the same embedding
118
+ model the [web app](https://mmacpherson.github.io/python-lucide/) uses,
119
+ so descriptive phrases ("an idea just occurred to me") work better than
120
+ keywords. Options:
121
+
122
+ - `--model multilingual` — search in 50+ languages (default `bge-small` ranks
123
+ best for English)
124
+ - `--limit N` — number of results; `--verbose` — include each icon's description
125
+
126
+ The first search downloads the embedding model (~67 MB) and the pre-built
127
+ search index (11 MB); both are cached, and every search after that runs
128
+ locally in well under a second.
129
+
130
+ The same search is available as a Python API:
131
+
132
+ ```python
133
+ from lucide.search import search_icons
134
+
135
+ for r in search_icons("secure login", limit=5):
136
+ print(r.name, r.score)
137
+ ```
138
+
139
+ Prefer not to install anything? The [browser version](https://mmacpherson.github.io/python-lucide/)
140
+ runs the whole pipeline client-side.
141
+
86
142
  ## Framework Integration Examples
87
143
 
88
144
  ### Starlette
@@ -18,6 +18,7 @@ in your Python projects, with no javascript in sight.
18
18
 
19
19
  ## Features
20
20
  - 🎨 **Access 1600+ Lucide icons** directly from Python
21
+ - 🔍 **Semantic search** - find icons by meaning, in the terminal or [in the browser](https://mmacpherson.github.io/python-lucide/)
21
22
  - 🛠 **Customize icons** with classes, sizes, colors, and other SVG attributes
22
23
  - 🚀 **Framework-friendly** with examples for FastHTML, Flask, Django, and more
23
24
  - 📦 **Lightweight** with minimal dependencies
@@ -58,6 +59,60 @@ lucide_icon("user", fill="currentColor") # Inherit color from CSS
58
59
  lucide_icon("user", stroke="#ff6b6b") # Hex colors work too
59
60
  ```
60
61
 
62
+ ## Semantic Search
63
+
64
+ Find icons by describing what you mean, not what they're called — in English
65
+ or [50+ languages](https://mmacpherson.github.io/python-lucide/):
66
+
67
+ <p align="center">
68
+ <a href="https://mmacpherson.github.io/python-lucide/">
69
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-english.png" width="49%" alt="Searching &quot;celebrate a big achievement&quot; returns trophy, award, medal, party-popper">
70
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-multilingual.png" width="49%" alt="Searching in Japanese with the multilingual model returns key, user-key, lock icons">
71
+ </a>
72
+ </p>
73
+
74
+ ```bash
75
+ # One-off, no install (requires uv)
76
+ uvx --from "python-lucide[search]" lucide search "waiting for a download"
77
+
78
+ # Or install the search extra
79
+ pip install "python-lucide[search]"
80
+ lucide search "celebrate a big achievement"
81
+ ```
82
+
83
+ <img src="https://raw.githubusercontent.com/mmacpherson/python-lucide/main/.github/images/search-cli.png" width="360" alt="Terminal output of lucide search: each result icon renders inline next to its name and match score">
84
+
85
+ In terminals supporting the Kitty graphics protocol (kitty, Ghostty,
86
+ WezTerm), the icons render inline as above — inside tmux too, with
87
+ `tmux set -g allow-passthrough on`. Elsewhere you get the plain text
88
+ list. (Rendering uses the cairo system library; if it's missing, the
89
+ CLI prints a hint.)
90
+
91
+ Icons are matched against AI-generated descriptions using the same embedding
92
+ model the [web app](https://mmacpherson.github.io/python-lucide/) uses,
93
+ so descriptive phrases ("an idea just occurred to me") work better than
94
+ keywords. Options:
95
+
96
+ - `--model multilingual` — search in 50+ languages (default `bge-small` ranks
97
+ best for English)
98
+ - `--limit N` — number of results; `--verbose` — include each icon's description
99
+
100
+ The first search downloads the embedding model (~67 MB) and the pre-built
101
+ search index (11 MB); both are cached, and every search after that runs
102
+ locally in well under a second.
103
+
104
+ The same search is available as a Python API:
105
+
106
+ ```python
107
+ from lucide.search import search_icons
108
+
109
+ for r in search_icons("secure login", limit=5):
110
+ print(r.name, r.score)
111
+ ```
112
+
113
+ Prefer not to install anything? The [browser version](https://mmacpherson.github.io/python-lucide/)
114
+ runs the whole pipeline client-side.
115
+
61
116
  ## Framework Integration Examples
62
117
 
63
118
  ### Starlette
@@ -11,6 +11,7 @@ dev = [
11
11
  "mypy>=1.0.0",
12
12
  "plotly>=5.0.0",
13
13
  "pre-commit>=3.0.0",
14
+ "pydantic-ai-slim[google]>=1.0.0",
14
15
  "pytest-cov>=4.0.0",
15
16
  "pytest>=7.0.0",
16
17
  "ruff>=0.1.0",
@@ -40,10 +41,10 @@ license = {text = "MIT"}
40
41
  name = "python-lucide"
41
42
  readme = "README.md"
42
43
  requires-python = ">=3.10"
43
- version = "0.3.0"
44
+ version = "0.5.0"
44
45
 
45
46
  [project.optional-dependencies]
46
- search = ["fastembed>=0.4.0"]
47
+ search = ["cairosvg>=2.7.0", "fastembed>=0.4.0"]
47
48
 
48
49
  [project.scripts]
49
50
  check-lucide-version = "lucide.dev_utils:print_version_status"
@@ -13,51 +13,68 @@ import logging
13
13
  import os
14
14
  import pathlib
15
15
  import sqlite3
16
- import urllib.request
17
16
  from datetime import datetime, timezone
18
17
 
18
+ from pydantic import BaseModel, field_validator
19
+ from pydantic_ai import Agent
20
+ from pydantic_ai.models import Model
21
+
22
+ from .config import DEFAULT_SEARCH_MODEL_ID
23
+
19
24
  logger = logging.getLogger(__name__)
20
25
 
21
26
  CLUSTER_NAMING_MODEL = "gemini-2.5-flash"
22
27
 
23
- NAMING_PROMPT_TEMPLATE = """\
24
- Here are icon names that form a visual/semantic cluster: {icon_names}
28
+ NAMING_INSTRUCTIONS = """\
29
+ You name clusters of icons. Given icon names that form a visual/semantic
30
+ cluster, respond with a short, evocative theme name (2-4 words).
31
+ Be specific about what unifies the icons.
32
+ Do not use generic labels like "UI elements" or "miscellaneous"."""
25
33
 
26
- Give this cluster a short, evocative theme name (2-4 words).
27
- Be specific about what unifies these icons.
28
- Do not use generic labels like "UI elements" or "miscellaneous".
34
+ # Generous bound for a "2-4 word" name; anything longer means the model
35
+ # ignored the instruction (e.g. leaked its reasoning)
36
+ MAX_THEME_LENGTH = 40
29
37
 
30
- Return ONLY the theme name, nothing else."""
31
38
 
32
- GEMINI_API_URL = (
33
- "https://generativelanguage.googleapis.com/v1beta/models/"
34
- "{model}:generateContent?key={api_key}"
35
- )
39
+ def _sanitize_theme(raw: str) -> str | None:
40
+ """Validate a model-proposed theme name.
36
41
 
42
+ Models occasionally return their full chain-of-thought instead of just
43
+ the name — two shipped clusters once carried ~4k-char reasoning dumps
44
+ as their theme. Reject anything multi-line or implausibly long rather
45
+ than trying to salvage it.
46
+
47
+ Args:
48
+ raw: The raw model response text.
49
+
50
+ Returns:
51
+ The cleaned theme name, or None if the response is unusable.
52
+ """
53
+ theme = raw.strip().strip("\"'").strip()
54
+ if not theme or "\n" in theme or len(theme) > MAX_THEME_LENGTH:
55
+ return None
56
+ return theme
37
57
 
38
- def _call_gemini_text(prompt: str, api_key: str) -> str | None:
39
- """Call Gemini API with a text-only prompt."""
40
- payload = {"contents": [{"parts": [{"text": prompt}]}]}
41
- body = json.dumps(payload).encode("utf-8")
42
- url = GEMINI_API_URL.format(model=CLUSTER_NAMING_MODEL, api_key=api_key)
43
- req = urllib.request.Request(
44
- url,
45
- data=body,
46
- headers={"Content-Type": "application/json"},
47
- method="POST",
48
- )
49
- try:
50
- with urllib.request.urlopen(req, timeout=30) as resp:
51
- result = json.loads(resp.read().decode("utf-8"))
52
- candidates = result.get("candidates", [])
53
- if candidates:
54
- parts = candidates[0].get("content", {}).get("parts", [])
55
- if parts:
56
- text: str = parts[0].get("text", "")
57
- return text.strip()
58
- except Exception:
59
- logger.warning("Gemini API call failed", exc_info=True)
60
- return None
58
+
59
+ class ClusterTheme(BaseModel):
60
+ """Structured output for cluster naming.
61
+
62
+ A failed validation here becomes a retry request to the model, so a
63
+ leaked chain-of-thought gets re-asked instead of stored or discarded.
64
+ """
65
+
66
+ theme: str
67
+
68
+ @field_validator("theme")
69
+ @classmethod
70
+ def _must_be_short_single_line(cls, value: str) -> str:
71
+ clean = _sanitize_theme(value)
72
+ if clean is None:
73
+ raise ValueError(
74
+ f"theme must be a single line of at most {MAX_THEME_LENGTH} "
75
+ "characters — return only the 2-4 word name itself"
76
+ )
77
+ return clean
61
78
 
62
79
 
63
80
  def discover_clusters(
@@ -83,7 +100,9 @@ def discover_clusters(
83
100
  "SELECT e.name, e.embedding, d.description "
84
101
  "FROM icon_embeddings e "
85
102
  "JOIN icon_descriptions d ON e.name = d.name "
86
- "ORDER BY e.name"
103
+ "WHERE e.model = ? "
104
+ "ORDER BY e.name",
105
+ (DEFAULT_SEARCH_MODEL_ID,),
87
106
  ).fetchall()
88
107
  conn.close()
89
108
 
@@ -140,19 +159,40 @@ def name_clusters(
140
159
  data: dict,
141
160
  *,
142
161
  api_key: str | None = None,
162
+ model: Model | None = None,
143
163
  ) -> dict:
144
- """Name each cluster using Gemini Flash.
164
+ """Name each cluster using Gemini Flash via a Pydantic AI agent.
165
+
166
+ Output is validated by ``ClusterTheme``; an invalid response (e.g. a
167
+ leaked chain-of-thought) triggers an automatic retry instead of being
168
+ stored. Only after retries are exhausted does a cluster fall back to a
169
+ ``Cluster {id}`` placeholder.
145
170
 
146
171
  Args:
147
172
  data: Output from ``discover_clusters()``.
148
173
  api_key: Gemini API key. Falls back to ``GEMINI_API_KEY`` env var.
174
+ model: Model override, used by tests to avoid real API calls.
149
175
 
150
176
  Returns:
151
177
  The same data dict with ``theme`` populated for each cluster.
152
178
  """
153
- api_key = api_key or os.environ.get("GEMINI_API_KEY")
154
- if not api_key:
155
- raise ValueError("Gemini API key required. Set GEMINI_API_KEY.")
179
+ if model is None:
180
+ from pydantic_ai.models.google import GoogleModel # noqa: PLC0415
181
+ from pydantic_ai.providers.google import GoogleProvider # noqa: PLC0415
182
+
183
+ api_key = api_key or os.environ.get("GEMINI_API_KEY")
184
+ if not api_key:
185
+ raise ValueError("Gemini API key required. Set GEMINI_API_KEY.")
186
+ model = GoogleModel(
187
+ CLUSTER_NAMING_MODEL, provider=GoogleProvider(api_key=api_key)
188
+ )
189
+
190
+ agent = Agent(
191
+ model,
192
+ output_type=ClusterTheme,
193
+ instructions=NAMING_INSTRUCTIONS,
194
+ output_retries=3,
195
+ )
156
196
 
157
197
  clusters = data["clusters"]
158
198
  for lid in sorted(clusters, key=lambda k: -len(clusters[k]["icons"])):
@@ -163,17 +203,15 @@ def name_clusters(
163
203
  icons = clusters[lid]["icons"]
164
204
  # Cap at 40 names to keep prompt short
165
205
  icon_names = ", ".join(icons[:40])
166
- prompt = NAMING_PROMPT_TEMPLATE.format(icon_names=icon_names)
167
- theme = _call_gemini_text(prompt, api_key)
168
-
169
- if theme:
170
- # Strip quotes if the model wraps it
171
- theme = theme.strip("\"'")
172
- clusters[lid]["theme"] = theme
173
- logger.info("Cluster %s (%d icons): %s", lid, len(icons), theme)
174
- else:
206
+ try:
207
+ result = agent.run_sync(f"Icon cluster: {icon_names}")
208
+ clusters[lid]["theme"] = result.output.theme
209
+ logger.info(
210
+ "Cluster %s (%d icons): %s", lid, len(icons), result.output.theme
211
+ )
212
+ except Exception:
175
213
  clusters[lid]["theme"] = f"Cluster {lid}"
176
- logger.warning("Failed to name cluster %s", lid)
214
+ logger.warning("Failed to name cluster %s", lid, exc_info=True)
177
215
 
178
216
  return data
179
217
 
@@ -260,8 +298,7 @@ def build_cluster_visualization(
260
298
  fig.update_layout(
261
299
  title={
262
300
  "text": (
263
- "Lucide Icon Embedding Clusters \u2014 "
264
- "themes discovered via HDBSCAN in 768d space"
301
+ "Lucide Icon Embedding Clusters \u2014 themes discovered via HDBSCAN"
265
302
  ),
266
303
  "font": {"size": 16},
267
304
  },
@@ -30,10 +30,9 @@ from datetime import datetime, timezone
30
30
  from typing import TypedDict
31
31
 
32
32
  from .config import (
33
- DEFAULT_EMBEDDING_DIM,
34
- DEFAULT_EMBEDDING_MODEL,
35
33
  DEFAULT_VLM_MODEL,
36
- EMBEDDING_DOCUMENT_PREFIX,
34
+ EMBEDDING_MODELS,
35
+ SEARCH_DB_SCHEMA_VERSION,
37
36
  )
38
37
 
39
38
  logger = logging.getLogger(__name__)
@@ -486,9 +485,10 @@ def _ensure_search_tables(conn: sqlite3.Connection) -> None:
486
485
  )
487
486
  conn.execute(
488
487
  "CREATE TABLE IF NOT EXISTS icon_embeddings ("
489
- " name TEXT PRIMARY KEY,"
488
+ " name TEXT NOT NULL,"
490
489
  " embedding BLOB NOT NULL,"
491
- " model TEXT NOT NULL"
490
+ " model TEXT NOT NULL,"
491
+ " PRIMARY KEY (name, model)"
492
492
  ")"
493
493
  )
494
494
  conn.execute(
@@ -513,23 +513,35 @@ def _write_search_db( # noqa: PLR0913
513
513
  search_db_path: pathlib.Path,
514
514
  ordered_names: list[str],
515
515
  records: dict[str, DescriptionRecord],
516
- embeddings: list,
516
+ embeddings: dict[str, dict[str, object]],
517
517
  clusters_path: pathlib.Path,
518
518
  *,
519
519
  version: str | None = None,
520
520
  verbose: bool = False,
521
521
  ) -> None:
522
- """Write descriptions, embeddings, clusters, and metadata to SQLite."""
522
+ """Write descriptions, embeddings, clusters, and metadata to SQLite.
523
+
524
+ Args:
525
+ search_db_path: Output SQLite path (recreated from scratch).
526
+ ordered_names: Icon names in insertion order.
527
+ records: Description records keyed by icon name.
528
+ embeddings: Embedding vectors as ``{model_id: {icon_name: vector}}``.
529
+ A model may cover only a subset of icons; absent names are
530
+ simply not inserted for that model.
531
+ clusters_path: JSON file with cluster assignments.
532
+ version: Lucide icon-set version for the metadata table.
533
+ verbose: Verbose logging.
534
+ """
523
535
  import numpy as np # noqa: PLC0415
524
536
 
525
537
  search_db_path.parent.mkdir(parents=True, exist_ok=True)
538
+ # Recreate rather than DELETE-and-reuse: the schema itself may have
539
+ # changed since the last build (CREATE IF NOT EXISTS won't migrate it)
540
+ search_db_path.unlink(missing_ok=True)
526
541
  conn = sqlite3.connect(search_db_path)
527
542
  try:
528
543
  _ensure_search_tables(conn)
529
544
 
530
- conn.execute("DELETE FROM icon_descriptions")
531
- conn.execute("DELETE FROM icon_embeddings")
532
-
533
545
  for name in ordered_names:
534
546
  rec = records[name]
535
547
  conn.execute(
@@ -538,15 +550,18 @@ def _write_search_db( # noqa: PLR0913
538
550
  (name, rec["description"], rec["model"]),
539
551
  )
540
552
 
541
- for name, emb in zip(ordered_names, embeddings, strict=True):
542
- blob = np.array(emb, dtype=np.float32).tobytes()
543
- conn.execute(
544
- "INSERT INTO icon_embeddings (name, embedding, model) VALUES (?, ?, ?)",
545
- (name, blob, DEFAULT_EMBEDDING_MODEL),
546
- )
553
+ for model_id, vectors_by_name in embeddings.items():
554
+ for name in ordered_names:
555
+ if name not in vectors_by_name:
556
+ continue
557
+ blob = np.array(vectors_by_name[name], dtype=np.float32).tobytes()
558
+ conn.execute(
559
+ "INSERT INTO icon_embeddings (name, embedding, model)"
560
+ " VALUES (?, ?, ?)",
561
+ (name, blob, model_id),
562
+ )
547
563
 
548
564
  # Load clusters
549
- conn.execute("DELETE FROM icon_clusters")
550
565
  cluster_data = json.loads(clusters_path.read_text())
551
566
  for cid, cluster in cluster_data["clusters"].items():
552
567
  theme = cluster.get("theme", f"Cluster {cid}")
@@ -584,10 +599,11 @@ def _write_search_db( # noqa: PLR0913
584
599
  first = records[ordered_names[0]]
585
600
  resolved_version = version or first.get("lucide_version", "unknown")
586
601
  now = datetime.now(tz=timezone.utc).isoformat()
602
+ model_dims = json.dumps({mid: EMBEDDING_MODELS[mid].dim for mid in embeddings})
587
603
  for key, value in [
588
604
  ("version", resolved_version),
589
- ("embedding_model", DEFAULT_EMBEDDING_MODEL),
590
- ("embedding_dim", str(DEFAULT_EMBEDDING_DIM)),
605
+ ("schema_version", str(SEARCH_DB_SCHEMA_VERSION)),
606
+ ("embedding_models", model_dims),
591
607
  ("description_model", DEFAULT_VLM_MODEL),
592
608
  ("built_at", now),
593
609
  ]:
@@ -631,9 +647,10 @@ def build_search_db(
631
647
  ) -> None:
632
648
  """Build the SQLite search database from descriptions, embeddings, and clusters.
633
649
 
634
- Reads descriptions from *jsonl_path*, computes embeddings with fastembed,
635
- loads cluster assignments from *clusters_path*, and writes everything to
636
- *search_db_path*. The DB is rebuilt from scratch each time.
650
+ Reads descriptions from *jsonl_path*, computes one embedding set per
651
+ model in ``EMBEDDING_MODELS`` with fastembed, loads cluster assignments
652
+ from *clusters_path*, and writes everything to *search_db_path*. The DB
653
+ is rebuilt from scratch each time.
637
654
 
638
655
  When *icons_db_path* is provided, only icons present in that database are
639
656
  included and the version metadata is read from it.
@@ -687,12 +704,18 @@ def build_search_db(
687
704
  if rec["categories"]:
688
705
  parts.append(f"Categories: {', '.join(rec['categories'])}")
689
706
  parts.append(rec["description"])
690
- return f"{EMBEDDING_DOCUMENT_PREFIX}{'. '.join(parts)}"
691
-
692
- texts = [_embedding_text(records[n]) for n in ordered_names]
693
- embedder = TextEmbedding(model_name=DEFAULT_EMBEDDING_MODEL)
694
- embeddings = list(embedder.embed(texts))
695
- logger.info("Computed %d embeddings", len(embeddings))
707
+ return ". ".join(parts)
708
+
709
+ base_texts = [_embedding_text(records[n]) for n in ordered_names]
710
+ embeddings: dict[str, dict[str, object]] = {}
711
+ for model_id, model_cfg in EMBEDDING_MODELS.items():
712
+ texts = [f"{model_cfg.document_prefix}{t}" for t in base_texts]
713
+ embedder = TextEmbedding(model_name=model_cfg.fastembed_model)
714
+ vecs = list(embedder.embed(texts))
715
+ embeddings[model_id] = dict(zip(ordered_names, vecs, strict=True))
716
+ logger.info(
717
+ "Computed %d embeddings with %s", len(texts), model_cfg.fastembed_model
718
+ )
696
719
 
697
720
  _write_search_db(
698
721
  search_db_path,