Skip to content

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:

example/notes/notes.ts
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.

PUT /notes/<name> uploads the request’s body as a note, with its metadata from the query string:

example/notes/api.ts
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);
}
  • metadata is typed by the declaration: version: '2' (a string) doesn’t compile, nor does a field you didn’t declare.
  • wait holds the upload until the note is indexed, or the time runs out. Indexing takes a while: measured live, 75 to 110 seconds. Leave wait out, and the upload returns as soon as Cloudflare has the file, with its status saying queued.

GET /search?q=… finds the passages that match, and product or since narrow it:

example/notes/api.ts
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:

example/notes/api.ts
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 }));

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.ts
example/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 finds

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

AiSearch.memory(Notes) is the same stand-in, in memory, for tests:

test/ai-search.test.ts
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']);

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.

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.

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

  • 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.Boolean or Schema.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: use include.
  • Not done yet: namespaces, for an instance per tenant made at runtime.