jekyll-client-search 0.3.5 → 0.5.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4bd0e3f73bbbf16d364c059483ad2ad40382232dda34ca1e8a0f83b0286fa605
4
- data.tar.gz: 643dfe41f83ab07d100fa9f841e7b80f07ce1a8db5d5e3d2e5e43d4a29880a2e
3
+ metadata.gz: 3e0accecae865157aa09bda9bf9df5060bcad7a45eeb55555137f963fe1cca93
4
+ data.tar.gz: 8bb447d56a507fb5c7a632ae38433606317bccfda3e2cb01388c129617a49b8a
5
5
  SHA512:
6
- metadata.gz: 2e0e6fbfde2eaf9695f31bc4f79980f5a0d5e88b299253fde5523faa137be6606c60fecf25de2d2ed4b0e3e05d08d2afc5a9033346dd64b25c3dbde8156a44b4
7
- data.tar.gz: b2cdf16bbca7714c81b6ce4a8054f94fa654a30d3e30f5c926fde2f0f3c312cc28ed66d249f3c9dcd38f03cbd9ccb40bf437ce6180c13f867928afa16c6a1f30
6
+ metadata.gz: bcb6ee7ce75d7b75facd5c69db2f0c8479f4e70c0d5e6830b92f65cec936235596f3eb8e97db6621e9b6ec3559868b1f34d3b3ea1b8f5610464b880b88261104
7
+ data.tar.gz: 1c35127143ea928d0156ba202c0c4e6a485729de69afd69e3203f45d5625e0f00848a17e2f4cfe50f9085b8c61e76d16f81b36e05ffa8f27352cb655a42f5eaf
data/CHANGELOG.md CHANGED
@@ -1,6 +1,112 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.5.0 — 2026-11-08
4
+
5
+ ### Added
6
+
7
+ - `rake docs:demo` — captures the checked-in illustrative overview as
8
+ animated AVIF + WebP + static PNG (headless Chrome + ffmpeg + img2webp;
9
+ build-host tools), embedded in README via `<picture>`. Examples are
10
+ schematic, not generated from a live Jekyll build.
11
+
12
+ - Dev-tooling bumps: `markdownlint-cli2` 0.24.0, `eslint` ~10.14.0,
13
+ `globals` 17.14.0, `rubocop` 1.92.2, `ollama-ruby` 1.26.0,
14
+ `excon` 1.11.0, `sass-embedded` 1.108.1, `addressable` 2.9.2,
15
+ `csv` 3.3.7, `io-console` 0.9.5, `parser` 3.3.13.0,
16
+ `irb` 1.19.0, `tins` 1.62.0; `BUNDLED WITH` 4.0.9 → 4.0.24.
17
+
18
+ ### Changed
19
+
20
+ - The live dropdown now follows the ARIA combobox/listbox pattern:
21
+ focused input with `aria-activedescendant`, uniquely identified plain
22
+ text options, and no nested links inside listbox options.
23
+ - CI Node.js matrix is now `24` / `26` — matching the `engines:
24
+ >=24` floor. Node 22 dropped (maintenance-only).
25
+ - CI action pins bumped: `ruby/setup-ruby` v1.338.0,
26
+ `sonarqube-scan-action` v8.4.1, `semgrep` image 1.183.0.
27
+ - `spec/search_index_page_spec.rb` expanded — now covers the default
28
+ root-level output path (`search-index.json`), an empty document
29
+ list, and field/order preservation.
30
+ - `test/meta.test.js` cross-engine check split into one named `test()`
31
+ per exact-match query — a single failure no longer hides the
32
+ remaining queries.
33
+
34
+ ### Fixed
35
+
36
+ - Benchmark scripts access environment via `ENV.fetch("KEY", default)`
37
+ instead of `ENV["KEY"] || default` (SonarQube: environment variable
38
+ access without a default value).
39
+ - SonarQube coverage denominator no longer counts `benchmarks/**` —
40
+ the ~850 lines of manual benchmark tooling are not instrumented by
41
+ SimpleCov, which diluted the reported project coverage to ~50%.
42
+
43
+ ### Security
44
+
45
+ - `overrides` pins `re2js` 2.8.8 — markdownlint 0.42.0 (via
46
+ markdownlint-cli2 0.24.0) pins the vulnerable 2.8.6 (ReDoS in
47
+ lookbehind matching, GHSA-gg6h-894w-4m9c).
48
+ - `nokogiri` 1.19.7 (dev-only, via `ollama-ruby`) — fixes
49
+ GHSA-3w22-6jfg-527w (use-after-free), GHSA-4p39-36wj-23h4
50
+ (mutation XSS), GHSA-v995-44v6-5fqm (JRuby SSRF).
51
+
52
+ ## 0.4.0 — 2026-10-24
53
+
54
+ - Dev-tooling bump: `jsdom` 30.1.3 (patch).
55
+
56
+ ### Added
57
+
58
+ - `benchmarks/embedding_model_benchmark.rb`,
59
+ `benchmarks/related_model_benchmark.rb`, and
60
+ `benchmarks/related_quality_judge.rb` — measure Ollama embedding
61
+ latency per model on a real site, compare `related` output across
62
+ models, and score judged relevance with a blind LLM judge
63
+ (10 local embedding models benchmarked on a 152-document site).
64
+ - `embeddinggemma-2` document/query prefixes (same prompt format as
65
+ `embeddinggemma`), `voyage-4-nano` prefixes (open-weight Voyage
66
+ model via community GGUF), and `qwen3-embedding` prefixes (official
67
+ instruct-format query prefix; empty document prefix).
68
+ - README model-selection guidance: measured per-document latency table,
69
+ LLM-judged quality ratings, quality-per-cost value index, amortized
70
+ yearly cost, `qwen3-embedding` build-time-only notes, and the
71
+ `localhost` → `127.0.0.1` loopback gotcha.
72
+ - Split index/related embedding models: `semantic.index_model` selects
73
+ the Ollama model whose vectors go into `search-index.json` (must match
74
+ the browser query embedder); `related.index_model` selects the model
75
+ used for related-article analysis. Both default to `embedding.model`;
76
+ equal models share one embedding pass, divergent models embed each
77
+ document twice into separate cached vector fields.
78
+ `semantic.document_prefix` / `semantic.query_prefix` override the
79
+ index model's prefixes. Related-model vectors are cached separately
80
+ in `.jekyll-client-search-related-cache.json` and never written to
81
+ the index.
82
+ - `embeddinggemma-2` (`:270m`, `:570m`, untagged) maps to
83
+ `onnx-community/embeddinggemma-2-ONNX` for the transformers.js query
84
+ embedder. `test/onnx-parity.test.js` is an opt-in check
85
+ (`ONNX_PARITY=1`, requires `@huggingface/transformers` and a running
86
+ Ollama) that compares ONNX and Ollama vectors and rankings;
87
+ `embeddinggemma:300m` remains the verified mapping.
88
+
89
+ ### Changed
90
+
91
+ - Default `embedding.model` is now `embeddinggemma-2:570m` (was
92
+ `embeddinggemma:300m`) — the judged-quality leader among license-free
93
+ local models (111 vs 96) at ~2.5× the per-document latency; its text
94
+ vectors are byte-identical to `embeddinggemma-2:270m`. The
95
+ transformers.js query embedder maps it to
96
+ `onnx-community/embeddinggemma-2-ONNX`; sites that prefer the verified
97
+ browser mapping can set `semantic.index_model: embeddinggemma:300m`
98
+ while related analysis stays on the default model.
99
+ - CI: Semgrep moved from an inline `pip3 install semgrep==1.176.0` pin to hashed `.github/requirements-ci.txt` (compiled from `requirements-ci.in`, Python 3.12), installed with `--require-hashes`. Bumped Semgrep to 1.180.0; the version is now Dependabot-visible via a new `pip` ecosystem entry for `/.github`.
100
+
101
+ ### Fixed
102
+
103
+ - A `localhost` `embedding.base_url` (the default) is resolved once to
104
+ the loopback literal that accepts a TCP connection — `127.0.0.1` or
105
+ `[::1]` — because `ollama-ruby`/Excon does not fall back across
106
+ address families and Ollama commonly listens on IPv4 loopback only.
107
+ Embedding calls that died with `Excon::Error::Socket "Broken pipe"`
108
+ when `localhost` resolved to `::1` first now connect on both
109
+ IPv4-only and IPv6-only servers.
4
110
 
