{"slug": "we-built-a-chatbot-without-an-llm-heres-how-it-works", "title": "We Built a Chatbot Without an LLM: Here’s How It Works", "summary": "A developer detailed how they built a deterministic chatbot without an LLM, prioritizing predictability over generative creativity for business-critical answers. The system uses intent matching with normalization, exact phrase matching, and keyword coverage, with responses managed by the business via a CMS like Sanity.", "body_md": "When you hear \"chatbot\" in 2026, the obvious architecture is something like:\n\nFor one of our projects, we deliberately didn't do that.\n\nThe chatbot needed to answer questions about things like:\n\nFor those kinds of questions, we cared more about predictability than creativity.\n\nIf the business says:\n\nWe only work by appointment.\n\nwe don't want a model to turn that into:\n\nAppointments are recommended, but you may be able to come without one.\n\nIt sounds helpful.\n\nIt is also wrong.\n\nSo we built a deterministic chatbot with:\n\nNo LLM generates the customer-facing answers.\n\nHere's how it works.\n\nInstead of asking a model:\n\nWhat should I answer?\n\nwe ask our system:\n\nWhich known intent does this question most likely belong to?\n\nThe business controls the actual response.\n\nConceptually:\n\nThe important part isn't actually Fuse.js.\n\nIt's everything around it.\n\nWe didn't want prices, answers, keywords, or conversation options buried inside the application code.\n\nAn intent can look approximately like this:\n\n```\nexport interface ChatIntent {\n  id: string\n  title: string\n\n  phrases: string[]\n  keywords: string[]\n  negativeKeywords?: string[]\n\n  answer: string\n\n  priority?: number\n\n  contextTags?: string[]\n  requiredContextTags?: string[]\n\n  buttons?: ChatButton[]\n\n  enabled: boolean\n}\n```\n\nFor example:\n\n```\n{\n  \"title\": \"Consultation price\",\n  \"phrases\": [\n    \"How much does a consultation cost?\",\n    \"What is the price of a consultation?\",\n    \"What do you charge for a consultation?\"\n  ],\n  \"keywords\": [\n    \"price\",\n    \"cost\",\n    \"charge\",\n    \"consultation\",\n    \"consult\"\n  ],\n  \"answer\": \"A consultation costs...\",\n  \"enabled\": true\n}\n```\n\nThis separation turned out to be useful.\n\n**The matcher decides what the user means.**\n\n**The business decides what the answer is.**\n\nIf the business changes a price or opening hour, it can be updated from Sanity without changing the matching algorithm.\n\nReal users don't type like your test data.\n\nThey write:\n\n```\nhow much consult\nconsultation price???\nHOW MUCH\nhow mutch is consultation\n```\n\nOr, in Romanian:\n\n```\ncat costa consultatia\n```\n\ninstead of:\n\n```\nCât costă consultația?\n```\n\nSo before matching anything, we normalize the input.\n\nA simplified version:\n\n```\nexport function normalizeText(value: string): string {\n  return value\n    .toLowerCase()\n    .normalize('NFD')\n    .replace(/\\p{Diacritic}/gu, '')\n    .replace(/[^\\p{L}\\p{N}\\s]/gu, ' ')\n    .replace(/\\s+/g, ' ')\n    .trim()\n}\n```\n\nNormalization gets rid of a surprising amount of unnecessary complexity.\n\nBut it isn't enough.\n\nBefore doing anything clever, check the obvious cases.\n\n```\nfunction exactPhraseMatch(\n  message: string,\n  phrases: string[],\n): boolean {\n  return phrases.some(\n    phrase => normalizeText(phrase) === message\n  )\n}\n```\n\nIf the user asks exactly something we already know, there is little reason to rely on fuzzy matching.\n\nWe can also look for known phrases inside longer messages:\n\n```\nfunction containedPhraseMatch(\n  message: string,\n  phrases: string[],\n): boolean {\n  return phrases.some(phrase =>\n    message.includes(normalizeText(phrase))\n  )\n}\n```\n\nBut things become harder when someone writes:\n\nHi, I have a dog and I'd like to know roughly how much it would cost to bring him in for a consultation.\n\nThat's where multiple signals become useful.\n\nAn intent about consultation pricing might contain:\n\n```\n[\n  'price',\n  'cost',\n  'charge',\n  'consult',\n  'consultation'\n]\n```\n\nWe can calculate keyword coverage:\n\n```\nfunction keywordCoverage(\n  message: string,\n  keywords: string[],\n): number {\n  if (!keywords.length) return 0\n\n  const matches = keywords.filter(keyword =>\n    message.includes(normalizeText(keyword))\n  )\n\n  return matches.length / keywords.length\n}\n```\n\nBut imagine the user only writes:\n\n```\nprice\n```\n\nWe might have:\n\n```\nconsultation price\nvaccine price\nsubscription price\nanalysis price\n```\n\nTechnically, they all match.\n\nSo keywords become another signal rather than the decision.\n\nWe use Fuse.js to catch approximate wording and typos.\n\nSomething roughly like:\n\n``` python\nimport Fuse from 'fuse.js'\n\nconst fuse = new Fuse(searchablePhrases, {\n  includeScore: true,\n  threshold: 0.35,\n  keys: ['text'],\n})\n```\n\nThen:\n\n``` js\nconst results = fuse.search(normalizedMessage)\n```\n\nThis helps with variations such as:\n\n```\nconsultation\nconsutation\nconsultaton\n```\n\nBut this is where one of the more important lessons from the project appeared:\n\nThe best fuzzy result isn't necessarily a safe answer.\n\nFuse will try to find the nearest thing.\n\nOur chatbot needs to decide whether that nearest thing is actually good enough.\n\nConceptually, each candidate gets something like:\n\n```\ninterface MatchSignals {\n  exactPhrase: number\n  containedPhrase: number\n  keywordCoverage: number\n  fuzzySimilarity: number\n  contextBoost: number\n  priorityBoost: number\n  negativePenalty: number\n}\n```\n\nAnd those signals can contribute to a score:\n\n```\nfunction calculateScore(signals: MatchSignals) {\n  return (\n    signals.exactPhrase * 0.35 +\n    signals.containedPhrase * 0.20 +\n    signals.keywordCoverage * 0.20 +\n    signals.fuzzySimilarity * 0.15 +\n    signals.contextBoost * 0.05 +\n    signals.priorityBoost * 0.05 -\n    signals.negativePenalty\n  )\n}\n```\n\nThose weights are illustrative.\n\nThe real point is the architecture:\n\n```\nExact phrase\n      +\nKeywords\n      +\nFuzzy similarity\n      +\nContext\n      +\nPriority\n      -\nNegative signals\n      ↓\nConfidence\n```\n\nNo single signal gets complete control.\n\nConsider:\n\n```\nconsultation price\ncancel consultation\n```\n\nBoth contain `consultation`\n\n.\n\nFor the pricing intent we might have:\n\n```\nkeywords: [\n  'price',\n  'cost',\n  'charge'\n]\n```\n\nbut also:\n\n```\nnegativeKeywords: [\n  'cancel',\n  'cancellation',\n  'reschedule'\n]\n```\n\nIf someone writes:\n\nHow do I cancel my consultation?\n\nthe word `consultation`\n\nhelps both candidates, but `cancel`\n\nactively hurts the pricing candidate.\n\nSometimes **knowing what an intent isn't** is almost as useful as knowing what it is.\n\nSuppose our matcher returns:\n\n```\n[\n  {\n    intent: 'consultation-price',\n    score: 0.81\n  },\n  {\n    intent: 'subscription-price',\n    score: 0.79\n  }\n]\n```\n\nTechnically, `consultation-price`\n\nwon.\n\nBut did it really?\n\nThe difference is:\n\n```\n0.02\n```\n\nWe don't want:\n\n```\nreturn matches[0]\n```\n\nInstead, we can use both an answer threshold and an ambiguity margin.\n\n``` js\nconst ANSWER_THRESHOLD = 0.75\nconst AMBIGUITY_MARGIN = 0.10\n\nconst [best, second] = matches\n\nif (best.score < ANSWER_THRESHOLD) {\n  return fallback()\n}\n\nif (\n  second &&\n  best.score - second.score < AMBIGUITY_MARGIN\n) {\n  return clarification()\n}\n\nreturn answer(best.intent)\n```\n\nAgain, the numbers are only examples.\n\nThe idea is much more important:\n\n**A candidate isn't trustworthy merely because it came first.**\n\nInstead of:\n\n```\nmatched\nnot matched\n```\n\nwe use:\n\n```\ntype MatchResult =\n  | {\n      type: 'answer'\n      intent: ChatIntent\n      confidence: number\n    }\n  | {\n      type: 'clarify'\n      candidates: ChatIntent[]\n    }\n  | {\n      type: 'fallback'\n    }\nUser:\nHow much does a consultation cost?\n\nBot:\nA consultation costs...\nUser:\nHow much does it cost?\n\nBot:\nWhich service would you like the price for?\n\n[Consultation]\n[Tests]\n[Subscription]\nUser:\nI have a complicated situation...\n\nBot:\nI don't have enough information to answer that correctly.\n\nWould you like me to send your question to the team?\n```\n\nFor this project, **refusing to answer is a feature**.\n\nThen we ran into conversations like this:\n\n```\nUser:\nHow much does the consultation cost?\n\nBot:\n...\n\nUser:\nAnd what does it include?\n```\n\nAnalyzed independently:\n\n```\nand what does it include\n```\n\nis almost useless.\n\nSo we keep lightweight conversation context:\n\n```\ninterface ConversationContext {\n  previousIntent?: string\n  activeTopic?: string\n  contextTags: string[]\n}\n```\n\nAfter the first question:\n\n```\n{\n  previousIntent: 'consultation-price',\n  activeTopic: 'consultation',\n  contextTags: ['consultation']\n}\n```\n\nAnother intent can require:\n\n```\nrequiredContextTags: ['consultation']\n```\n\nand receive a small scoring boost.\n\nWe don't need an LLM-sized memory system for every type of conversational context.\n\nSometimes remembering **what we're currently talking about** is enough.\n\nThe UI doesn't contain the matching logic.\n\nIt sends the message to an Astro API endpoint:\n\n```\nPOST /api/chatbot/message\n```\n\nFor example:\n\n```\n{\n  \"message\": \"how much does a consultation cost\",\n  \"sessionId\": \"...\"\n}\n```\n\nA simplified endpoint:\n\n``` python\nimport type { APIRoute } from 'astro'\nimport { matchMessage } from '@/lib/chatbot/matcher'\n\nexport const POST: APIRoute = async ({ request }) => {\n  const body = await request.json()\n\n  const result = await matchMessage({\n    message: body.message,\n    sessionId: body.sessionId,\n  })\n\n  return new Response(\n    JSON.stringify(result),\n    {\n      headers: {\n        'Content-Type': 'application/json',\n      },\n    },\n  )\n}\n```\n\nThe frontend receives a predictable result:\n\n```\n{\n  \"type\": \"answer\",\n  \"message\": \"A consultation costs...\",\n  \"buttons\": [\n    {\n      \"label\": \"Book an appointment\",\n      \"action\": \"...\"\n    }\n  ]\n}\n```\n\nThis also means we can replace or redesign the chat UI without rewriting the matcher.\n\nThe intents don't change every few seconds.\n\nSo querying Sanity for every user message would add unnecessary work.\n\nInstead, the knowledge base can be cached:\n\n``` js\nlet cachedKnowledge: KnowledgeBase | null = null\nlet expiresAt = 0\n\nexport async function getKnowledge() {\n  if (\n    cachedKnowledge &&\n    Date.now() < expiresAt\n  ) {\n    return cachedKnowledge\n  }\n\n  const intents = await fetchIntentsFromSanity()\n\n  cachedKnowledge = buildKnowledgeBase(intents)\n  expiresAt = Date.now() + CACHE_TTL\n\n  return cachedKnowledge\n}\n```\n\nSanity remains the source of truth.\n\nIt doesn't necessarily need to be part of the critical path for every message.\n\nOriginally, the goal was straightforward:\n\nReduce repetitive customer-support questions.\n\nBut then we started thinking about the fallback data.\n\nImagine seeing:\n\n```\n37 × \"do you provide emergency services?\"\n21 × \"can I pay monthly?\"\n18 × \"are you open on Saturdays?\"\n```\n\nThose aren't only chatbot failures.\n\nThey're customer signals.\n\nThey can indicate:\n\nThis changed how I think about the system.\n\nThe chatbot isn't only an answering machine.\n\nIt can also become a **customer research interface**.\n\nWe deliberately avoided generative AI for official answers.\n\nBut I think AI could be extremely useful one step later.\n\nImagine collecting 500 unanswered questions and asking a model to cluster them.\n\nIt might identify:\n\n```\nCluster: Emergency availability\n\n- do you handle emergencies?\n- can I come in urgently?\n- do you offer emergency consultations?\n- do you accept emergencies at night?\n```\n\nThen a human decides:\n\nThat gives us a separation I like:\n\n```\nAI → analysis\n\nDeterministic system → official answers\n```\n\nIt's not really \"AI vs no AI.\"\n\nIt's about putting each tool in the part of the system where its characteristics are useful.\n\nThe difficult part of this chatbot wasn't teaching it to answer questions.\n\nIt was teaching it **when not to answer**.\n\nA fuzzy search system can almost always find something that looks similar.\n\nA trustworthy system needs another capability:\n\n```\nI found something,\nbut I'm not confident enough to use it.\n```\n\nFor prices, schedules, policies, service conditions, and similar business information, that behavior can be more valuable than generating a natural-sounding response every time.\n\nAnd the questions it refuses to answer?\n\nThose may eventually become the most interesting data in the whole system.\n\nIf you're interested in the longer implementation guide and the product reasoning behind the experiment, I've documented the project in more detail on the [Digital Empr Research & Development site](https://digitalempr.ro/cercetare-si-dezvoltare). *Unfortunately, the website is currently only available in Romanian, but we’re planning to translate it into English soon.*\n\n**Disclosure:** I designed and implemented the system described here. AI tools were used to assist with editing and structuring this article; the technical decisions and project experience are my own.", "url": "https://wpnews.pro/news/we-built-a-chatbot-without-an-llm-heres-how-it-works", "canonical_source": "https://dev.to/baltacmihai/we-built-a-chatbot-without-an-llm-heres-how-it-works-39o6", "published_at": "2026-09-04 07:30:15+00:00", "updated_at": "2026-09-04 07:53:49.712118+00:00", "lang": "en", "topics": ["developer-tools", "ai-products", "natural-language-processing"], "entities": ["Fuse.js", "Sanity"], "alternates": {"html": "https://wpnews.pro/news/we-built-a-chatbot-without-an-llm-heres-how-it-works", "markdown": "https://wpnews.pro/news/we-built-a-chatbot-without-an-llm-heres-how-it-works.md", "text": "https://wpnews.pro/news/we-built-a-chatbot-without-an-llm-heres-how-it-works.txt", "jsonld": "https://wpnews.pro/news/we-built-a-chatbot-without-an-llm-heres-how-it-works.jsonld"}}