Retrieval Experiment Harness

The harness at com.wikantik.search.embedding.experiment scores three candidate embedding models against a page-level ground-truth CSV, so we can pick a winner before committing to a pgvector schema on the production search path. It runs entirely outside the wiki's serving code — no WikiEngine, no SearchManager, no REST wiring — and talks to the running wiki only for BM25 via /api/search.

This page is the operating manual. For the design (why three retrievers, why chunk-level dense with max-score-per-page, why sandbox BYTEA instead of vector(n)) see the file-level javadoc in the experiment package.


1. What gets compared

RetrieverSourceNotes
BM25-onlyGET /api/search?q=… on the running wikiLucene lexical baseline
Dense-onlyCosine similarity over per-chunk vectors, aggregated to pages by max-scoreOne run per candidate model
HybridReciprocal Rank Fusion (k=60) of the two rankings above

Three candidate models (all served by Ollama at inference.jakefear.com:11434):

CodeOllama tagDimensionAsymmetric prefix
nomic-embed-v1.5nomic-embed-text:v1.5768search_query: / search_document:
bge-m3bge-m3:latest1024none
qwen3-embedding-0.6bqwen3-embedding:0.6b1024instruction prompt on queries only

Each run produces eval/report-<model>.txt with overall, per-category, and per-query metrics (recall@5, recall@20, MRR). ExperimentCompare then prints a side-by-side table across all three reports.


2. Prerequisites (first-time setup)

  1. Models pulled on the Ollama host. Check with:

    curl -s http://inference.jakefear.com:11434/api/tags | jq '.models[].name'
    

    All three tags above must be present.

  2. Wiki running locally at http://localhost:8080. /api/health should show engine: UP.

  3. kg_content_chunks populated. On a fresh checkout this table is empty — nothing to embed. Populate it by triggering the async rebuild:

    bin/trigger-rebuild-indexes.sh
    

    This posts to /admin/content/rebuild-indexes with the testbot credentials embedded in the (gitignored) script. Rebuild is async; poll until chunking finishes:

    curl -s -u testbot:<pw> http://localhost:8080/admin/content/index-status | jq
    

    Expect ~1K chunks from ~1K markdown pages after a few minutes.

  4. Sandbox DDL. eval/experiment-embeddings.sql creates the dimension-agnostic experiment_embeddings(chunk_id, model_code, dim, vec) table. The runner applies it idempotently.


3. One-shot run

The full pipeline (DDL → indexer × 3 → evaluator × 3 → compare) is wrapped by bin/run-embedding-experiment.sh:

source <(grep -v '^#' test.properties | sed 's/^test.user.//' | sed 's/=/="/' | sed 's/$/"/')

DB_PASSWORD='<jspwiki db pw>' \
WIKI_USER="${login}" WIKI_PASSWORD="${password}" \
    bin/run-embedding-experiment.sh

Required env: DB_PASSWORD, WIKI_USER, WIKI_PASSWORD.

Optional env:

VarDefaultPurpose
MODELSall three codesSpace-separated subset to test
DB_HOST / DB_NAME / DB_USERlocalhost / jspwiki / jspwiki
WIKI_URLhttp://localhost:8080
OUTPUT_DIRevalWhere reports land
SKIP_DDL=1offSkip the DDL step
SKIP_INDEX=1offSkip indexer (re-score existing embeddings)
MVN_QUIET=1off-q on Maven (cuts chatter)

The indexer fails fast with a clear message if kg_content_chunks is empty — you'll see it immediately rather than after a silent 0-row run.


4. Running pieces individually

Each stage is a main() reachable via mvn exec:java. The runner script is just shorthand for these.

Apply the sandbox DDL

PGPASSWORD='<pw>' psql -h localhost -U jspwiki -d jspwiki \
    -f eval/experiment-embeddings.sql

Indexer (once per model)

mvn -pl wikantik-main -am -q exec:java \
    -Dexec.mainClass=com.wikantik.search.embedding.experiment.ExperimentIndexer \
    -Dexec.args="nomic-embed-v1.5" \
    -Dwikantik.experiment.db.password='<pw>'

