Skip to content

Build a help center with semantic search using Appwrite VectorsDB_

Create a vector collection in the Appwrite Console, embed help articles as they are published, and rank answers by meaning in a TanStack Start app.

Someone's card just got declined. They open your help center, type "my card got rejected", and get nothing back. The answer they need is sitting right there, filed under "What to do when a card payment is declined". Keyword search never connects the two, because the two phrases have no words in common.

Vector search closes that gap. Each article becomes a vector that holds its meaning, and the question becomes a vector in the same space. The database ranks articles by distance, so the reader stops having to guess your wording. This tutorial builds that help center on Appwrite VectorsDB with a TanStack Start front end. It covers an authoring screen for publishing articles and a search page that shows how close each answer is.

A help center with articles grouped by category
A help center with articles grouped by category

Prerequisites

  • An Appwrite project
  • Node.js 22 or later
  • An API key for the project with these scopes:
ScopeUsed for
embeddings.writeTurning text into vectors
vectorsdb.documents.readReading and searching articles
vectorsdb.documents.writePublishing, editing, and deleting articles
vectorsdb.indexes.writeCreating the vector index
sessions.writeSigning an author in from the server

Create the vector database

Open your project, go to Databases, and select Create database. The wizard lists the Appwrite databases alongside the native SQL engines. Choose VectorsDB.

The create database wizard with VectorsDB among the available database types
The create database wizard with VectorsDB among the available database types

Name the database, then select your preferred tier. The summary panel on the right confirms the type and the monthly cost before anything is created.

Naming the database and selecting a specification tier
Naming the database and selecting a specification tier

Select Create database to finish.

Create the collection

Open the database and select Create collection. A vector collection does not ask for columns. It asks for an embedding model, because the model decides how many components every vector holds.

The create collection dialog with the all-minilm embedding model selected
The create collection dialog with the all-minilm embedding model selected

The dropdown offers four models:

ModelComponentsNotes
nomic-embed-text768General-purpose English embeddings with an 8K context window
all-minilm384Lightweight English embeddings for constrained workloads
embedding-gemma (coming soon)768Multilingual embeddings for more than 100 languages
bge-small (coming soon)384Compact English embeddings with strong retrieval quality

Choose all-minilm. More components capture finer distinctions, and fewer components use less storage and keep search faster as the collection grows. A help center holds a few hundred short articles in one language, which 384 components describe well.

all-minilm reads 256 tokens, around 190 English words, and drops the rest. Keep each article near that length, or split a longer one into sections and store each section as its own document.

Name the collection Articles and select Create.

Every document in the collection now has a fixed shape. The article text lives in metadata, and the vector lives in embeddings.

FieldTypeContents
embeddingsvector384 numbers that represent the meaning of the article
metadataobjecttitle, body, category, and updatedAt

The Articles collection with the embeddings and metadata columns
The Articles collection with the embeddings and metadata columns

Add the vector index

Similarity search works without an index, but it then reads every document. An HNSW index keeps it fast as the collection grows.

The index type must match the query metric, so a cosine query needs hnsw_cosine.

TypeScript
import { Client, VectorsDB, VectorsDBIndexType } from 'node-appwrite'
const client = new Client()
.setEndpoint('https://fra.cloud.appwrite.io/v1')
.setProject('<PROJECT_ID>')
.setKey('<YOUR_API_KEY>')
await new VectorsDB(client).createIndex({
databaseId: '<DATABASE_ID>',
collectionId: '<COLLECTION_ID>',
key: 'embeddings_cosine',
type: VectorsDBIndexType.HnswCosine,
attributes: ['embeddings'],
})

Set up the application

Install the dependencies in a TanStack Start project.

Bash
npm install @tanstack/react-router @tanstack/react-start node-appwrite react react-dom dotenv
npm install -D @tailwindcss/vite @vitejs/plugin-react tailwindcss typescript vite vite-tsconfig-paths @types/node @types/react @types/react-dom

Put the connection details in .env. The API key stays on the server, so no value uses a VITE_ prefix.

Bash
APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=<PROJECT_ID>
APPWRITE_API_KEY=<YOUR_API_KEY>
APPWRITE_DATABASE_ID=<DATABASE_ID>
APPWRITE_COLLECTION_ID=<COLLECTION_ID>
SESSION_SECRET=<A_LONG_RANDOM_STRING>

Collect the settings in one module, src/lib/config.ts.

TypeScript
export const ENDPOINT = process.env.APPWRITE_ENDPOINT ?? 'https://fra.cloud.appwrite.io/v1'
export const PROJECT_ID = process.env.APPWRITE_PROJECT_ID ?? ''
export const API_KEY = process.env.APPWRITE_API_KEY ?? ''
export const DATABASE_ID = process.env.APPWRITE_DATABASE_ID ?? ''
export const COLLECTION_ID = process.env.APPWRITE_COLLECTION_ID ?? ''
export const EMBEDDING_MODEL = 'all-minilm'

Turn text into a vector

The embeddings service takes an array of strings and returns one vector for each string. Ask for the same model the collection was created with, and the length always matches.

One thing to watch: a failed embedding still comes back with status 200. The vector is empty and the reason sits in an error field on that entry, so check it before using the vector.

