jekyll-client-search 0.3.5 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4bd0e3f73bbbf16d364c059483ad2ad40382232dda34ca1e8a0f83b0286fa605
4
- data.tar.gz: 643dfe41f83ab07d100fa9f841e7b80f07ce1a8db5d5e3d2e5e43d4a29880a2e
3
+ metadata.gz: 749a1708fe34bce1624db0eb4d91ea58a569f2663f8d78b8200365188faf7ae4
4
+ data.tar.gz: 833b1c87a57b2f78a05f42103efef6f9aa2ec09ee0726cad734e0f28f268fc50
5
5
  SHA512:
6
- metadata.gz: 2e0e6fbfde2eaf9695f31bc4f79980f5a0d5e88b299253fde5523faa137be6606c60fecf25de2d2ed4b0e3e05d08d2afc5a9033346dd64b25c3dbde8156a44b4
7
- data.tar.gz: b2cdf16bbca7714c81b6ce4a8054f94fa654a30d3e30f5c926fde2f0f3c312cc28ed66d249f3c9dcd38f03cbd9ccb40bf437ce6180c13f867928afa16c6a1f30
6
+ metadata.gz: 169936c38c680b2f4ec657243909aaad0a902b6bbbc52e9c586d3d986651e1529fdfb2d2a9dcde69f997075b8c901c5178b7148d86f064242ee018a228b2e457
7
+ data.tar.gz: 3e75798e1b41dac7982ca34e83e61e8b28b03b347f6ca60b6e527570a72d4a69a9f5ddf6293197410c6935a699b54a7001b601145c57c7e09302e4f1edf4d426
data/CHANGELOG.md CHANGED
@@ -1,6 +1,63 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.4.0 — 2026-10-24
4
+
5
+ - Dev-tooling bump: `jsdom` 30.1.3 (patch).
6
+
7
+ ### Added
8
+
9
+ - `benchmarks/embedding_model_benchmark.rb`,
10
+ `benchmarks/related_model_benchmark.rb`, and
11
+ `benchmarks/related_quality_judge.rb` — measure Ollama embedding
12
+ latency per model on a real site, compare `related` output across
13
+ models, and score judged relevance with a blind LLM judge
14
+ (10 local embedding models benchmarked on a 152-document site).
15
+ - `embeddinggemma-2` document/query prefixes (same prompt format as
16
+ `embeddinggemma`), `voyage-4-nano` prefixes (open-weight Voyage
17
+ model via community GGUF), and `qwen3-embedding` prefixes (official
18
+ instruct-format query prefix; empty document prefix).
19
+ - README model-selection guidance: measured per-document latency table,
20
+ LLM-judged quality ratings, quality-per-cost value index, amortized
21
+ yearly cost, `qwen3-embedding` build-time-only notes, and the
22
+ `localhost` → `127.0.0.1` loopback gotcha.
23
+ - Split index/related embedding models: `semantic.index_model` selects
24
+ the Ollama model whose vectors go into `search-index.json` (must match
25
+ the browser query embedder); `related.index_model` selects the model
26
+ used for related-article analysis. Both default to `embedding.model`;
27
+ equal models share one embedding pass, divergent models embed each
28
+ document twice into separate cached vector fields.
29
+ `semantic.document_prefix` / `semantic.query_prefix` override the
30
+ index model's prefixes. Related-model vectors are cached separately
31
+ in `.jekyll-client-search-related-cache.json` and never written to
32
+ the index.
33
+ - `embeddinggemma-2` (`:270m`, `:570m`, untagged) maps to
34
+ `onnx-community/embeddinggemma-2-ONNX` for the transformers.js query
35
+ embedder. `test/onnx-parity.test.js` is an opt-in check
36
+ (`ONNX_PARITY=1`, requires `@huggingface/transformers` and a running
37
+ Ollama) that compares ONNX and Ollama vectors and rankings;
38
+ `embeddinggemma:300m` remains the verified mapping.
39
+
40
+ ### Changed
41
+
42
+ - Default `embedding.model` is now `embeddinggemma-2:570m` (was
43
+ `embeddinggemma:300m`) — the judged-quality leader among license-free
44
+ local models (111 vs 96) at ~2.5× the per-document latency; its text
45
+ vectors are byte-identical to `embeddinggemma-2:270m`. The
46
+ transformers.js query embedder maps it to
47
+ `onnx-community/embeddinggemma-2-ONNX`; sites that prefer the verified
48
+ browser mapping can set `semantic.index_model: embeddinggemma:300m`
49
+ while related analysis stays on the default model.
50
+ - 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`.
51
+
52
+ ### Fixed
53
+
54
+ - A `localhost` `embedding.base_url` (the default) is resolved once to
55
+ the loopback literal that accepts a TCP connection — `127.0.0.1` or
56
+ `[::1]` — because `ollama-ruby`/Excon does not fall back across
57
+ address families and Ollama commonly listens on IPv4 loopback only.
58
+ Embedding calls that died with `Excon::Error::Socket "Broken pipe"`
59
+ when `localhost` resolved to `::1` first now connect on both
60
+ IPv4-only and IPv6-only servers.
4
61
 
5
62
  ## 0.3.5 — 2026-10-06
6
63
 
data/README.developer.md CHANGED
@@ -237,9 +237,10 @@ To run the full integration tests that call a real Ollama server:
237
237
 
238
238
  1. Install [Ollama](https://ollama.ai/)
239
239
 
240
- 2. Pull the required model:
240
+ 2. Pull the required models:
241
241
 
242
242
  ```bash
