Simon Hearne
LinkedIn
Search

Adding site search to static sites using Zilliz Cloud

A guide to building hybrid site search for a static site with 11ty, GitHub Actions, Cloudflare Workers and Zilliz Cloud. Typeahead, BM25 and vector search on free tiers, with no servers to look after.

Back in 2024 I wrote about adding search to this site with Elasticsearch. It worked well for over two years, but it relied on a shared Elastic Cloud cluster and a public API key baked into the page.

I now work at Zilliz, the company behind Milvus, so I had a good reason to rebuild the search on my site. The plan:

  1. Create a search index at build time with 11ty
  2. Push the index to Milvus after every content change
  3. Serve search from a stateless API
  4. Make it good: typeahead, full-text and semantic matching, deep-linked results to relevant sections
  5. Have ~zero running costs (I am cheap after all!)

The tools I settled on are currently 11ty for indexing, GitHub Actions for syncing, Zilliz Cloud as the search engine and a Cloudflare Worker to host the search API. The site itself still deploys to Netlify exactly as before. Everything runs on free tiers.

With the help of my current best friend, Claude Code, this is the process we built:

Create a search index at build timePermalink to this heading

With my first search implementation I indexed one document per post: title, tags, excerpt and URL. That was enough for simple keyword search, but it throws away the body text which is where most of the useful matches live.

This time each post is split into chunks: one for the intro (excerpt plus everything before the first h2) and one per h2 section. A search hit can then link straight to the top-level section that matched best.

The chunking implementation is a plain Node module so it is easy to unit test. It parses the rendered HTML of each post, drops the noise (heading anchors, embedded videos, charts, scripts) then chunks each clean section into at most 6,000 characters:

/* --- lib/search-index.js (abridged) --- */
const { parse } = require("node-html-parser");

function splitSections(html) {
const root = parse(html || "");
for (const selector of [".heading-anchor", "lite-youtube", ".vega-chart", "script", "style", "svg"]) {
root.querySelectorAll(selector).forEach((el) => el.remove());
}
const sections = [{ anchor: "", section: "", blocks: [] }];
for (const node of root.childNodes) {
if (node.tagName === "H2" && node.getAttribute("id")) {
sections.push({ anchor: node.getAttribute("id"), section: cleanText(node.text), blocks: [] });
continue;
}
const text = cleanText(node.text);
if (text) sections[sections.length - 1].blocks.push(text);
}
return sections;
}

function chunkPost(html, meta) {
const [intro, ...rest] = splitSections(html);
const chunks = [record({ id: `${meta.url}#`, body: [meta.excerpt, ...intro.blocks].join("\n\n") })];
for (const { anchor, section, blocks } of rest) {
packBlocks(blocks, 6000).forEach((body, i) => {
chunks.push(record({ id: `${meta.url}#${anchor}${i ? `~${i}` : ""}`, anchor, section, body }));
});
}
return chunks;
}

Each chunk carries the post metadata (URL, title, excerpt, image, tags, publish date), the text body (plus a hash), a lowercased suggest_norm string for typeahead.

The index itself is just an 11ty page. A nunjucks filter calls chunkPost for every published post and a template writes the result to the output directory:

