• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
kamenxrider

kamenxrider

slimarmor

Semantic vector DB on Val Town SQLite — DiskANN, hybrid search
Public
Like
slimarmor
Home
Code
7
CHANGES.md
GUIDE.md
HANDOVER.md
README.md
H
api.ts
ui.ts
vectordb.ts
Environment variables
4
Branches
1
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
README.md
Code
/
README.md
Search
…
Viewing readonly version of main branch: v88
View latest version
README.md

🛡️ SlimArmor — Lightweight Vector Database for Val Town

Semantic search powered by libSQL/Turso DiskANN — runs entirely on Val Town's built-in SQLite. No external database needed.

→ Fork this val on Val Town to deploy your own instance in ~2 minutes.


What Is This?

SlimArmor is a self-hosted vector database that lets you store text and search it by meaning, not just keywords.

Example:

  • You store: "Dogs are loyal companions"
  • You search: "furry pets"
  • It finds the dog text — even though the words are different ✅

This is called semantic search and it's powered by AI embeddings. SlimArmor handles all the hard parts: embedding generation, storage, indexing, deduplication, and retrieval.


Features

FeatureDetail
🔍 Semantic searchCosine similarity via libSQL DiskANN vector index
⚡ Hybrid searchVector + BM25 keyword boosting
🧠 Smart dedupContent-hashing skips re-embedding unchanged text
📦 Batch upsertSend hundreds of records in one API call
🔖 Metadata filtersFilter results by any JSON metadata field
📄 Chunked upsertAuto-split long documents into overlapping chunks
🔄 Export / ImportMigrate data between instances or providers
🖥️ Browser CLIBuilt-in terminal UI at /ui — no client needed
🔐 Optional authBearer token protection for write operations
🔌 Multi-providerNebius, OpenAI, OpenRouter, or any OpenAI-compatible API

Deploy Your Own (2 minutes)

Step 1 — Fork the val

Go to val.town/x/kamenxrider/slimarmor and click Fork.

Step 2 — Set environment variables

In your forked val, go to Settings → Environment Variables and add:

Required — pick one:

NEBIUS_API_KEY=your-nebius-api-key        # default provider, free tier available
OPENAI_API_KEY=sk-your-key                # + set EMBEDDING_PROVIDER=openai
EMBEDDING_API_URL=https://...             # any OpenAI-compatible API
EMBEDDING_API_KEY=your-key
EMBEDDING_MODEL=your-model-name
EMBEDDING_DIM=1536                        # must match your model's output dimensions

Any OpenAI-compatible embedding API works. The only thing that matters is setting EMBEDDING_DIM to match your model's output dimensions — this determines how the database table is created. See Provider Configuration below for details and a model comparison table.

Recommended:

ADMIN_TOKEN=some-secret-token

Without ADMIN_TOKEN, all operations are open (anyone can write). Set this to protect write endpoints.

Step 3 — Open your endpoint

Your API is live at the URL shown in your val's HTTP endpoint. Visit /ui for the interactive browser CLI, or / for the API info page.

Step 4 — Add data and search

# Add a record curl -X POST https://YOUR_ENDPOINT/upsert \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ -d '{"id": "note-1", "text": "Dogs are loyal companions", "meta": {"category": "animals"}}' # Search curl -X POST https://YOUR_ENDPOINT/search \ -H "Content-Type: application/json" \ -d '{"query": "furry pets", "k": 5}'

That's it. 🎉


Provider Configuration

SlimArmor works with any OpenAI-compatible embedding API — the provider doesn't matter, only two things do:

  1. The API URL accepts POST /v1/embeddings with {"model": "...", "input": [...]}
  2. The embedding dimensions your chosen model outputs — this is baked into the database schema at first run and cannot be changed without a full reset

⚠️ Dimensions are locked at first run. When setup() is called for the first time, the table is created with a fixed F32_BLOB(N) column where N is your model's output dimension. Switching to a model with different dimensions requires exporting your data, clearing the DB, and re-importing. Use EMBEDDING_DIM=auto to detect dimensions automatically.

Set env vars to configure your provider:

Nebius (Default — Best Value)

NEBIUS_API_KEY=your-key
ModelDimensionsNotes
Qwen/Qwen3-Embedding-8B4096Default — high quality, generous free tier

OpenAI

EMBEDDING_PROVIDER=openai
OPENAI_API_KEY=sk-your-key
ModelDimensionsNotes
text-embedding-3-small1536Fast & cheap — fits ~130k records/GB
text-embedding-3-large3072Higher quality — fits ~65k records/GB

