πŸ›‘οΈ 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.

# 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 with 6 real-world use cases
RAG_CHATBOT.mdπŸ“– Full tutorial: build a RAG chatbot using SlimArmor + Val Town's free OpenAI proxy
CHANGES.mdChangelog
HANDOVER.mdTechnical deep-dive for developers

License

MIT β€” fork freely.