jekyll-client-search 0.3.4 → 0.4.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.
data/README.developer.md CHANGED
@@ -8,13 +8,12 @@ See [README.md](README.md) for user-facing configuration and usage docs.
8
8
  ## Quick start
9
9
 
10
10
  ```bash
11
- rbenv install 3.4.10 # if not already installed
12
- rbenv local 3.4.10
11
+ rbenv install # reads .ruby-version
13
12
  bundle install
14
13
  npm ci
15
- bundle exec rspec # Ruby tests (248 examples; 7 Ollama pending)
14
+ bundle exec rspec # Ruby tests
16
15
  OLLAMA_INTEGRATION=1 bundle exec rspec # all tests including Ollama integration
17
- npm test # JavaScript tests (134 tests)
16
+ npm test # JavaScript tests
18
17
  ```
19
18
 
20
19
  Both test suites pass with only the committed Wikipedia fixtures and the
@@ -29,16 +28,22 @@ The project uses a focused quality stack: RuboCop (style), bundler-audit
29
28
  maintenance burden of additional code-smell tools on a small focused gem.
30
29
 
31
30
  External automated code review services:
31
+
32
32
  - [CodeQL](https://codeql.github.com) — GitHub native security scanning
33
33
  (Ruby + JavaScript), runs on every push.
34
34
  - [Semgrep](https://semgrep.dev) — CI security scan using the Semgrep Pro
35
- engine with the organization's full policy (2,900+ rules). Runs as a CI
35
+ engine with the organization's full policy ruleset. Runs as a CI
36
36
  job (`semgrep ci`) and in the pre-commit hook (custom rules only). See
37
37
  `.semgrep.yml` for local configuration and usage instructions.
38
38
  - [CodeFactor](https://www.codefactor.io/repository/github/gundestrup/jekyll-client-search) —
39
39
  automated code review (RuboCop, Brakeman, bundler-audit, duplication).
40
40
  - [Dependabot](https://docs.github.com/en/code-security/dependabot) — weekly
41
41
  GitHub Actions version updates (SHA-pinned actions updated automatically).
42
+ - [Codecov](https://codecov.io/gh/gundestrup/jekyll-client-search) — coverage
43
+ reporting. CI uploads Cobertura XML generated by `simplecov-cobertura` from
44
+ the Ruby 3.4 / Node 24 matrix job; status check thresholds live in
45
+ `codecov.yml`. Uploads authenticate via OIDC (`id-token: write`), with the
46
+ `CODECOV_TOKEN` secret as a fallback.
42
47
 
43
48
  ### Semgrep usage
44
49
 
@@ -85,8 +90,9 @@ comments. Dependabot updates the SHAs and version comments automatically.
85
90
 
86
91
  ### Git hooks
87
92
 
88
- Git hooks are not committed to the repository. Install them locally after
89
- cloning:
93
+ Hooks are tracked in `bin/hooks/`; `bin/install-hooks.sh` points git at
94
+ them via `core.hooksPath` (no copying — tracked hooks can't drift from
95
+ installed ones). Run once after cloning:
90
96
 
91
97
  ```bash
92
98
  bin/install-hooks.sh
@@ -106,11 +112,11 @@ CI. Skip either with `git commit --no-verify` or `git push --no-verify`.
106
112
  ### CI checks
107
113
 
108
114
  The [CI workflow](.github/workflows/ci.yml) runs on every push and pull
109
- request across Ruby 3.2/3.3/3.4 and Node 22/24:
115
+ request across Ruby 3.3/3.4 and Node 22/24:
110
116
 
111
117
  - `bundle exec rake ci` (rspec, rubocop, syntax checks, npm test, gem build)
112
118
  - `bundle exec bundle-audit check --update` (Ruby dependency security)
113
- - `npm audit --audit-level=high` (JavaScript dependency security)
119
+ - `npm run audit` (JavaScript dependency security gate — see `scripts/npm-audit.mjs`)
114
120
  - `npm outdated` (non-blocking — warns about outdated npm packages)
115
121
  - `semgrep ci` (security scan with custom rules from `.semgrep.yml`)
116
122
 
@@ -163,7 +169,7 @@ test files are `spec/related_analyzer_spec.rb`, `spec/related_tag_spec.rb`,
163
169
  ## What is committed
164
170
 
165
171
  | Artifact | Committed? | Why |
166
- |---|---|---|
172
+ | --- | --- | --- |
167
173
  | Wikipedia fixture posts (40) | Yes | CC BY-SA 3.0 allows redistribution with attribution |
168
174
  | arXiv fixture posts (40) | **No** | Mixed/restrictive licenses — see below |
169
175
  | Baseline search-index JSON | Yes | Generated test artifact (indexed data, not raw text) |
@@ -193,6 +199,7 @@ ruby spec/fixtures/download_arxiv.rb
193
199
  ```
194
200
 
195
201
  This script:
202
+
196
203
  - Fetches 40 recent arXiv papers from 5 CS/AI subfields
197
204
  - Downloads each PDF and extracts text with `pdftotext`
198
205
  - Records the actual per-paper license in each post's frontmatter
@@ -230,9 +237,10 @@ To run the full integration tests that call a real Ollama server:
230
237
 
231
238
  1. Install [Ollama](https://ollama.ai/)
232
239
 
233
- 2. Pull the required model:
240
+ 2. Pull the required models:
234
241
 
235
242
  ```bash
243
+ ollama pull embeddinggemma-2:570m
236
244
  ollama pull embeddinggemma:300m
237
245
  ```
238
246
 
@@ -243,7 +251,18 @@ To run the full integration tests that call a real Ollama server:
243
251
  OLLAMA_INTEGRATION=1 bundle exec rspec spec/llm_injection_spec.rb
244
252
  ```
245
253
 
254
+ To verify cross-runtime parity between the transformers.js ONNX twin and
255
+ the Ollama build model (opt-in — needs the model downloaded once):
256
+
257
+ ```bash
258
+ npm i -D @huggingface/transformers@3.8.1
259
+ ONNX_PARITY=1 node --test test/onnx-parity.test.js
260
+ ```
261
+
262
+ Set `OLLAMA_MODEL`, `ONNX_MODEL`, or `ONNX_DTYPE` to check other mappings.
263
+
246
264
  These tests:
265
+
247
266
  - Build the full 80-post index with real Ollama embeddings
248
267
  - Verify embedding dimensions and validity
249
268
  - Test semantic search quality with concept queries
@@ -272,7 +291,7 @@ ruby spec/fixtures/generate_semantic_gold.rb
272
291
  ## Test categories
273
292
 
274
293
  | Tag | Description | Requires arXiv? | Requires Ollama? |
275
- |---|---|---|---|
294
+ | --- | --- | --- | --- |
276
295
  | `:unit` | Ruby unit tests | No | No |
277
296
  | `:system` | Ruby system tests (build fixture site) | Partially | No |
278
297
  | `:integration` | Ruby integration tests | No | No |
@@ -298,7 +317,9 @@ always return an asset).
298
317
 
299
318
  The coverage report is generated at `coverage/index.html` after each RSpec
300
319
  run. Raw data is in `coverage/.resultset.json` (SimpleCov 1.0+ format with
301
- `lines` and `branches` keys per file).
320
+ `lines` and `branches` keys per file). In CI (`ENV["CI"]`), SimpleCov emits
321
+ Cobertura XML at `coverage/coverage.xml` instead, which is uploaded to
322
+ Codecov by the `codecov/codecov-action` step in `ci.yml`.
302
323
 
303
324
  To check remaining uncovered lines and branches after a run:
304
325
 
@@ -440,7 +461,8 @@ This check also runs in the release workflow — the publish will fail if
440
461
  ```
441
462
 
442
463
  The Ollama integration tests require a running [Ollama](https://ollama.ai/)
443
- server with the `embeddinggemma:300m` model pulled (`ollama pull
464
+ server with the `embeddinggemma-2:570m` and `embeddinggemma:300m` models
465
+ pulled (`ollama pull embeddinggemma-2:570m` and `ollama pull
444
466
  embeddinggemma:300m`). Do not publish a release until the Ollama
445
467
  integration tests pass locally — they are skipped by default and will
446
468
  not block CI, so they must be run manually before each release.
@@ -492,7 +514,7 @@ The [`release.yml`](.github/workflows/release.yml) workflow, triggered by
492
514
  pushing a `v*` tag:
493
515
 
494
516
  1. Checks out the repository at the release tag (`persist-credentials: false`).
495
- 2. Sets up Ruby 3.4.10.
517
+ 2. Sets up Ruby from `.ruby-version`.
496
518
  3. Verifies the tag name matches `v<gem version>` (fails on mismatch).
497
519
  4. Verifies `CHANGELOG.md` has a `## X.Y.Z` entry for the version
498
520
  (`rake version:check_changelog` — fails if missing).
data/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # jekyll-client-search
2
2
 
3
+ [![codecov](https://codecov.io/gh/gundestrup/jekyll-client-search/branch/main/graph/badge.svg)](https://codecov.io/gh/gundestrup/jekyll-client-search)
4
+
5
+
3
6
  ![jekyll-client-search logo](docs/assets/icon-256.png)
4
7
 
5
8
  [![CI](https://github.com/gundestrup/jekyll-client-search/actions/workflows/ci.yml/badge.svg)](https://github.com/gundestrup/jekyll-client-search/actions/workflows/ci.yml)
6
- [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A5%203.2-red.svg)](https://www.ruby-lang.org/)
9
+ [![Gem Version](https://img.shields.io/gem/v/jekyll-client-search)](https://rubygems.org/gems/jekyll-client-search)
10
+ [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A5%203.3-red.svg)](https://www.ruby-lang.org/)
7
11
  [![Jekyll](https://img.shields.io/badge/jekyll-4.x-blue.svg)](https://jekyllrb.com/)
8
12
  [![License: AGPL v3](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue.svg)](LICENSE)
9
- [![DeepWiki](https://img.shields.io/badge/DeepWiki-docs-7B68EE.svg)](https://deepwiki.com/gundestrup/jekyll-client-search)
13
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/gundestrup/jekyll-client-search)
10
14
  [![Coverage](https://img.shields.io/badge/coverage-100%25%20branches-brightgreen.svg)](#testing-strategy)
11
15
  [![Semgrep](https://img.shields.io/badge/semgrep-scanning-brightgreen.svg)](https://semgrep.dev)
12
16
  [![CodeFactor](https://www.codefactor.io/repository/github/gundestrup/jekyll-client-search/badge)](https://www.codefactor.io/repository/github/gundestrup/jekyll-client-search)
@@ -445,15 +449,18 @@ client_search:
445
449
  output: search-relations.json
446
450
  minimum_similarity: 0.55
447
451
  max_items: null
452
+ index_model: null # default: embedding.model
448
453
  embedding:
449
454
  enabled: false
450
- model: embeddinggemma:300m
455
+ model: embeddinggemma-2:570m
451
456
  base_url: http://localhost:11434
452
457
  connect_timeout: 5
453
458
  read_timeout: 120
454
459
  fail_on_error: true
455
460
  query_embedder:
456
461
  type: transformers
462
+ semantic:
463
+ index_model: null # default: embedding.model
457
464
  ```
458
465
 
459
466
  All options:
@@ -482,9 +489,13 @@ All options:
482
489
  | `related.semantic` | `true` | Include vector relations when document embeddings are available. |
483
490
  | `related.minimum_similarity` | `0.55` | Cosine cutoff for semantic relations; no fixed relation count. |
484
491
  | `related.max_items` | `null` | Optional safety cap after cutoff; `null` keeps every matching relation. |
492
+ | `related.index_model` | `embedding.model` | Ollama model for related-article similarity. May differ from the index model since relations never reach the browser. |
493
+ | `semantic.index_model` | `embedding.model` | Ollama model whose vectors go into the index; must match the browser query model. |
494
+ | `semantic.document_prefix` | Model-specific | Document prefix for `semantic.index_model` (defaults to `embedding.document_prefix` when the models match). |
495
+ | `semantic.query_prefix` | Model-specific | Query prefix for `semantic.index_model` (defaults to `embedding.query_prefix` when the models match). |
485
496
  | `embedding.enabled` | `false` | Generate embedding vectors at build time via Ollama. |
486
- | `embedding.model` | `embeddinggemma:300m` | Ollama model name for embedding generation. |
487
- | `embedding.base_url` | `http://localhost:11434` | Ollama server URL. |
497
+ | `embedding.model` | `embeddinggemma-2:570m` | Ollama model name for embedding generation. |
498
+ | `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. |
488
499
  | `embedding.connect_timeout` | `5` | Ollama connection timeout in seconds. |
489
500
  | `embedding.read_timeout` | `120` | Ollama response timeout in seconds. |
490
501
  | `embedding.fail_on_error` | `true` | Fail the build when an embedding cannot be generated. |
@@ -603,23 +614,36 @@ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
603
614
  ### Setup
604
615
 
605
616
  1. Install [Ollama](https://ollama.ai/) and pull an embedding model:
617
+
606
618
  ```bash
607
- ollama pull embeddinggemma:300m
619
+ ollama pull embeddinggemma-2:570m
608
620
  ```
621
+
609
622
  2. Add `ollama-ruby` to your site's Gemfile (optional dependency):
623
+
610
624
  ```ruby
611
625
  gem "ollama-ruby", "~> 1.23"
612
626
  ```
627
+
613
628
  3. Enable embeddings in `_config.yml`:
629
+
614
630
  ```yaml
615
631
  client_search:
616
632
  engine: semantic
617
633
  embedding:
618
634
  enabled: true
619
- model: embeddinggemma:300m
620
635
  base_url: http://localhost:11434
621
636
  ```
622
637
 
638
+ With `query_embedder.type: transformers` (the default), the build
639
+ model needs a packaged browser ONNX mapping — `embeddinggemma` and
640
+ `embeddinggemma-2` models have one (see
641
+ [Browser query models](#browser-query-models)); any other
642
+ `embedding.model` requires an explicit `query_embedder.model` or
643
+ `query_embedder.type: ollama_api`. For build-time-only embeddings — a
644
+ lexical engine with `related` analysis — any Ollama model works
645
+ as-is.
646
+
623
647
  ### How it works
624
648
 
625
649
  - At build time, the `OllamaEmbeddingAdapter` sends each document's text
@@ -632,34 +656,175 @@ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
632
656
  - A content-hash cache (`.jekyll-client-search-cache.json` in the site
633
657
  source) tracks which documents have already been embedded. Unchanged
634
658
  documents reuse the cached embedding — only new or modified documents
635
- are sent to the model.
659
+ are sent to the model. When the index and related models differ, the
660
+ related vectors are cached separately in
661
+ `.jekyll-client-search-related-cache.json`.
636
662
  - Cache entries include the provider, model, endpoint, and cache schema. A
637
663
  model or embedding configuration change automatically invalidates old
638
664
  vectors and re-embeds the documents.
639
- - Cache writes are atomic. The cache file is git-ignored and safe to delete
640
- (it will be rebuilt on the next `jekyll build`).
665
+ - Cache writes are atomic. The cache files are git-ignored and safe to
666
+ delete (they will be rebuilt on the next `jekyll build`).
641
667
  - By default, an embedding failure fails the build rather than silently
642
668
  producing an unusable semantic index. Set `embedding.fail_on_error: false`
643
669
  only when warning-only behavior is intentional.
644
670
 
645
671
  ### Choosing a model
646
672
 
647
- Any Ollama-compatible embedding model works. Common choices:
648
-
649
- | Model | Dimensions | Good for |
650
- | --- | --- | --- |
651
- | `embeddinggemma:300m` | 768 | Default, multilingual, good quality |
652
- | `nomic-embed-text` | 768 | Multilingual, good quality |
653
- | `bge-m3` | 1024 | Multilingual, high quality |
654
- | `all-minilm` | 384 | English, fast, lightweight |
673
+ Any Ollama-compatible embedding model works. Hosted embedding APIs (OpenAI
674
+ `text-embedding-3-small`, Gemini Embedding, the Voyage API) are not
675
+ supported — the adapter only speaks Ollama's `/api/embed` protocol, and
676
+ local embeddings keep the corpus on your machine. Open-weight models that
677
+ ship GGUF builds (e.g. `voyage-4-nano`) can still be tested through a
678
+ local Ollama server.
679
+
680
+ Measured on a 152-document site (Apple Silicon, sequential `/api/embed`
681
+ calls, GPU-resident models, `related.minimum_similarity: 0.55`):
682
+
683
+ | Model | Dims | ~ms/doc | Full corpus | Quality* | Value† | ~Cost/yr‡ | Notes |
684
+ | --- | --- | --- | --- | --- | --- | --- | --- |
685
+ | `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) |
686
+ | `embeddinggemma-2:270m` | 768 | ~260 | ~40 s | 111 | 0.47 | ~67 s | Same text vectors as 570m; shares its ONNX twin (verify parity) |
687
+ | `embeddinggemma:300m` | 768 | ~105 | ~16 s | 96 | 1.00 | ~27 s | Fast; verified transformers.js browser mapping |
688
+ | `nomic-embed-text` | 768 | ~100 | ~15 s | 105 | **1.16** | ~25 s | Fast; best measured value |
689
+ | `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) |
690
+ | `bge-m3` | 1024 | ~305 | ~46 s | 107 | 0.38 | ~78 s | Multilingual; strong judged quality |
691
+ | `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) |
692
+ | `bge-large` | 1024 | ~115 | ~17 s | 106 | 1.01 | ~29 s | English-only yet still strong judged quality |
693
+ | `mxbai-embed-large` | 1024 | ~340 | ~52 s | 107 | 0.35 | ~87 s | Strong judged quality |
694
+ | `snowflake-arctic-embed2` | 1024 | ~505 | ~77 s | 81 | 0.17 | ~130 s | Conservative scores — few edges pass the 0.55 cutoff, weakest judged picks |
695
+ | `all-minilm` | 384 | — | — | — | — | — | English only; not measured |
696
+ | `qwen3-embedding:0.6b` | 1024 | ~350 | ~53 s | 90 | 0.28 | ~89 s | Strong multilingual results upstream |
697
+ | `qwen3-embedding:4b` | 2560 | ~4000 | ~10 min | 87 | 0.02 | ~17 min | Poor value: ~38× the cost for below-average judged picks |
698
+ | `qwen3-embedding:8b` | 4096 | ~10800 | ~27 min | 100 | 0.01 | ~46 min | Reference quality but ~103× the cost; build-time only |
699
+
700
+ \* judged relevance: `gpt-oss:120b-cloud` scored every deduplicated
701
+ related-article suggestion 0–2, blind to which model proposed it
702
+ (`benchmarks/related_quality_judge.rb`); mean precision normalized to
703
+ `qwen3-embedding:8b` = 100. Raw mean scores ranged 0.70–0.96 of 2.00.
704
+ † quality ÷ ms/doc, normalized to `embeddinggemma:300m` = 1.00.
705
+ ‡ amortized embedding time per year assuming one cold re-embed plus weekly
706
+ builds changing two posts — at this scale every model is affordable, so
707
+ quality dominates the choice.
708
+
709
+ The index cache amortizes cold-corpus cost — only new or changed documents
710
+ re-embed — but every changed post pays the per-document latency at each
711
+ build.
712
+
713
+ Judged quality inverted the earlier agreement-based ranking: models with
714
+ "hot" similarity distributions (`embeddinggemma-2`, `nomic-embed-text`,
715
+ `voyage-4-nano`, `bge-m3`, `mxbai-embed-large`) were not producing noise —
716
+ the judge rated their novel picks as the *most* relevant. Conservative
717
+ models (snowflake, the small Qwen3s) scored worst on judged precision
718
+ despite high agreement with `qwen3-embedding:8b`. Practical takeaway for
719
+ this corpus size: `nomic-embed-text`, `voyage-4-nano`, and
720
+ `embeddinggemma:300m` offer the best measured value; the default
721
+ `embeddinggemma-2:570m` (and its identical-text-vector twin `:270m`)
722
+ trades ~2.5× the per-document latency for the best judged relatedness of
723
+ any license-free model; the large Qwen3 models do not pay for themselves
724
+ below several thousand documents.
725
+
726
+ "Build-time only" models are too large to run in the browser for
727
+ `engine: semantic` — use them only with a lexical engine, where embeddings
728
+ serve `related` analysis and never reach the visitor's browser, or with
729
+ `query_embedder.type: ollama_api` where every visitor can reach the API.
655
730
 
656
731
  The model used at build time must match the query model's weights, vector
657
732
  space, dimensions, and preprocessing. Cross-runtime compatibility is verified
658
- for the default `embeddinggemma:300m` mapping. An `all-minilm` mapping is also
659
- provided, but sites should independently verify ranking when changing models.
660
- For any other Ollama model, set `embedding.query_embedder.model` to a
661
- compatible Transformers.js model explicitly or use `ollama_api` so both paths
662
- call the same Ollama model.
733
+ for the `embeddinggemma:300m` → `onnx-community/embeddinggemma-300m-ONNX`
734
+ mapping. The `embeddinggemma-2` family maps to
735
+ `onnx-community/embeddinggemma-2-ONNX` — packaged but not yet parity-verified
736
+ against the Ollama vectors, so confirm ranking on your own corpus. An
737
+ `all-minilm` mapping is also provided. For any other Ollama model, set
738
+ `embedding.query_embedder.model` to a compatible transformers.js model
739
+ explicitly or use `ollama_api` so both paths call the same Ollama model.
740
+
741
+ ### Splitting the index and related models
742
+
743
+ `search-relations.json` is computed entirely at build time, so related
744
+ analysis can use a different vector space than the semantic index — the
745
+ index vectors are the only ones the browser query embedder must match.
746
+ Two optional keys control this; both default to `embedding.model`:
747
+
748
+ ```yaml
749
+ client_search:
750
+ engine: semantic
751
+ embedding:
752
+ enabled: true
753
+ model: embeddinggemma-2:570m # default build model
754
+ semantic:
755
+ index_model: embeddinggemma:300m # vectors written into the index
756
+ related:
757
+ enabled: true
758
+ index_model: embeddinggemma-2:570m # vectors used for related analysis
759
+ ```
760
+
761
+ When `semantic.index_model` and `related.index_model` resolve to the same
762
+ model, documents are embedded once and the vector serves both outputs —
763
+ there is no extra cost. When they differ, each document is embedded twice
764
+ (once per model) and the related vectors are kept in a separate field,
765
+ analyzed by `RelatedAnalyzer`, then stripped before `search-index.json`
766
+ is written. Each model gets its own content-hash cache, so per-model
767
+ vectors still amortize across builds. `semantic.document_prefix` /
768
+ `semantic.query_prefix` can override the index model's prefixes; explicit
769
+ `embedding.document_prefix` / `embedding.query_prefix` apply to
770
+ `embedding.model`.
771
+
772
+ `semantic.index_model` matters whenever vectors go into the index —
773
+ including a lexical engine with `embedding.include_in_index: true`. It
774
+ must always match the browser query model; `semantic.index_model:
775
+ embeddinggemma:300m` is the safe choice for the verified transformers.js
776
+ mapping.
777
+
778
+ ### Browser query models
779
+
780
+ With `query_embedder.type: transformers`, the query model runs in the
781
+ visitor's browser and must be a transformers.js-compatible ONNX build of
782
+ the *index* model's weights. These Ollama models have a packaged mapping:
783
+
784
+ | `embedding.model` / `semantic.index_model` | transformers.js model |
785
+ | --- | --- |
786
+ | `embeddinggemma:300m` | `onnx-community/embeddinggemma-300m-ONNX` (verified) |
787
+ | `embeddinggemma-2`, `embeddinggemma-2:270m`, `embeddinggemma-2:570m` | `onnx-community/embeddinggemma-2-ONNX` (verify parity) |
788
+ | `all-minilm` | `Xenova/all-MiniLM-L6-v2` |
789
+
790
+ For anything else, set `embedding.query_embedder.model` to a compatible
791
+ transformers.js model or use `query_embedder.type: ollama_api`. The full
792
+ set of supported architectures and tasks is in the
793
+ [transformers.js docs](https://huggingface.co/docs/transformers.js/index);
794
+ compatible ONNX weights are tagged
795
+ [`library:transformers.js` on the Hub](https://huggingface.co/models?library=transformers.js).
796
+
797
+ Document/query prefixes are applied automatically for `embeddinggemma`,
798
+ `embeddinggemma-2`, `nomic-embed-text`, and `voyage-4-nano`; other models
799
+ get no prefix.
800
+ Qwen3 embedding models take no document prefix; for semantic search the
801
+ instruct-format query prefix must be set via `embedding.query_prefix`.
802
+ Cosine similarity distributions differ substantially per model — some run
803
+ "narrow" (snowflake: almost nothing passes 0.55) and some run "hot"
804
+ (nomic, embeddinggemma-2: nearly everything does) — so retune
805
+ `related.minimum_similarity` and the semantic threshold when switching
806
+ rather than comparing at the default.
807
+
808
+ To benchmark your own site and corpus:
809
+
810
+ ```bash
811
+ cd jekyll-client-search
812
+ bundle install
813
+ OLLAMA_BASE_URL=http://127.0.0.1:11434 \
814
+ EMBEDDING_MODELS="embeddinggemma:300m,nomic-embed-text,bge-m3" \
815
+ bundle exec ruby benchmarks/related_model_benchmark.rb /path/to/site
816
+ ```
817
+
818
+ The script times `OllamaEmbeddingAdapter.embed` per document and runs the
819
+ real `RelatedAnalyzer`, then diffs each model's related lists against the
820
+ baseline (`BASELINE_MODEL` env, default `embeddinggemma-2:570m`). Raw vectors
821
+ are cached under `benchmarks/.related-embeddings/` so re-runs are cheap.
822
+
823
+ A `localhost` `base_url` is resolved automatically to the loopback literal
824
+ that accepts a TCP connection (`127.0.0.1` or `[::1]`), because the HTTP
825
+ client does not fall back across address families and Ollama often listens
826
+ on IPv4 loopback only. If `ollama embed` calls still fail with a socket
827
+ error, set `base_url: http://127.0.0.1:11434` explicitly.
663
828
 
664
829
  ## Related articles reference
665
830
 
@@ -967,7 +1132,7 @@ client_search:
967
1132
  engine: semantic
968
1133
  embedding:
969
1134
  enabled: true
970
- model: embeddinggemma:300m
1135
+ model: embeddinggemma-2:570m
971
1136
  query_embedder:
972
1137
  type: ollama_api
973
1138
  api_url: https://embeddings.example.com/api/embed
@@ -1012,18 +1177,18 @@ Recommended policy:
1012
1177
 
1013
1178
  ## License
1014
1179
 
1180
+ Copyright (C) 2026 Svend Gundestrup.
1181
+
1015
1182
  This project is licensed under the GNU Affero General Public License v3.0 or
1016
1183
  later (`AGPL-3.0-or-later`). See [LICENSE](LICENSE).
1017
1184
 
1018
1185
  ## Development
1019
1186
 
1020
- Development uses Ruby 3.4.10 through rbenv, while the gem supports Ruby 3.2
1021
- and newer. The development version is stored in `.ruby-version`, and CI tests
1022
- Ruby 3.2, 3.3, and 3.4.
1187
+ Development uses the Ruby version pinned in `.ruby-version` (via rbenv),
1188
+ while the gem supports Ruby 3.3 and newer. CI tests Ruby 3.3 and 3.4.
1023
1189
 
1024
1190
  ```bash
1025
- rbenv install 3.4.10 # if not already installed
1026
- rbenv local 3.4.10
1191
+ rbenv install # reads .ruby-version
1027
1192
  bundle install
1028
1193
  npm ci
1029
1194
  bundle exec rake ci
@@ -1153,7 +1318,7 @@ Additional automated code quality and security services:
1153
1318
  - [CodeQL](https://codeql.github.com) — GitHub native code scanning for
1154
1319
  security vulnerabilities (Ruby + JavaScript).
1155
1320
  - [Semgrep](https://semgrep.dev) — CI security scan using the Semgrep Pro
1156
- engine with the organization's full policy (2,900+ rules). Custom ReDoS
1321
+ engine with the organization's full policy ruleset. Custom ReDoS
1157
1322
  detection rules are defined in `.semgrep.yml`. Findings are triaged on the
1158
1323
  [Semgrep AppSec Platform](https://semgrep.dev) dashboard. False positives
1159
1324
  and acceptable risks are annotated inline with `# nosemgrep: rule.id`.
@@ -15,8 +15,10 @@
15
15
  # scripts: true (default) to load runtime scripts, false to skip
16
16
  ---
17
17
 
18
- {% assign related_sort = include.sort | default: "relevance" %}
19
- {% assign include_scripts = include.scripts | default: true %}
18
+ {% comment %}Bracket access: bare include.sort falls back to Hash#sort on
19
+ non-empty param hashes, so the default would silently stop applying.{% endcomment %}
20
+ {% assign related_sort = include["sort"] | default: "relevance" %}
21
+ {% assign include_scripts = include["scripts"] | default: true %}
20
22
  {% if include_scripts == false or include_scripts == "false" %}
21
23
  {% assign include_scripts = false %}
22
24
  {% endif %}
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var config = window.ClientSearchEmbedderConfig || {};
5
- var model = config.model || config.buildModel || "embeddinggemma:300m";
5
+ var model = config.model || config.buildModel || "embeddinggemma-2:570m";
6
6
  var apiUrl = config.apiUrl || "http://localhost:11434/api/embed";
7
7
  var activeController = null;
8
8
 
@@ -27,7 +27,7 @@ module Jekyll
27
27
  "icon_field" => "icon_url",
28
28
  "embedding" => {
29
29
  "enabled" => false,
30
- "model" => "embeddinggemma:300m",
30
+ "model" => "embeddinggemma-2:570m",
31
31
  "base_url" => "http://localhost:11434",
32
32
  "connect_timeout" => 5,
33
33
  "read_timeout" => 120,
@@ -41,9 +41,7 @@ module Jekyll
41
41
  def initialize(site)
42
42
  configured = normalize_configured(site.config["client_search"])
43
43
  @values = DEFAULTS.merge(configured)
44
- @live_search = LiveSearchConfiguration.new(configured["live_search"] || {}, engine: engine)
45
- @related = RelatedConfiguration.new(configured["related"] || {})
46
- @dropdown = DropdownConfiguration.new(configured["dropdown"] || {})
44
+ build_sub_configurations(configured)
47
45
  merge_embedding_config(configured["embedding"])
48
46
  validate_engine!
49
47
  return unless embedding_enabled?
@@ -54,6 +52,13 @@ module Jekyll
54
52
 
55
53
  private
56
54
 
55
+ def build_sub_configurations(configured)
56
+ @live_search = LiveSearchConfiguration.new(configured["live_search"] || {}, engine: engine)
57
+ @related = RelatedConfiguration.new(configured["related"] || {})
58
+ @semantic = SemanticConfiguration.new(configured["semantic"] || {})
59
+ @dropdown = DropdownConfiguration.new(configured["dropdown"] || {})
60
+ end
61
+
57
62
  def normalize_configured(value)
58
63
  return { "enabled" => false } if value == false
59
64
  return value if value.is_a?(Hash)
@@ -70,13 +75,9 @@ module Jekyll
70
75
  "client_search embedding configuration must be a mapping"
71
76
  end
72
77
  @values["embedding"] = DEFAULTS.fetch("embedding").merge(embedding)
73
- merge_query_embedder_config(embedding["query_embedder"])
74
- end
75
-
76
- def merge_query_embedder_config(configured_query_embedder)
77
78
  @query_embedder = QueryEmbedderConfiguration.new(
78
- configured_query_embedder || {},
79
- build_model: embedding_model,
79
+ embedding["query_embedder"] || {},
80
+ build_model: embedding_index_model,
80
81
  build_base_url: embedding_base_url,
81
82
  query_prefix: embedding_query_prefix
82
83
  )
@@ -20,6 +20,19 @@ module Jekyll
20
20
  @related
21
21
  end
22
22
 
23
+ # The Ollama model whose vectors are written into the search index and
24
+ # must match the browser query embedder's vector space. Defaults to
25
+ # embedding.model; override with semantic.index_model.
26
+ def embedding_index_model
27
+ @semantic.index_model || embedding_model
28
+ end
29
+
30
+ # The Ollama model used for related-article similarity analysis.
31
+ # Defaults to embedding.model; override with related.index_model.
32
+ def embedding_related_model
33
+ @related.index_model || embedding_model
34
+ end
35
+
23
36
  def query_embedder_type
24
37
  @query_embedder.type
25
38
  end