243
+ ollama pull embeddinggemma-2:570m
243
244
  ollama pull embeddinggemma:300m
244
245
  ```
245
246
 
@@ -250,6 +251,16 @@ To run the full integration tests that call a real Ollama server:
250
251
  OLLAMA_INTEGRATION=1 bundle exec rspec spec/llm_injection_spec.rb
251
252
  ```
252
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
+
253
264
  These tests:
254
265
 
255
266
  - Build the full 80-post index with real Ollama embeddings
@@ -450,7 +461,8 @@ This check also runs in the release workflow — the publish will fail if
450
461
  ```
451
462
 
452
463
  The Ollama integration tests require a running [Ollama](https://ollama.ai/)
453
- 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
454
466
  embeddinggemma:300m`). Do not publish a release until the Ollama
455
467
  integration tests pass locally — they are skipped by default and will
456
468
  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/),
@@ -450,15 +449,18 @@ client_search:
450
449
  output: search-relations.json
451
450
  minimum_similarity: 0.55
452
451
  max_items: null
452
+ index_model: null # default: embedding.model
453
453
  embedding:
454
454
  enabled: false
455
- model: embeddinggemma:300m
455
+ model: embeddinggemma-2:570m
456
456
  base_url: http://localhost:11434
457
457
  connect_timeout: 5
458
458
  read_timeout: 120
459
459
  fail_on_error: true
460
460
  query_embedder:
461
461
  type: transformers
462
+ semantic:
463
+ index_model: null # default: embedding.model
462
464
  ```
463
465
 
464
466
  All options:
@@ -487,9 +489,13 @@ All options:
487
489
  | `related.semantic` | `true` | Include vector relations when document embeddings are available. |
488
490
  | `related.minimum_similarity` | `0.55` | Cosine cutoff for semantic relations; no fixed relation count. |
489
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). |
490
496
  | `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. |
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. |
493
499
  | `embedding.connect_timeout` | `5` | Ollama connection timeout in seconds. |
494
500
  | `embedding.read_timeout` | `120` | Ollama response timeout in seconds. |
495
501
  | `embedding.fail_on_error` | `true` | Fail the build when an embedding cannot be generated. |
