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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +109 -1
- data/LICENSE +654 -15
- data/README.developer.md +37 -15
- data/README.md +195 -30
- data/assets/includes/related-articles.html +4 -2
- data/assets/query-embedders/ollama-api.js +1 -1
- data/lib/jekyll/client_search/configuration.rb +11 -10
- data/lib/jekyll/client_search/configuration_accessors.rb +13 -0
- data/lib/jekyll/client_search/dropdown_tag.rb +24 -41
- data/lib/jekyll/client_search/embedding_configuration.rb +51 -15
- data/lib/jekyll/client_search/generator.rb +66 -19
- data/lib/jekyll/client_search/index_cache.rb +3 -2
- data/lib/jekyll/client_search/ollama_embedding_adapter.rb +47 -1
- data/lib/jekyll/client_search/query_embedder_configuration.rb +3 -0
- data/lib/jekyll/client_search/related_analyzer.rb +4 -1
- data/lib/jekyll/client_search/related_configuration.rb +15 -0
- data/lib/jekyll/client_search/related_tag.rb +23 -17
- data/lib/jekyll/client_search/script_tags.rb +41 -0
- data/lib/jekyll/client_search/search_tag.rb +20 -34
- data/lib/jekyll/client_search/semantic_configuration.rb +46 -0
- data/lib/jekyll/client_search/version.rb +1 -1
- data/lib/jekyll/client_search.rb +2 -0
- metadata +4 -2
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
|
|
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
|
|
14
|
+
bundle exec rspec # Ruby tests
|
|
16
15
|
OLLAMA_INTEGRATION=1 bundle exec rspec # all tests including Ollama integration
|
|
17
|
-
npm test # JavaScript 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
|
|
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
|
-
|
|
89
|
-
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
+
[](https://codecov.io/gh/gundestrup/jekyll-client-search)
|
|
4
|
+
|
|
5
|
+
|
|
3
6
|

|
|
4
7
|
|
|
5
8
|
[](https://github.com/gundestrup/jekyll-client-search/actions/workflows/ci.yml)
|
|
6
|
-
[](https://rubygems.org/gems/jekyll-client-search)
|
|
10
|
+
[](https://www.ruby-lang.org/)
|
|
7
11
|
[](https://jekyllrb.com/)
|
|
8
12
|
[](LICENSE)
|
|
9
|
-
[](https://deepwiki.com/gundestrup/jekyll-client-search)
|
|
10
14
|
[](#testing-strategy)
|
|
11
15
|
[](https://semgrep.dev)
|
|
12
16
|
[](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:
|
|
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:
|
|
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:
|
|
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
|
|
640
|
-
(
|
|
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.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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:
|
|
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
|
|
1021
|
-
and newer.
|
|
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
|
|
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
|
|
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
|
-
{%
|
|
19
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
build_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
|