Writes embeddings into experiment_embeddings with ON CONFLICT DO NOTHING, so reruns only top up what's missing. Batches of 32 chunks per HTTP call.

Evaluator (once per model)

mvn -pl wikantik-main -am -q exec:java \
    -Dexec.mainClass=com.wikantik.search.embedding.experiment.ExperimentEvaluator \
    -Dexec.args="nomic-embed-v1.5 eval/retrieval-queries.csv eval/report-nomic.txt" \
    -Dwikantik.experiment.db.password='<pw>' \
    -Dwikantik.experiment.wiki.user=testbot \
    -Dwikantik.experiment.wiki.password='<pw>'

Side-by-side comparison

mvn -pl wikantik-main -am -q exec:java \
    -Dexec.mainClass=com.wikantik.search.embedding.experiment.ExperimentCompare \
    -Dexec.args="eval/report-nomic-embed-v1.5.txt eval/report-bge-m3.txt eval/report-qwen3-embedding-0.6b.txt"

5. Output

Per-model report (eval/report-<model>.txt):

Retrieval evaluation — model: <model>  dim=<n>
Date: 2026-04-18T…
Queries: 40

Overall:
  retriever  recall@5  recall@20  MRR
  bm25         0.550     0.800   0.519
  dense        <model-dependent>
  hybrid       <model-dependent>

Per-category:
  <7 categories × 3 retrievers>

Per-query (rank of ideal_page; 0 = miss):
  <40 rows>

BM25 baseline is fixed at recall@5=0.550, recall@20=0.800, MRR=0.519 (40 queries, 7 categories) and does not move with the embedding model — Lucene indexes the chunk table, not any vector store.

ExperimentCompare consolidates overall lines across model reports:

model                        retriever  recall@5  recall@20  MRR
nomic-embed-v1.5             bm25         0.550     0.800    0.519
nomic-embed-v1.5             dense        0.625     0.800    0.474
nomic-embed-v1.5             hybrid       0.650     0.900    0.530
bge-m3                       bm25         0.550     0.800    0.519
bge-m3                       dense        0.700     0.875    0.503
bge-m3                       hybrid       0.750     0.900    0.615
qwen3-embedding-0.6b         bm25         0.550     0.800    0.519
qwen3-embedding-0.6b         dense        0.750     0.900    0.490
qwen3-embedding-0.6b         hybrid       0.750     0.925    0.602

Exact numbers from the 2026-04-18 run — the decision-making run documented in Section 7 below.


PathPurpose
bin/trigger-rebuild-indexes.shPopulate kg_content_chunks (gitignored — embeds testbot creds)
bin/run-embedding-experiment.shEnd-to-end driver
eval/experiment-embeddings.sqlSandbox DDL (not a migration)
eval/retrieval-queries.csv40-query, 7-category ground truth
wikantik-main/src/main/java/com/wikantik/search/embedding/experiment/ExperimentIndexer, ExperimentEvaluator, ExperimentCompare, ExperimentAggSweep, ExperimentRrfSweep, ExperimentFinalSweep, ExperimentGrandFinale, ExperimentHarness, Bm25Client, ExperimentDb, QueryCorpus, ReciprocalRankFusion, CosineSimilarity, VectorCodec
wikantik-main/src/main/java/com/wikantik/search/embedding/Production-side client + config (now enabled=true by default; feature-flag remains as the kill switch)

The experiment code stays in place after each decision so regression runs stay one Maven command away.


7. Model selection — the 2026-04-18 decision

This is the run that picked qwen3-embedding-0.6b as the production embedding model. All three candidates indexed the same ~30k-chunk corpus, same BM25 baseline, same 40-query / 7-category ground truth, same max-score page aggregation at that point.

Raw results

Modeldimbm25 r@5dense r@5dense r@20dense MRRhybrid r@5hybrid r@20hybrid MRR
nomic-embed-v1.57680.5500.6250.8000.4740.6500.9000.530
bge-m310240.5500.7000.8750.5030.7500.9000.615
qwen3-embedding-0.6b10240.5500.7500.9000.4900.7500.9250.602