/* --- .eleventy.js --- */
eleventyConfig.addNunjucksFilter("searchChunks", (posts) => JSON.stringify(
posts.flatMap((post) => chunkPost(post.templateContent, {
url: post.url,
title: post.data.title,
excerpt: post.data.excerpt,
image: post.data.image,
tags: post.data.tags,
date: post.date,
}))
));
{# --- search-chunks.json.njk --- #}
---
permalink: /search-chunks.json
eleventyExcludeFromCollections: true
---
{{ collections.posts | searchChunks | safe }}

Each build now produces _site/search-chunks.json. At the time of publishing this post - there are 332 chunks from 72 posts. The sync script reads it straight from the build output then the Netlify build command deletes it after the tests pass. The only copy that matters for the actual search is the JSON that the GitHub Action builds.

Designing the collectionPermalink to this heading

Milvus is best known as a vector database, but a single collection can hold scalar fields, full-text (BM25) indexes and dense vectors (and more!). That means one collection can serve all three search types I wanted:

  • Typeahead: an NGRAM index on the lowercased suggest_norm field, queried with LIKE. I wrote up why this is the fastest approach previously.
  • Keyword search: two BM25 functions, one over the body text and one over the title (plus section and tags), each produces an indexed sparse vector.
  • Semantic search: one 1,024-d dense vector per chunk using bge-m3.

The schema is declared once in a shared module:

/* --- scripts/search-sync/schema.mjs (abridged) --- */
import { DataType, FunctionType } from "@zilliz/milvus2-sdk-node";

const english = { enable_analyzer: true, analyzer_params: { type: "english" } };

export const fields = [
{ name: "id", data_type: DataType.VarChar, is_primary_key: true, max_length: 1024 },
{ name: "post_url", data_type: DataType.VarChar, max_length: 512 },
{ name: "anchor", data_type: DataType.VarChar, max_length: 256 },
{ name: "section", data_type: DataType.VarChar, max_length: 1024 },
{ name: "title", data_type: DataType.VarChar, max_length: 1024 },
{ name: "excerpt", data_type: DataType.VarChar, max_length: 4096 },
{ name: "image", data_type: DataType.VarChar, max_length: 512 },
{ name: "tags", data_type: DataType.Array, element_type: DataType.VarChar, max_capacity: 32, max_length: 256 },
{ name: "published", data_type: DataType.Int64 },
{ name: "body", data_type: DataType.VarChar, max_length: 65535, ...english },
{ name: "title_text", data_type: DataType.VarChar, max_length: 4096, ...english },
{ name: "body_sparse", data_type: DataType.SparseFloatVector },
{ name: "title_sparse", data_type: DataType.SparseFloatVector },
{ name: "dense", data_type: DataType.FloatVector, dim: 1024 },
{ name: "suggest_norm", data_type: DataType.VarChar, max_length: 2048 },
{ name: "content_hash", data_type: DataType.VarChar, max_length: 64 },
];

export const functions = [
{ name: "body_bm25", type: FunctionType.BM25, input_field_names: ["body"], output_field_names: ["body_sparse"], params: {} },
{ name: "title_bm25", type: FunctionType.BM25, input_field_names: ["title_text"], output_field_names: ["title_sparse"], params: {} },
];

export const indexParams = [
{ field_name: "dense", index_type: "AUTOINDEX", metric_type: "COSINE" },
{ field_name: "body_sparse", index_type: "SPARSE_INVERTED_INDEX", metric_type: "BM25" },
{ field_name: "title_sparse", index_type: "SPARSE_INVERTED_INDEX", metric_type: "BM25" },
{ field_name: "suggest_norm", index_type: "NGRAM", params: { min_gram: 2, max_gram: 3 } },
{ field_name: "published", index_type: "STL_SORT" },
];

The BM25 functions run inside Milvus at insert time, so the sync script only has to send plain text and the dense vector. plus the scalar fields like publish date.

Sync the index to Zilliz CloudPermalink to this heading

My previous search implementation used a curl in the Netlify build command. It was lazy and it worked (the best type of implementation), but it also meant a failed index update could ship with a successful deploy.

This time the sync lives in a GitHub Actions workflow that triggers on pushes to main which touch posts, templates or the sync script itself. It builds the site, runs the tests and then runs one script:

# --- .github/workflows/search-index.yml (abridged) --- #
on:
push:
branches: [main]
paths: ["posts/**", "_includes/**", ".eleventy.js", "lib/search-index.js", "scripts/search-sync/**"]

jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm install --no-audit --no-fund
- uses: actions/cache@v4
with:
path: .cache
key: search-cache-$
restore-keys: search-cache-
- run: npm run build && npm test
- run: node scripts/search-sync/index.mjs
env:
ZILLIZ_URI: $
ZILLIZ_TOKEN: $
CF_ACCOUNT_ID: $
CF_AI_TOKEN: $

The script implements a blue/green deploy of the index with a collection alias. Every run creates a new collection named after the timestamp and commit, inserts to it, checks it, and then points the blog_search alias at it. Production always has a complete index, and if anything fails the alias is untouched and the broken collection is dropped.

Embeddings, cached by content hashPermalink to this heading

Embedding 320 chunks on every push would be slow and wasteful, so the script keeps a JSON cache keyed by each chunk's content_hash. GitHub's cache action restores it between runs. A typical post edit only re-embeds a handful of chunks.

The embeddings come from Cloudflare's Workers AI REST API, calling the same bge-m3 model that the Worker will later use for queries.

/* --- scripts/search-sync/embed.mjs (abridged) --- */
export function workersAiEmbedder({ accountId, token }) {
return async (texts) => {
const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/@cf/baai/bge-m3`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ text: texts }),
});
if (!res.ok) throw new Error(`Workers AI ${res.status}: ${await res.text()}`);
return (await res.json()).result.data;
};
}

Create, fill, check, swapPermalink to this heading

The main script is too long to put here, but the shape is simple:

/* --- scripts/search-sync/index.mjs (abridged) --- */
const client = new MilvusClient({ address: env("ZILLIZ_URI"), token: env("ZILLIZ_TOKEN"), database: "blog" });
const name = collectionName(new Date(), process.env.GITHUB_SHA);

await client.createCollection({ collection_name: name, fields, functions, index_params: indexParams });
for (let i = 0; i < rows.length; i += 100) {
await client.insert({ collection_name: name, data: rows.slice(i, i + 100) });
}
await client.flushSync({ collection_names: [name] });
await client.loadCollectionSync({ collection_name: name });

// Smoke tests: row count, the newest post's title finds itself, typeahead returns something.
// Advisory eval: recall@3 and MRR over a hand-written list of queries.

const previous = await aliasTarget();
if (previous) await client.alterAlias({ alias: "blog_search", collection_name: name });
else await client.createAlias({ alias: "blog_search", collection_name: name });

// Drop every managed collection except the new one and the one we just replaced.

The smoke tests check that the collection has the correct number of chunks, that searching for the newest post's title return that post first, and that typeahead for mi returns results. If any of them fail the alias stays where it was and the workflow goes red for me to fix.

An eval set you can fit in your headPermalink to this heading

As a search engineer I had to include an eval. I built a JSON file of about twenty queries with the URL I expect at the top:

[
{ "q": "cache-control header settings", "expected_url": "/2022/caching-header-best-practices/" },
{ "q": "grafana on a raspberry pi", "expected_url": "/2020/pi-influx-grafana/" },
{ "q": "autocomplete suggestions as you type", "expected_url": "/2026/fast-typeahead-milvus/" },
{ "q": "what to show instead of a loading spinner", "expected_url": "/2020/alternatives-to-spinners/" },
{ "q": "cost of retrieval augmented generation at scale", "expected_url": "/2026/rag-cost-curve/" },
{ "q": "move self-hosted milvus to zilliz cloud", "expected_url": "/2026/migrating-milvus/" },
{ "q": "milvus typeahead", "expected_url": "/2026/fast-typeahead-milvus/" },
{ "q": "vector search", "expected_url": "/2026/visualising-vector-search/" },
...
]

The script scores recall@3 and mean reciprocal rank for each, then writes the results to the GitHub Actions step summary along with any misses. It does not fail the build, it just tells me when I have made search worse. At the time of writing the index scores recall@3 of 0.95 and MRR of 0.89 over 22 queries.

Build the search APIPermalink to this heading

My previous search implementation had the browser call Elasticsearch directly with a public, restricted API key. That works, but it fely wrong to expose a production cluster to internet with no WAF, caching or rate limiting.

For this build I used a Cloudflare Worker routed on simonhearne.com/api/search*. Cloudflare already fronts the domain so the route costs nothing extra, and Netlify deploy previews proxy the same paths to the Worker's workers.dev address so search works on preview builds.

The Worker has three endpoints:

  • GET /api/search/suggest?q= for typeahead
  • GET /api/search?q= for results
  • GET /api/search/tag?q= for every post carrying a tag, newest first

Note: the Milvus Node SDK speaks gRPC, but Workers don't support it so I use the REST API wrapped in a thin client with fetch. Also the Worker's Zilliz token is scoped to a read-only role, so even if it leaked the worst case is someone running searches.

/* --- worker/src/zilliz.js --- */
export function zillizClient({ uri, token, timeoutMs = 2000 }) {
async function call(path, body) {
const res = await fetch(`${uri}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ dbName: "blog", collectionName: "blog_search", ...body }),
signal: AbortSignal.timeout(timeoutMs),
});
const payload = await res.json();
if (payload.code !== 0) throw new Error(`${payload.code}: ${payload.message}`);
return payload.data;
}
return {
query: (body) => call("/v2/vectordb/entities/query", body),
hybridSearch: (body) => call("/v2/vectordb/entities/hybrid_search", body),
};
}