TypeScript
import { Client, Embeddings, ID, Query, VectorsDB } from 'node-appwrite'
import {
API_KEY, COLLECTION_ID, DATABASE_ID, EMBEDDING_MODEL,
ENDPOINT, PROJECT_ID,
} from './config'
const client = new Client()
.setEndpoint(ENDPOINT)
.setProject(PROJECT_ID)
.setKey(API_KEY)
const embeddings = new Embeddings(client)
const vectorsDB = new VectorsDB(client)
const collection = { databaseId: DATABASE_ID, collectionId: COLLECTION_ID }
export async function embed(texts: string[]): Promise<number[][]> {
const { embeddings: results } = await embeddings.createTextEmbeddings({
texts,
model: EMBEDDING_MODEL,
})
return results.map((entry) => {
if (entry.error) throw new Error(entry.error)
return entry.embedding
})
}

Publish an article

An article becomes one document. Embed the title and the body together, because both carry meaning. Write the text itself into metadata.

TypeScript
export async function createArticle(input: {
title: string
body: string
category: string
}) {
const [vector] = await embed([`${input.title}\n\n${input.body}`])
return vectorsDB.createDocument({
...collection,
documentId: ID.unique(),
data: {
embeddings: vector,
metadata: {
title: input.title,
body: input.body,
category: input.category,
updatedAt: new Date().toISOString(),
},
},
})
}

The article is searchable as soon as the write completes, because the HNSW index accepts the new vector directly.

Search by meaning

Embed the question with the same helper. Then rank the collection with Query.vectorCosine. Use createQuery, which sends the queries in the request body rather than the URL.

TypeScript
export async function searchArticles(question: string, limit = 6) {
const [vector] = await embed([question])
const result = await vectorsDB.createQuery({
...collection,
queries: [Query.vectorCosine('embeddings', vector), Query.limit(limit)],
total: false,
})
return result.documents.map((document) => ({
id: document.$id,
metadata: document.metadata,
distance: document.$distance,
}))
}

Each result carries a $distance field. For a cosine query, a lower value means a closer match, so 1 - distance gives a similarity between 0 and 1. The search page plots every result on one fixed scale, which shows the gap between the best answer and the rest.

Search results ranked by cosine similarity on a fixed scale
Search results ranked by cosine similarity on a fixed scale

The question "my card got rejected when paying" shares no words with "What to do when a card payment is declined". That article still ranks first with a similarity of 0.709, and the next result sits at 0.566. Keyword search returns nothing useful for the same question.

An article page can use the same query to suggest related articles. Search with the title of the current article. Then drop the article itself from the results.

Use the title rather than the complete body. A long body produces a vector close to the average of all articles, and every article then scores about the same.

TypeScript
const related = (await searchArticles(article.metadata.title, 4)).filter(
(candidate) => candidate.id !== article.id,
)

An article page with the closest related answers
An article page with the closest related answers

Add the authoring screen

Readers search without an account, and authors sign in. Appwrite returns a session secret only to a client that holds an API key. The sign in call therefore runs on the server, and the secret never reaches the browser. The adminSession helper below wraps the encrypted cookie session from TanStack Start.

TypeScript
export async function signIn(email: string, password: string) {
const account = new Account(
new Client().setEndpoint(ENDPOINT).setProject(PROJECT_ID).setKey(API_KEY),
)
const created = await account.createEmailPasswordSession({ email, password })
const session = await adminSession()
await session.update({ secret: created.secret, email })
}

To confirm the author on later requests, build a client with setSession and read the account.

TypeScript
export async function currentAuthor() {
const session = await adminSession()
if (!session.data.secret) return null
try {
const client = new Client()
.setEndpoint(ENDPOINT)
.setProject(PROJECT_ID)
.setSession(session.data.secret)
const account = await new Account(client).get()
return { email: account.email, name: account.name }
} catch {
await session.clear()
return null
}
}

The authoring screen writes the title, the category, and the body, then calls createArticle. Every save embeds the text again, so an edit updates the vector and the ranking at the same time.

The authoring screen with a draft article and the published list
The authoring screen with a draft article and the published list

Run the demo

Start the development server and open the help center.

Bash
npm run dev

Publish an article from the authoring screen. Then search for it in words that do not appear in the text. An article titled "Pausing a retainer for a month" answers "can I pause billing for a client temporarily" at a similarity of 0.743, because the ranking compares meaning and not spelling.

Deploy to Appwrite Sites

Push the project to a GitHub repository. In your Appwrite project, open Sites, select Create site, and choose Connect a repository.

Select the repository, then confirm the build settings. TanStack Start builds to dist, so the defaults are:

SettingValue
Install commandnpm install
Build commandnpm run build
Output directory./dist

Add the same variables you put in .env under the site's environment variables. The API key is read on the server, so it stays out of the browser bundle.

Select Deploy. When the deployment finishes, select Visit site to open the help center.

Where to go from here

  • Filter results by category with an equal query on metadata.category. Metadata is JSON, so pass the value as a string.
  • Show the similarity score to readers only during development. The score helps while you tune the article set, and it distracts a reader who wants an answer.
  • Re-embed every article after a model change. Vectors from two models are not comparable, so a switch needs a full rewrite of the collection.
  • Feed the top results into an answer model for retrieval augmented generation. The same query returns the passages that ground the answer.

Read next

Appwrite now speaks Postgres

Jake Barnby

Run a managed PostgreSQL instance inside your Appwrite project and connect to it with psql, your ORM, and the entire PostgreSQL ecosystem.

6 min read

Ready to build?_