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 +4 -4
- data/CHANGELOG.md +58 -1
- data/README.developer.md +14 -2
- data/README.md +178 -23
- 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/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/semantic_configuration.rb +46 -0
- data/lib/jekyll/client_search/version.rb +1 -1
- data/lib/jekyll/client_search.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 749a1708fe34bce1624db0eb4d91ea58a569f2663f8d78b8200365188faf7ae4
|
|
4
|
+
data.tar.gz: 833b1c87a57b2f78a05f42103efef6f9aa2ec09ee0726cad734e0f28f268fc50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 169936c38c680b2f4ec657243909aaad0a902b6bbbc52e9c586d3d986651e1529fdfb2d2a9dcde69f997075b8c901c5178b7148d86f064242ee018a228b2e457
|
|
7
|
+
data.tar.gz: 3e75798e1b41dac7982ca34e83e61e8b28b03b347f6ca60b6e527570a72d4a69a9f5ddf6293197410c6935a699b54a7001b601145c57c7e09302e4f1edf4d426
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
|
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:
|
|
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
|
[](https://semgrep.dev)
|
|
16
16
|
[](https://www.codefactor.io/repository/github/gundestrup/jekyll-client-search)
|
|
17
17
|
[](https://sonarcloud.io/project/overview?id=gundestrup_jekyll-client-search)
|
|
18
|
-
[](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:
|
|
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:
|
|
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:
|
|
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
|
|
650
|
-
(
|
|
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.
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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:
|
|
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:
|
|
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
|
|
@@ -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("
|
|
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("
|
|
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" =>
|
|
96
|
+
"model" => model,
|
|
59
97
|
"base_url" => embedding_base_url,
|
|
60
|
-
"document_prefix" =>
|
|
61
|
-
"query_prefix" =>
|
|
98
|
+
"document_prefix" => document_prefix,
|
|
99
|
+
"query_prefix" => query_prefix,
|
|
62
100
|
"schema" => 2
|
|
63
101
|
}
|
|
64
102
|
end
|
|
65
103
|
|
|
66
|
-
|
|
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
|
-
|
|
108
|
+
config_key = "#{role}_prefix"
|
|
109
|
+
return embedding[config_key].to_s if model == embedding_model && embedding.key?(config_key)
|
|
71
110
|
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
116
|
+
embedding = adapter.embed(embedding_text(document, job[:document_prefix]))
|
|
78
117
|
if embedding
|
|
79
|
-
document[
|
|
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
|
|
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
|
-
"#{
|
|
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,
|
|
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:
|
|
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(
|
|
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
|
data/lib/jekyll/client_search.rb
CHANGED
|
@@ -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.
|
|
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
|