Zero to a Vector-Search Notes App in One Shell Session
Editor's note (2026-10-15): Steps 8 and 9 are corrected to the current releases. A self-hosted HeliosDB Nano container is reached over the PostgreSQL wire protocol with the password you set when you start it (see the installation guide). Every edition speaks the PostgreSQL wire protocol, so the SQL carries over unchanged, but each edition has its own HTTP surface, and HeliosDB Full 8.3.0 runs as a single server, with clustering and multi-region replication planned (see HeliosDB Full).
This is the hands-on half of the BaaS series. In one shell session we are going to provision a database, define a schema with vector search, seed it with embeddings, branch for a feature, merge it back, and use time-travel to debug a regression. Everything except embedding generation runs on HeliosDB BaaS — there is no backend to write, and the "backend" code we do write is a tiny Node.js script that forwards user requests after attaching a scoped API key.
Prerequisites: a terminal with curl and jq, Node.js 20+, and an embedding endpoint. We will use OpenAI's text-embedding-3-small (1536 dim) for the demo; the same code works against any embedding provider that returns a fixed-dimension float array.
Step 1: Provision the Database
One POST creates the account, the organisation, the database, and an API key:
curl -sS -X POST https://cloud.heliosdb.com/api/v1/quick-start \
-H "Content-Type: application/json" \
-d '{
"email": "me@example.com",
"password":"'\$(openssl rand -base64 18)'",
"username":"me",
"org_name":"notes-demo",
"db_name": "main"
}' | tee /tmp/quickstart.json | jq
Stash the identifiers we'll need for every subsequent call:
export HDB_ORG=$(jq -r '.data.org_id' /tmp/quickstart.json)
export HDB_DB=$(jq -r '.data.db_id' /tmp/quickstart.json)
export HDB_KEY=$(jq -r '.data.api_key' /tmp/quickstart.json)
export HDB="https://cloud.heliosdb.com/api/v1/organizations/$HDB_ORG/databases/$HDB_DB"
echo "Database ready at $HDB"
No local install. No migration tool. The database is live and accepts arbitrary SQL. On the free "Verified" tier this gives you up to 5 databases, 50 MB storage, 500 queries per hour, and 10 branches per database — more than enough to finish this tutorial.
Step 2: Define the Schema
A notes app needs three columns beyond identity: the title, the body, and the embedding we'll use for semantic search. Vector columns are first-class:
curl -sS -X POST "$HDB/execute" \
-H "X-API-Key: $HDB_KEY" \
-H "Content-Type: application/json" \
-d '{"sql":
"CREATE TABLE notes (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
embedding VECTOR(1536),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX notes_user_idx ON notes(user_id);
CREATE INDEX notes_embedding_hnsw ON notes USING hnsw(embedding vector_cosine_ops);
CREATE INDEX notes_body_fts ON notes USING bm25(body);"
}' | jq
Three index strategies in one statement: a B-tree on user_id for scoped queries, an HNSW index for vector similarity, and a BM25 index for keyword search. The BM25 and HNSW indexes can be combined with Reciprocal Rank Fusion (RRF) or Maximal Marginal Relevance (MMR) in a single query — a topic for a later post.
Step 3: Embed and Insert
We need a tiny script to compute embeddings and POST rows. Save as seed.mjs:
// seed.mjs
const { HDB, HDB_KEY } = process.env;
const OPENAI_KEY = process.env.OPENAI_API_KEY;
const userId = crypto.randomUUID();
const notes = [
{ title: "Vectors 101", body: "Dense vectors encode semantic meaning as floats." },
{ title: "Cosine distance", body: "Cosine distance measures angle between two vectors." },
{ title: "Monday shopping", body: "Eggs, milk, coffee, flour, oranges." },
{ title: "HNSW graphs", body: "HNSW builds a navigable small-world graph for ANN." },
{ title: "Weekend plan", body: "Hike Saturday, dinner with parents Sunday." }
];
async function embed(text) {
const r = await fetch("https://api.openai.com/v1/embeddings", {
method: "POST",
headers: {
"Authorization": `Bearer ${OPENAI_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ model: "text-embedding-3-small", input: text })
});
const j = await r.json();
return j.data[0].embedding;
}
async function insert(note) {
const vec = await embed(`${note.title}. ${note.body}`);
const vecLit = "[" + vec.join(",") + "]";
const sql = `INSERT INTO notes (user_id, title, body, embedding)
VALUES ('${userId}', $1, $2, '${vecLit}'::vector)`;
const r = await fetch(`${HDB}/execute`, {
method: "POST",
headers: {
"X-API-Key": HDB_KEY,
"Content-Type": "application/json"
},
body: JSON.stringify({ sql, params: [note.title, note.body] })
});
console.log(note.title, "=>", r.status);
}
for (const n of notes) await insert(n);
Run it:
OPENAI_API_KEY=sk-... node seed.mjs
# Vectors 101 => 200
# Cosine distance => 200
# Monday shopping => 200
# HNSW graphs => 200
# Weekend plan => 200
Step 4: Semantic Search
Now ask the database a question in vector form. Embed the query text with the same model, then post an ORDER BY embedding <=> $1 query:
// search.mjs — takes a query string, returns top-3 semantic matches
const query = process.argv[2] || "how do approximate-nearest-neighbour structures work?";
const vec = await embed(query);
const vecLit = "[" + vec.join(",") + "]";
const sql = `
SELECT title, body,
1 - (embedding <=> '${vecLit}'::vector) AS similarity
FROM notes
ORDER BY embedding <=> '${vecLit}'::vector
LIMIT 3
`;
const r = await fetch(`${HDB}/execute`, {
method: "POST",
headers: { "X-API-Key": HDB_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ sql })
});
console.log(JSON.stringify(await r.json(), null, 2));
$ node search.mjs "how does approximate nearest neighbour search work?"
{
"rows": [
{ "title": "HNSW graphs", "similarity": 0.83 },
{ "title": "Vectors 101", "similarity": 0.61 },
{ "title": "Cosine distance", "similarity": 0.55 }
]
}
"Monday shopping" and "Weekend plan" are nowhere near the top. We have semantic search, and the closest thing to a backend we've written is a script that forwards HTTP calls.
Step 5: Per-User Scoped API Keys
In production you do not want your app's admin key sitting in a mobile client. Issue a per-user scoped key from a small token-exchange endpoint. The key's scope is the database; you enforce per-user isolation with a RLS policy or a column predicate.
// Called by your login endpoint after the user authenticates
async function keyForUser(userId) {
const r = await fetch(`${HDB}/api-keys`, {
method: "POST",
headers: { "X-API-Key": ADMIN_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
name: `user:${userId}`,
scopes: ["read", "write"]
})
});
const { api_key } = await r.json();
// Returned ONCE. Ship to client, never persist the raw value server-side.
return api_key;
}
Rotate a user's key on password change; revoke it on logout or account suspension. Every key's blast radius is a single database, and you can list and revoke all of them from the same endpoint:
GET ${HDB}/api-keys # list
DELETE ${HDB}/api-keys/{key_id} # revoke
Step 6: Branch for a Feature
Say you want to add a tags TEXT[] column and backfill it. On a traditional database this means a migration script, a downtime window, or a blue-green deploy. With branching it is three calls:
# Create a branch off the current state of main
curl -sS -X POST "$HDB/branches" \
-H "X-API-Key: $HDB_KEY" \
-d '{"name": "feat-tags", "as_of": "now"}' | jq
# Switch to it
curl -sS -X POST "$HDB/branches/switch" \
-H "X-API-Key: $HDB_KEY" \
-d '{"name": "feat-tags"}'
# Run the migration on the branch only
curl -sS -X POST "$HDB/execute" \
-H "X-API-Key: $HDB_KEY" \
-d '{"sql": "ALTER TABLE notes ADD COLUMN tags TEXT[] DEFAULT ARRAY[]::TEXT[];"}'
Run your test suite against the branch (same API key, same URL, the engine resolves against the active branch). When it's green, merge:
curl -sS -X POST "$HDB/branches/merge" \
-H "X-API-Key: $HDB_KEY" \
-d '{"source": "feat-tags", "target": "main"}'
Branching is a copy-on-write operation at the storage layer — O(1) regardless of how large the table is. You can keep dozens of branches open concurrently on the free tier (10 on Verified, 20 on Trusted).
Step 7: Time-Travel for Debugging
Twenty minutes after the merge, a user reports that the body of one of their notes got truncated. You don't know when. You can query the database as of a point in time:
# How did the row look an hour ago?
curl -sS -X POST "$HDB/query-at" \
-H "X-API-Key: $HDB_KEY" \
-d "{
\"sql\": \"SELECT title, body FROM notes WHERE id = 42\",
\"as_of\": \"$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)\"
}" | jq
The free-tier lookback is 24 hours; Starter is 7 days; Pro is 90. The SQL form also works directly: SELECT * FROM notes AS OF TIMESTAMP '2026-05-07T10:00:00Z' WHERE id = 42.
Combined with branching, time-travel gives you a post-mortem workflow most databases can't: create a branch from the moment before the bug hit, reproduce, fix forward, merge. No backup restore, no downtime.
Step 8: Run the Same SQL Self-Hosted
Updated Oct 2026: the original version of this step used an unofficial image name and a self-hosted quick-start call. The official image is now ghcr.io/heliosdatabase/heliosdb-nano, and account provisioning (/api/v1/quick-start, API keys) is a HeliosDB Cloud feature — a self-hosted Nano container does not provision tenants. The step below reflects what the container actually does.
Run HeliosDB Nano locally in a Docker container:
docker run -d --name helios-local \
-p 5432:5432 \
-e HELIOSDB_PASSWORD=change-me \
-v helios_data:/data \
ghcr.io/heliosdatabase/heliosdb-nano:latest
psql "postgresql://postgres:change-me@localhost:5432/postgres?sslmode=require"
The same schema, the same SQL — tables, VECTOR columns, CREATE BRANCH, AS OF TIMESTAMP — now runs against your own container over the PostgreSQL wire protocol, so any Postgres driver works unchanged. The sample publishes only the PostgreSQL port; authenticate with the password you set in HELIOSDB_PASSWORD.
That is the portability guarantee from the previous article: the data model and the SQL travel with you. What changes between the managed service and a self-hosted engine is the account layer — API keys and provisioning — not the queries.
Step 9: Scale Up When You Need To
When traffic outgrows the free tier, move to Starter or Pro on the managed service — same URL, same code. When you need a single-tenant deployment or a custom region, self-host: HeliosDB Lite on a VM or Kubernetes behind your own load balancer, or HeliosDB Full when you want more wire protocols on one server (HeliosDB Full 8.3.0 runs as a single server; clustering and multi-region replication are planned). Every edition speaks the PostgreSQL wire protocol, so the schema and the SQL travel unchanged. The HTTP surfaces differ by edition — Nano serves a PostgREST-style REST API, Lite a REST API and Full an HTTP SQL gateway — and the account layer of the managed service (quick-start provisioning and scoped API keys) stays with HeliosDB Cloud.
What You Didn't Write
Look back at the tutorial. You did not write: a connection-pool library, a migration runner, an SDK, a per-environment auth scheme, a secrets-rotation job, a read-replica failover client, or a vector-search service. The closest thing to a "backend" in this walkthrough is seed.mjs — a 30-line script that forwards HTTP requests. Everything else is the database.
That is what "backend as a service" should mean. Not a managed add-on, not a layer on top of Postgres, not a vendor SDK — a database that is already a backend.
Previous in this series: Database-Agnostic by Default. Next: the three-protocol architecture and when to pick which surface. Reference: BaaS page and the OpenAPI 3.1 spec.