DEV Community

Cover image for We Built a Chatbot Without an LLM: Here’s How It Works
Mihai-Cristian Bâltac
Mihai-Cristian Bâltac

Posted on Originally published at digitalempr.ro AI-assisted

We Built a Chatbot Without an LLM: Here’s How It Works

When you hear "chatbot" in 2026, the obvious architecture is something like:

For one of our projects, we deliberately didn't do that.

The chatbot needed to answer questions about things like:

  • prices;
  • opening hours;
  • appointments;
  • service availability;
  • business policies.

For those kinds of questions, we cared more about predictability than creativity.

If the business says:

We only work by appointment.

we don't want a model to turn that into:

Appointments are recommended, but you may be able to come without one.

It sounds helpful.

It is also wrong.

So we built a deterministic chatbot with:

  • Astro;
  • TypeScript;
  • Sanity;
  • Fuse.js.

No LLM generates the customer-facing answers.

Here's how it works.


The basic idea

Instead of asking a model:

What should I answer?

we ask our system:

Which known intent does this question most likely belong to?

The business controls the actual response.

Conceptually:

The important part isn't actually Fuse.js.

It's everything around it.


Sanity is the knowledge source

We didn't want prices, answers, keywords, or conversation options buried inside the application code.

An intent can look approximately like this:

export interface ChatIntent {
  id: string
  title: string

  phrases: string[]
  keywords: string[]
  negativeKeywords?: string[]

  answer: string

  priority?: number

  contextTags?: string[]
  requiredContextTags?: string[]

  buttons?: ChatButton[]

  enabled: boolean
}
Enter fullscreen mode Exit fullscreen mode

For example:

{
  "title": "Consultation price",
  "phrases": [
    "How much does a consultation cost?",
    "What is the price of a consultation?",
    "What do you charge for a consultation?"
  ],
  "keywords": [
    "price",
    "cost",
    "charge",
    "consultation",
    "consult"
  ],
  "answer": "A consultation costs...",
  "enabled": true
}
Enter fullscreen mode Exit fullscreen mode

This separation turned out to be useful.

The matcher decides what the user means.

The business decides what the answer is.

If the business changes a price or opening hour, it can be updated from Sanity without changing the matching algorithm.


First, normalize everything

Real users don't type like your test data.

They write:

how much consult
consultation price???
HOW MUCH
how mutch is consultation
Enter fullscreen mode Exit fullscreen mode

Or, in Romanian:

cat costa consultatia
Enter fullscreen mode Exit fullscreen mode

instead of:

Cât costă consultația?
Enter fullscreen mode Exit fullscreen mode

So before matching anything, we normalize the input.

A simplified version:

export function normalizeText(value: string): string {
  return value
    .toLowerCase()
    .normalize('NFD')
    .replace(/\p{Diacritic}/gu, '')
    .replace(/[^\p{L}\p{N}\s]/gu, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}
Enter fullscreen mode Exit fullscreen mode

Normalization gets rid of a surprising amount of unnecessary complexity.

But it isn't enough.


Exact matches should still win

Before doing anything clever, check the obvious cases.

function exactPhraseMatch(
  message: string,
  phrases: string[],
): boolean {
  return phrases.some(
    phrase => normalizeText(phrase) === message
  )
}
Enter fullscreen mode Exit fullscreen mode

If the user asks exactly something we already know, there is little reason to rely on fuzzy matching.

We can also look for known phrases inside longer messages:

function containedPhraseMatch(
  message: string,
  phrases: string[],
): boolean {
  return phrases.some(phrase =>
    message.includes(normalizeText(phrase))
  )
}
Enter fullscreen mode Exit fullscreen mode

But things become harder when someone writes:

Hi, I have a dog and I'd like to know roughly how much it would cost to bring him in for a consultation.

That's where multiple signals become useful.


Keywords help, but they aren't the answer

An intent about consultation pricing might contain:

[
  'price',
  'cost',
  'charge',
  'consult',
  'consultation'
]
Enter fullscreen mode Exit fullscreen mode

We can calculate keyword coverage:

function keywordCoverage(
  message: string,
  keywords: string[],
): number {
  if (!keywords.length) return 0

  const matches = keywords.filter(keyword =>
    message.includes(normalizeText(keyword))
  )

  return matches.length / keywords.length
}
Enter fullscreen mode Exit fullscreen mode

But imagine the user only writes:

price
Enter fullscreen mode Exit fullscreen mode

We might have:

consultation price
vaccine price
subscription price
analysis price
Enter fullscreen mode Exit fullscreen mode

Technically, they all match.

So keywords become another signal rather than the decision.


Then comes fuzzy matching

We use Fuse.js to catch approximate wording and typos.

Something roughly like:

import Fuse from 'fuse.js'

const fuse = new Fuse(searchablePhrases, {
  includeScore: true,
  threshold: 0.35,
  keys: ['text'],
})
Enter fullscreen mode Exit fullscreen mode

Then:

const results = fuse.search(normalizedMessage)
Enter fullscreen mode Exit fullscreen mode

This helps with variations such as:

consultation
consutation
consultaton
Enter fullscreen mode Exit fullscreen mode

But this is where one of the more important lessons from the project appeared:

The best fuzzy result isn't necessarily a safe answer.

Fuse will try to find the nearest thing.

Our chatbot needs to decide whether that nearest thing is actually good enough.


So we combine signals

Conceptually, each candidate gets something like:

interface MatchSignals {
  exactPhrase: number
  containedPhrase: number
  keywordCoverage: number
  fuzzySimilarity: number
  contextBoost: number
  priorityBoost: number
  negativePenalty: number
}
Enter fullscreen mode Exit fullscreen mode

And those signals can contribute to a score:

function calculateScore(signals: MatchSignals) {
  return (
    signals.exactPhrase * 0.35 +
    signals.containedPhrase * 0.20 +
    signals.keywordCoverage * 0.20 +
    signals.fuzzySimilarity * 0.15 +
    signals.contextBoost * 0.05 +
    signals.priorityBoost * 0.05 -
    signals.negativePenalty
  )
}
Enter fullscreen mode Exit fullscreen mode

Those weights are illustrative.

The real point is the architecture:

Exact phrase
      +
Keywords
      +
Fuzzy similarity
      +
Context
      +
Priority
      -
Negative signals
      ↓
Confidence
Enter fullscreen mode Exit fullscreen mode

No single signal gets complete control.


Negative keywords were surprisingly useful

Consider:

consultation price
cancel consultation
Enter fullscreen mode Exit fullscreen mode

Both contain consultation.

For the pricing intent we might have:

keywords: [
  'price',
  'cost',
  'charge'
]
Enter fullscreen mode Exit fullscreen mode

but also:

negativeKeywords: [
  'cancel',
  'cancellation',
  'reschedule'
]
Enter fullscreen mode Exit fullscreen mode

If someone writes:

How do I cancel my consultation?

the word consultation helps both candidates, but cancel actively hurts the pricing candidate.

Sometimes knowing what an intent isn't is almost as useful as knowing what it is.


The most important check: ambiguity

Suppose our matcher returns:

[
  {
    intent: 'consultation-price',
    score: 0.81
  },
  {
    intent: 'subscription-price',
    score: 0.79
  }
]
Enter fullscreen mode Exit fullscreen mode

Technically, consultation-price won.

But did it really?

The difference is:

0.02
Enter fullscreen mode Exit fullscreen mode

We don't want:

return matches[0]
Enter fullscreen mode Exit fullscreen mode

Instead, we can use both an answer threshold and an ambiguity margin.

const ANSWER_THRESHOLD = 0.75
const AMBIGUITY_MARGIN = 0.10

const [best, second] = matches

if (best.score < ANSWER_THRESHOLD) {
  return fallback()
}

if (
  second &&
  best.score - second.score < AMBIGUITY_MARGIN
) {
  return clarification()
}

return answer(best.intent)
Enter fullscreen mode Exit fullscreen mode

Again, the numbers are only examples.

The idea is much more important:

A candidate isn't trustworthy merely because it came first.


We have three outcomes

Instead of:

matched
not matched
Enter fullscreen mode Exit fullscreen mode

we use:

type MatchResult =
  | {
      type: 'answer'
      intent: ChatIntent
      confidence: number
    }
  | {
      type: 'clarify'
      candidates: ChatIntent[]
    }
  | {
      type: 'fallback'
    }
Enter fullscreen mode Exit fullscreen mode

1. Answer

User:
How much does a consultation cost?

Bot:
A consultation costs...
Enter fullscreen mode Exit fullscreen mode

2. Clarify

User:
How much does it cost?

Bot:
Which service would you like the price for?

[Consultation]
[Tests]
[Subscription]
Enter fullscreen mode Exit fullscreen mode

3. Fallback

User:
I have a complicated situation...

Bot:
I don't have enough information to answer that correctly.

Would you like me to send your question to the team?
Enter fullscreen mode Exit fullscreen mode

For this project, refusing to answer is a feature.


What about follow-up questions?

Then we ran into conversations like this:

User:
How much does the consultation cost?

Bot:
...

User:
And what does it include?
Enter fullscreen mode Exit fullscreen mode

Analyzed independently:

and what does it include
Enter fullscreen mode Exit fullscreen mode

is almost useless.

So we keep lightweight conversation context:

interface ConversationContext {
  previousIntent?: string
  activeTopic?: string
  contextTags: string[]
}
Enter fullscreen mode Exit fullscreen mode

After the first question:

{
  previousIntent: 'consultation-price',
  activeTopic: 'consultation',
  contextTags: ['consultation']
}
Enter fullscreen mode Exit fullscreen mode

Another intent can require:

requiredContextTags: ['consultation']
Enter fullscreen mode Exit fullscreen mode

and receive a small scoring boost.

We don't need an LLM-sized memory system for every type of conversational context.

Sometimes remembering what we're currently talking about is enough.


Astro exposes the matcher

The UI doesn't contain the matching logic.

It sends the message to an Astro API endpoint:

POST /api/chatbot/message
Enter fullscreen mode Exit fullscreen mode

For example:

{
  "message": "how much does a consultation cost",
  "sessionId": "..."
}
Enter fullscreen mode Exit fullscreen mode

A simplified endpoint:

import type { APIRoute } from 'astro'
import { matchMessage } from '@/lib/chatbot/matcher'

export const POST: APIRoute = async ({ request }) => {
  const body = await request.json()

  const result = await matchMessage({
    message: body.message,
    sessionId: body.sessionId,
  })

  return new Response(
    JSON.stringify(result),
    {
      headers: {
        'Content-Type': 'application/json',
      },
    },
  )
}
Enter fullscreen mode Exit fullscreen mode

The frontend receives a predictable result:

{
  "type": "answer",
  "message": "A consultation costs...",
  "buttons": [
    {
      "label": "Book an appointment",
      "action": "..."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This also means we can replace or redesign the chat UI without rewriting the matcher.


Sanity shouldn't sit in front of every message

The intents don't change every few seconds.

So querying Sanity for every user message would add unnecessary work.

Instead, the knowledge base can be cached:

let cachedKnowledge: KnowledgeBase | null = null
let expiresAt = 0

export async function getKnowledge() {
  if (
    cachedKnowledge &&
    Date.now() < expiresAt
  ) {
    return cachedKnowledge
  }

  const intents = await fetchIntentsFromSanity()

  cachedKnowledge = buildKnowledgeBase(intents)
  expiresAt = Date.now() + CACHE_TTL

  return cachedKnowledge
}
Enter fullscreen mode Exit fullscreen mode

Sanity remains the source of truth.

It doesn't necessarily need to be part of the critical path for every message.


The unexpected part: unanswered questions are useful

Originally, the goal was straightforward:

Reduce repetitive customer-support questions.

But then we started thinking about the fallback data.

Imagine seeing:

37 × "do you provide emergency services?"
21 × "can I pay monthly?"
18 × "are you open on Saturdays?"
Enter fullscreen mode Exit fullscreen mode

Those aren't only chatbot failures.

They're customer signals.

They can indicate:

  • a missing chatbot intent;
  • unclear website content;
  • terminology customers use that the business doesn't;
  • a potential article;
  • a UX problem;
  • a possible new service or package.

This changed how I think about the system.

The chatbot isn't only an answering machine.

It can also become a customer research interface.


Ironically, this is where AI becomes interesting

We deliberately avoided generative AI for official answers.

But I think AI could be extremely useful one step later.

Imagine collecting 500 unanswered questions and asking a model to cluster them.

It might identify:

Cluster: Emergency availability

- do you handle emergencies?
- can I come in urgently?
- do you offer emergency consultations?
- do you accept emergencies at night?
Enter fullscreen mode Exit fullscreen mode

Then a human decides:

  • Should we create another intent?
  • Is the website missing important information?
  • Are customers using different terminology?
  • Is there demand for something the business doesn't currently offer?

That gives us a separation I like:

AI → analysis

Deterministic system → official answers
Enter fullscreen mode Exit fullscreen mode

It's not really "AI vs no AI."

It's about putting each tool in the part of the system where its characteristics are useful.


The main thing I learned

The difficult part of this chatbot wasn't teaching it to answer questions.

It was teaching it when not to answer.

A fuzzy search system can almost always find something that looks similar.

A trustworthy system needs another capability:

I found something,
but I'm not confident enough to use it.
Enter fullscreen mode Exit fullscreen mode

For prices, schedules, policies, service conditions, and similar business information, that behavior can be more valuable than generating a natural-sounding response every time.

And the questions it refuses to answer?

Those may eventually become the most interesting data in the whole system.


If 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. Unfortunately, the website is currently only available in Romanian, but we’re planning to translate it into English soon.

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.

Top comments (0)