Docs Chat Build
How was the docs chatbot implemented?
Docs Indexing
- Indexed rows are stored without a
publicflag, and/docs/chatretrieves from those rows as written. - Sync reads a Git ref. User guide search and
/llms.txtread the working tree when those routes are built.
src/lib/docs/compose.ts builds the text that later goes into docs_section. npm run sync-docs calls it as composeDocsCorpus({ forIndex: true, gitRef: branch }):
- Reads the docs MDX files from a Git ref (
BRANCH, defaultmain). - Shared
_snippetsfiles are pasted into the page that imports them, so the index stores the full wording, not an import line. - A page with
search: falseis skipped.forIndexkeeps the same pages as user guide search. - A page becomes one overview chunk plus one chunk per heading that has body text (the heading's
hrefmay include#anchor).
npm run sync-docs runs scripts/sync-docs.ts to embed that text and upsert it into docs_section:
- Gemini (
gemini-embedding-001, 768 dimensions,RETRIEVAL_DOCUMENT) turns chunks into vectors in batches of 20 for meaning search. - Each chunk is stored with a
content_hashof that text. A matching hash skips Gemini and the write. - Title, heading, and body fill a weighted
search_vectoron the table (English full text, so "running" and "run" can match). A title match outranks a heading, and a heading outranks the body. - Rows are upserted with the Supabase
service_roleclient (createServiceClient()). Chunk IDs that disappeared from the corpus are deleted. - Vectors already computed stay in
internal/docsbot/embed-cache.json(gitignored), so a machine that embedded a chunk can skip Gemini when the row is missing. A merged docs change onmainruns the same script from GitHub Actions, which usescontent_hashbecause that cache file is not in the repo.
Chatbot Interface
Clicking the ask the documentation button (DocsChat) opens the DocsChatPanel. /docs/chat/route.ts:
- Restores message history if the browser has a
docs_chat_idcookie. (GET) - Looks up matching docs in
docs_section, then asks Gemini to write a summary from those excerpts only on Send. (POST) - Clears the message history by dropping the current
docs_chat_idcookie and the matching Supabase session when Clear Chat is clicked. (DELETE)
- Gemini (
gemini-embedding-001, 768 dimensions,RETRIEVAL_QUERY) turns the question into a vector so pages can match by meaning, not only by the same words. If embed quota or high demand is hit, keyword search still runs. - The same wording reuses
docs_query_embedding(scripts/docs-chat.sql). The row ID is a hash of the question, so the question text is not stored. The server instance also keeps vectors it has already loaded, and a trigger keeps the newest 200 rows. Row-level security is on with noanon/authenticatedpolicies./docs/chatreads and writes those rows with theservice_roleclient. - Postgres then runs two lookups — wording match (
search_docs_sections, ranked onsearch_vector) and meaning match (match_docs_sections, cosine distance). Each lookup keeps up to 20 rows. Those ranked lists are combined (RRF) in/docs/chatso a page that does well on either can still surface. - At most three pages are kept. A page is dropped when its combined score is under 65% of the best hit. If wording search returned any pages, a page that matched only by meaning is dropped unless its score is at least 85% of the best hit.
- Short words such as
apiandaddare kept for matching (three characters or more). - If a heading on that page matches the question, that heading's excerpt is used instead of the page overview.
- Gemini (
gemini-3.6-flash) writes the reply from those retrieved excerpts only — it does not read the rest of the docs. If that model is unavailable, the same prompt runs ongemini-flash-lite-latest. - It answers from those excerpts even when they are brief, and only says "I could not find that information in the docs. Try phrasing the question differently, or ask another question." if the excerpts are about a different topic.
- Answers are instructed not to invent pages, fabricate information, or provide general advice unrelated to the CareerChive application.
- The stream lists Relevant Guides (links to those pages) first, then Summary.
- The same browser IP is hashed and limited to 5 questions per hour and 10 per day (
consumeDocsChatRateLimit), stored indocs_chat_limits.
Chat History
docs_chat_session,docs_chat_message, anddocs_chat_limitsin Supabase, created byscripts/docs-chat.sql, store chat history per browser and chat IP quotas. That file also createsdocs_sectionanddocs_query_embedding.- The browser never talks to these tables — row-level security is on with no
anon/authenticatedpolicies./docs/chatreads and writes them with theservice_roleclient. Quotas go throughconsume_docs_chat_quotaon that same client.
docs_chat_sessionis one conversation — cookiedocs_chat_id(httpOnly, path/, five years) stores that row's ID so opening ask the documentation again restores the same thread.- Send upserts that session (a leftover cookie still captures) and writes the question immediately, then the reply after Gemini finishes.
- Clear Chat deletes that cookie and this session row.
docs_chat_messageis each turn in that conversation — the user's question or the assistant's reply (role), the wording (content), and for replies the retrieved page links (sources).- Deleting a session deletes associated messages.
docs_chat_limitsis the chat IP quota store — one row per hashed IP window (key), how many questions have been used (points), and when that window expires (expire).consume_docs_chat_quotawrites these rows.- Send hashes the browser IP and counts 5 questions per hour and 10 per day (
consumeDocsChatRateLimit). - Clear Chat does not reset quotas.
Chatbot Reference
Loading API reference…