OpenRouter

EMBEDDING_PROVIDER=openrouter
OPENROUTER_API_KEY=your-key
EMBEDDING_MODEL=openai/text-embedding-3-small
EMBEDDING_DIM=1536

Any Other OpenAI-Compatible API

If your provider exposes a /v1/embeddings endpoint, just point SlimArmor at it:

EMBEDDING_API_URL=https://your-provider.com/v1/embeddings
EMBEDDING_API_KEY=your-key
EMBEDDING_MODEL=your-model-name
EMBEDDING_DIM=768

This works with Ollama, LM Studio, Together AI, Fireworks, Mistral, Cohere (via their OpenAI-compat layer), and any other compatible service.

Choosing a Model — Dimensions vs Capacity

Dimensions determine both search quality and storage capacity:

DimensionsExample ModelsRecords per 1 GBQuality
4096Nebius Qwen3-8B~47,500🟢 Excellent
3072OpenAI text-embedding-3-large~65,000🟢 Excellent
1536OpenAI text-embedding-3-small~130,000🟡 Good
768nomic-embed-text, bge-small~260,000🟡 Good
384all-MiniLM-L6-v2~520,000🟠 Okay for simple tasks

Higher dimensions = better semantic understanding but more storage. For most use cases, 1536 (OpenAI small) or 768 (nomic) is the sweet spot.

⚠️ Switching models requires a full reset. Embeddings from different models are incompatible — a vector from model A means nothing when compared to a vector from model B. Export your data first (GET /export), then POST /clear?confirm=yes, update env vars, then POST /import.


API Reference

All endpoints are relative to your val's HTTP endpoint URL.

Core Endpoints

POST /upsert 🔐

Insert or update one or many records.

Single record:

{ "id": "doc-1", "text": "Your text here", "meta": { "category": "notes" } }

Batch (array):

[ { "id": "doc-1", "text": "First document", "meta": { "tag": "a" } }, { "id": "doc-2", "text": "Second document", "meta": { "tag": "b" } } ]

Response:

{ "success": true, "embedded": 2, "skipped": 0, "results": [...] }
  • embedded — records that were re-embedded (text changed or new)
  • skipped — records whose text was unchanged (only meta updated, no API call)

POST /search

Search for semantically similar records.

Request:

{ "query": "search query text", "k": 10, "maxDistance": 0.65, "offset": 0, "filters": { "category": "tech" }, "hybrid": { "enabled": true, "alpha": 0.25 } }
FieldTypeDefaultDescription
querystringrequiredNatural language search query
knumber10Number of results to return (max 200)
maxDistancenumbernoneFilter out results with distance > this value
offsetnumber0Skip first N results (for pagination)
filtersobjectnoneKey/value metadata filters
hybrid.enabledbooleanfalseEnable keyword boosting
hybrid.alphanumber0.25Keyword weight 0–1 (0 = pure vector, 1 = pure keyword)
hybrid.keyword_knumber2×kNumber of keyword candidates to consider

Response:

{ "success": true, "count": 3, "results": [ { "id": "doc-1", "text": "Dogs are loyal companions", "meta": { "category": "animals" }, "distance": 0.49, "score": 0.43, "keyword_score": 0.12 } ] }

Distance guide:

DistanceMeaning
0.0 – 0.4Very similar — always include
0.4 – 0.6Related — good default range
0.6 – 0.7Somewhat related
0.7+Likely unrelated — filter out

Use GET /calibrate?q=your+query to find the right threshold for your data.


POST /delete 🔐

Delete a record by ID.

{ "id": "doc-1" }

GET /get?id=<id>

Retrieve a single record by ID.


GET /list

List record IDs.

ParamDefaultDescription
limit100Max records to return (max 1000)
offset0Pagination offset
prefixnoneFilter IDs by prefix

POST /upsert_chunked 🔐

Automatically split a long document into overlapping chunks and upsert each one.

{ "id": "doc-long", "text": "...very long document...", "meta": { "source": "book" }, "chunkSize": 800, "overlap": 100 }

Chunks are stored as doc-long::chunk1, doc-long::chunk2, etc., each with parent_id, chunk_index, and chunk_total in their metadata.


GET /export 🔐

Export records as JSON (without embeddings — text + meta only).

ParamDefaultDescription
limit200Max records
offset0Pagination offset

POST /import 🔐

Import records from a previous export (re-embeds all text).

{ "records": [{ "id": "doc-1", "text": "...", "meta": {} }] }

Or pass an array directly.


Admin Endpoints

GET / — API info + stats

GET /ping — Health check