5
111
  ## 0.3.5 — 2026-10-06
6
112
 
data/README.developer.md CHANGED
@@ -20,12 +20,27 @@ Both test suites pass with only the committed Wikipedia fixtures and the
20
20
  committed baseline JSON artifacts. No network access or external services
21
21
  are required for the default test run.
22
22
 
23
+ Specs that need a Jekyll site use `jekyll-test-harness` (`jekyll_build` +
24
+ `jekyll_files` DSL, installed in `spec/spec_helper.rb`) instead of hand-rolled
25
+ `Dir.mktmpdir`/`Jekyll::Site.new` scaffolding — see
26
+ `spec/support/site_helpers.rb` and `spec/generator_spec.rb` for the pattern.
27
+ Note that the gem's `Jekyll::ClientSearch::Generator` subclasses
28
+ `Jekyll::Generator`, so it runs automatically inside `jekyll_build`'s
29
+ `site.process`; specs should assert on the built site directly and stub
30
+ `build_embedding_adapter` (see `stub_embedding_adapter`) before building
31
+ rather than calling `generate` manually. Specs that rebuild the same source
32
+ directory multiple times (`spec/embedding_cache_spec.rb`,
33
+ `spec/system_spec.rb`, Ollama integration) still use their own helpers,
34
+ since the harness builds each site in a fresh temporary directory.
35
+
23
36
  ## Quality checks and git hooks
