Skip to content

Build a support chatbot with Appwrite Functions and VectorsDB_

Seed a help center, retrieve relevant articles with VectorsDB, and answer customer questions through an Appwrite Function. Deploy the chat interface on Appwrite Sites.

A customer asks, "Can I get my tasks into a spreadsheet?" Your help center has an article called "Export a project as CSV." A support chatbot can find that article, explain the steps, and link the customer to it.

This tutorial builds Harbor Help, a chatbot for a fictional project-management product with 40 help articles and FAQs about accounts, tasks, projects, billing, and integrations. It handles follow-up questions such as "Does that include attachments?" and refuses questions the help center does not cover.

Appwrite VectorsDB stores the articles as embeddings, an Appwrite Function retrieves them and generates answers through OpenRouter, and Appwrite Sites hosts the React interface. The article covers the Appwrite setup and SDK calls.

How the chatbot works

Every question goes through one Appwrite Function. The Function embeds the question with Appwrite's Embeddings service, asks VectorsDB for the six closest excerpts, and passes them to the language model. The model must answer from that text or say that it cannot. It replies with JSON holding the answer and the numeric IDs of the excerpts it used. The Function maps those IDs back to the retrieved documents and builds the source links itself, so a citation can only point at an article that was in the results.

This is retrieval-augmented generation, or RAG. The model never sees the whole help center. It only sees the excerpts that match the current question, so editing an article and reseeding changes the next answer without any model training.

Prerequisites

You will need:

  • An Appwrite project with access to VectorsDB, Functions, and Sites.
  • Node.js 22 or later and pnpm 10.
  • An OpenRouter API key with credit for a model that supports structured outputs. This example uses openai/gpt-5.6-luna.

Create the vector collection

In your project at appwrite.io, open Databases and select Create database. Choose VectorsDB, name it Harbor support, and set its custom ID to support. Select your preferred tier and create the database.

Inside the database, select Create collection. Name it Articles, set the custom ID to articles, and choose the all-minilm embedding model. The model fixes the collection's vector size at 384 dimensions. The seed script and the Function embed text with the same model, so a question's vector is comparable with the stored article vectors.

Leave the collection permissions empty. Visitors never read the collection directly. They ask questions through the Function, and the Function reads the collection with the API key Appwrite issues to it for each execution.

Each document stores its vector in embeddings and its data in metadata, an object that VectorsDB returns with the match. The language model cannot read a vector, so the excerpt text goes in metadata along with what the interface needs to render a source link:

FieldPurpose
articleIdIdentifies the original help article
titleLabels the source link
categoryGroups articles in the help center
kindDistinguishes an article from an FAQ
textThe excerpt the language model reads
urlPath of the original article, such as /help/export-project

Seed the help articles and FAQs

The seed script runs on your machine with an API key. Open API Keys and create one with these scopes:

ScopePurpose
vectorsdb.collections.readCheck the collection's dimension
vectorsdb.indexes.readCheck whether the index is ready
vectorsdb.indexes.writeCreate the similarity index
vectorsdb.documents.readInspect the seeded documents
vectorsdb.documents.writeCreate or update excerpts
embeddings.writeGenerate embeddings

In your local copy of the repository, copy .env.example to .env and fill it in. The endpoint and project ID are shown under the project's Settings:

.env
APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=<PROJECT_ID>
APPWRITE_API_KEY=<SEED_API_KEY>
APPWRITE_DATABASE_ID=support
APPWRITE_COLLECTION_ID=articles

Install the dependencies and run the importer:

Shell
pnpm install
pnpm seed

Similarity search needs an index on the embeddings field, and the index type has to match the query the Function runs later. The script creates an HNSW cosine index and waits for it to become available before writing any documents:

JavaScript
import { VectorsDBIndexType } from 'node-appwrite'

await vectorsDB.createIndex({
  databaseId,
  collectionId,
  key: 'embeddings_cosine',
  type: VectorsDBIndexType.HnswCosine,
  attributes: ['embeddings'],
})

It then walks data/articles.json. The script embeds each excerpt with the article's title in front of its body, so the vector carries the subject as well as the details. It stores the vector together with its metadata:

JavaScript
import { EmbeddingModel } from 'node-appwrite'

const result = await embeddings.createTextEmbeddings({
  texts: [chunk.input],
  model: EmbeddingModel.Allminilm,
})

const vector = result.embeddings[0]
if (vector.error || vector.embedding.length !== 384) {
  throw new Error(`Could not embed ${chunk.id}`)
}

await vectorsDB.createDocument({
  databaseId,
  collectionId,
  documentId: chunk.id,
  data: {
    embeddings: vector.embedding,
    metadata: chunk.metadata,
  },
})

Document IDs are the article ID plus a chunk number. When creation returns a conflict, the script updates the document instead, so reseeding overwrites articles rather than duplicating them. The bundled articles fit in one chunk each. The script splits anything longer than 650 characters into several chunks that share the same title and URL. Longer or non-English content may need a smaller chunk size. When an article shrinks, its stale chunks stay in the collection until you delete them.

Create the chatbot Function

Open Functions and create a Node.js 22 Function named Support chatbot with the ID support-chat. Configure it as follows:

SettingValue
Entrypointsrc/main.js
Build commandnpm install --omit=dev
PermissionsAny
Scopesembeddings.write, vectorsdb.documents.read

Setting Permissions to Any on the Security tab lets visitors ask questions without signing in. The two scopes define the API key that Appwrite issues to the Function for each execution. Under Variables, add:

VariableValue
APPWRITE_DATABASE_IDsupport
APPWRITE_COLLECTION_IDarticles
OPENROUTER_API_KEYYour OpenRouter key, marked as secret
OPENROUTER_MODELopenai/gpt-5.6-luna