GET /stats — Detailed storage stats

GET /auth — Auth mode status

GET /test 🔐

Runs a smoke test: inserts 5 demo records, performs 3 searches, returns results and timing.

GET /seed?n=100 🔐

Seeds N synthetic records across 4 categories (tech, science, business, lifestyle). Useful for testing and threshold calibration. Max 1000 per call.

GET /calibrate?q=<query>

Analyzes the top 20 results for a query and suggests distance thresholds:

  • tight — top 3 results only
  • balanced — top 10 (recommended default)
  • loose — all retrieved

GET /validate

Runs self-checks: auth, stats, list, and search. Add ?write=yes for full write tests (requires ALLOW_WRITE_TESTS=1 env var).

POST /reindex 🔐

Drops and recreates the DiskANN vector index with current settings. Use after changing INDEX_* env vars.

POST /clear?confirm=yes 🔐

Deletes ALL records from the main table and FTS index. Cannot be undone.

🔐 = requires Authorization: Bearer <ADMIN_TOKEN> header when ADMIN_TOKEN env var is set.


Using as a TypeScript Module

Import vectordb.ts directly into any other val:

import * as db from "https://esm.town/v/kamenxrider/slimarmor/vectordb.ts"; // First-time setup (idempotent — safe to call on every cold start) await db.setup(); // Upsert a record await db.upsert("note-1", "Buy milk and eggs", { tag: "shopping" }); // Batch upsert await db.upsertMany([ { id: "note-2", text: "Call the dentist", meta: { tag: "health" } }, { id: "note-3", text: "Finish the quarterly report", meta: { tag: "work" } }, ]); // Search const results = await db.search("grocery shopping", 5, 0.65); // → [{ id, text, meta, distance }, ...] // Search with filters const workItems = await db.search("meetings", 10, undefined, { filters: { tag: "work" } }); // Hybrid search (vector + keyword) const hybrid = await db.search("dentist appointment", 5, undefined, { hybrid: { enabled: true, alpha: 0.3 } }); // Get a single record const record = await db.get("note-1"); // Delete await db.remove("note-1"); // Stats const { count, estimated_storage_mb } = await db.stats(); // Chunk and upsert a long document await db.upsertChunked("essay-1", longText, { source: "blog" }, { size: 800, overlap: 100, });

Note: When importing as a module, SlimArmor uses the same SQLite database as your val — so it shares data with any HTTP API you've deployed. Set your embedding env vars in the importing val.


Environment Variables Reference

Embedding Provider

VariableRequiredDefaultDescription
NEBIUS_API_KEYIf using Nebius—Nebius API key
OPENAI_API_KEYIf using OpenAI—OpenAI API key
OPENROUTER_API_KEYIf using OpenRouter—OpenRouter API key
EMBEDDING_PROVIDERNonebiusPreset: nebius, openai, openrouter
EMBEDDING_API_URLNo(from preset)Custom API URL
EMBEDDING_API_KEYNo—Generic key fallback
EMBEDDING_MODELNo(from preset)Override model name
EMBEDDING_DIMNo(from preset)Override dimensions, or auto to detect

Auth

VariableRequiredDefaultDescription
ADMIN_TOKENNo—Bearer token for write operations. If unset, all ops are open.

Index Tuning (Advanced)

VariableDefaultDescription
INDEX_METRICcosineDistance metric: cosine or l2
INDEX_MAX_NEIGHBORS64DiskANN graph degree (8–256). Lower = smaller index, less accurate
INDEX_COMPRESS_NEIGHBORSfloat8Neighbor compression: float8, float16, floatb16, float32, float1bit, none
INDEX_ALPHA1.2Graph density (≥1). Lower = faster/sparser
INDEX_SEARCH_L200Query-time search effort. Higher = more accurate, slower
INDEX_INSERT_L70Insert-time graph quality. Higher = better index, slower inserts

After changing any INDEX_* variable, call POST /reindex to apply the new settings.

Validation

VariableDefaultDescription
ALLOW_WRITE_TESTS0Set to 1 to enable /validate?write=yes
ALLOW_WRITE_TESTS_NOAUTH0Set to 1 to allow write tests without auth header

Capacity & Performance

Benchmarked with Nebius Qwen/Qwen3-Embedding-8B (4096 dims):

MetricValue
Storage per record~22 KB
Max records per 1 GB~47,500
Embedding latency~460ms per batch
Search latency<100ms
Batch sizeUp to 96 texts per API call

Storage scales directly with dimensions — lower-dim models fit far more records:

