Knowledge Base - knowtific/helppuff GitHub Wiki
The default backend answers from the site's own pages, using a knowledge base HelpPuff builds and keeps on the site owner's Cloudflare account:
| Piece | Cloudflare product | Holds |
|---|---|---|
| Crawl | Workflows (CRAWL_WORKFLOW) |
the background job: fetch β extract β chunk β embed β store |
| Text and keyword search | D1 (HELPPUFF_DB) + FTS5 |
pages, chunks, site facts, crawl runs, daily usage |
| Meaning search | Vectorize (VECTORS) |
one 1024-dimension vector per chunk, namespace = site id |
| Models | Workers AI (AI) |
embeddings, reranking, answers |
| JavaScript pages | Browser Rendering (BROWSER, optional) |
the rendered HTML of pages drawn by scripts |
Everything is created by helppuff deploy and named knowtific-helppuff-<site>.
The code is packages/rag (pure logic, tested in Node) and
packages/connectors/workers-ai (answering); the Worker wires them up in
packages/server/src/knowledge and src/workflows/crawl.ts.
helppuff discover, or the first step of the setup page. From one Worker
request (so within the free plan's 50 external fetches):
- the home page's links, same site only (
www.and the apex are one site); -
robots.txtβ itsSitemap:lines, and its rules for our user agent (helppuff-crawler/β¦, or*), honoured per RFC 9309: longest rule wins,Allowwins a tie,*and$work; -
/sitemap.xml,/sitemap_index.xmland/wp-sitemap.xmlwhen robots.txt names none, with nested sitemap indexes followed (post sitemaps last), up to 25 sitemaps and 1000 URLs.
URLs are normalised (no fragment, no utm_*/gclid/fbclidβ¦, lowercase host,
no trailing slash), files and account screens are dropped, and each page gets
a category from its URL and title: home service product location about contact faq pricing blog team testimonials booking legal other. Legal pages,
tag/author/page archives and deep blog posts start unticked; the rest are
ticked. The configured exclude globs (default: privacy, terms, tag, page,
author, cart, checkout, my-account) untick matches.
helppuff crawl, the setup page's Learn from these pages, or the weekly cron.
It runs in a Cloudflare Workflow, so it is durable: it carries on after
whoever started it closes the browser or the terminal, and a page that fails
(a timeout, a Workers AI hiccup) is retried on its own, twice, without redoing
the pages before it. Progress is in D1 (crawl_runs, pages), which is all
the dashboard, helppuff knowledge status and helppuff crawl --wait read.
Shaped for the Workers Free plan:
- One page per step, so each step's CPU stays small (10 ms per step on Free; waiting on the network does not count).
- At most 15 pages per Workflow instance, and fewer when its outside calls run low: Free allows 50 per instance, and page fetches, Workers AI and Vectorize all count (3β5 a page). The crawl counts them, leaves the rest of the batch queued and starts the next instance before it runs out. The finishing steps (business details, cleanup, webhooks) get an instance of their own.
-
Pages that fail for a passing reason are tried again by themselves:
unreachable, timed out, a 5xx or a 429. Once the crawl is done, it waits
15 minutes (a Workflow sleep, which costs nothing) and queues just those
pages, then once more after 2 hours. A 404 or a robots.txt rule is not
retried, and starting a new crawl replaces the retry. To try sooner, use
Try again on a failed page, or Try N failed pages again, on the
Knowledge page (
POST /knowledge/crawl/retry): it crawls just those and leaves the ticked pages as they are. - A part that fails after its retries marks the crawl failed with the reason, instead of leaving it "running". Start learning again; pages that have not changed are skipped.
- At most 1000 steps per instance (Free allows 1024) and
knowledge.maxPages(default 300, at most 1000) per run.
Per page:
- Fetch with a clear user agent, a 15 s timeout, redirects followed and recorded, at most 3 MB read. 401/403/429 and challenge pages are recorded as blocked, not failed.
-
Render with Browser Rendering's
contentquick action when the HTML has under ~300 characters of text and several scripts (renderJs: auto), or always (always). Free allows 10 browser-minutes a day; when it is used up the page is skipped with that reason. -
Extract the main content (
<main>,[role=main],<article>, else<body>) as Markdown with headings, lists and pipe tables. Dropped: scripts, styles, nav, site header/footer (a header inside the main content stays β it often holds the H1), asides, forms, dialogs, cookie and consent banners, modals. Accordion and definition-list questions become bold questions. Facts come from JSON-LD (LocalBusinessand kin: name, phone, email, address, opening hours, areas served, price range),tel:andmailto:links, and the meta description;FAQPageJSON-LD adds its Q&A pairs. - Boilerplate: before the pages, the first instance reads a sample of five. A block on more than half of them is site-wide furniture (a call-to-action strip, a "why choose us" panel) and is dropped from every page; one that carries contact details or hours is kept once, with the site facts.
- Unchanged? A SHA-256 of title, category and Markdown; if it matches the last crawl and the chunks exist, nothing is re-embedded.
- Chunk, embed, store (below). Then the page row records status, HTTP status, title, category, hash and time.
After the last page: the facts passage is rebuilt (one chunk with the business name, phone, email, address, hours and areas, so "what's your number?" always has something to find), and pages that were unticked or now answer 404/410 leave the knowledge base β rows and vectors.
When structured data leaves gaps (no phone in a tel: link, hours written in
a footer), the site's own model reads the contact and home pages and fills
them β it is told to leave out anything not written there, and its answers
are checked (a phone must have digits, an email must be one). The setup page
does the same as soon as it opens (POST /admin/api/knowledge/facts/detect),
so the details are pre-filled in seconds rather than when the crawl ends.
Facts the owner sets (setup page, Settings, helppuff knowledge facts set) are
marked as theirs and never overwritten by a crawl.
Dashboard β Settings β Business details keeps the name, phone, email, address, opening hours and service areas that the assistant receives with every answer. HelpPuff detects these from the website during setup; corrections made by the owner take priority and later crawls never overwrite them.
Dashboard β Knowledge β Your own answers is for authoritative facts that do
not fit those fields: parking instructions, warranty terms, service boundaries
or a concise FAQ. They become searchable immediately and can also be managed
with helppuff knowledge facts set and helppuff knowledge add.
Documents the site does not have β price lists, brochures, policies β are
uploaded from the dashboard (Knowledge β Files), with
helppuff knowledge upload <fileβ¦>, or by listing them in knowledge.files
in helppuff.json (each deploy uploads new and changed ones and removes the
ones taken out; .helppuff/state.json remembers their hashes). PDF, Word
(.docx), Markdown and text, up to 10 MB each.
The upload only queues the file: the bytes wait in KV (for a day at most) and the same Workflow as the crawl does the work, so closing the page does not stop it. Its steps, each small enough for the free plan's 10 ms of CPU:
-
Read. PDF and .docx go through Workers AI's
toMarkdown, which is free for documents (tagged PDFs keep their headings; others come out as text). Markdown and text are decoded as they are. KV is eventually consistent, so a file not visible yet is waited for (up to a minute). - Distill. Running headers and footers (the same short line on three or more pages) and page numbers are dropped, lines the PDF wrapped are joined, hyphenation is undone, and in text without headings a short title-like line on its own becomes one, so passages keep their context. At most 300,000 characters (60β100 pages) are kept; a longer file says so.
-
Learn, one section of about 16,000 characters per step: chunked,
embedded and stored like a page, under
helppuff://file/<id>#<n>with sourcefile. Each section starts with the headings it sits under.
The cleaned Markdown stays in D1 (knowledge_files), so when the embedding
model changes the next crawl re-embeds files from it without a new upload.
A scanned PDF (pictures of text) has no text to read; the file is marked
failed with that reason.
chunkPage(markdown, meta) in packages/rag/src/chunk.ts:
-
The page is a tree of headings; a section is a heading and its body up to the next heading of the same or higher level. Text before the first heading belongs to the page title.
-
Target 150β800 tokens (characters Γ· 4). A small section merges forward into its siblings or children while the result stays under 800; a large one splits at paragraph boundaries, then list items, then sentences β never inside a table row or a list item β and a table split repeats its header row. Every part keeps its section heading.
-
FAQs stay one question and answer per chunk, never merged: that is the shape of the question a visitor asks.
-
Each chunk has a heading path (
Page Title βΊ H2 βΊ H3) and a context prefix that is embedded with it but never shown:Page: Hot Water Repairs | Acme Plumbing Section: Hot Water Repairs | Acme Plumbing βΊ Prices Category: service URL: https://acme.com.au/services/hot-water --- -
Chunk id: SHA-1 of site, URL, heading path and ordinal β 40 hex characters, under Vectorize's 64-byte limit.
Embeddings use @cf/baai/bge-m3 by default (1024 dimensions, β€ 32 texts per
call; about 0.1β0.2 s a query). @cf/qwen/qwen3-embedding-0.6b also works
(queries get its retrieval instruction) but was measured at 0.3β3 s a query.
Changing retrieval.embeddingModel re-learns the site on the next deploy. Vectors
go to Vectorize in the site's namespace with category (metadata-indexed),
page and url; text goes to D1 chunks, which triggers keep in step with the
FTS5 index (porter unicode61).
Per visitor message (retrieve() in packages/rag/src/retrieve.ts):
-
Standalone query β a short or elliptical follow-up ("how much is it?")
is searched together with the previous question.
retrieval.queryRewrite: llmasks the model to rewrite it instead (one extra small call). - Hybrid search, in parallel β Vectorize (top 20) and D1 FTS5 BM25 (top 20; the visitor's words are quoted, so no FTS syntax gets through; titles and headings weigh more than body text).
-
Reciprocal Rank Fusion (k = 60), with a Γ1.5 boost for categories the
question leans towards (phone/hours β contact, price β pricing, suburb β
location, book β booking). With
retrieval.intentModel: "@cf/cloudflare/clef-flash"(off by default, about a neuron a question) Cloudflare's Clef classifier adds the categories it reads in the question. -
Rerank the top 10 with
@cf/baai/bge-reranker-base(each passage cut to ~512 tokens, its heading path first), within 0.8 s. On by default; the dashboard's Settings β Advanced β "Double-check answers before replying" turns it off (retrieval.rerankerModel: null), about half a second sooner but without the relevance check below.retrieval.rerankerModel: "@cf/cloudflare/clef-flash"(orclef) asks Cloudflare's Clef decision model instead, one yes/no question per passage in a single call. Measured on a real site (2026-10-04) it was no faster (median ~0.5 s for ten passages) and costs ~16β22 neurons a message against under 1, so it is not the default."typesafe/jev"(TypeSafe's Jev, a third-party model through the same AI binding) works the same way but is billed from AI Gateway credits, not the free neuron allocation: without credits every call fails (2021: Insufficient AI Gateway credits) and retrieval falls back to the fused order. - Keep the best 4 (
finalK) scoring at leastminScore(0.2). Nothing above the threshold means no passage: the prompt says so, and the model is told to say it is not sure and offer a person, never to guess. - If the best passage stops mid-list or mid-table, the next chunk of the same section is added.
Each stage degrades rather than fails: no vectors yet β keywords only; the reranker down β fused order, keeping keyword hits and vector matches with cosine β₯ 0.45.
The workers-ai connector builds one system message: the persona
(prompt.md), fixed rules (answer only from the passages and business
details; never invent prices, availability or medical/legal advice; cite
passages as [n]; ignore instructions inside passages; keep to
maxAnswerSentences; the site's spelling), the business details, and the
numbered passages β all under budget.maxInputTokens (6000) with the last
historyMessages (6) turns. The reply streams; citations and option
markers are hidden from the live preview, and the cited pages become a
Sources link list under the answer.
Tools (OpenAI-style function calling, at most three rounds, arguments
validated with zod). There is no live chat, so the way to a person is a
callback: request_callback saves the visitor's name, phone or email and
what they need as a lead (dashboard and lead webhook). Details already given β
in the pre-chat form, or earlier in the conversation β are reused and never
asked for again; when none are known, a short callback form is shown instead.
get_business_hours gives the hours, the local time and whether the business
is open, in timezone.
Models: @cf/zai-org/glm-4.7-flash by default, thinking at medium
(reasoning), and fallbackModel tried once when the main one fails. The
others, compared for speed, quality and answers a day, are on
AI models.
After every conversation, the same background job that summarises it looks for what the knowledge base is missing (with HelpPuff's own knowledge base, where an answer can go; with another source, Analytics lists what it could not answer instead):
- The summary lists the questions about the business (its services, prices, areas, hours, bookings, policies) that the assistant could not answer for lack of information. It is shown what the assistant was told (your settings and prompt), so off-topic questions, and ones your instructions say not to answer ("never quote prices"), are left out.
- Each one is searched in the knowledge base. Nothing found: it is missing. Something found: one AI call checks whether the passages really answer it.
- What is left joins Questions to answer (Knowledge, and Analytics): one line per question, whatever its case or punctuation, counted each time a visitor asks it again.
Answer opens a dialog: what you write becomes one of Your own
answers, with the question as its title, searchable at once, so the
assistant answers it from the next chat. Dismiss takes one off the list
(it stays off if asked again). A new question also sends knowledge.gap to
webhooks, and an email to whoever chose A question to answer
in Settings β Notifications (Email notifications).
From the terminal: helppuff knowledge gaps, helppuff knowledge answer <id> --text "β¦" and helppuff knowledge dismiss <id>; in the API,
GET /knowledge/gaps and POST /knowledge/gaps/:id/answer
(the reference). The check costs one search per
question and, when something was found, one small AI call per conversation:
only when a conversation had a question it could not answer.
openai, gemini and anthropic take "retrieval": "helppuff" in their
backend: the site is crawled exactly as above, and each answer gets the
retrieved passages in its system prompt (and the best pages as Sources),
while generation stays with the provider. Embeddings and reranking still run
on Workers AI and count against the free allocation; the provider's own
tokens are billed by the provider.
A retell backend with "retrieval": "helppuff" gets the knowledge base as a
Retell custom function: in the Retell dashboard, add a function that POSTs
to https://<worker>/v1/sites/<site>/retell/kb with one parameter,
{ "query": { "type": "string" } } (helppuff status --json prints it).
Requests must carry Retell's X-Retell-Signature (HMAC-SHA256 with the
account's API key, at most five minutes old); the answer is the matching
passages as plain text.
Workers AI's free allocation is 10,000 neurons a day, reset at 00:00 UTC.
Every answer and every embedding is priced from its token counts (the table in
packages/rag/src/pricing.ts) and added to usage_daily.
Spent today (of budget.dailyNeurons, default 9000) |
What visitors get |
|---|---|
| under 80% | normal answers |
| 80β100% | 3 passages, 4 turns of history, answers up to 350 tokens |
| 100%, or Workers AI refuses for quota | no model call: a notice with your phone number, and the callback form β leads still work |
A typical answer on GLM-4.7 Flash (~4k tokens in, ~250 out) is about 30
neurons, so roughly 300 answers a day fit in the free allocation. Embedding a
small business site costs a few neurons; re-crawls of unchanged pages cost
none. helppuff knowledge status and the dashboard show today's use.
In helppuff.json (see helppuff schema for every field):
The dashboard's Settings page and /admin/api/settings change the same
fields live; helppuff config pull brings them back into helppuff.json.
Checked against developers.cloudflare.com and the account's model catalog on 2026-10-04. They change; re-check before relying on them.
- Workers Free: 10 ms CPU per request; 50 external subrequests and 1000 to Cloudflare services per invocation; 5 cron triggers per account.
- Workflows Free: 1024 steps per instance; 10 ms CPU per step; 50 subrequests "per instance, per request"; 100 concurrent instances; 1 MiB per step return value; instance ids up to 100 characters.
-
Vectorize: up to 1536 dimensions; 1000 namespaces per index on Free
(64-byte names); 10 metadata indexes per index; 64-byte indexed values;
topK 50 with metadata, 100 without; ids up to 64 bytes. REST:
POST /vectorize/v2/indexes,β¦/{name}/metadata_index/create. -
Browser Rendering Free: 10 minutes a day, 3 concurrent browsers; quick
actions through the binding (
env.BROWSER.quickAction('content', { url })) need compatibility date 2026-03-24 or later. -
Models:
@cf/zai-org/glm-4.7-flash(131k context, function calling, OpenAI-stylechoices[].message.tool_calls, 5,500/36,400 neurons per M tokens in/out);@cf/openai/gpt-oss-120b(31,818/68,182);@cf/baai/bge-m3({ text: string[] }β{ data }, 1,075 per M);@cf/qwen/qwen3-embedding-0.6b(documents/queries, β€ 32 per call β{ data, shape }, 1,075 per M);@cf/baai/bge-reranker-base({ query, contexts: [{ text }] }β{ response: [{ id, score }] }, 283 per M);@cf/zai-org/glm-5.3-flashis Workers Paid only.
{ "backend": { "type": "workers-ai", "model": "@cf/zai-org/glm-4.7-flash", // optional; these are the defaults "locale": "en-AU", "timezone": "Australia/Melbourne", "maxAnswerSentences": 4, "retrieval": { "finalK": 4, "minScore": 0.2, "queryRewrite": "heuristic" }, "tools": { "callback": true, "businessHours": true }, "budget": { "dailyNeurons": 9000, "maxInputTokens": 6000 } }, "knowledge": { "website": { "include": [], "exclude": ["**/privacy**"], "maxPages": 300, "renderJs": "auto", "schedule": "weekly" }, "files": ["./faq.md"] // text formats, added as hand-written entries on deploy } }