Website FAQ Chatbot with JavaScript
Solution for the use case Website Chatbot for Small Business.
- AI tools
- JavaScript (ES modules), JSON knowledge base, TF-IDF style keyword matching, localStorage, node:test
- Language
- JavaScript
- Published
- 2025-07-04
Approach
A lightweight chat widget that answers common customer questions from a JSON knowledge base. It runs entirely in the browser: plain JavaScript modules, no framework, no server, no AI model and no API costs.
- Keyword matching with stopword removal, synonyms, light stemming and IDF weights, so rare words such as "refund" decide the match.
- A confidence threshold: below it, the bot gives a fallback answer with your contact address instead of a wrong answer.
- Clickable suggestions for related questions and chat history in localStorage.
- Embedded with one tag; colors are CSS variables.
Implementation steps
- Collect the 10 to 50 questions customers ask most often and write them into faq.json with short answers.
- Add alternative phrasings, keywords and synonyms that customers really use.
- Normalize and tokenize questions: lowercase, strip accents and punctuation, remove stopwords, map synonyms, stem suffixes.
- Score each FAQ entry by the IDF-weighted share of the question's words it contains and pick the best one.
- Return a fallback answer with your contact address when the best score is below the threshold (default 0.35).
- Build the widget: toggle button, message list, suggestion buttons and input form; render text with textContent only.
- Test with npm test: every FAQ question must find its own answer, and your own paraphrased questions must find the right one.
- Embed the widget on your website with one script tag and adjust the colors in chatbot.css.
Code (JavaScript)
// FAQ matcher: finds the best answer for a question in a JSON knowledge base.
// No AI model and no server: keyword matching with IDF weights, synonyms and light stemming.
// Works in the browser and in Node.js (ES module, no dependencies).
const STOPWORDS = new Set(
("a about also an and any are be can could do does for from get has have how i in is it just know like me " +
"my need of on or our please some something take tell the there this to want we what when which who why " +
"will with would yet you your").split(" "),
);
export const DEFAULT_OPTIONS = {
minScore: 0.35, // below this, the bot says it does not know
suggestionCount: 3,
unknownWeight: 0.5, // weight of question words that appear in no entry (known words: about 1.1 to 2.5)
fallback: "Sorry, I don't know the answer to that yet. Please contact us and we will reply as soon as possible.",
};
export function normalize(text) {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "") // strip accents
.replace(/[^a-z0-9\s]/g, " ");
}
// Very small suffix stemmer: "shipping" -> "ship", "prices" -> "price", "returned" -> "return".
export function stem(word) {
if (word.length <= 4 || word.endsWith("ss")) return word;
for (const suffix of ["ing", "ed", "es", "s"]) {
if (word.endsWith(suffix) && word.length - suffix.length >= 3) {
let base = word.slice(0, -suffix.length);
if ((suffix === "ing" || suffix === "ed") && base.length >= 4 && /([b-df-hj-np-tv-z])\1$/.test(base)) {
base = base.slice(0, -1); // shipp -> ship, stopp -> stop
}
if (suffix === "es" && !/(ss|sh|ch|x|z)$/.test(base)) base = word.slice(0, -1); // prices -> price
return base;
}
}
return word;
}
export function tokenize(text, synonyms = {}) {
const tokens = [];
for (const word of normalize(text).split(/\s+/)) {
if (!word || STOPWORDS.has(word)) continue;
const canonical = synonyms[word] ?? synonyms[stem(word)] ?? word;
for (const part of canonical.split(" ")) tokens.push(stem(part));
}
return tokens;
}
export class FaqMatcher {
constructor(knowledgeBase, options = {}) {
this.options = { ...DEFAULT_OPTIONS, ...options };
this.synonyms = knowledgeBase.synonyms ?? {};
this.entries = knowledgeBase.entries.map((entry) => {
const text = [entry.question, ...(entry.alternatives ?? []), ...(entry.keywords ?? [])].join(" ");
return { ...entry, terms: new Set(tokenize(text, this.synonyms)) };
});
// Inverse document frequency: rare terms ("refund") count more than common ones ("order").
const docCount = new Map();
for (const entry of this.entries) for (const t of entry.terms) docCount.set(t, (docCount.get(t) ?? 0) + 1);
this.idf = new Map([...docCount].map(([t, n]) => [t, Math.log(1 + this.entries.length / n)]));
}
score(queryTerms, entry) {
if (queryTerms.length === 0) return 0;
let matched = 0;
let total = 0;
for (const t of new Set(queryTerms)) {
const weight = this.idf.get(t) ?? this.options.unknownWeight; // words not in any entry count a little
total += weight;
if (entry.terms.has(t)) matched += weight;
}
return matched / total; // share of the question's meaning covered by this entry, 0..1
}
ask(question) {
const terms = tokenize(question, this.synonyms);
const ranked = this.entries
.map((entry) => ({ entry, score: this.score(terms, entry) }))
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score);
const best = ranked[0];
const suggestions = ranked.slice(best && best.score >= this.options.minScore ? 1 : 0)
.slice(0, this.options.suggestionCount)
.map((r) => r.entry.question);
if (!best || best.score < this.options.minScore) {
return { id: null, answer: this.options.fallback, score: best?.score ?? 0, suggestions };
}
return { id: best.entry.id, answer: best.entry.answer, score: best.score, suggestions };
}
}
export
…
Performance
Expectations, not measured results:
- Answers are computed in the browser in well under a millisecond for a few hundred FAQ entries; no network call after the FAQ file is loaded.
- The whole widget is three small files without dependencies.
- Matching quality depends on how well the FAQ covers your customers' words; review unanswered questions regularly.
Lessons learned
Visitors click a chat button in the corner, see three example questions, and type their own. The answer appears immediately, with related questions as buttons. If the bot does not know an answer, it says so and shows your contact address. The conversation is kept when the visitor moves to another page. Works on mobile and desktop.