Note that collectionName is the alias, the Worker never needs to know which collection is live.

TypeaheadPermalink to this heading

Suggestions are a scalar query, not a search. The Worker lowercases the input, escapes it for LIKE, and queries for intro chunks whose suggest_norm starts with the prefix or contains it at a word boundary:

/* --- worker/src/suggest.js (abridged) --- */
const rows = await zilliz.query({
filter: `anchor == "" and (suggest_norm like "${p}%" or suggest_norm like "% ${p}%")`,
outputFields: ["id", "title", "tags", "published", "post_url"],
limit: 50,
orderByFields: ["published:desc", "id:asc"],
});
return { suggestions: extractSuggestions(sortRecent(rows), q) };

The NGRAM index makes the LIKE cheap, and orderByFields has Milvus sort server-side so the newest matching posts always come back within the limit, however large the archive grows.

A full search embeds the query with Workers AI and then runs three sub-searches in a single request: dense vector, BM25 over the body and BM25 over the title. Milvus fuses them with reciprocal rank fusion and returns the top chunks. The Worker keeps the best chunk per post, builds a highlighted snippet and returns ten results.

/* --- worker/src/search.js (abridged) --- */
export async function search(q, { zilliz, ai }) {
let vector = null;
try {
vector = (await withTimeout(ai.run("@cf/baai/bge-m3", { text: [q] }), 2000)).data[0];
} catch (err) {
console.error("embed failed, falling back to BM25 only:", err);
}

const hits = await zilliz.hybridSearch({
search: [
...(vector ? [{ data: [vector], annsField: "dense", limit: 30 }] : []),
{ data: [q], annsField: "body_sparse", limit: 30 },
{ data: [q], annsField: "title_sparse", limit: 30 },
],
rerank: { strategy: "rrf", params: { k: 60 } },
outputFields: ["post_url", "anchor", "section", "title", "excerpt", "image", "tags", "published", "body"],
limit: 50,
});

const terms = queryTerms(q);
const results = bestPerPost(hits).slice(0, 10).map((h) => toResult(h, terms));
return vector ? { results } : { results, degraded: "no-dense" };
}

