AI Search
Workers AI and Vectorize give you the parts of a search: a model that makes vectors, an index that finds the closest. AI Search is Cloudflare’s whole thing, put together: hand it files (or a website to crawl), and it splits them into passages, embeds them, indexes them, and answers searches and questions from them, citing what it used.
In forge-effect, an instance is a class, and its metadata fields are declared:
import { Schema } from 'effect';
import { AiSearch } from '@forge-effect/infra';
export class Notes extends AiSearch.Instance<Notes>()('notes', { metadata: { product: Schema.String, version: Schema.Int, written: Schema.Date }, // Short notes: smaller chunks find the sentence that answers. chunks: { size: 256 },}) {}This page walks through
example/notes:
notes you can upload, search and ask, each saying which product it’s
about, for which version, and when it was written.
Upload a note
Section titled “Upload a note”PUT /notes/<name> uploads the request’s body as a note, with its
metadata from the query string:
case 'PUT notes': { const version = query.get('version'); const note = yield* notes.upload(rest.join('/'), yield* Effect.promise(() => request.text()), { metadata: { ...(query.has('product') ? { product: query.get('product')! } : {}), ...(version === null ? {} : { version: Number(version) }), written: new Date(yield* Clock.currentTimeMillis), }, // Searchable once this returns. wait: '2 minutes', }); return json({ id: note.id, key: note.key, status: note.status }, 201);}metadatais typed by the declaration:version: '2'(a string) doesn’t compile, nor does a field you didn’t declare.waitholds the upload until the note is indexed, or the time runs out. Indexing takes a while: measured live, 75 to 110 seconds. Leavewaitout, and the upload returns as soon as Cloudflare has the file, with itsstatussayingqueued.
Search, narrowed by metadata
Section titled “Search, narrowed by metadata”GET /search?q=… finds the passages that match, and product or
since narrow it:
case 'GET search': { const since = query.get('since'); const found = yield* notes.search(query.get('q') ?? '', { filter: { ...(query.has('product') ? { product: query.get('product')! } : {}), ...(since === null ? {} : { written: { $gte: new Date(since) } }), }, max: 5, }); return json(found.chunks.map((c) => ({ note: c.item.key, product: c.item.metadata.product, score: Math.round(c.score * 100) / 100, text: c.text })));}The filter is typed too: written takes a Date, and $gte, $lt and
friends; a filter on a field you didn’t declare doesn’t compile.
What’s found comes back the same way: c.item.metadata.written is a
Date, not the text Cloudflare keeps it as.
chat searches, then has a model answer from what it found, and tells
you which passages it used:
const answer = yield* notes.chat(query.get('q') ?? '', { max: 3 });return json({ answer: answer.text, from: answer.chunks.map((c) => c.item.key) });chatStream is the same, as it’s written: a Stream of events, first
the passages (Chunks), then the answer a piece at a time (Text).
That’s the shape a chat UI wants: show the sources, then type out the
answer.
const events = yield* Stream.runCollect(notes.chatStream(question, { max: 3 }));Try it locally
Section titled “Try it locally”AI Search runs only on Cloudflare, so infra dev gives the Worker a
stand-in instead, and says so:
$ pnpm infra dev example/notes/app.tsexample/notes/app.ts, locally (state in .infra/dev, beside it): api http://api.localhost:8787/ and http://127.0.0.1:8787/ goes to api ! api: NOTES is the AI Search instance notes-notes, which runs only on Cloudflare: dev's stand-in searches its items by their words, and answers a chat with the passages it findsThe stand-in indexes at once and searches by words, not meaning. It keeps the metadata, so your filters work as they will on Cloudflare:
$ curl -X PUT --data-binary @kv.md 'http://api.localhost:8787/notes/kv.md?product=kv&version=2'{"id":"e8ad389b-d72f-4b14-92b1-49c0e3b0e91e","key":"kv.md","status":"completed"}$ curl 'http://api.localhost:8787/search?q=when+is+a+write+seen&product=kv'[{"note":"kv.md","product":"kv","score":1,"text":"# Workers KV\n\nKV caches values at the edge. A write takes up to a minute to be seen everywhere."}]$ curl 'http://api.localhost:8787/ask?q=what+is+D1+built+on'{"answer":"# D1\n\nD1 is a SQL database built on SQLite, with a primary and read replicas.\n\n(A local stand-in: these are the passages found, as no model answers here.)","from":["d1.md"]}No model runs on your machine, so a chat answers with the passages it found, and says so.
Test it
Section titled “Test it”AiSearch.memory(Notes) is the same stand-in, in memory, for tests:
const program = Effect.gen(function* () { const notes = yield* Notes; yield* notes.upload('kv.md', kv, { metadata: { product: 'kv', version: 2, written: new Date('2026-10-01') } }); yield* notes.upload('d1.md', d1, { metadata: { product: 'd1', version: 1, written: new Date('2026-09-01') } }); const d1Only = yield* notes.search('database', { filter: { product: 'd1' } }); return d1Only.chunks.map((c) => c.item.key);});assert.deepEqual(await Effect.runPromise(Effect.provide(program, AiSearch.memory(Notes))), ['d1.md']);Crawl a website
Section titled “Crawl a website”Instead of (or as well as) uploads, an instance can crawl a site:
class Site extends AiSearch.Instance<Site>()('site', { metadata: { section: Schema.String }, source: AiSearch.website('https://example.com', { pages: 20, depth: 2 }),}) {}pages is required. On a paid plan, Cloudflare’s crawler has no page
limit of its own, and indexing is billed by what’s indexed, so a crawl
that follows every link could cost you. Live, a crawl of this guide with
pages: 5 took 5 pages, in about 3 minutes. It syncs again every 6
hours, unless sync says otherwise.
Index a bucket
Section titled “Index a bucket”Or point it at an R2 bucket of your app’s, and it indexes the objects in it:
class Files extends R2.Bucket<Files>()('files') {}class Library extends AiSearch.Instance<Library>()('library', { source: AiSearch.bucket(Files, { include: ['/docs/**'], exclude: ['/docs/drafts/**'] }),}) {}
export default Worker.make('api', { fetch }).bind(Library, Files);include and exclude are paths from the bucket’s root. Bind the bucket
in the same Worker as the instance: ship makes the bucket first, then
the instance over it. Write files to the bucket as usual, and the instance
picks them up on its next sync (every 6 hours by default; sync changes
that, and library.sync asks for one now).
AI Search reads a bucket with your account’s AI Search service token.
The dashboard makes and registers it the first time you create an
instance over a bucket there: do that once (you can delete that instance
after, but keep the token). ship finds the token itself, and stops,
saying what to do, if there isn’t one.
The other settings
Section titled “The other settings”embeddingModel, model (the one that answers), chunks, index
(hybrid by default: meaning and words), tokenizer, rewrite,
rerank, results, cache and sync. ship makes the instance with
them, as <app>-notes, and keeps it to them: change one, and the next
ship updates it (and puts it back if the ship fails).
Good to know
Section titled “Good to know”- Metadata goes up as text, as Cloudflare’s upload takes it (it
refuses a JSON number or boolean, measured), and comes back as its
field’s type, decoded. Up to 5 fields, each
Schema.String,Schema.Finite,Schema.Int,Schema.BooleanorSchema.Date. - Removing a note (
notes.remove(id)) takes a moment too: live, a removed note was still found for under a minute. - A delete can be refused (409) while an instance’s status changes, so ship tries again.
- A bucket’s
prefix: Cloudflare’s API takes one, keeps it, and doesn’t filter by it (measured), so infra leaves it out: useinclude. - Not done yet: namespaces, for an instance per tenant made at runtime.
- Workers AI and Vectorize: the parts, for when you want to build search yourself.
- Plan and ship: ship it, and see what a ship does first.