Appwrite passes that per-execution key in the x-appwrite-key header, along with the endpoint and project ID as environment variables. The Function in functions/chat/src/main.js builds its client from those, so the seed key from the previous step never leaves your machine:

JavaScript
import { Client, Embeddings, VectorsDB } from 'node-appwrite'

const client = new Client()
  .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT)
  .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
  .setKey(req.headers['x-appwrite-key'])

const embeddings = new Embeddings(client)
const vectorsDB = new VectorsDB(client)

A follow-up such as "Does that include attachments?" is a poor search query on its own. When the request carries conversation history, the Function first asks the model to rewrite the question as a standalone one, using the last six messages for context. The Function searches the first message in a conversation as written.

Retrieval is one embedding call and one query. The query uses the cosine metric, matching the index created during seeding:

JavaScript
import { EmbeddingModel, Query } from 'node-appwrite'

const result = await embeddings.createTextEmbeddings({
  texts: [query],
  model: EmbeddingModel.Allminilm,
})

const vector = result.embeddings[0]
if (vector?.error || vector?.embedding.length !== 384) {
  throw new Error('Embedding generation failed')
}

const matches = await vectorsDB.listDocuments({
  databaseId: process.env.APPWRITE_DATABASE_ID,
  collectionId: process.env.APPWRITE_COLLECTION_ID,
  queries: [
    Query.vectorCosine('embeddings', vector.embedding),
    Query.limit(6),
  ],
})

The matches go to OpenRouter as numbered excerpts, and the model has to reply in this shape:

JavaScript
const answerSchema = {
  type: 'object',
  properties: {
    supported: { type: 'boolean' },
    answer: { type: 'string' },
    sourceIds: { type: 'array', items: { type: 'integer' } },
  },
  required: ['supported', 'answer', 'sourceIds'],
  additionalProperties: false,
}

Similarity search ranks whatever is in the collection, so an unrelated question still returns the nearest articles. The supported flag lets the model state that those excerpts do not answer the question. The Function returns a fixed fallback message when supported is false, when the answer is empty, or when any ID in sourceIds is not one of the retrieved excerpts. It builds source links from the stored url metadata rather than from model output, and several cited chunks from one article collapse into a single link.

The interface calls the Function synchronously, and synchronous executions have a 30-second hard limit regardless of the Function's own timeout. The Function gives its model calls a shared 23-second deadline and returns an error if they run past it.

Deploy the Function

Deploy through GitHub or the Appwrite CLI.

For GitHub, fork the companion repository and connect your fork to the Function under Settings > Configuration > Git settings. Set the production branch to main and the root directory to functions/chat. Each push to main then builds and activates a new deployment. See deploying Functions from Git.

For the CLI, the repository includes an appwrite.config.json that declares the support-chat Function with the settings above. Set its endpoint and projectId to your project's values, install the Appwrite CLI, and push from the repository root:

Shell
appwrite login
appwrite push functions --function-id support-chat --activate

See the CLI Functions guide for the configuration options.

Deploy the interface on Appwrite Sites

The Vite React interface includes the chat, a browsable help center, and an article page for every source link. All three read the same data/articles.json that the seed script imports.

In Sites, connect your GitHub fork, select main as the production branch, and keep the repository root as the root directory. Choose Vite and configure:

SettingValue
Build runtimeNode.js 22
Install commandpnpm install
Build commandpnpm build
Output directorydist
RenderingStatic
Fallback fileindex.html

The fallback file lets a direct visit or refresh at /help/export-project load the React app and display that article.

Add these Site variables before the first build:

.env
VITE_APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=<PROJECT_ID>
VITE_APPWRITE_FUNCTION_ID=support-chat

Vite inlines VITE_ variables into the browser bundle. These three values are public, so that is safe. Never put an API key in one.

Choose an available Appwrite subdomain and deploy. Under the project's Apps section, register the Site's hostname as a Web app if it is not already listed. Add localhost as well if you want to run the interface locally with pnpm dev.

The browser calls the Function through the Appwrite Web SDK, sending the question and the last six messages of the conversation:

TypeScript
import { Client, Functions, ExecutionMethod } from 'appwrite'

const client = new Client()
  .setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT)
  .setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID)
const functions = new Functions(client)

const execution = await functions.createExecution({
  functionId: import.meta.env.VITE_APPWRITE_FUNCTION_ID,
  body: JSON.stringify({
    question: current,
    history: previous.slice(-6).map(({ role, content }) => ({ role, content })),
  }),
  async: false,
  method: ExecutionMethod.POST,
})

const result = JSON.parse(execution.responseBody || '{}')

The handler checks execution.responseStatusCode before rendering the answer and its source links. If the request fails, it puts the question back in the input so the visitor can retry.

Test the chatbot

Ask "Can I get my tasks into a spreadsheet?" The answer should describe CSV export and cite Export a project as CSV, even though the question shares no keywords with the title. Open the source link and refresh the page to confirm that the Sites fallback works.

Follow up with "Does that include the attachments?" The answer should say that attachments are not part of a CSV export. Then ask about quantum-encrypted video calls. That should return the fallback message with no sources.

The repository also has unit tests and a live suite that runs those cases against the deployed Function:

Shell
pnpm test
pnpm test:live

For the live suite, add APPWRITE_FUNCTION_ID=support-chat to .env and grant your key the executions.write scope. Run it after changing the prompt, the model, or the articles.

Use it with your own help center

Replace the entries in data/articles.json with your product's articles, run pnpm seed, and redeploy the Site so the source pages match the retrieved text. The chatbot then answers from your content with the same citation checks.

The demo accepts questions from anyone. For internal documentation, restrict execute access to signed-in users and check the session inside the Function before retrieving anything.

Resources

Read next

Ready to build?_