This document explains how the pieces fit together, why the design looks the way it does, and where the free-tier constraints shaped it.
- Zero external dependencies at runtime. Everything runs inside one Cloudflare Worker using only Cloudflare bindings: Workers AI, Vectorize, D1, Static Assets and Cron Triggers. No API keys to third parties.
- Free-tier first. Every request path must fit the Workers Free plan limits (10 ms CPU per invocation, 10k Workers AI neurons/day, 5M stored Vectorize dimensions).
- Multi-tenant. One deployment serves many documentation sites, each isolated and locked to its own domain.
- Boring, auditable code. Hono router, strict TypeScript, SQL in one file, no framework magic.
| Component | File(s) | Responsibility |
|---|---|---|
| HTTP API + cron handler | src/index.ts |
Routing, validation, auth, SSE streaming, error mapping, scheduled entrypoint |
| Indexing pipeline | src/services/indexer.ts |
Sitemap → queue → crawl → chunk → embed → upsert, batch processing |
| Crawler | src/services/crawler.ts |
Sitemap parsing (incl. sitemap index + gzip), HTML text extraction with HTMLRewriter, token estimate, section-aware chunking |
| AI | src/services/ai.ts |
Embedding calls, cross-encoder reranking, RAG prompt, generation (sync + stream), Workers AI SSE parsing, rate-limit detection |
| Vector store | src/services/vectorize.ts |
Upsert, namespace-scoped query, deletion |
| Database | src/db/schema.ts, migrations/0000_init.sql |
D1 schema, prepared statements, repository class |
| CORS / origin policy | src/utils/cors.ts |
Preflight handling, per-site domain checks |
| Widget | widget/widget.src.js → public/widget.js |
Shadow-DOM chat UI, SSE client, Markdown-lite renderer |
| Admin UI | public/admin.html |
Static single-file page over the /api/sites/* endpoints: register sitemaps, progress bars, batch/loop processing, re-crawl, delete, embed snippet, recent questions |
[ai] binding = "AI" # Workers AI
[[vectorize]] binding = "VECTOR_INDEX" # 384-dim cosine index "docflare-index"
[[d1_databases]] binding = "DB" # "docflare-db"
[assets] binding = "ASSETS" # ./public
[triggers] crons = ["* * * * *"] # background indexerWorkers AI calls are plain env.AI.run(model, input) invocations; Cloudflare handles model hosting, scaling and billing. Vectorize and D1 are reached through their bindings with no network configuration.
erDiagram
sites ||--o{ pages : has
sites ||--o{ queries : logs
sites {
TEXT id PK "slug chosen by the operator"
TEXT domain "hostname allowed to embed the widget"
TEXT sitemap_url
DATETIME created_at
}
pages {
TEXT id PK "sha256(url)[0:24]"
TEXT site_id FK
TEXT url
TEXT title
INTEGER chunk_count "vectors stored for this page"
TEXT status "pending | indexed | failed"
TEXT error
DATETIME updated_at
}
queries {
TEXT id PK "uuid"
TEXT site_id
TEXT prompt
TEXT response
DATETIME created_at
}
The pages table doubles as the indexing queue: status = 'pending' rows are picked up oldest-first by the cron handler. Page ids are deterministic (hash of the URL), so re-indexing is idempotent and vector ids (<pageId>-<chunkIndex>) can be reconstructed for deletion without storing them.
- Index: 384 dimensions, cosine metric, matching
@cf/baai/bge-small-en-v1.5. - One namespace per site (
namespace = siteId). Queries are scoped with{ namespace }, which gives hard tenant isolation without creating a metadata index for filtering. - Vector id:
<pageId>-<chunkIndex>. - Metadata (flat, < 10 KiB):
siteId,pageId,url,title,chunk,text. Storing the chunk text in metadata means the chat path needs no D1 read to build the prompt.
sequenceDiagram
autonumber
participant Op as Operator (curl / CI)
participant W as Worker (Hono)
participant SM as Sitemap + pages
participant D1 as D1
participant AI as Workers AI
participant VX as Vectorize
participant CR as Cron trigger
Op->>W: POST /api/sites/index {siteId, sitemapUrl}
W->>SM: GET sitemap.xml (follows sitemap index, gunzips .gz)
SM-->>W: <loc> URLs
W->>W: filter: http(s), same host, HTML-looking, max MAX_PAGES
W->>D1: upsert site, enqueue pages (status = pending)
W-->>Op: 202 {queued, progress, next}
loop every minute (cron) or POST /api/sites/:id/process
CR->>W: scheduled()
W->>D1: SELECT pending pages LIMIT INDEX_BATCH_SIZE
loop for each page
W->>SM: GET page.html
W->>W: HTMLRewriter: drop nav/header/footer/script/style, collect text
W->>W: chunk ~350 tokens at headings/sentences, 40 overlap
W->>AI: bge-small-en-v1.5 (batch of chunks)
AI-->>W: 384-dim vectors
W->>VX: upsert {id, values, namespace, metadata}
W->>D1: UPDATE pages SET status='indexed', chunk_count, title
end
end
The Workers Free plan caps CPU time at 10 ms per invocation. HTML parsing, chunking and JSON (de)serialisation of embedding vectors all burn CPU, so crawling a whole site in one request would be killed. Instead:
POST /api/sites/indexonly parses the sitemap and writes the queue (cheap).- Each cron tick or
/processcall handlesINDEX_BATCH_SIZEpages (default 3) and returns. Network waits (fetching pages, calling Workers AI, Vectorize) do not count as CPU time. - Failures are recorded per page (
status = 'failed',error) and never block the rest of the queue. - A Workers AI rate limit stops the current batch early and leaves the remaining pages
pending; the next tick retries them.
Cloudflare Queues would be the "proper" tool but are not available on the Free plan; the D1-backed queue costs nothing extra and is trivially inspectable with wrangler d1 execute.
HTMLRewriter is a streaming, native (Rust) HTML parser exposed to Workers, so extracting text costs very little CPU even for large pages. The crawler:
- Tracks a skip depth for boilerplate selectors (
head, script, style, noscript, template, svg, iframe, form, nav, footer, aside, body > header, [role=navigation|banner|contentinfo|search], [hidden]), incremented on the start tag and decremented ononEndTag. - Collects text chunks under
<html>while the skip depth is zero. - Inserts newlines before block-level elements so sentences from adjacent elements do not merge, and writes
<h1>-<h4>as Markdown heading lines (## Title) that the chunker and the LLM can see. - Captures
<title>(falling back to the first<h1>, then the URL). - Decodes HTML entities, normalises whitespace and caps the output at 200k characters.
Chunks target ~350 tokens with ~40 tokens of overlap. Sizes are measured with estimateTokens, a cheap upper bound of bge-small's WordPiece count (≈4.5 ASCII letters per token, ≈0.7 punctuation characters per token, one token per non-ASCII character). A fixed characters-per-token ratio does not work across scripts: Vietnamese runs at 2.3-3 characters per token, so 1,800-character chunks were silently truncated by the model's 512-token window and their tails were never searchable.
Text is split into paragraphs and sentences; units are greedily packed into a chunk, and the trailing sentences of each chunk are carried into the next one as overlap. A heading line (## …) starts a new chunk (without overlap) as soon as the current chunk holds SECTION_MIN_TOKENS (80), so a chunk rarely mixes unrelated sections of a landing page. Oversized units (long code blocks) are hard-split. The page title and meta description are prepended to the embedding input with their own token caps (40 / 80), keeping every input under 512 tokens.
sequenceDiagram
autonumber
participant U as Browser widget
participant W as Worker (Hono)
participant D1 as D1
participant AI as Workers AI
participant VX as Vectorize
U->>W: POST /api/chat {siteId, question, stream: true, history}
W->>W: validate payload, normalise siteId
W->>D1: SELECT site
W->>W: Origin must match site.domain (or ALLOWED_ORIGINS)
W->>AI: embed question (bge-small-en-v1.5)
AI-->>W: 384-dim vector
W->>VX: query(vector, {namespace: siteId, topK: 20, returnMetadata: "all"})
VX-->>W: 20 candidate chunks with url/title/text
W->>AI: bge-reranker-base (question + 20 passages)
AI-->>W: relevance score per passage
W->>W: sort by reranker score, keep TOP_K (max 2 per page)
alt no chunks
W-->>U: canned "not found" answer (LLM skipped, no neurons spent)
else chunks found
W->>AI: llama-3.1-8b-instruct-fp8 (system prompt + numbered context + history + question, stream)
AI-->>W: SSE tokens
W-->>U: SSE: meta{id, sources} → token{text}… → done
end
W->>D1: INSERT queries (id, siteId, prompt, response) via waitUntil
Bi-encoder similarity (one vector per chunk, one per question) is cheap but coarse: a question like "open source of p10node" scores highest against chunks that repeat the site name (privacy policy, terms) rather than the paragraph that says "Open source: tooling we maintain in the open". @cf/baai/bge-reranker-base is a cross-encoder: it reads question and passage together and returns a relevance score, which fixes exactly this class of miss. Reranking 20 passages costs about 2 neurons and one extra round trip; if the call fails for any reason the vector order is used unchanged, so the chat path never depends on it. Set RERANK = "false" to turn it off.
The system prompt:
- Names the site (
domain) so the model stays in character. - Provides the retrieved chunks as numbered excerpts, each with title and URL.
- Instructs the model to answer only from the excerpts, cite them inline as
[n], say when the answer is not in the docs, and never invent URLs/flags/versions. - Asks for concise Markdown (lists, fenced code) which the widget renders.
The last six turns of conversation history (trimmed to 1,500 characters each) are included so follow-up questions work.
Workers AI returns data: {"response":"…"} SSE lines ending in data: [DONE]. The Worker re-emits them as its own events so the widget never depends on Workers AI's wire format:
event: meta
data: {"id":"<uuid>","sources":[{"index":1,"url":"…","title":"…","score":0.82}]}
event: token
data: {"text":"Deploy with "}
event: done
data: {"id":"<uuid>"}
The full answer is accumulated while streaming and written to D1 after the stream closes using ctx.waitUntil, so logging never delays the first token.
| Concern | Mechanism |
|---|---|
| Tenant isolation in search | Vectorize namespace per siteId |
| Who may embed the widget | /api/chat compares the request Origin hostname with sites.domain (exact, www. variant, or *.wildcard), plus the ALLOWED_ORIGINS allow-list. Requests without an Origin (curl, server-side) are allowed. |
| Who may index / delete | /api/sites/* requires Authorization: Bearer <ADMIN_TOKEN>; compared in constant time. Routes return 503 if the secret is unset so a fresh deployment is never open by accident. The admin page is plain static HTML; it only becomes useful once a valid token is pasted in (kept in sessionStorage, never in the URL). |
| SSRF / crawling third parties | Only http(s) URLs whose hostname is the site's domain (or a subdomain) are queued; child sitemaps are held to the same rule. |
| Abuse of the LLM | Questions capped at 1,000 characters, history at 10 turns; no context → no LLM call. |
| CORS | Preflights reflect the Origin; the real policy is enforced per site after the body is parsed (the site id is not known at preflight time). |
| Widget isolation | Shadow DOM with all: initial on the host; nothing leaks in or out. |
| Limit | How DocFlare stays under it |
|---|---|
| 10 ms CPU / invocation | Small crawl batches, native HTMLRewriter, chunk text stored in vector metadata (no joins at query time) |
| 10k neurons / day | Cheap embedding model, LLM skipped when retrieval is empty, max_tokens capped at 768, rate limits surfaced as 429 and retried later |
| 5M stored dimensions | 384-dim model (~13k chunks); MAX_PAGES caps each site |
| D1 writes | One write per page per crawl, one per chat |
Structured JSON lines are logged for batches (index.batch), page failures (index.page_failed), rate limits (index.rate_limited) and unhandled errors. With [observability] enabled = true they are visible in the Cloudflare dashboard's Workers Logs. GET /api/sites/:siteId/status exposes queue counts, and the queries table is the analytics source for "what are people asking".
- Models: change
EMBEDDING_MODEL/CHAT_MODELinsrc/services/ai.ts. A different embedding size needs a new Vectorize index with matching dimensions. - Retrieval:
TOP_K/RERANKvars; swapRERANK_MODELinsrc/services/ai.ts, or add hybrid keyword search (D1 FTS5) next tosearchChunksinsrc/index.ts. - Ingestion:
registerSiteaccepts any URL list; add Markdown/llms.txtsources by extendingfetchSitemapUrlsor writing a second enqueue path. - Private docs: gate
/api/chatbehind your own auth (e.g. Cloudflare Access JWT) in a Hono middleware.