Adding an On-Site AI Q&A to a Static Blog: Search Index plus Two-Channel Retrieval
After the blog passed a hundred posts, a new problem appeared: readers’ questions rarely belong to just one article. “How do your multi-agent sessions collaborate?” or “How is this bot itself implemented?” — the answers are scattered across three or four places, left for readers to stitch together themselves.
So the blog got an on-site AI Q&A: a floating widget in the lower-left corner, asked in natural language, answering strictly from on-site articles with source links. This post covers its architecture and one counterintuitive finding — in our scenario, semantic retrieval is unreliable; what actually carries the load is old-fashioned keyword matching.
Architecture: A Static Site Borrows a Backend
GitHub Pages is purely static hosting and cannot run any server-side logic, yet AI Q&A needs a server that can keep API keys and execute retrieval. Standing up a third-party SaaS (Chatbase and the like) is fastest, but content would leave the site, the free tier has limits, and someone else’s brand hangs on it. In the end I took the smoother path — borrowing the Jianshi project’s server, which already runs a FastAPI backend and model calls, so the marginal cost of adding one endpoint is nearly zero.
The three pieces: the full-text index generated by Hexo on site (search.xml, about 1.5MB, covering all posts) as the data source; a POST /api/blog/ask endpoint on the server handling retrieval plus generation; and a zero-dependency JS floating widget on the blog side handling the conversation.
Retrieval: Two Channels, Clear Hierarchy
The first thing the endpoint does when a question arrives is not ask the model — it is digging for evidence in the site’s own articles. The digging runs through two channels.
The keyword channel (the backbone): the question is flattened (whitespace and symbols stripped) and cut into adjacent bigrams plus ASCII words, scored with IDF rarity weighting — generic words everyone has like “what”, “method”, “machine” get weights approaching zero, while rare tokens like “plug-u”, “u-dri”, “install-sys” decide the ranking. This channel hits technical articles with great precision.
The semantic channel (the fallback): the question and article chunks are vectorized and compared by cosine similarity, catching paraphrased questions where “keywords miss entirely but the meaning is close”. It uses MiniMax embo-01 (1536 dimensions).
The routing rule was calibrated by real tests: whenever the keyword channel has hits, keyword ordering rules; semantics takes charge only at zero keyword hits, and otherwise fills in when keyword results are insufficient.
Two Pitfalls Found the Hard Way
Pitfall one: spaces kill matching. A reader asks “plug USB drive install system” (插U盘装系统); after flattening, the question side yields bigrams “插u”, “u盘”; but the Cobbler article’s original text writes “插 U 盘” — with spaces, so matching against the original never hits. The fix: at index-build time the document also generates a “flattened version” used for matching, while the original is displayed as-is. This single character-level fix rescued the article that most deserved to hit from “no results found” back to the top spot.
Pitfall two: the embedding model is noise for long technical articles. embo-01 discriminates short sentences reasonably well, but for long technical articles packed with command output, cosine similarity is completely distorted — in testing, an unrelated disk-partitioning article scored 0.50 while the correct answer scored only 0.22. The first version fused the two rankings with RRF, and the semantic noise polluted the keywords’ correct ranking. In the end I accepted reality: this model only deserves to be the fallback on this corpus, and the fusion was changed to routing.
Also, MiniMax’s embeddings API takes different parameters than the OpenAI protocol — it wants a texts array plus a type (db for ingestion, query for questions); passing input directly reports a missing parameter. For pitfalls of this “similar in shape, different in spirit” protocol kind, error messages basically don’t help you.
Anti-Abuse: Three Locks on a Public Endpoint
The endpoint is public, which amounts to hanging “free LLM calls” on the public internet — all three locks are mandatory:
- Nginx layer: separate rate limiting for the Q&A path (10 requests per IP per minute, burst 5), request body capped at 8KB;
- Application layer: 10 requests per IP per hour, questions over 200 characters rejected outright;
- Content layer: the site index is external XML, and any DTD/entity declaration is rejected before parsing (guarding against entity-expansion attacks); the system prompt enforces “answer only from the excerpts, cite reference numbers, and say so honestly when coverage is insufficient” — when asked an off-site question (“how do I make braised pork”), it dutifully answered that the site doesn’t cover it, and even listed the four articles it had retrieved as proof it had looked.
The Widget: Lower-Left Corner, Numbers Are Links
The floating widget sits in the lower-left corner — the lower right is taken by the theme’s back-to-top button and dark-mode toggle. The 【1】【2】 numbers in answers are automatically replaced with source-article links; dark mode is supported, and on mobile the text collapses to an icon. The system prompt hard-codes the identity: the blog assistant “Ganjiang”, discussing on-site content only.
Three Test Questions: Its Current Level
At launch I ran acceptance with three question types, each representing a typical scenario.
Scenario one: a keyword-hit technical question. Asking “I don’t want to plug a USB drive into each machine to install the OS — is there a way to have machines install themselves at boot” — it returned the Cobbler article pinned at the top, with the answer precise down to “tested on Rocky Linux 9 + Cobbler 3.3.7, machines boot from network and install automatically”, with the source link going straight there. This score was only possible after pitfall one’s fix: before it, the same question returned nothing.
Scenario two: a zero-relevance off-topic question. Asking “how do I make braised pork” — it answered that on-site articles don’t cover it, and listed the titles of the four articles it had looked through during retrieval as evidence of “I really did search”. An honest refusal matters far more than improvising a recipe — that is the system prompt’s “say so honestly when coverage is insufficient” doing its job.
Scenario three: a meta-question requiring answers stitched across articles. Asking “how many series does this blog have” — it gave a partial answer based on the two retrieved Jianshi-App-Store posts, and proactively declared “based only on the few excerpts retrieved; the blog may actually have more”. Incomplete, but every word had a provenance.
The Floating Widget: A Few Hundred Lines of JS
The widget itself is zero-dependency native JS: it injects a floating button and a panel, submits questions via fetch, and regex-replaces the 【1】【2】 numbers in answers with source-article links. Three details: the position is lower-left because the theme’s back-to-top button and dark-mode toggle occupy the lower right; dark mode follows the theme’s data-theme attribute, with a separate color palette written for it; messages in the panel auto-scroll to the bottom, and while loading it shows a “digging through site articles…” placeholder so users don’t think it froze.
Cost and the Boundary of Honesty
On the cost side: each question = one embedding call plus one LLM call (with the retrieved passages as context), negligible at DeepSeek/MiniMax prices; the vector cache persists to disk and refreshes every six hours, so there’s no repeated billing.
Two known boundaries: new on-site articles wait for the index cache to expire (up to six hours) before entering the bot’s knowledge — this post, for instance, the bot can’t have “read” for a few hours yet; and embo-01 is weak on long technical articles, so the semantic channel remains a fallback for now — the next step, if a local bge model runs on the server (a config field is already reserved), is when the semantic channel truly comes into its own.
The “🤖 Ask Ganjiang” in the lower-left corner is the finished product. Ask it “how is this bot implemented”, and it should be able to find this very article and answer you — the answer, after all, is already in its index.
Related posts: for the server infrastructure carrying this endpoint, see Jianshi’s App Store Journey (Part 2): Full-Pipeline Automation from Kickoff to Launch (Chinese); for the pre-release quality process, see Blog Production Line (Part 3): Six Automated Pre-Publish Checks (Chinese).

