# 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.

Author: Simon Hearne (https://simonhearne.com/about/)
Published: 2026-11-04
Canonical: https://simonhearne.com/2026/static-site-search-zilliz/
Tags: search, Milvus, Zilliz, netlify, cloudflare

---
Back in 2024 I wrote about [adding search to this site with Elasticsearch](/2024/netlify-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](https://zilliz.com/), the company behind [Milvus](https://milvus.io/), 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](https://www.11ty.dev/)
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 time

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:

```js
/* --- 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:

```js
/* --- .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,
  }))
));
```

```njk
{# --- 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 collection

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](/2026/fast-typeahead-milvus/) 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](https://developers.cloudflare.com/workers-ai/models/bge-m3/).

The schema is declared once in a shared module:

```js
/* --- 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 Cloud

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:

```yaml
# --- .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-${{ github.run_id }}
          restore-keys: search-cache-
      - run: npm run build && npm test
      - run: node scripts/search-sync/index.mjs
        env:
          ZILLIZ_URI: ${{ secrets.ZILLIZ_URI }}
          ZILLIZ_TOKEN: ${{ secrets.ZILLIZ_TOKEN }}
          CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
          CF_AI_TOKEN: ${{ secrets.CF_AI_TOKEN }}
```

The script implements a blue/green deploy of the index with a [collection alias](https://milvus.io/docs/manage-aliases.md). 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 hash

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](https://developers.cloudflare.com/workers-ai/) REST API, calling the same `bge-m3` model that the Worker will later use for queries.

```js
/* --- 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, swap

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

```js
/* --- 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 head

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:

```json
[
  { "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 API

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](https://docs.zilliz.com/reference/restful/data-plane-v2) wrapped in a thin client with `fetch`. Also the Worker's Zilliz token is scoped to a [read-only role](https://docs.zilliz.com/docs/cluster-users), so even if it leaked the worst case is someone running searches.

```js
/* --- 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.

### Typeahead

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:

```js
/* --- 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.

### Hybrid search

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](https://milvus.io/docs/rrf-ranker.md) and returns the top chunks. The Worker keeps the best chunk per post, builds a highlighted snippet and returns ten results.

```js
/* --- 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](https://milvus.io/docs/grouping-search.md) 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.

### Tags

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.

```js
/* --- 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 limit

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](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) 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 client

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
/* --- 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.

```js
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](/search/).

## Productionising

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](https://milvus.io/docs/multi-language-analyzers.md) 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 costs

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.

