| 1 | --- |
| 2 | title: Add AI Search (AutoRAG) |
| 3 | description: Stand up a Cloudflare AI Search (AutoRAG) pipeline over an R2 bucket with one Search call, bind it into your Worker as a typed Effect client, and answer questions over your own documents. |
| 4 | sidebar: |
| 5 | order: 7 |
| 6 | --- |
| 7 | |
| 8 | Cloudflare **AI Search** indexes the documents in an |
| 9 | R2 bucket and answers questions over them with retrieval-augmented |
| 10 | generation — chunking, embedding, vector search, reranking, and |
| 11 | generation, all managed by Cloudflare. |
| 12 | |
| 13 | A working pipeline needs more than one resource: the instance itself, |
| 14 | plus a scoped API token so the indexer can read your R2 bucket. The |
| 15 | `Cloudflare.AI.Search` helper wires all of that up in a single call. |
| 16 | We'll use it here and unpack exactly what it creates. |
| 17 | |
| 18 | ## Create the R2 bucket |
| 19 | |
| 20 | AI Search indexes objects from an R2 bucket, so that's the source. |
| 21 | Create `src/Docs.ts` with a bucket definition. |
| 22 | |
| 23 | ```typescript |
| 24 | // src/Docs.ts |
| 25 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 26 | |
| 27 | export const Docs = Cloudflare.R2.Bucket("Docs", {}); |
| 28 | ``` |
| 29 | |
| 30 | This is the bucket you'll drop Markdown, PDFs, or text files into — |
| 31 | AI Search picks them up on its next sync. |
| 32 | |
| 33 | ## Declare the AI Search pipeline |
| 34 | |
| 35 | Add a `Search` construct and pass the `Docs` bucket as `source`. That |
| 36 | field selects R2 as the data source. |
| 37 | |
| 38 | ```typescript |
| 39 | // src/Search.ts |
| 40 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 41 | import { Docs } from "./Docs.ts"; |
| 42 | |
| 43 | export const Search = Cloudflare.AI.Search("Search", { |
| 44 | source: Docs, |
| 45 | }); |
| 46 | ``` |
| 47 | |
| 48 | `Search` is a **construct**, not a single resource. Calling it expands |
| 49 | into several resources so you don't have to wire them up by hand. |
| 50 | |
| 51 | :::note[Crawling a website instead] |
| 52 | Passing a URL as `source` (instead of a bucket) indexes a website by |
| 53 | crawling it, with optional `parse` / `crawl` / `store` options and no |
| 54 | service token. This tutorial focuses on the R2 source — see the |
| 55 | [`Search` reference](/providers/cloudflare/ai/search) for the |
| 56 | web-crawler options. |
| 57 | ::: |
| 58 | |
| 59 | ## What the helper creates |
| 60 | |
| 61 | For an R2 source, the indexer needs a service token to read your bucket. |
| 62 | Cloudflare only creates that token for you through the dashboard or |
| 63 | Wrangler — never on a programmatic API create — so `Search` provisions |
| 64 | it as stable children of the construct: |
| 65 | |
| 66 | - a least-privilege `Cloudflare.ApiToken.AccountApiToken` scoped to the |
| 67 | **AI Search Index Engine** permission group, |
| 68 | - a `Cloudflare.AI.SearchToken` that wraps that API token into the |
| 69 | service-token shape AI Search expects, |
| 70 | - the `Cloudflare.AI.SearchInstance` itself, with the service token's |
| 71 | id wired into its `tokenId`. |
| 72 | |
| 73 | So the single `Search("Search", …)` call above is shorthand for |
| 74 | roughly this: |
| 75 | |
| 76 | ```typescript |
| 77 | const apiToken = yield* Cloudflare.ApiToken.AccountApiToken("Token", { |
| 78 | policies: [ |
| 79 | { |
| 80 | effect: "allow", |
| 81 | permissionGroups: ["AI Search Index Engine"], |
| 82 | resources: { [`com.cloudflare.api.account.${accountId}`]: "*" }, |
| 83 | }, |
| 84 | ], |
| 85 | }); |
| 86 | const serviceToken = yield* Cloudflare.AI.SearchToken("Token", { |
| 87 | cfApiId: apiToken.tokenId, |
| 88 | cfApiKey: apiToken.value, |
| 89 | }); |
| 90 | const instance = yield* Cloudflare.AI.SearchInstance("Instance", { |
| 91 | source: Docs.bucketName, |
| 92 | tokenId: serviceToken.id, |
| 93 | }); |
| 94 | ``` |
| 95 | |
| 96 | `Search` translates the `source` bucket into the instance's `type: "r2"` + |
| 97 | `source: bucket.bucketName` for you. The value it returns **is** the |
| 98 | `SearchInstance` (with the managed `serviceToken` attached), so you can pass |
| 99 | it straight to anything that expects a `SearchInstance` — no destructuring |
| 100 | needed. |
| 101 | |
| 102 | ## Index only part of the bucket |
| 103 | |
| 104 | By default AI Search indexes every object in the bucket. Pass `prefix` to |
| 105 | scope indexing to one key prefix, and `include` / `exclude` glob patterns |
| 106 | for finer control. |
| 107 | |
| 108 | ```diff lang="typescript" |
| 109 | export const Search = Cloudflare.AI.Search("Search", { |
| 110 | source: Docs, |
| 111 | + prefix: "docs/", |
| 112 | + include: ["/docs/**"], |
| 113 | + exclude: ["/docs/drafts/**"], |
| 114 | }); |
| 115 | ``` |
| 116 | |
| 117 | `prefix` limits indexing to keys under `docs/`. `include` / `exclude` are |
| 118 | [micromatch](https://github.com/micromatch/micromatch) glob patterns (`*` |
| 119 | matches within a path segment, `**` across segments; up to 10 each) — only |
| 120 | objects matching an `include` pattern are indexed, and `exclude` wins over |
| 121 | `include`. |
| 122 | |
| 123 | ## Reuse an existing token |
| 124 | |
| 125 | If your account already has a registered AI Search service token, pass |
| 126 | its `tokenId` and `Search` skips minting the `AccountApiToken` + |
| 127 | `SearchToken` children, wiring your token into the instance instead. |
| 128 | |
| 129 | ```typescript |
| 130 | export const Search = Cloudflare.AI.Search("Search", { |
| 131 | source: Docs, |
| 132 | tokenId: existingToken.id, |
| 133 | }); |
| 134 | ``` |
| 135 | |
| 136 | Otherwise the default — letting the construct provision a least-privilege |
| 137 | token that lives in your stack's state and is torn down on `destroy` — is |
| 138 | the simplest path. |
| 139 | |
| 140 | ## Group pipelines under a namespace |
| 141 | |
| 142 | Pass a `SearchNamespace` resource as `namespace` to group related |
| 143 | pipelines under it instead of the account-provided `default` namespace. |
| 144 | |
| 145 | ```typescript |
| 146 | const Knowledge = Cloudflare.AI.SearchNamespace("Knowledge", {}); |
| 147 | |
| 148 | export const Search = Cloudflare.AI.Search("Search", { |
| 149 | source: Docs, |
| 150 | namespace: Knowledge, |
| 151 | }); |
| 152 | ``` |
| 153 | |
| 154 | Passing the namespace resource (not its name) lets the engine order the |
| 155 | pipeline after the namespace on deploy and tear it down before the |
| 156 | namespace on destroy. The namespace is immutable — changing it replaces |
| 157 | the pipeline. |
| 158 | |
| 159 | ## Add both to the stack |
| 160 | |
| 161 | ```diff lang="typescript" |
| 162 | // alchemy.run.ts |
| 163 | +import { Docs } from "./src/Docs.ts"; |
| 164 | +import { Search } from "./src/Search.ts"; |
| 165 | import Api from "./src/Api.ts"; |
| 166 | |
| 167 | export default Alchemy.Stack( |
| 168 | "CloudflareWorkerExample", |
| 169 | { providers: Cloudflare.providers(), state: Cloudflare.state() }, |
| 170 | Effect.gen(function* () { |
| 171 | const api = yield* Api; |
| 172 | + const docs = yield* Docs; |
| 173 | + const search = yield* Search; |
| 174 | |
| 175 | return { |
| 176 | url: api.url.as<string>(), |
| 177 | + bucket: docs.bucketName, |
| 178 | + search: search.instanceId, |
| 179 | }; |
| 180 | }), |
| 181 | ); |
| 182 | ``` |
| 183 | |
| 184 | `yield* Search` registers the construct — the bucket, both tokens, and |
| 185 | the instance all get created on the next deploy, in dependency order. |
| 186 | The value is the `SearchInstance` itself, so `search.instanceId` reads |
| 187 | straight off it. |
| 188 | |
| 189 | ## Bind the instance into the Worker |
| 190 | |
| 191 | `Cloudflare.AI.QuerySearch(search)` attaches the |
| 192 | single-instance `ai_search` binding and returns a typed Effect client. |
| 193 | Call it during the Worker's Construction phase. |
| 194 | |
| 195 | ```diff lang="typescript" |
| 196 | // src/Api.ts |
| 197 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 198 | import * as Effect from "effect/Effect"; |
| 199 | +import { Search } from "./Search.ts"; |
| 200 | |
| 201 | export default class Api extends Cloudflare.Worker<Api>()( |
| 202 | "Api", |
| 203 | { main: import.meta.url }, |
| 204 | Effect.gen(function* () { |
| 205 | + const aiSearch = yield* Search; |
| 206 | + const search = yield* Cloudflare.AI.QuerySearch(aiSearch); |
| 207 | |
| 208 | return { |
| 209 | fetch: Effect.gen(function* () { |
| 210 | // …existing routes |
| 211 | }), |
| 212 | }; |
| 213 | - }), |
| 214 | + }).pipe(Effect.provide(Cloudflare.AI.QuerySearchBinding)), |
| 215 | ) {} |
| 216 | ``` |
| 217 | |
| 218 | `Cloudflare.AI.QuerySearchBinding` is the runtime side of the |
| 219 | binding. Provide it once at the bottom of the constructor's layer chain and the |
| 220 | `QuerySearch(...)` above resolves to a live `SearchInstance` handle at runtime. |
| 221 | |
| 222 | ## Answer questions on `/ask` |
| 223 | |
| 224 | `search.chatCompletions({ messages })` runs the full RAG pipeline — |
| 225 | retrieve the relevant chunks, then answer with the configured generation |
| 226 | model. It returns an Effect, so call it like any other. |
| 227 | |
| 228 | ```diff lang="typescript" |
| 229 | +import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 230 | +import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 231 | |
| 232 | return { |
| 233 | fetch: Effect.gen(function* () { |
| 234 | const request = yield* HttpServerRequest; |
| 235 | + const url = new URL(request.url, "http://api"); |
| 236 | |
| 237 | + if (url.pathname === "/ask") { |
| 238 | + const query = url.searchParams.get("q") ?? "What is this about?"; |
| 239 | + const answer = yield* search |
| 240 | + .chatCompletions({ messages: [{ role: "user", content: query }] }) |
| 241 | + .pipe(Effect.orDie); |
| 242 | + return yield* HttpServerResponse.json({ |
| 243 | + response: answer.choices[0]?.message.content, |
| 244 | + sources: answer.chunks.map((c) => c.item.key), |
| 245 | + }); |
| 246 | + } |
| 247 | |
| 248 | return HttpServerResponse.text("Not Found", { status: 404 }); |
| 249 | }), |
| 250 | }; |
| 251 | ``` |
| 252 | |
| 253 | `answer.choices[0].message.content` is the generated text; `answer.chunks` |
| 254 | are the source chunks it drew from, so we surface their item keys as |
| 255 | citations. `Effect.orDie` turns an `AiSearchError` into a 500 — swap in |
| 256 | `Effect.catchTag("AiSearchError", …)` if you want typed handling. |
| 257 | |
| 258 | ## Retrieve chunks without generation on `/search` |
| 259 | |
| 260 | When you only want the matching chunks (e.g. to render your own UI or |
| 261 | feed another model), use `search.search({ query })` — same retrieval, |
| 262 | no generation step, so it's cheaper and faster. |
| 263 | |
| 264 | ```diff lang="typescript" |
| 265 | + if (url.pathname === "/search") { |
| 266 | + const query = url.searchParams.get("q") ?? ""; |
| 267 | + const hits = yield* search.search({ query }).pipe(Effect.orDie); |
| 268 | + return yield* HttpServerResponse.json(hits.chunks); |
| 269 | + } |
| 270 | ``` |
| 271 | |
| 272 | `hits.chunks` is the ranked list of chunks with their scores and source |
| 273 | metadata — no generated answer, because nothing was generated. |
| 274 | |
| 275 | ## Try it |
| 276 | |
| 277 | Deploy, upload a document to the bucket, and ask a question. |
| 278 | |
| 279 | ```sh |
| 280 | bun alchemy deploy |
| 281 | |
| 282 | # upload a doc into the indexed bucket (via wrangler) |
| 283 | echo "Alchemy deploys with 'bun alchemy deploy'." > faq.txt |
| 284 | bunx wrangler r2 object put "$(bun alchemy stack output bucket)/faq.txt" --file faq.txt --remote |
| 285 | |
| 286 | # ask over it |
| 287 | curl "$(bun alchemy stack output url)/ask?q=How%20do%20I%20deploy%3F" |
| 288 | ``` |
| 289 | |
| 290 | You can also drag files straight into the bucket from the Cloudflare |
| 291 | dashboard → **R2** → your bucket — anything in there gets indexed. |
| 292 | |
| 293 | Indexing is asynchronous — a freshly uploaded file takes a short while |
| 294 | to appear in results. Open the Cloudflare dashboard → **AI** → |
| 295 | **AI Search** → your instance to watch the sync status and confirm the |
| 296 | document was indexed before querying. |
| 297 | |
| 298 | ## Drop down to the low-level resources |
| 299 | |
| 300 | The `Search` helper is the fast path for the common R2 case. Reach |
| 301 | for the underlying resources directly when you need to: |
| 302 | |
| 303 | - **share one token across many instances** — create the |
| 304 | `SearchToken` once and pass its `id` as `tokenId` to each |
| 305 | `SearchInstance`, |
| 306 | - **adopt an existing instance or token** rather than create one. |
| 307 | |
| 308 | Everything the helper does is just these resources composed — there's |
| 309 | no behavior you lose by dropping down. |
| 310 | |
| 311 | ## What you have now |
| 312 | |
| 313 | - An R2 bucket whose contents are indexed for retrieval. |
| 314 | - A managed, least-privilege API token + AI Search service token, both |
| 315 | owned by your stack and torn down on `destroy`. |
| 316 | - A `SearchInstance` bound into your Worker as a typed Effect client. |
| 317 | - `/ask` (full RAG) and `/search` (retrieval only) routes over your own |
| 318 | documents. |
| 319 | |
| 320 | For the full prop surface — embedding/generation models, R2 prefix and |
| 321 | include/exclude filters, reranking, query rewriting, and similarity |
| 322 | caching — see the [AI Search resource reference](/providers/cloudflare/ai/search). |