@@ -610,7 +616,7 @@ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
610
616
  1. Install [Ollama](https://ollama.ai/) and pull an embedding model:
611
617
 
612
618
  ```bash
613
- ollama pull embeddinggemma:300m
619
+ ollama pull embeddinggemma-2:570m
614
620
  ```
615
621
 
616
622
  2. Add `ollama-ruby` to your site's Gemfile (optional dependency):
@@ -626,10 +632,18 @@ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
626
632
  engine: semantic
627
633
  embedding:
628
634
  enabled: true
629
- model: embeddinggemma:300m
630
635
  base_url: http://localhost:11434
631
636
  ```
632
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
+
633
647
  ### How it works
634
648
 
635
649
  - At build time, the `OllamaEmbeddingAdapter` sends each document's text
@@ -642,34 +656,175 @@ Jekyll build time using a local [Ollama](https://ollama.ai/) server and the
642
656
  - A content-hash cache (`.jekyll-client-search-cache.json` in the site
643
657
  source) tracks which documents have already been embedded. Unchanged
644
658
  documents reuse the cached embedding — only new or modified documents
645
- 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`.
646
662
  - Cache entries include the provider, model, endpoint, and cache schema. A
647
663
  model or embedding configuration change automatically invalidates old
648
664
  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`).
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`).
651
667
  - By default, an embedding failure fails the build rather than silently
652
668
  producing an unusable semantic index. Set `embedding.fail_on_error: false`
653
669
  only when warning-only behavior is intentional.
654
670
 
655
671
  ### Choosing a model
656
672
 
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 |
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.
665
730
 
666
731
  The model used at build time must match the query model's weights, vector
667
732
  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.
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.
673
828
 
674
829
  ## Related articles reference
675
830
 
@@ -977,7 +1132,7 @@ client_search:
977
1132
  engine: semantic
978
1133
  embedding:
979
1134
  enabled: true
980
- model: embeddinggemma:300m
1135
+ model: embeddinggemma-2:570m
981
1136
  query_embedder:
982
1137
  type: ollama_api
983
1138
  api_url: https://embeddings.example.com/api/embed
@@ -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
@@ -9,9 +9,25 @@ module Jekyll
9
9
  "document" => "title: none | text: ",
10
10
  "query" => "task: search result | query: "
11
11
  },
12
+ "embeddinggemma-2" => {
13
+ "document" => "title: none | text: ",
14
+ "query" => "task: search result | query: "
15
+ },
12
16
  "nomic-embed-text" => {
13
17
  "document" => "search_document: ",
14
18
  "query" => "search_query: "
19
+ },
20
+ "voyage-4-nano" => {
21
+ "document" => "Represent the document for retrieval: ",
22
+ "query" => "Represent the query for retrieving supporting documents: "
23
+ },
24
+ "qwen3-embedding" => {
25
+ "document" => "",
26
+ "query" => "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: "
27
+ },
28
+ "bge-large" => {
29
+ "document" => "",
30
+ "query" => "Represent this sentence for searching relevant passages: "
15
31
  }
16
32
  }.freeze
17
33
 
@@ -44,43 +60,63 @@ module Jekyll
44
60
  configured.nil? ? engine == "semantic" : configured == true
45
61
  end
46
62
 
63
+ # Document prefix for the index model (semantic.index_model).
47
64
  def embedding_document_prefix
48
- configured_prefix("document_prefix", "document")
65
+ configured_prefix("document", model: embedding_index_model, override: @semantic.document_prefix)
49
66
  end
50
67
 
68
+ # Query prefix for the index model — must match the browser query
69
+ # embedder's vector space.
51
70
  def embedding_query_prefix
52
- configured_prefix("query_prefix", "query")
71
+ configured_prefix("query", model: embedding_index_model, override: @semantic.query_prefix)
72
+ end
73
+
74
+ # Document prefix for the related-analysis model (related.index_model).
75
+ def embedding_related_document_prefix
76
+ configured_prefix("document", model: embedding_related_model)
53
77
  end