Reports on disk: eval/report-nomic-embed-v1.5.txt, eval/report-bge-m3.txt, eval/report-qwen3-embedding-0.6b.txt.

Decision rationale

Per-category highlights

Where qwen3's dense recall pulled away:

Aggregation sweep — ExperimentAggSweep

With qwen3 locked in, the next question was which chunk → page aggregation to use. ExperimentAggSweep produced eval/agg-sweep-qwen3-embedding-0.6b.txt:

aggregationdense r@5dense r@20dense MRR
MAX0.7500.9000.490
MEAN_TOP_30.7500.9500.576
MEAN_TOP_50.7500.9000.589
SUM_TOP_30.8000.9750.602
SUM_TOP_50.7750.9250.612
MEAN_TOP_3_LOG_NORM0.3500.8500.175

SUM_TOP_3 dominates. MEAN_TOP_3_LOG_NORM is the sanity-check negative result — log-normalising chunk scores before summing kills signal.

Joint sweep — ExperimentFinalSweep and ExperimentGrandFinale

The final sweeps fan every aggregation across every fusion strategy (dense-only, RRF with three weighting variants, plain score averaging with dense-heavy / bm25-heavy / equal weights) to confirm the winner survives hyperparameter interaction.

Best combination per model (eval/grand-finale.txt):

ModelBest aggregationBest fusionr@5r@20MRR
nomic-embed-v1.5SUM_TOP_5RRF_RECALL0.7500.9250.558
bge-m3MEAN_TOP_3SCORE_DENSE_HEAVY0.7750.9000.622
qwen3-embedding-0.6bSUM_TOP_3dense-only0.8000.9750.602

qwen3 + SUM_TOP_3 + dense-only wins r@5 and r@20 outright. bge-m3's best-case MRR (0.622) edges qwen3's (0.602), but qwen3 comes within 0.020 at r@5=0.800 vs bge-m3's 0.775. The decision stood: qwen3 with SUM_TOP_3 aggregation.

Production defaults picked from this data

wikantik-main/.../search/hybrid/HybridConfig.java:

DEFAULT_PAGE_AGGREGATION = PageAggregation.SUM_TOP_3;
DEFAULT_RRF_K            = 60;
DEFAULT_BM25_WEIGHT      = 1.0;
DEFAULT_DENSE_WEIGHT     = 1.5;   // dense-heavy, matches SCORE_DENSE_HEAVY
DEFAULT_RRF_TRUNCATE     = 20;
DEFAULT_DENSE_CHUNK_TOP  = 500;
DEFAULT_DENSE_PAGE_TOP   = 100;

Dense is weighted 1.5× vs BM25 in the RRF fusion because the grand finale shows dense-leaning hybrids consistently within 0.05 of the top recall while beating BM25-heavy variants on MRR. RRF k=60 is the standard Cormack/Clarke/Büttcher default carried from the literature — the sweep confirmed no nearby value beat it on our corpus by enough to justify a non-conventional choice.


8. Chunker improvement results (2026-04-19)

Two targeted changes to the chunking + embedding pipeline landed together and were evaluated against the frozen qwen3-embedding-0.6b baseline. Both are structural — no retriever, fusion weights, or query-side logic changed.

What changed

  1. Atomic list chunking. ContentChunker.isAtomic(Node) now treats BulletList and OrderedList as indivisible up to maxTokens × 4 (≈ 2048 tokens). Previously, Flexmark emitted each list item as a separate block and the merge pass sometimes split related items across chunks. Lists of command flags, step-by-step instructions, and config options now live in one chunk with their siblings.
  2. Heading path prepended at embed time. A new EmbeddingTextBuilder renders "<Top> > <Mid> > <Leaf>\n\n<body>" and is the single rendering point for both EmbeddingIndexService (production) and ExperimentIndexer (sandbox). The stored chunk text in kg_content_chunks.text stays body-only; the heading-path-aware string only exists on the wire to the embedder. Chunk identity (content_hash = sha256(heading_path + text)) is unchanged.

