My blog had topic filters, but no search box.

That was fine when it had ten posts. At 28 published posts, it was no longer fine. A reader should be able to search for a detail inside an article, even when the title uses different words.

I built two versions:

  1. A small full-text search on /blog.
  2. A semantic search mode powered by Ternlight.

The split matters. The useful default and the interesting experiment are not the same feature.

What the normal search does

The blog page now fetches a 134 KB search index only after the reader focuses the search box or starts typing. The index contains the title, summary, tags, and full article text for each published post.

The matcher is plain browser JavaScript. It normalizes the query, splits it into terms, and checks every term against the full document. Topic filters and text search work together.

There is no search service, account, analytics request, or new runtime dependency on the main page.

I tested it with a phrase buried inside an article: trash survived refresh. It returned the browser todo post that contains that detail.

That is the boring version. It is also the version most readers need.

The semantic question

Keyword search fails when the query and the article use different words.

For example, a reader might ask for the cached result that made a speed score look better. The relevant article talks about Lighthouse variance and an unchanged rendering path. It does not use that exact query.

Ternlight runs an embedding model inside WebAssembly. It needs no API call and returns a 384-number vector for each text. Similar meanings should produce nearby vectors.

Its published packages have two sizes:

ModelGzipped modelPublished median per embeddingMax input
Mini5.0 MB2.5 msabout 95 words
Base7.2 MB5.1 msabout 95 words

The starting point was the GattyWorks Ternlight search. Its later full-body evaluation found only a small aggregate change across 21 posts.

I reused its 70-word chunk design, but I did not reuse its conclusion. This blog has different articles and a different query set. The result had to earn its way into this site again.

The input limit forced an important design choice. I could not embed each full article as one document. I split the 28 published posts into 352 chunks of 70 words.

I wrote a small evaluation first

I made 20 queries from details found inside article bodies. I avoided direct title copies. Each query had one expected post.

I tested four combinations on the same machine:

Model and corpusTop 1Top 3Top 5MRRIndex timeMedian query
Mini, title and summary40%70%75%0.560about 0.7 s3 to 4 ms
Mini, full body75%85%90%0.816about 12 s3 to 4 ms
Base, title and summary55%65%85%0.647about 1.3 s6 to 11 ms
Base, full body85%85%95%0.88121 to 23 s7 to 11 ms

Top 1 means the expected article ranked first. MRR rewards the correct article more when it appears near the top.

The biggest gain did not come from the larger model. It came from indexing the article body.

Mini improved by 35 percentage points at Top 1 when I changed the corpus from summaries to full text. Base improved by 30 points. Base then added another 10 points over Mini on the full corpus.

This is the first lesson: retrieval quality depends on the material you index before it depends on model size.

Why Base lives behind a switch

The production build tells the other half of the story.

The Base WebAssembly file is 10.2 MB before compression and about 7.2 MB over the wire. Semantic mode also needs the corpus used to build local vectors. My first browser build of all 352 vectors took 24.1 seconds.

That cost is unacceptable for a normal blog listing.

The blog loads the model only after a reader turns on the Semantic switch. A cancellable dialog shows the download and indexing progress. It then stores the vectors in IndexedDB. On my repeat test, all 352 cached vectors were ready in about two seconds. Sample browser queries took 10 to 80 ms and ranked the Lighthouse article first.

The main /blog route never downloads the model.

This is the second lesson: lazy loading is not enough when the lazy asset is seven megabytes. The user should also cross an explicit intent boundary.

The result

I kept both search modes.

The search box on /blog is the product feature. It is fast, direct, and good for exact facts.

Semantic mode is still a lab. It handles paraphrases better and keeps every query on the device. It also makes its model size, build time, and cache state visible.

I would not replace the keyword search with embeddings. I would not remove the semantic version either.

One helps readers now. The other gives me a measured place to improve retrieval without making every reader pay for the experiment.

What I will test next

  • Compress the corpus before embedding it.
  • Test sentence-aware chunks against fixed 70-word chunks.
  • Add more hard queries before changing models.
  • Measure low-end mobile build time.
  • Prebuild vectors only if the download becomes smaller than local generation.

Search looked like one input box. The real work was deciding which costs belong on that page.