54
78
 
55
79
  def embedding_identity
80
+ embedding_identity_for(model: embedding_index_model,
81
+ document_prefix: embedding_document_prefix,
82
+ query_prefix: embedding_query_prefix)
83
+ end
84
+
85
+ def embedding_related_identity
86
+ embedding_identity_for(model: embedding_related_model,
87
+ document_prefix: embedding_related_document_prefix,
88
+ query_prefix: configured_prefix("query", model: embedding_related_model))
89
+ end
90
+
91
+ private
92
+
93
+ def embedding_identity_for(model:, document_prefix:, query_prefix:)
56
94
  {
57
95
  "provider" => "ollama",
58
- "model" => embedding_model,
96
+ "model" => model,
59
97
  "base_url" => embedding_base_url,
60
- "document_prefix" => embedding_document_prefix,
61
- "query_prefix" => embedding_query_prefix,
98
+ "document_prefix" => document_prefix,
99
+ "query_prefix" => query_prefix,
62
100
  "schema" => 2
63
101
  }
64
102
  end
65
103
 
66
- private
104
+ def configured_prefix(role, model:, override: nil)
105
+ return override.to_s if override
67
106
 
68
- def configured_prefix(config_key, role)
69
107
  embedding = @values.fetch("embedding")
70
- return embedding.fetch(config_key).to_s if embedding.key?(config_key)
108
+ config_key = "#{role}_prefix"
109
+ return embedding[config_key].to_s if model == embedding_model && embedding.key?(config_key)
71
110
 
72
- model_name = embedding_model.split(":").first
73
- PREFIXES.fetch(model_name, {}).fetch(role, "")
111
+ PREFIXES.fetch(model.split(":").first, {}).fetch(role, "")
74
112
  end
75
113
 
76
114
  def positive_timeout(key)
77
115
  value = @values.fetch("embedding").fetch(key).to_f
78
- unless value.positive? && value.finite?
79
- raise Jekyll::Errors::FatalException,
80
- "embedding #{key} must be a finite value greater than zero"
81
- end
116
+ return value if value.positive? && value.finite?
82
117
 
83
- value
118
+ raise Jekyll::Errors::FatalException,
119
+ "embedding #{key} must be a finite value greater than zero"
84
120
  end
85
121
 
86
122
  def validate_embedding!
@@ -25,6 +25,7 @@ module Jekyll
25
25
 
26
26
  def add_generated_pages(site, documents, configuration)
27
27
  add_related_page(site, documents, configuration) if configuration.related_enabled?
28
+ documents.each { |document| document.delete("related_embedding") }
28
29
  remove_embeddings(documents) unless configuration.embedding_include_in_index?
29
30
  site.pages << SearchIndexPage.new(site, configuration.output, documents)
30
31
  site.pages << RuntimeConfigPage.new(site, configuration) if configuration.copy_runtime?
@@ -39,18 +40,60 @@ module Jekyll
39
40
  end
40
41
 
41
42
  def enhance_with_embeddings(site, documents, configuration)
42
- cache = IndexCache.new(site.source, embedding_identity: configuration.embedding_identity)
43
- adapter = build_embedding_adapter(configuration)
44
-
45
- documents.each { |entry| embed_document(entry, cache, adapter, configuration) }
46
- cache.prune(documents.map { |doc| doc["id"] })
47
- cache.save
43
+ embedding_jobs(configuration).each do |job|
44
+ cache = IndexCache.new(site.source, embedding_identity: job[:identity],
45
+ file_name: job[:cache_file])
46
+ adapter = build_embedding_adapter(configuration, model: job[:model])
47
+
48
+ documents.each { |entry| embed_document(entry, cache, adapter, configuration, job) }
49
+ cache.prune(documents.map { |doc| doc["id"] })
50
+ cache.save
51
+ end
48
52
  documents
49
53
  end
50
54
 