ModelDimsStorage/recordRecords/GB
Nebius Qwen3-8B4096~22 KB~47,500
OpenAI text-embedding-3-large3072~18 KB~65,000
OpenAI text-embedding-3-small1536~10 KB~130,000
nomic-embed-text / bge-small768~7 KB~260,000

Browser CLI

Visit /ui on your endpoint for a full terminal-style interface.

Commands:

CommandDescription
helpList all commands
search "query" [-k 10] [--max 0.65] [--mode balanced] [--filter key=value] [--hybrid] [--alpha 0.25]Semantic search
upsert <id> "text"Add or update a record
get <id>Show a single record
del <id>Delete a record
list [--limit 20] [--prefix id-prefix]List record IDs
statsStorage and index info
calibrate "query"Suggest distance thresholds
seed <n>Seed N synthetic records
clear --yesDelete all records
reindexRebuild vector index
auth <token>Set admin token for session
logoutClear auth token
pingHealth check
clsClear screen

Search modes (shorthand for --max):

  • --mode tight → maxDistance 0.5
  • --mode balanced → maxDistance 0.64
  • --mode loose → maxDistance 0.7

Architecture

┌──────────────────────────────────────────────────────┐
│              Browser CLI  (ui.ts)                    │
│         Terminal UI at /ui — no JS framework         │
└─────────────────────────┬────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────┐
│               HTTP API  (api.ts)                     │
│  /upsert  /search  /delete  /stats  /seed  ...       │
│  Bearer token auth · Input validation · Error handling│
└──────────────┬───────────────────────────────────────┘
               │
               ▼
┌──────────────────────────────────────────────────────┐
│           Vector DB Core  (vectordb.ts)              │
│  setup · upsert · upsertMany · search · remove       │
│  Content-hash dedup · Batch embed · FTS sync         │
└───────────┬──────────────────────┬───────────────────┘
            │                      │
            ▼                      ▼
┌───────────────────┐  ┌───────────────────────────────┐
│  Embedding API    │  │   Val Town SQLite (libSQL)    │
│  (OpenAI-compat)  │  │   F32_BLOB · DiskANN index   │
│  Nebius / OpenAI  │  │   FTS5 · vector_top_k        │
│  OpenRouter / etc │  │   vector_distance_cos        │
└───────────────────┘  └───────────────────────────────┘

Database Schema

-- Main records table CREATE TABLE vectordb ( id TEXT PRIMARY KEY, text TEXT NOT NULL, text_hash TEXT NOT NULL, -- SHA-256 for change detection embedding F32_BLOB(4096), -- Vector column (dim varies by provider) meta_json TEXT, -- Optional JSON metadata updated_at INTEGER NOT NULL -- Unix ms timestamp ); -- DiskANN vector index CREATE INDEX vectordb_embedding_idx ON vectordb (libsql_vector_idx(embedding, 'metric=cosine', 'max_neighbors=64', 'compress_neighbors=float8' )); -- Full-text search index for hybrid search CREATE VIRTUAL TABLE vectordb_fts USING fts5(id, text); -- Internal metadata (stores resolved embedding dim, etc.) CREATE TABLE vectordb_meta (key TEXT PRIMARY KEY, value TEXT);

Troubleshooting

Embedding API error 401 → Your API key is missing or expired. Check the relevant env var (NEBIUS_API_KEY, OPENAI_API_KEY, etc.).

Embedding dim mismatch → Your EMBEDDING_DIM env var doesn't match what the model returns. Either remove EMBEDDING_DIM (use preset default) or set EMBEDDING_DIM=auto.

vector index(insert): failed to insert shadow row → The DiskANN index is out of sync (usually after a raw SQL DELETE). Call POST /reindex to rebuild it.

Search returns irrelevant results → Lower maxDistance (try 0.5 instead of 0.7). Use /calibrate?q=... to find the right value for your data.

Search returns no results → Check GET /stats to confirm data exists. Try raising maxDistance, or simplify your query.

Slow inserts → Each batch of up to 96 records makes one embedding API call (~460ms). For large imports, use POST /import with large batches rather than individual /upsert calls.

Switched providers, search broken → Embeddings are model-specific. Export data, POST /clear?confirm=yes, update env vars, then POST /import.


Files

FileDescription
api.tsHTTP API — the main entry point (deploy this as your val's HTTP handler)
vectordb.tsCore library — import this directly in other vals
ui.tsBrowser CLI — terminal interface served at /ui
README.mdThis document
GUIDE.mdBeginner step-by-step guide
CHANGES.mdChangelog

License

MIT — fork freely.

FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.