Corpus impact

BeforeAfter
kg_content_chunks rows23,65639,264
Avg tokens / chunk~230103

More, smaller chunks — atomic lists stop the merge pass from gluing unrelated blocks together, so prose paragraphs are no longer inflated by adjacent list content.

Retrieval metrics (40 queries, 7 categories)

Overall:

retrieverrecall@5recall@20MRR
bm250.550 → 0.775 (+0.225)0.800 → 0.975 (+0.175)0.519 → 0.650 (+0.131)
dense0.750 → 0.750 (+0.000)0.900 → 0.950 (+0.050)0.490 → 0.627 (+0.137)
hybrid0.750 → 0.850 (+0.100)0.925 → 0.975 (+0.050)0.602 → 0.667 (+0.065)

Categories that moved the most:

Why the gains break down this way

What this means for overlap

Overlap (replaying the last N tokens of chunk i as the first N tokens of chunk i+1) was the obvious next lever — until these two changes absorbed most of what overlap was meant to fix:

Recall@20 hybrid is 0.975. There are 39 of 40 queries recovered; the miss budget for overlap to improve against is one query. If overlap is worth revisiting, the signal will show up in dense recall@5 (stuck at 0.750), not in the hybrid overall.

Reports on disk

PathWhat
eval/report-qwen3-embedding-0.6b-baseline-prechunk.txtBefore, 2026-04-18
eval/report-qwen3-embedding-0.6b-2026-04-19T20-01-22-331504860Z.txtAfter, 2026-04-19

Reproduce with:

mvn -pl wikantik-main exec:java \
    -Dexec.mainClass=com.wikantik.search.embedding.experiment.ExperimentCompare \
    -Dexec.args="<before.txt> <after.txt>"

9. Chunker rebuild — merge-forward floor raised (2026-04-23)

During the Phase 2 entity-extractor benchmark work it became clear that chunk count, not chunk size, was the bottleneck on full-corpus batch extraction: at ~39k chunks the projected extractor wall-clock was ~95 h on the shipping model. Inspection of ContentChunker.java revealed two advertised config keys (target_tokens, min_tokens) that were never referenced in the class — dead knobs — and one lever, merge_forward_tokens, that actually controls chunk consolidation.

What changed

Corpus impact

BeforeAfter
kg_content_chunks rows39,26423,256 (−41%)
content_chunk_embeddings rows39,26423,256
Mean tokens / chunk103174 (+69%)
p50 tokens / chunk77166
p95 tokens / chunk261335
Max tokens / chunk1,9631,963 (atomic blocks unchanged)
Embedding re-index wall-clock (qwen3-embedding-0.6b)6m 18s

Retrieval quality — live search top-10 diff