51
- def build_embedding_adapter(configuration)
55
+ # Each job embeds every document once per model into a target field.
56
+ # The index model writes "embedding" (kept when include_in_index); a
57
+ # divergent related model writes "related_embedding" (stripped before
58
+ # the index is emitted). Identical models share a single pass.
59
+ def embedding_jobs(configuration)
60
+ jobs = []
61
+ jobs << index_embedding_job(configuration) if configuration.embedding_include_in_index?
62
+ jobs.concat(related_embedding_jobs(configuration)) if configuration.related_enabled?
63
+ jobs
64
+ end
65
+
66
+ def index_embedding_job(configuration)
67
+ {
68
+ model: configuration.embedding_index_model,
69
+ field: "embedding",
70
+ document_prefix: configuration.embedding_document_prefix,
71
+ identity: configuration.embedding_identity,
72
+ cache_file: IndexCache::CACHE_FILE
73
+ }
74
+ end
75
+
76
+ def related_embedding_jobs(configuration)
77
+ return [related_embedding_job("embedding", IndexCache::CACHE_FILE, configuration)] unless
78
+ configuration.embedding_include_in_index?
79
+ return [] if configuration.embedding_related_model == configuration.embedding_index_model
80
+
81
+ [related_embedding_job("related_embedding", IndexCache::RELATED_CACHE_FILE, configuration)]
82
+ end
83
+
84
+ def related_embedding_job(field, cache_file, configuration)
85
+ {
86
+ model: configuration.embedding_related_model,
87
+ field: field,
88
+ document_prefix: configuration.embedding_related_document_prefix,
89
+ identity: configuration.embedding_related_identity,
90
+ cache_file: cache_file
91
+ }
92
+ end
93
+
94
+ def build_embedding_adapter(configuration, model: configuration.embedding_model)
52
95
  OllamaEmbeddingAdapter.new(
53
- model: configuration.embedding_model,
96
+ model: model,
54
97
  base_url: configuration.embedding_base_url,
55
98
  connect_timeout: configuration.embedding_connect_timeout,
56
99
  read_timeout: configuration.embedding_read_timeout
@@ -66,17 +109,13 @@ module Jekyll
66
109
  documents.each { |document| document.delete("embedding") }
67
110
  end
68
111
 
69
- def embed_document(document, cache, adapter, configuration)
70
- hash = IndexCache.content_hash(document)
71
- cached = cache.lookup(document["id"], hash)
72
- if cached && cached["embedding"]
73
- document["embedding"] = cached["embedding"]
74
- return
75
- end
112
+ def embed_document(document, cache, adapter, configuration, job)
113
+ hash = IndexCache.content_hash(document.except("embedding", "related_embedding"))
114
+ return if embedding_restored?(document, cache, hash, job[:field])
76
115
 
77
- embedding = adapter.embed(embedding_text(document, configuration))
116
+ embedding = adapter.embed(embedding_text(document, job[:document_prefix]))
78
117
  if embedding
79
- document["embedding"] = embedding
118
+ document[job[:field]] = embedding
80
119
  cache.store(document["id"], hash, embedding)
81
120
  elsif configuration.embedding_fail_on_error?
82
121
  raise Jekyll::Errors::FatalException,
@@ -84,9 +123,17 @@ module Jekyll
84
123
  end
85
124
  end
86
125
 
87
- def embedding_text(document, configuration)
126
+ def embedding_restored?(document, cache, hash, field)
127
+ cached = cache.lookup(document["id"], hash)
128
+ return false unless cached && cached["embedding"]
129
+
130
+ document[field] = cached["embedding"]
131
+ true
132
+ end
133
+
134
+ def embedding_text(document, document_prefix)
88
135
  text = [document["title"], document["excerpt"], document["content"]].compact.join(" ")
89
- "#{configuration.embedding_document_prefix}#{text}"
136
+ "#{document_prefix}#{text}"
90
137
  end
91
138
 
92
139
  def collection_documents(site, configuration, builder)
@@ -12,11 +12,12 @@ module Jekyll
12
12
  # should be git-ignored.
13
13
  class IndexCache
14
14
  CACHE_FILE = ".jekyll-client-search-cache.json"
15
+ RELATED_CACHE_FILE = ".jekyll-client-search-related-cache.json"
15
16
 
16
17
  attr_reader :path
17
18
 
18
- def initialize(site_source, embedding_identity: nil)
19
- @path = File.join(site_source, CACHE_FILE)
19
+ def initialize(site_source, embedding_identity: nil, file_name: CACHE_FILE)
20
+ @path = File.join(site_source, file_name)
20
21
  @embedding_identity = embedding_identity
21
22
  @entries = load
22
23
  end
@@ -11,6 +11,11 @@ module Jekyll
11
11
  # enabled: true
12
12
  # model: all-minilm # or nomic-embed-text, bge-m3, etc.
13
13
  # base_url: http://localhost:11434
14
+ #
15
+ # A +localhost+ base_url is resolved once to the loopback literal
16
+ # (127.0.0.1 or ::1) that accepts a TCP connection, because Excon
17
+ # does not fall back across address families when the DNS name maps
18
+ # to a family the server is not listening on.
14
19
  class OllamaEmbeddingAdapter
15
20
  attr_reader :model, :base_url, :connect_timeout, :read_timeout
16
21
 
@@ -48,12 +53,53 @@ module Jekyll
48
53
  @client ||= begin
49
54
  require "ollama"
50
55
  Ollama::Client.new(
51
- base_url: @base_url,
56
+ base_url: resolved_base_url,
52
57
  connect_timeout: @connect_timeout,
53
58
  read_timeout: @read_timeout
54
59
  )
55
60
  end
56
61
  end
62
+
63
+ # "localhost" may resolve to ::1 while the server only listens on
64
+ # 127.0.0.1, or the reverse. Excon does not fall back across
65
+ # address families, so probe both loopback literals once and use
66
+ # the one that accepts a TCP connection. Any other host name is
67
+ # used exactly as configured.
68
+ def resolved_base_url
69
+ @resolved_base_url ||= resolve_localhost(@base_url)
70
+ end
71
+
72
+ def resolve_localhost(base_url)
73
+ require "uri"
74
+ uri = URI.parse(base_url)
75
+ return base_url unless uri.host.to_s.chomp(".").casecmp("localhost").zero?
76
+
77
+ resolved = loopback_base_url(uri)
78
+ return base_url unless resolved
79
+
80
+ Jekyll.logger.info "ClientSearch:", "resolved localhost to #{resolved}"
81
+ resolved
82
+ rescue URI::InvalidURIError
83
+ base_url
84
+ end
85
+
86
+ def loopback_base_url(uri)
87
+ %w[127.0.0.1 ::1].each do |address|
88
+ next unless tcp_reachable?(address, uri.port)
89
+
90
+ resolved = uri.dup
91
+ resolved.host = address.include?(":") ? "[#{address}]" : address
92
+ return resolved.to_s
93
+ end
94
+ nil
95
+ end
96
+
97
+ def tcp_reachable?(address, port)
98
+ require "socket"
99
+ Socket.tcp(address, port, connect_timeout: @connect_timeout) { true }
100
+ rescue SystemCallError, SocketError
101
+ false
102
+ end
57
103
  end
58
104
  end
59
105
  end
@@ -8,6 +8,9 @@ module Jekyll
8
8
 
9
9
  MODEL_MAP = {
10
10
  "embeddinggemma:300m" => "onnx-community/embeddinggemma-300m-ONNX",
11
+ "embeddinggemma-2" => "onnx-community/embeddinggemma-2-ONNX",
12
+ "embeddinggemma-2:270m" => "onnx-community/embeddinggemma-2-ONNX",
13
+ "embeddinggemma-2:570m" => "onnx-community/embeddinggemma-2-ONNX",
11
14
  "all-minilm" => "Xenova/all-MiniLM-L6-v2"
12
15
  }.freeze
13
16
 
@@ -29,7 +29,10 @@ module Jekyll
29
29
 
30
30
  def build_relation(source, target)
31
31
  metadata = metadata_relation(source, target)
32
- semantic_similarity = similarity(source["embedding"], target["embedding"])
32
+ semantic_similarity = similarity(
33
+ source["related_embedding"] || source["embedding"],
34
+ target["related_embedding"] || target["embedding"]
35
+ )
33
36
  return unless metadata || semantic_match?(semantic_similarity)
34
37
 
35
38
  relation = base_relation(target, metadata, semantic_similarity)
@@ -62,12 +62,27 @@ module Jekyll
62
62
  @values.fetch("max_items")
63
63
  end
64
64
 
65
+ # Ollama model used for related-article similarity analysis. nil means
66
+ # the site uses the general embedding.model.
67
+ def index_model
68
+ value = @values["index_model"]
69
+ value&.to_s
70
+ end
71
+
65
72
  private
66
73
 
67
74
  def validate!
68
75
  validate_booleans!
69
76
  validate_similarity!
70
77
  validate_max_items!
78
+ validate_index_model!
79
+ end
80
+
81
+ def validate_index_model!
82
+ return if index_model.nil? || !index_model.empty?
83
+
84
+ raise Jekyll::Errors::FatalException,
85
+ "client_search related index_model must not be empty"
71
86
  end
72
87
 
73
88
  def validate_booleans!
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module ClientSearch
5
+ # Validates semantic-engine index settings: which Ollama model's vectors
6
+ # are written into the search index and must match the browser query
7
+ # embedder's vector space.
8
+ class SemanticConfiguration
9
+ def initialize(config)
10
+ unless config.is_a?(Hash)
11
+ raise Jekyll::Errors::FatalException,
12
+ "client_search semantic configuration must be a mapping"
13
+ end
14
+
15
+ @values = config
16
+ validate!
17
+ end
18
+
19
+ # Ollama model embedded into the search index. nil means the site uses
20
+ # the general embedding.model.
21
+ def index_model
22
+ value = @values["index_model"]
23
+ value&.to_s
24
+ end
25
+
26
+ def document_prefix
27
+ value = @values["document_prefix"]
28
+ value&.to_s
29
+ end
30
+
31
+ def query_prefix
32
+ value = @values["query_prefix"]
33
+ value&.to_s
34
+ end
35
+
36
+ private
37
+
38
+ def validate!
39
+ return if index_model.nil? || !index_model.empty?
40
+
41
+ raise Jekyll::Errors::FatalException,
42
+ "client_search semantic index_model must not be empty"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Jekyll
4
4
  module ClientSearch
5
- VERSION = "0.3.5"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -9,6 +9,7 @@ require_relative "client_search/version"
9
9
  require_relative "client_search/embedding_configuration"
10
10
  require_relative "client_search/live_search_configuration"
11
11
  require_relative "client_search/related_configuration"
12
+ require_relative "client_search/semantic_configuration"
12
13
  require_relative "client_search/dropdown_configuration"
13
14
  require_relative "client_search/query_embedder_configuration"
14
15
  require_relative "client_search/configuration_accessors"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-client-search
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svend Gundestrup
@@ -78,6 +78,7 @@ files:
78
78
  - lib/jekyll/client_search/script_tags.rb
79
79
  - lib/jekyll/client_search/search_index_page.rb
80
80
  - lib/jekyll/client_search/search_tag.rb
81
+ - lib/jekyll/client_search/semantic_configuration.rb
81
82
  - lib/jekyll/client_search/tasks.rb
82
83
  - lib/jekyll/client_search/version.rb
83
84
  homepage: https://github.com/gundestrup/jekyll-client-search