24
37
 
25
38
  The project uses a focused quality stack: RuboCop (style), bundler-audit
26
39
  (security), RSpec (Ruby tests), `npm test` (JavaScript tests), and Semgrep
27
40
  (security scanning). This covers the actionable ground without the
28
41
  maintenance burden of additional code-smell tools on a small focused gem.
42
+ `npm test` also runs ESLint, Markdownlint, and jsdom tests for the browser
43
+ runtime, including the dropdown combobox/listbox keyboard and ARIA contract.
29
44
 
30
45
  External automated code review services:
31
46
 
@@ -112,7 +127,7 @@ CI. Skip either with `git commit --no-verify` or `git push --no-verify`.
112
127
  ### CI checks
113
128
 
114
129
  The [CI workflow](.github/workflows/ci.yml) runs on every push and pull
115
- request across Ruby 3.3/3.4 and Node 22/24:
130
+ request across Ruby 3.3/3.4 and Node 24/26:
116
131
 
117
132
  - `bundle exec rake ci` (rspec, rubocop, syntax checks, npm test, gem build)
118
133
  - `bundle exec bundle-audit check --update` (Ruby dependency security)
@@ -237,9 +252,10 @@ To run the full integration tests that call a real Ollama server:
237
252
 
238
253
  1. Install [Ollama](https://ollama.ai/)
239
254
 
240
- 2. Pull the required model:
255
+ 2. Pull the required models:
241
256
 
242
257
  ```bash
258
+ ollama pull embeddinggemma-2:570m
243
259
  ollama pull embeddinggemma:300m
244
260
  ```
245
261
 
@@ -250,6 +266,16 @@ To run the full integration tests that call a real Ollama server:
250
266
  OLLAMA_INTEGRATION=1 bundle exec rspec spec/llm_injection_spec.rb
251
267
  ```
252
268
 
269
+ To verify cross-runtime parity between the transformers.js ONNX twin and
270
+ the Ollama build model (opt-in — needs the model downloaded once):
271
+
272
+ ```bash
273
+ npm i -D @huggingface/transformers@3.8.1
274
+ ONNX_PARITY=1 node --test test/onnx-parity.test.js
275
+ ```
276
+
277
+ Set `OLLAMA_MODEL`, `ONNX_MODEL`, or `ONNX_DTYPE` to check other mappings.
278
+
253
279
  These tests:
254
280
 
255
281
  - Build the full 80-post index with real Ollama embeddings
@@ -450,7 +476,8 @@ This check also runs in the release workflow — the publish will fail if
450
476
  ```
451
477
 
452
478
  The Ollama integration tests require a running [Ollama](https://ollama.ai/)
453
- server with the `embeddinggemma:300m` model pulled (`ollama pull
479
+ server with the `embeddinggemma-2:570m` and `embeddinggemma:300m` models
480
+ pulled (`ollama pull embeddinggemma-2:570m` and `ollama pull
454
481
  embeddinggemma:300m`). Do not publish a release until the Ollama
455
482
  integration tests pass locally — they are skipped by default and will
456
483
  not block CI, so they must be run manually before each release.
data/README.md CHANGED
@@ -15,7 +15,6 @@
15
15
  [![Semgrep](https://img.shields.io/badge/semgrep-scanning-brightgreen.svg)](https://semgrep.dev)
16
16
  [![CodeFactor](https://www.codefactor.io/repository/github/gundestrup/jekyll-client-search/badge)](https://www.codefactor.io/repository/github/gundestrup/jekyll-client-search)
17
17
  [![SonarQube](https://sonarcloud.io/api/project_badges/measure?project=gundestrup_jekyll-client-search&metric=alert_status)](https://sonarcloud.io/project/overview?id=gundestrup_jekyll-client-search)
18
- [![codecov](https://codecov.io/gh/gundestrup/jekyll-client-search/branch/main/graph/badge.svg)](https://codecov.io/gh/gundestrup/jekyll-client-search)
19
18
 
20
19
  A Jekyll plugin that generates a JSON document index for client-side search.
21
20
  Supports lexical search ([MiniSearch](https://lucaong.github.io/minisearch/),
@@ -24,6 +23,19 @@ via pre-computed embeddings from a local [Ollama](https://ollama.ai) server.
24
23
  Related articles can be generated at build time from shared tags, categories,
25
24
  and vector similarity.
26
25
 
26
+ <!-- markdownlint-disable MD033 -->
27
+ <picture>
28
+ <source
29
+ srcset="docs/assets/jekyll-client-search-demo.avif"
30
+ type="image/avif">
31
+ <source
32
+ srcset="docs/assets/jekyll-client-search-demo.webp"
33
+ type="image/webp">
34
+ <img src="docs/assets/jekyll-client-search-demo.png"
35
+ alt="jekyll-client-search demo">
36
+ </picture>
37
+ <!-- markdownlint-enable MD033 -->
38
+
27
39
  ## Table of contents
28
40
 
29
41
  - [Lightning start](#lightning-start)
@@ -269,7 +281,8 @@ Tag modes:
269
281
  `/search/`) with `?q=...` for full results on the search page
270
282
  - Arrow Up/Down → highlight items, Enter on highlighted → navigate
271
283
  - Click an item → navigate to that document's URL
272
- - Escape or click outside → close dropdown
284
+ - Escape → close the popup and keep focus in the combobox; click outside
285
+ → close the dropdown
273
286
  - Index is lazy-loaded on first keystroke (no cost on pages where the
274
287
  visitor doesn't search)
275
288
 
@@ -290,24 +303,32 @@ and the search index includes that field (e.g. from jekyll-documents),
290
303
  an `<img>` icon is rendered before each result title. Set `icon_field:
291
304
  null` to disable.
292
305
 
293
- **CSS styling:** The dropdown emits no framework classes. Style with:
306
+ **CSS styling:** The dropdown emits no framework classes. Its input uses
307
+ the ARIA combobox pattern; the popup is a listbox whose options are plain
308
+ text containers (not nested links or controls). Style with:
294
309
 
295
310
  ```css
296
311
  .client-search-dropdown { position: relative; }
297
- .client-search-dropdown ul[role="listbox"] {
312
+ .client-search-dropdown [role="listbox"] {
298
313
  position: absolute; top: 100%; right: 0;
299
314
  min-width: 300px; z-index: 30;
300
315
  background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
316
+ margin: 0; padding: 0;
317
+ }
318
+ .client-search-dropdown [role="listbox"] ul[role="presentation"] {
301
319
  list-style: none; margin: 0; padding: 0;
302
320
  }
303
- .client-search-dropdown li { padding: 0.5rem 1rem; }
304
- .client-search-dropdown li:hover,
305
- .client-search-dropdown li[aria-selected="true"] { background: #f5f5f5; }
306
- .client-search-dropdown li a { text-decoration: none; color: inherit; }
321
+ .client-search-dropdown [role="option"] {
322
+ cursor: pointer; padding: 0.5rem 1rem;
323
+ }
324
+ .client-search-dropdown [role="option"]:hover,
325
+ .client-search-dropdown [role="option"][aria-selected="true"] {
326
+ background: #f5f5f5;
327
+ }
307
328
 
308
329
  /* Type-based styling via data attributes */
309
- .client-search-dropdown li[data-source="documents"] { border-left: 3px solid #48c774; }
310
- .client-search-dropdown li[data-file-type="pdf"]::before {
330
+ .client-search-dropdown [role="option"][data-source="documents"] { border-left: 3px solid #48c774; }
331
+ .client-search-dropdown [role="option"][data-file-type="pdf"]::before {
311
332
  content: "PDF"; background: #ff0000; color: white;
312
333
  padding: 0 0.3em; font-size: 0.7em; margin-right: 0.3em;
313
334
  }
@@ -450,15 +471,18 @@ client_search:
450
471
  output: search-relations.json
451
472
  minimum_similarity: 0.55
452
473
  max_items: null
474
+ index_model: null # default: embedding.model
453
475
  embedding:
454
476
  enabled: false
455
- model: embeddinggemma:300m
477
+ model: embeddinggemma-2:570m
456
478
  base_url: http://localhost:11434
457
479
  connect_timeout: 5
458
480
  read_timeout: 120
459
481
  fail_on_error: true
460
482
  query_embedder:
461
483
  type: transformers
484
+ semantic:
485
+ index_model: null # default: embedding.model
462
486
  ```
463
487
 
464
488
  All options:
@@ -487,9 +511,13 @@ All options:
487
511
  | `related.semantic` | `true` | Include vector relations when document embeddings are available. |
488
512
  | `related.minimum_similarity` | `0.55` | Cosine cutoff for semantic relations; no fixed relation count. |
489
513
  | `related.max_items` | `null` | Optional safety cap after cutoff; `null` keeps every matching relation. |
514
+ | `related.index_model` | `embedding.model` | Ollama model for related-article similarity. May differ from the index model since relations never reach the browser. |
515
+ | `semantic.index_model` | `embedding.model` | Ollama model whose vectors go into the index; must match the browser query model. |
516
+ | `semantic.document_prefix` | Model-specific | Document prefix for `semantic.index_model` (defaults to `embedding.document_prefix` when the models match). |
517
+ | `semantic.query_prefix` | Model-specific | Query prefix for `semantic.index_model` (defaults to `embedding.query_prefix` when the models match). |
490
518
  | `embedding.enabled` | `false` | Generate embedding vectors at build time via Ollama. |
491
- | `embedding.model` | `embeddinggemma:300m` | Ollama model name for embedding generation. |
492
- | `embedding.base_url` | `http://localhost:11434` | Ollama server URL. |
519
+ | `embedding.model` | `embeddinggemma-2:570m` | Ollama model name for embedding generation. |
520
+ | `embedding.base_url` | `http://localhost:11434` | Ollama server URL. `localhost` is resolved once to the loopback literal (`127.0.0.1` or `[::1]`) that accepts a TCP connection, so IPv4-only and IPv6-only servers both work. |
493
521
  | `embedding.connect_timeout` | `5` | Ollama connection timeout in seconds. |
494
522
  | `embedding.read_timeout` | `120` | Ollama response timeout in seconds. |
495
523
  | `embedding.fail_on_error` | `true` | Fail the build when an embedding cannot be generated. |
@@ -610,7 +638,7 @@ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
610
638
  1. Install [Ollama](https://ollama.ai/) and pull an embedding model:
611
639
 
612
640
  ```bash
613
- ollama pull embeddinggemma:300m
641
+ ollama pull embeddinggemma-2:570m
614
642
  ```
615
643
 
616
644
  2. Add `ollama-ruby` to your site's Gemfile (optional dependency):
@@ -626,10 +654,18 @@ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
626
654
  engine: semantic
627
655
  embedding:
628
656
  enabled: true
629
- model: embeddinggemma:300m
630
657
  base_url: http://localhost:11434
631
658
  ```
632
659
 
660
+ With `query_embedder.type: transformers` (the default), the build
661
+ model needs a packaged browser ONNX mapping — `embeddinggemma` and
662
+ `embeddinggemma-2` models have one (see
663
+ [Browser query models](#browser-query-models)); any other
664
+ `embedding.model` requires an explicit `query_embedder.model` or
665
+ `query_embedder.type: ollama_api`. For build-time-only embeddings — a
666
+ lexical engine with `related` analysis — any Ollama model works
667
+ as-is.
668
+
633
669
  ### How it works
634
670
 
635
671
  - At build time, the `OllamaEmbeddingAdapter` sends each document's text
@@ -642,34 +678,175 @@ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
642
678
  - A content-hash cache (`.jekyll-client-search-cache.json` in the site
643
679
  source) tracks which documents have already been embedded. Unchanged
644
680
  documents reuse the cached embedding — only new or modified documents
645
- are sent to the model.
681
+ are sent to the model. When the index and related models differ, the
682
+ related vectors are cached separately in
683
+ `.jekyll-client-search-related-cache.json`.
646
684
  - Cache entries include the provider, model, endpoint, and cache schema. A
647
685
  model or embedding configuration change automatically invalidates old
648
686
  vectors and re-embeds the documents.
649
- - Cache writes are atomic. The cache file is git-ignored and safe to delete
650
- (it will be rebuilt on the next `jekyll build`).
687
+ - Cache writes are atomic. The cache files are git-ignored and safe to
688
+ delete (they will be rebuilt on the next `jekyll build`).
651
689
  - By default, an embedding failure fails the build rather than silently
652
690
  producing an unusable semantic index. Set `embedding.fail_on_error: false`
653
691
  only when warning-only behavior is intentional.
654
692
 
655
693
  ### Choosing a model
656
694
 
657
- Any Ollama-compatible embedding model works. Common choices:
658
-
659
- | Model | Dimensions | Good for |
660
- | --- | --- | --- |
661
- | `embeddinggemma:300m` | 768 | Default, multilingual, good quality |
662
- | `nomic-embed-text` | 768 | Multilingual, good quality |
663
- | `bge-m3` | 1024 | Multilingual, high quality |
664
- | `all-minilm` | 384 | English, fast, lightweight |
695
+ Any Ollama-compatible embedding model works. Hosted embedding APIs (OpenAI
696
+ `text-embedding-3-small`, Gemini Embedding, the Voyage API) are not
697
+ supported — the adapter only speaks Ollama's `/api/embed` protocol, and
698
+ local embeddings keep the corpus on your machine. Open-weight models that
699
+ ship GGUF builds (e.g. `voyage-4-nano`) can still be tested through a
700
+ local Ollama server.
701
+
702
+ Measured on a 152-document site (Apple Silicon, sequential `/api/embed`
703
+ calls, GPU-resident models, `related.minimum_similarity: 0.55`):
704
+
705
+ | Model | Dims | ~ms/doc | Full corpus | Quality* | Value† | ~Cost/yr‡ | Notes |
706
+ | --- | --- | --- | --- | --- | --- | --- | --- |
707
+ | `embeddinggemma-2:570m` | 768 | ~246 | ~37 s | 111 | 0.50 | ~63 s | **Default** — best judged relatedness short of non-commercial jina; text vectors byte-identical to 270m; ONNX twin packaged (verify parity) |
708
+ | `embeddinggemma-2:270m` | 768 | ~260 | ~40 s | 111 | 0.47 | ~67 s | Same text vectors as 570m; shares its ONNX twin (verify parity) |
709
+ | `embeddinggemma:300m` | 768 | ~105 | ~16 s | 96 | 1.00 | ~27 s | Fast; verified transformers.js browser mapping |
710
+ | `nomic-embed-text` | 768 | ~100 | ~15 s | 105 | **1.16** | ~25 s | Fast; best measured value |
711
+ | `voyage-4-nano` | 1024 | ~115 | ~17 s | 107 | 1.03 | ~29 s | Open-weight (Apache 2.0); community GGUF at its 1024-dim base — official 2048 dim (post-hoc linear head) retested, judged equivalent (103) |
712
+ | `bge-m3` | 1024 | ~305 | ~46 s | 107 | 0.38 | ~78 s | Multilingual; strong judged quality |
713
+ | `jina-embeddings-v3` | 1024 | ~241 | ~37 s | **112** | 0.51 | ~62 s | CC-BY-NC-4.0 (non-commercial); community GGUF base weights — best judged picks + 88% corroboration; text-matching LoRA tested via llama.cpp: worse (98) |
714
+ | `bge-large` | 1024 | ~115 | ~17 s | 106 | 1.01 | ~29 s | English-only yet still strong judged quality |
715
+ | `mxbai-embed-large` | 1024 | ~340 | ~52 s | 107 | 0.35 | ~87 s | Strong judged quality |
716
+ | `snowflake-arctic-embed2` | 1024 | ~505 | ~77 s | 81 | 0.17 | ~130 s | Conservative scores — few edges pass the 0.55 cutoff, weakest judged picks |
717
+ | `all-minilm` | 384 | — | — | — | — | — | English only; not measured |
718
+ | `qwen3-embedding:0.6b` | 1024 | ~350 | ~53 s | 90 | 0.28 | ~89 s | Strong multilingual results upstream |
719
+ | `qwen3-embedding:4b` | 2560 | ~4000 | ~10 min | 87 | 0.02 | ~17 min | Poor value: ~38× the cost for below-average judged picks |
720
+ | `qwen3-embedding:8b` | 4096 | ~10800 | ~27 min | 100 | 0.01 | ~46 min | Reference quality but ~103× the cost; build-time only |
721
+
722
+ \* judged relevance: `gpt-oss:120b-cloud` scored every deduplicated
723
+ related-article suggestion 0–2, blind to which model proposed it
724
+ (`benchmarks/related_quality_judge.rb`); mean precision normalized to
725
+ `qwen3-embedding:8b` = 100. Raw mean scores ranged 0.70–0.96 of 2.00.
726
+ † quality ÷ ms/doc, normalized to `embeddinggemma:300m` = 1.00.
727
+ ‡ amortized embedding time per year assuming one cold re-embed plus weekly
728
+ builds changing two posts — at this scale every model is affordable, so
729
+ quality dominates the choice.
730
+
731
+ The index cache amortizes cold-corpus cost — only new or changed documents
732
+ re-embed — but every changed post pays the per-document latency at each
733
+ build.
734
+
735
+ Judged quality inverted the earlier agreement-based ranking: models with
736
+ "hot" similarity distributions (`embeddinggemma-2`, `nomic-embed-text`,
737
+ `voyage-4-nano`, `bge-m3`, `mxbai-embed-large`) were not producing noise —
738
+ the judge rated their novel picks as the *most* relevant. Conservative
739
+ models (snowflake, the small Qwen3s) scored worst on judged precision
740
+ despite high agreement with `qwen3-embedding:8b`. Practical takeaway for
741
+ this corpus size: `nomic-embed-text`, `voyage-4-nano`, and
742
+ `embeddinggemma:300m` offer the best measured value; the default
743
+ `embeddinggemma-2:570m` (and its identical-text-vector twin `:270m`)
744
+ trades ~2.5× the per-document latency for the best judged relatedness of
745
+ any license-free model; the large Qwen3 models do not pay for themselves
746
+ below several thousand documents.
747
+
748
+ "Build-time only" models are too large to run in the browser for
749
+ `engine: semantic` — use them only with a lexical engine, where embeddings
750
+ serve `related` analysis and never reach the visitor's browser, or with
751
+ `query_embedder.type: ollama_api` where every visitor can reach the API.
665
752
 
666
753
  The model used at build time must match the query model's weights, vector
667
754
  space, dimensions, and preprocessing. Cross-runtime compatibility is verified
668
- for the default `embeddinggemma:300m` mapping. An `all-minilm` mapping is also
669
- provided, but sites should independently verify ranking when changing models.
670
- For any other Ollama model, set `embedding.query_embedder.model` to a
671
- compatible Transformers.js model explicitly or use `ollama_api` so both paths
672
- call the same Ollama model.
755
+ for the `embeddinggemma:300m` → `onnx-community/embeddinggemma-300m-ONNX`
756
+ mapping. The `embeddinggemma-2` family maps to
757
+ `onnx-community/embeddinggemma-2-ONNX` — packaged but not yet parity-verified
758
+ against the Ollama vectors, so confirm ranking on your own corpus. An
759
+ `all-minilm` mapping is also provided. For any other Ollama model, set
760
+ `embedding.query_embedder.model` to a compatible transformers.js model
761
+ explicitly or use `ollama_api` so both paths call the same Ollama model.
762
+
763
+ ### Splitting the index and related models
764
+
765
+ `search-relations.json` is computed entirely at build time, so related
766
+ analysis can use a different vector space than the semantic index — the
767
+ index vectors are the only ones the browser query embedder must match.
768
+ Two optional keys control this; both default to `embedding.model`:
769
+
770
+ ```yaml
771
+ client_search:
772
+ engine: semantic
773
+ embedding:
774
+ enabled: true
775
+ model: embeddinggemma-2:570m # default build model
776
+ semantic:
777
+ index_model: embeddinggemma:300m # vectors written into the index
778
+ related:
779
+ enabled: true
780
+ index_model: embeddinggemma-2:570m # vectors used for related analysis
781
+ ```
782
+
783
+ When `semantic.index_model` and `related.index_model` resolve to the same
784
+ model, documents are embedded once and the vector serves both outputs —
785
+ there is no extra cost. When they differ, each document is embedded twice
786
+ (once per model) and the related vectors are kept in a separate field,
787
+ analyzed by `RelatedAnalyzer`, then stripped before `search-index.json`
788
+ is written. Each model gets its own content-hash cache, so per-model
789
+ vectors still amortize across builds. `semantic.document_prefix` /
790
+ `semantic.query_prefix` can override the index model's prefixes; explicit
791
+ `embedding.document_prefix` / `embedding.query_prefix` apply to
792
+ `embedding.model`.
793
+
794
+ `semantic.index_model` matters whenever vectors go into the index —
795
+ including a lexical engine with `embedding.include_in_index: true`. It
796
+ must always match the browser query model; `semantic.index_model:
797
+ embeddinggemma:300m` is the safe choice for the verified transformers.js
798
+ mapping.
799
+
800
+ ### Browser query models
801
+
802
+ With `query_embedder.type: transformers`, the query model runs in the
803
+ visitor's browser and must be a transformers.js-compatible ONNX build of
804
+ the *index* model's weights. These Ollama models have a packaged mapping:
805
+
806
+ | `embedding.model` / `semantic.index_model` | transformers.js model |
807
+ | --- | --- |
808
+ | `embeddinggemma:300m` | `onnx-community/embeddinggemma-300m-ONNX` (verified) |
809
+ | `embeddinggemma-2`, `embeddinggemma-2:270m`, `embeddinggemma-2:570m` | `onnx-community/embeddinggemma-2-ONNX` (verify parity) |
810
+ | `all-minilm` | `Xenova/all-MiniLM-L6-v2` |
811
+
812
+ For anything else, set `embedding.query_embedder.model` to a compatible
813
+ transformers.js model or use `query_embedder.type: ollama_api`. The full
814
+ set of supported architectures and tasks is in the
815
+ [transformers.js docs](https://huggingface.co/docs/transformers.js/index);
816
+ compatible ONNX weights are tagged
817
+ [`library:transformers.js` on the Hub](https://huggingface.co/models?library=transformers.js).
818
+
819
+ Document/query prefixes are applied automatically for `embeddinggemma`,
820
+ `embeddinggemma-2`, `nomic-embed-text`, and `voyage-4-nano`; other models
821
+ get no prefix.
822
+ Qwen3 embedding models take no document prefix; for semantic search the
823
+ instruct-format query prefix must be set via `embedding.query_prefix`.
824
+ Cosine similarity distributions differ substantially per model — some run
825
+ "narrow" (snowflake: almost nothing passes 0.55) and some run "hot"
826
+ (nomic, embeddinggemma-2: nearly everything does) — so retune
827
+ `related.minimum_similarity` and the semantic threshold when switching
828
+ rather than comparing at the default.
829
+
830
+ To benchmark your own site and corpus:
831
+
832
+ ```bash
833
+ cd jekyll-client-search
834
+ bundle install
835
+ OLLAMA_BASE_URL=http://127.0.0.1:11434 \
836
+ EMBEDDING_MODELS="embeddinggemma:300m,nomic-embed-text,bge-m3" \
837
+ bundle exec ruby benchmarks/related_model_benchmark.rb /path/to/site
838
+ ```
839
+
840
+ The script times `OllamaEmbeddingAdapter.embed` per document and runs the
841
+ real `RelatedAnalyzer`, then diffs each model's related lists against the
842
+ baseline (`BASELINE_MODEL` env, default `embeddinggemma-2:570m`). Raw vectors
843
+ are cached under `benchmarks/.related-embeddings/` so re-runs are cheap.
844
+
845
+ A `localhost` `base_url` is resolved automatically to the loopback literal
846
+ that accepts a TCP connection (`127.0.0.1` or `[::1]`), because the HTTP
847
+ client does not fall back across address families and Ollama often listens
848
+ on IPv4 loopback only. If `ollama embed` calls still fail with a socket
849
+ error, set `base_url: http://127.0.0.1:11434` explicitly.
673
850
 
674
851
  ## Related articles reference
675
852
 
@@ -977,7 +1154,7 @@ client_search:
977
1154
  engine: semantic
978
1155
  embedding:
979
1156
  enabled: true
980
- model: embeddinggemma:300m
1157
+ model: embeddinggemma-2:570m
981
1158
  query_embedder:
982
1159
  type: ollama_api
983
1160
  api_url: https://embeddings.example.com/api/embed