This includes a trick I learned from my performance engineering background: if the embedding call fails or takes longer than two seconds, the search still runs with the two keyword searches. In this case the response is marked degraded so it is not cached. And the three sub-searches ask for 30 candidates each with an overall limit of 50, more than the ten we show, because the top chunks often belong to the same post and we want ten distinct posts after de-duplication. I considered over-engineering a solution with grouping search on post_url, but at this scale fetching 50 rows and keeping the first per post is trivial. Grouping also applies to each sub-search before fusion, so unless you raise group_size, e.g. to 3, the body and title searches can pick different chunks for the same post which would degrade the RRF rankings.

TagsPermalink to this heading

Picking a tag from the suggestions is a different question. Nobody wants "posts that are textually similar to the word WebPerf", they want the posts with that tag, and for a blog the natural order is newest first. My first version just ran the tag through the hybrid search, which returned ten of the 56 WebPerf posts ranked by BM25 quirks, with the occasional untagged post that mentioned the phrase. Not what a tag means.

So a tag is a scalar query, not a search: an exact array_contains filter on the tags field, limited to the intro row of each post, with Milvus ordering by publish date.

/* --- worker/src/tag.js (abridged) --- */
const rows = await zilliz.query({
filter: `anchor == "" and array_contains(tags, "${quoteFilterString(tag)}")`,
outputFields: ["post_url", "title", "excerpt", "image", "tags", "published"],
limit: 500,
orderByFields: ["published:desc", "id:asc"],
});
return { results: rows.map(toResult) };