The retrieval harness was not re-run against the new chunks (the extractor benchmark was the day's priority and the harness requires a full corpus re-embed loop plus the 40-query evaluation pass). Instead, spot-check comparison on live /api/search against two high-traffic queries, with graph rerank disabled (mentions=0 after the rebuild cascaded them all):

"knowledge graph" — top 10:

RankOld 39k chunksNew 23k chunks
1WikantikKnowledgeGraphAdminInventionOfKnowledgeGraph
2InventionOfKnowledgeGraphWikantikKnowledgeGraphAdmin
3KnowledgeGraphCoreKnowledgeGraphCore
4KnowledgeGraphDogfoodingKnowledgeGraphVsRelationalDatabase
5KnowledgeGraphVsRelationalDatabaseGraphRAG
6GraphRAGKnowledgeGraphsAndManagement
7IndustrialKnowledgeGraphUseCasesIndustrialKnowledgeGraphUseCases
8KnowledgeGraphsAndManagementFederatedKnowledgeGraphs
9KnowledgeGraphCompletionKnowledgeGraphCompletion
10FederatedKnowledgeGraphsKnowledgeGraphConstructionPipeline

Set overlap: 8/10. Dropped: KnowledgeGraphDogfooding. Added: KnowledgeGraphConstructionPipeline. Top-3 set preserved, positions 1–2 swapped (both highly relevant).

"GraphRAG" — top 10 essentially identical, set overlap 8/10 with one substitution at position 8 (AiFunctionCallingAndToolUseAiMemoryAndPersistence).

Why it's not a regression

If we want the harness number

Re-running the harness against the new chunks takes:

bin/trigger-rebuild-indexes.sh          # already done — 23k chunks live
# Drop and recreate the sandbox embeddings (qwen3's prior vectors are
# still keyed by the old chunk ids which were cascaded away by V011):
PGPASSWORD='…' psql -h localhost -U jspwiki -d jspwiki -c "DELETE FROM experiment_embeddings WHERE model_code='qwen3-embedding-0.6b'"
# Re-index + evaluate:
mvn -pl wikantik-main exec:java \
    -Dexec.mainClass=com.wikantik.search.embedding.experiment.ExperimentIndexer \
    -Dexec.args="qwen3-embedding-0.6b" \
    -Dwikantik.experiment.db.password='…'
mvn -pl wikantik-main exec:java \
    -Dexec.mainClass=com.wikantik.search.embedding.experiment.ExperimentEvaluator \
    -Dexec.args="qwen3-embedding-0.6b eval/retrieval-queries.csv \
                 eval/report-qwen3-embedding-0.6b-2026-04-23-postmerge.txt" \
    -Dwikantik.experiment.db.password='…' \
    -Dwikantik.experiment.wiki.user=testbot \
    -Dwikantik.experiment.wiki.password='…'

Pending: not blocking extraction work, but the right next retrieval-side regression run to close the loop.


10. Evolution — from scratch to production

Condensed git-log narrative for anyone who needs to understand how each piece got here.

DateCommitWhat
2026-04-141da3a7dceInitial Chunk record and minimal ContentChunker
2026-04-1464a8de3ffHeading-aware splitting with heading_path
2026-04-14185dd80ccToken budget, atomic blocks, merge-forward
2026-04-14f141524feExplicit mergeForwardTokens Config field
2026-04-149b411eeeaContentChunkRepository with diff apply + stats
2026-04-15c386f21b0Save-time ChunkProjector page filter
2026-04-15632737f58Prometheus metrics for chunker and rebuild
2026-04-15e8966adbAsync page-save listener for incremental embedding reindex
2026-04-16a9a199041Stub TextEmbeddingClient + EmbeddingKind for Phase 1
2026-04-1646ad1441eCaffeine dep + TextEmbeddingClient stub for Phase 4
2026-04-16a9f2ae876EmbeddingIndexService — production chunk-embedding data layer
2026-04-17c0dabd53fDenseRetriever + placeholder ChunkVectorIndex
2026-04-17c169604ceHybridFuser for weighted RRF of BM25 and dense lists
2026-04-176b6527572PageAggregation + PageAggregator
2026-04-178717e533fQueryEmbedderConfig, CircuitState, metrics snapshot
2026-04-174e3fe9921Hand-rolled CLOSED/OPEN/HALF_OPEN circuit breaker
2026-04-174156d7a1fQueryEmbedder wraps embedding client with cache + timeout + breaker
2026-04-17e9bf62d1eInMemoryChunkVectorIndex for dense top-k
2026-04-170825bb97bOllama embedding client and model registry
2026-04-185211ea391Retrieval experiment harness + first model-comparison reports
2026-04-18373a024a2HybridConfig with defaults matching the winning experiment
2026-04-18376cdb877Phase 3: hybrid retrieval core (PageAggregation, HybridFuser, DenseRetriever)
2026-04-19b6a86fba7Hybrid perf pass: parallelized embedding, incremental index, heading-aware context
2026-04-19c4447350dRelease v1.1.6: hybrid retrieval, MCP access hardening, admin content ops
2026-04-232acccf102KG-RAG Phase 1-3: unified embeddings, extractor pipeline, graph-aware rerank + chunker merge-forward 8 → 150
2026-04-23c289fbdd7Standalone extract-CLI for Tomcat-less batch runs

The two inflection points: