How I Built a Retrieval-Augmented Generation (RAG) Chatbot platform for Hotel Owners

September 22, 2025

Author: Atum

16 min read

Hey ya’ll i’m here to tell yall about my web app which is like a web app for hotels and hospitality industries or just about anyone who could use concierge or customer support service.

The idea was that if i’m a guest at a hotel and i want to know things like what the wifi password is, how i can get a taxi, what restaurants are in the area, what there’s to do in the area or things like that, i would have to talk to a concierge and ask them.

But that requires human interaction which is so 2024. I just want to text someone and get an instant response not send an email like what’s some good indian food in the area and wait an undetermined amount of time before i get a response, and i don’t want to make a phone call.

So what’s there to do? Well what about an AI concierge chat? And to access it you just have to scan a qr code that’s in the hotel which takes you to their chat web app and now you can chat with an AI concierge instead of making a call or email or having to shower and get dressed so you can talk to a stranger.

And that’s the platform I created. A platform that allows hotels (or anyone signing up) to upload their documents, have those documents turned into the knowledge base for the chat system, and then created a chat ui for interfacing with an AI api and their knowledge base.

This…is the technical tale…

Architectural choices

Technical implementation

Document rag course

The foundation of this project was mainly from this mini course from Supabase. They created a course on how to upload documents, turn that into RAG embeddings, and create a chat app that interfaces with the RAG data.

I then turned the codebase into a saas platform where a user could create a chat, upload documents tied to that chat, create embeddings on those documents, and then get a url for their chat.

AI workflow

Regarding AI, there are two systems in this app.

  1. The RAG knowledge base for the hotel's chat data.
  2. The chat system which retrieves the data.

RAG Knowledge base

Document to embeddings
(Click to view full screen)

This system starts with a user’s chat object. The chat is created in the database and it allows users to upload documents linked to that chat.

Documents could be the hotel’s menu, their wifi information, hours of operation, contact information, nearby attractions, how to use the public transit or anything that a hotel’s concierge might know.

This knowledge from text is turned into embeddings and saved to the database and associated with the particular chat. When a user sends a message through the chat, the app turns the input into an embedding and retrieves the document section that matches closest and uses that to generate a response.

So if the user inputs “What’s the wifi information” this text gets turned into an embedding, gets sent to the backend, the backend retrieves the chat’s document embedding that matches closest to the user’s input, and then generates a response using this information and then sends that back to the frontend.

1. Upload file

The function which handles the frontend file uploading:

const handleUpload = async () => {
  if (!file) return;
  setUploading(true);
  setStatusMessage("Uploading...");

  // 1. Retrieve the current user to ensure authentication.
  const {
    data: { user },
    error: userError,
  } = await supabase.auth.getUser();

  if (userError || !user) {
    console.error("User not authenticated:", userError);
    setStatusMessage("User not authenticated");
    setUploading(false);
    return;
  }

  // 2. Build a storage path without chatId (only using user.id, randomUUID, sanitizedFilename)
  const uniqueId = crypto.randomUUID();
  const sanitizedFilename = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
  const storagePath = `${user.id}/${uniqueId}/${sanitizedFilename}`;

  const { error: uploadError } = await supabase.storage
    .from("files")
    .upload(storagePath, file, {
      upsert: false,
      metadata: { chat_id: chatId }, // Just use metadata directly
    });

  if (uploadError) {
    console.error("Error uploading file:", uploadError.message);
    setStatusMessage("Error uploading file");
    setUploading(false);
    return;
  }

  // 4. Update UI: the trigger will automatically create a document row,
  // associate it with the chat, and process it in the background.
  setStatusMessage("File uploaded! Processing in background...");
  setUploading(false);

  // 5. Refresh the page so the UI can reflect new document(s)
  router.refresh();
};

2. Supabase storage trigger gets called

After a document is uploaded to Supabase storage, a database trigger is activated. This trigger calls the process function:

The process function is a Supabase edge function:

Deno.serve(async (req) => {
  // IMPORTANT: Handle OPTIONS request first, before any other logic
  if (req.method === "OPTIONS") {
    return new Response(null, {
      status: 204, // No content for OPTIONS request
      headers: corsHeaders,
    });
  }

  if (!supabaseUrl || !supabaseAnonKey) {
    return new Response(
      JSON.stringify({
        error: "Missing environment variables.",
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      }
    );
  }

  const authorization =
    req.headers.get("Authorization") || req.headers.get("authorization");

  if (!authorization) {
    return new Response(
      JSON.stringify({ error: `No authorization header passed` }),
      {
        status: 500,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      }
    );
  }

  const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
    global: {
      headers: {
        authorization,
      },
    },
    auth: {
      persistSession: false,
    },
  });

  try {
    const { document_id, chat_id } = await req.json();

    const { data: document } = await supabase
      .from("documents_with_storage_path")
      .select()
      .eq("id", document_id)
      .single();

    if (!document?.storage_object_path) {
      return new Response(
        JSON.stringify({ error: "Failed to find uploaded document" }),
        {
          status: 500,
          headers: { "Content-Type": "application/json", ...corsHeaders },
        }
      );
    }

    const { data: file } = await supabase.storage
      .from("files")
      .download(document.storage_object_path);

    if (!file) {
      console.error(
        "Storage download failed for path:",
        document.storage_object_path
      );
      return new Response(
        JSON.stringify({
          error: "Failed to download storage object",
          details: "Storage path: " + document.storage_object_path,
        }),
        {
          status: 500,
          headers: { "Content-Type": "application/json", ...corsHeaders },
        }
      );
    }

    const fileContents = await file.text();
    const processedMd = processMarkdown(fileContents);

    const { data: sections, error } = await supabase
      .from("document_sections")
      .insert(
        processedMd.sections.map(({ content }) => ({
          document_id,
          content,
        }))
      )
      .select("id"); // Need to get back the created section IDs

    try {
      if (sections) {
        await supabase.from("chat_document_sections").insert(
          sections.map((section) => ({
            chat_id,
            document_section_id: section.id,
          }))
        );
      }
    } catch (error) {
      console.error("Error linking sections to chat:", error);

      return new Response(
        JSON.stringify({ error: "Failed to link sections to chat" }),
        {
          status: 500,
          headers: { "Content-Type": "application/json", ...corsHeaders },
        }
      );
    }

    if (error) {
      console.error(error);
      return new Response(
        JSON.stringify({ error: "Failed to save document sections" }),
        {
          status: 500,
          headers: { "Content-Type": "application/json", ...corsHeaders },
        }
      );
    }

    // Change to return success with 200 status and data
    return new Response(
      JSON.stringify({ success: true, sections: processedMd.sections.length }),
      {
        status: 200,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      }
    );
  } catch (error) {
    console.error("Unhandled error:", error);
    return new Response(
      JSON.stringify({
        error: "Internal server error",
        details: error.message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      }
    );
  }
});

2. Embed trigger gets called

After document sections are inserted into the database, another Supabase database trigger is called, embed.

This Supabase edge function turns the text into embeddings and saves it to the database.

We do this because we need to make the text searchable by meaning and not just keywords. By turning the text sections into embeddings, we can store their meaning as vectors in the database.

Then when the user asks the chat a question, we can search our database for text that matches the meaning of the input rather than just keywords.

After the embeddings are created and stored, the chat is ready to start its purpose.

const model = new Supabase.ai.Session("gte-small");

// Function to clean content before embedding
function cleanContent(content: string) {
  return (
    content
      // Remove proper markdown links but keep the text
      .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
      // Remove stray markdown links (parentheses not preceded by '[')
      .replace(/(?<!\[)\([^)]+\)[*]?/g, "")
      // Remove markdown headers
      .replace(/#{1,6}\s/g, "")
      // Replace multiple newlines with a single space
      .replace(/\n+/g, " ")
      // Replace multiple spaces with a single space
      .replace(/\s+/g, " ")
      // Remove escaped underscores and asterisks
      .replace(/\\_/g, "")
      .replace(/\\\*/g, "")
      // Trim extra whitespace
      .trim()
  );
}

// Function to generate embedding with retries
async function generateEmbedding(
  content: string,
  retries = 3
): Promise<number[]> {
  for (let i = 0; i < retries; i++) {
    try {
      const output= await model.run(content, {
        mean_pool: true,
        normalize: true,
      });
      return output as number[];
    } catch (error) {
      if (i= retries - 1) throw error;
      // Exponential backoff
      await new Promise((resolve)=> setTimeout(resolve, 1000 * (i + 1)));
    }
  }
  throw new Error("All embedding attempts failed");
}

Deno.serve(async (req)=> {
  if (req.method= "OPTIONS") {
    return new Response("ok", {
      status: 200,
      headers: {
        ...corsHeaders,
        "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
      },
    });
  }
  try {
    if (!supabaseUrl || !supabaseAnonKey) {
      throw new Error("Missing environment variables.");
    }

    const authorization= req.headers.get("Authorization");
    if (!authorization) {
      throw new Error("No authorization header passed");
    }

    const supabase= createClient<Database>(supabaseUrl, supabaseAnonKey, {
      global: {
        headers: { authorization },
      },
      auth: { persistSession: false },
    });

    const { ids, table, contentColumn, embeddingColumn } = await req.json();

    const { data: rows, error: selectError } = await supabase
      .from(table)
      .select(`id, ${contentColumn}` as "*")
      .in("id", ids)
      .is(embeddingColumn, null);

    if (selectError) {
      throw selectError;
    }

    for (const row of rows) {
      const { id, [contentColumn]: content } = row;

      if (!content) {
        console.error(
          `No content available in column '${contentColumn}' for id ${id}`
        );
        continue;
      }

      try {
        const cleanedContent = cleanContent(content);

        const output = await generateEmbedding(cleanedContent);
        const embedding = JSON.stringify(output);

        const { error } = await supabase
          .from(table)
          .update({ [embeddingColumn]: embedding })
          .eq("id", id);

        if (error) {
          console.error(`Failed to save embedding for id ${id}:`, error);
        }
      } catch (error) {
        console.error(`Error processing row ${id}:`, error);
        console.error("Error message:", error?.message);
        console.error("Error stack:", error?.stack);
      }
    }

    return new Response(null, {
      status: 204,
      headers: { "Content-Type": "application/json", ...corsHeaders },
    });
  } catch (error) {
    console.error("Top level error:", error);
    console.error("Error message:", error?.message);
    console.error("Error stack:", error?.stack);

    return new Response(
      JSON.stringify({ error: error.message || "Unknown error" }),
      {
        status: 500,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      }
    );
  }
});

Chat system

Document to embeddings
(Click to view full screen)

Now we're at the end user interface. Here is where the user is like going to enter their inputs.

1. Handle user input

import { usePipeline } from "@/lib/hooks/use-pipeline";

const generateEmbedding = usePipeline(
  "feature-extraction",
  "Supabase/gte-small"
);

const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  if (!input.trim()) return;

  setIsLoading(true);

  const userMsg = {
    id: Date.now().toString(),
    role: "user",
    content: input,
  };

  const updatedMessages = [...messages, userMsg];
  setMessages(updatedMessages);

  try {
    if (!generateEmbedding) {
      throw new Error("Unable to generate embeddings");
    }

    // Generate embedding for the input text
    const output = await generateEmbedding(input, {
      pooling: "mean",
      normalize: true,
    });

    // IMPORTANT: Pass the embedding as an array of floats
    const embedding = JSON.stringify(Array.from(output.data));

    // Send the chat request including the updated messages
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/chat`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          embedding, // now an array
          messages: updatedMessages,
          chat_id: chatId,
        }),
      }
    );

    const data = await res.json();

    const assistantMsg = {
      id: Date.now().toString() + "-assistant",
      role: "assistant",
      content: data.message,
    };

    setMessages((prev) => [...prev, assistantMsg]);
    setInput("");
  } catch (err: any) {
    console.error("Error in onSubmit:", err);
  } finally {
    setIsLoading(false);
  }
};

2. Use pipeline function

This is a small helper that gives your React app a “backstage” assistant for AI. It starts a background worker in the browser, loads the model for you, and hands you one simple function to call for results.

Why it exists: Running AI on the main thread can slow or freeze your UI. This moves the heavy lifting off the main thread so scrolling, typing, and clicks stay smooth.

import { Pipeline, PretrainedOptions, Tensor } from "@xenova/transformers";
import { useEffect, useState } from "react";
import {
  InitEventData,
  OutgoingEventData,
  RunEventData,
} from "../workers/pipeline";

export type PipeParameters = Parameters<Pipeline["_call"]>;
export type PipeReturnType = Awaited<ReturnType<Pipeline["_call"]>>;
export type PipeFunction = (...args: PipeParameters) => Promise<PipeReturnType>;

/**
 * Hook to build a Transformers.js pipeline function.
 *
 * Similar to `pipeline()`, but runs inference in a separate
 * Web Worker thread and asynchronous logic is
 * abstracted for you.
 *
 * *Important:* `options` must be memoized (if passed),
 * otherwise the hook will continuously rebuild the pipeline.
 */
export function usePipeline(
  task: string,
  model?: string,
  options?: PretrainedOptions
) {
  const [worker, setWorker] = useState<Worker>();
  const [pipe, setPipe] = useState<PipeFunction>();

  // Using `useEffect` + `useState` over `useMemo` because we need a
  // cleanup function and asynchronous initialization
  useEffect(() => {
    const { progress_callback, ...transferableOptions } = options ?? {};

    const worker = new Worker(
      new URL("../workers/pipeline.ts", import.meta.url),
      {
        type: "module",
      }
    );

    const onMessageReceived = (e: MessageEvent<OutgoingEventData>) => {
      const { type } = e.data;

      switch (type) {
        case "progress": {
          const { data } = e.data;
          progress_callback?.(data);
          break;
        }
        case "ready": {
          setWorker(worker);
          break;
        }
      }
    };

    worker.addEventListener("message", onMessageReceived);

    worker.postMessage({
      type: "init",
      args: [task, model, transferableOptions],
    } satisfies InitEventData);

    return () => {
      worker.removeEventListener("message", onMessageReceived);
      worker.terminate();

      setWorker(undefined);
    };
  }, [task, model, options]);

  // Using `useEffect` + `useState` over `useMemo` because we need a
  // cleanup function
  useEffect(() => {
    if (!worker) {
      return;
    }

    // ID to sync return values between multiple ongoing pipe executions
    let currentId = 0;

    const callbacks = new Map<number, (data: PipeReturnType)=> void>();

    const onMessageReceived = (e: MessageEvent<OutgoingEventData>) => {
      switch (e.data.type) {
        case "result":
          const { id, data: serializedData } = e.data;
          const { type, data, dims } = serializedData;
          const output = new Tensor(type, data, dims);
          const callback = callbacks.get(id);

          if (!callback) {
            throw new Error(`Missing callback for pipe execution id: ${id}`);
          }

          callback(output);
          break;
      }
    };

    worker.addEventListener("message", onMessageReceived);

    const pipe: PipeFunction = (...args) => {
      if (!worker) {
        throw new Error("Worker unavailable");
      }

      const id = currentId++;

      return new Promise<PipeReturnType>((resolve) => {
        callbacks.set(id, resolve);
        worker.postMessage({ type: "run", id, args } satisfies RunEventData);
      });
    };

    setPipe(() => pipe);

    return () => {
      worker?.removeEventListener("message", onMessageReceived);
      setPipe(undefined);
    };
  }, [worker]);

  return pipe;
}

3. Chat edge function

The chat edge function is the Supabase edge function which takes the user's input, calls the database function, match_document_sections and returns the documents.

If relevant information about the user's query is found then it takes that text and adds it to the API call for context to generate a chat response.

It then returns the response to be added to the chat UI.

Deno.serve(async (req) => {
  // 1. Handle the OPTIONS preflight request
  if (req.method === "OPTIONS") {
    // Return a 200 response with the CORS headers
    return new Response("ok", {
      status: 200,
      headers: {
        ...corsHeaders,
      },
    });
  }

  // 2. Check env vars
  if (!supabaseUrl || !supabaseAnonKey) {
    return new Response(
      JSON.stringify({ error: "Missing environment variables." }),
      {
        status: 500,
        headers: {
          ...corsHeaders,
        },
      }
    );
  }

  // 3. Use the anon key if no Authorization header is provided
  const authorization =
    req.headers.get("Authorization") || `Bearer ${supabaseAnonKey}`;

  // 4. Create the Supabase client
  const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
    global: { headers: { authorization } },
    auth: { persistSession: false },
  });

  try {
    // 5. Parse request body
    const { messages, embedding, chat_id } = await req.json();

    // 6. Validate input
    const validationErrors: string[] = [];
    if (!chat_id || typeof chat_id !== "string") {
      validationErrors.push("chat_id must be a non-empty string");
    }
    if (!Array.isArray(messages)) {
      validationErrors.push("messages must be an array");
    }
    if (!Array.isArray(embedding) || embedding.length === 0) {
      validationErrors.push("embedding must be a non-empty number array");
    } else if (
      !embedding.every((v) => typeof v === "number" && Number.isFinite(v))
    ) {
      validationErrors.push("embedding must contain only finite numbers");
    }
    if (validationErrors.length > 0) {
      return new Response(
        JSON.stringify({ error: validationErrors.join(", ") }),
        {
          status: 400,
          headers: { ...corsHeaders },
        }
      );
    }
    const { data: documents, error: matchError } = await supabase
      .rpc("match_document_sections", {
        in_chat_id: chat_id,
        in_embedding: embedding,
        match_threshold: 0.78,
      })
      .select("content")
      .limit(3);

    if (matchError) {
      // need to update this error message
      console.error(matchError);
      return new Response(
        JSON.stringify({
          error: "There was an error reading your documents, please try again.",
        }),
        {
          status: 500,
          headers: {
            ...corsHeaders,
          },
        }
      );
    }

    // 7. Build the prompt
    const injectedDocs = (() => {
      if (Array.isArray(documents) && documents.length > 0) {
        const joined = documents
          .map((row: unknown) => {
            if (
              row &&
              typeof row === "object" &&
              "content" in row &&
              typeof (row as { content?: unknown }).content === "string"
            ) {
              return (row as { content: string }).content;
            }
            return "";
          })
          .filter((c) => c.trim() !== "")
          .join("\n\n");
        return joined.length > 0 ? joined : "No documents found";
      }
      return "No documents found";
    })();

    const completionMessages = [
      {
        role: "system",
        content: codeBlock`
          You are a helpful, concise hotel concierge.
          You are given a guest's question and a set of reference documents.
          Answer using ONLY information from the documents below. If the documents do not contain the answer, say you don't know.
          Keep replies succinct, start with the most important information, remain non-technical, and end with enthusiasm.
          Documents:
          ${injectedDocs}
        `,
      },
      ...messages,
    ];

    const cleanedCompletionMessages = completionMessages.filter(
      (msg) => typeof msg.content === "string" && msg.content.trim() !== ""
    );

    const completion = await openai.chat.completions.create({
      model: "gpt-3.5-turbo-0125",
      messages: cleanedCompletionMessages,
      max_tokens: 1024,
      temperature: 0,
      stream: false,
    });

    // 9. Return the response with CORS headers
    return new Response(
      JSON.stringify({
        message: completion.choices[0].message.content,
      }),
      {
        headers: {
          ...corsHeaders,
        },
      }
    );
  } catch (err: unknown) {
    console.error("Error in chat function:", err);
    const message = err instanceof Error ? err.message : "Unknown error.";
    return new Response(JSON.stringify({ error: message }), {
      status: 500,
      headers: {
        ...corsHeaders,
      },
    });
  }
});

4. Match document sections

This is a SQL function.

It takes an embedding and returns the IDs and content of the documents most similar to the input embedding, ordered by the highest similarity.

BEGIN
  RETURN QUERY
  SELECT ds.id, ds.content
  FROM document_sections ds
  JOIN chat_document_sections cds ON cds.document_section_id = ds.id
  WHERE cds.chat_id = in_chat_id
    AND ds.embedding <#> in_embedding < -match_threshold
  ORDER BY ds.embedding <#> in_embedding;
END;

Conclusion

In conclusion, I think this app is cool and had potential. A platform to create your own AI assisted knowledge chatbot is a powerful platform. I'm into building not marketing so I decided to move onto another project.