A simple filtered lookup that comes back in a few tens of milliseconds, and the limit only has to exceed the number of posts. The rows are shaped like search results, with the excerpt standing in for the highlighted snippet, so they can be rendered with the same template as 'true' search results.

Cache and rate limitPermalink to this heading

The Worker wraps every request in the Cloudflare edge cache, keyed on the normalised query (or the exact tag, since array_contains is case-sensitive), for five minutes. Repeat searches never touch Zilliz or Workers AI at all. Cache misses go through a Workers rate limiter of 60 requests per 10 seconds per IP, which is far more than a person types and far less than a bot would like.

Every response also carries a Server-Timing header with the time spent embedding and querying, so I can diagnose performance issues from browser tools without any extra tooling.

Deployment is another small GitHub Actions workflow that runs the Worker's tests and calls wrangler deploy whenever the worker/ directory changes.

Implementing the search clientPermalink to this heading

The search page is still plain HTML with a deferred script, now with a role="combobox" input and a listbox for suggestions. Typing fetches suggestions; a full search only runs on Enter, on picking a suggestion, or on the search button. Picking a post title goes straight to the post; picking a tag calls the tag endpoint instead and lists every post with that tag, newest first. The page reflects whichever ran in the URL as ?q= or ?tag=, so a tag listing is shareable and survives a reload.

/* --- js/search.js (abridged) --- */
async function fetchSuggestions() {
const q = normalise(input.value);
if (q.length < 2) return closeSuggestions();
const res = await fetch(`/api/search/suggest?q=${encodeURIComponent(q)}`, { signal: suggestController.signal });
suggestions = (await res.json()).suggestions;
renderSuggestions(q);
}

async function runSearch(term, { tag = false } = {}) {
const url = tag ? "/api/search/tag" : "/api/search";
const res = await fetch(`${url}?q=${encodeURIComponent(term)}`, { signal: searchController.signal });
const { results } = await res.json();
showResults(results);
}

The result template is much the same as it was in 2024, with one addition: when the best chunk for a post is a section rather than the intro, the result shows the section name and links to its anchor.

const resultTemplate = (r, position) => `
<li class="postlist-item" data-position="
${position}">
<div class="hero-img-container">
<a href="
${escapeHtml(r.url)}" class="no-outline">${imageMap[r.url] ?? ""}</a>
</div>
<div class="meta-container">
<div class="post-title"><a href="
${escapeHtml(r.url)}" class="postlist-link">${r.title}</a></div>
<time class="postlist-date">
${formatDate(r.published)}</time>
${r.anchor ? `<div class="post-section">in §&nbsp;<a href="${escapeHtml(r.url)}#${encodeURIComponent(r.anchor)}">${escapeHtml(r.section)}</a></div>` : ""}
<div class="post-description">
${r.snippet}</div>
</div>
</li>
`
;

The title and snippet are returned already HTML-escaped with <mark> tags around the matched terms, so the front-end doesn't need to do any highlighting. The hero images come from a second build-time JSON file that maps post URLs to the same optimised, responsive <picture> markup the post list uses.

You can try it here.

ProductionisingPermalink to this heading

As before, this is a personal site and I stopped when it worked. If I were building this for a 'real' application I would take a few hardening steps:

  1. a separate database or alias for deploy previews, so unpublished posts can be searched before they go live
  2. query logging in the Worker, and a periodic job that turns popular queries with poor results into new eval cases
  3. a multi-lingual analyser if the content was not all English
  4. a benchmarked decision on chunk size. 6,000 characters was a guess that happens to work for my posts (or tell me if you think otherwise)

What it costsPermalink to this heading

Nothing.

Zilliz Cloud's free tier holds this index with room to spare, Cloudflare Workers and Workers AI have generous free allowances, and the GitHub Actions minutes are a rounding error. The whole build process takes just over a minute per push.

Overall this took me less than the three hours the Elasticsearch version did. But with parts I skipped last time: tests, the blue/green swap and the eval.