Vectors

Store an embedding vector on a record, then search the collection with three retrieval modes — semantic (cosine), lexical (BM25), or hybrid (RRF-fused). Powers semantic search, recommendation engines, RAG retrieval, and similarity matching — backed by Postgres + pgvector with HNSW indexing for sub-millisecond lookups.

Declaring a vector field

Vector fields are declared per collection from the dashboard or CLI. Open a collection, click Vector Fields, enter a field name (e.g. embedding), pick a dimension, and add it. From that point, every record in the collection has a slot for that field — set it on the records that need it, leave it unset on the rest.

The dimension is the length of every vector you'll store on that field. It must match exactly what your embedding model produces — see Supported dimensions below for the values Koolbase supports.

Mobile SDKs don't declare schema

No client SDK exposes declareVectorField — vector fields are dashboard/CLI operations, the same way collections and storage buckets are created. An app consumes what's declared; it doesn't define it.

Writing and reading vectors

Set a vector on a record from the SDK once you have an embedding from your model:

lib/embeddings.dart
Dart
final embedding = await myEmbeddingModel.encode(article.content);

await Koolbase.db.doc(article.id).setVector(
  'embedding',
  embedding,
);

Read it back later — useful for inspection or for re-running search on a known anchor:

lib/embeddings.dart
Dart
final v = await Koolbase.db.doc(article.id).getVector('embedding');
print('${v.vector.length}-dim, updated ${v.updatedAt}');

And remove a record's vector when you no longer need it. The field declaration stays — it's still settable on other records.

lib/embeddings.dart
Dart
await Koolbase.db.doc(article.id).deleteVector('embedding');

React Native

The RN SDK uses flat methods on the database client: Koolbase.db.setVector(recordId, field, vector), Koolbase.db.getVector(recordId, field), Koolbase.db.deleteVector(recordId, field). Same wire protocol, idiomatic JS shape.

Auto-embedding on write

You don't need a client-side embedding model. Configure a vector field with an embedding provider + model + source field, and Koolbase will embed records automatically on insert and update.

Configure the field from the dashboard (Database → your collection → Vector Fields → field settings) or via direct SQL:

dashboard
SQL
UPDATE collection_vector_fields
SET embedding_provider = 'gemini',
    embedding_model    = 'gemini-embedding-001',
    source_field       = 'content'
WHERE collection_id = '<your collection id>'
  AND field_name     = 'content_embedding';

From that point on, every record inserted or updated with a non-empty content field automatically gets a vector queued in embedding_jobs. A background worker processes the queue with 1s/2s/4s/8s/16s exponential backoff and writes the vector to record_vectors_<dim>. Same-text deduplication via content hash means re-embedding identical content from different records skips the API call entirely.

On the same write, Koolbase also populates a lexical (BM25) row in db_record_lexical for the configured source field. This makes lexical and hybrid search work out of the box — no extra configuration, no extra worker. The lexical index is a Postgres tsvector tokenized with the vector field's configured language (see Language for lexical indexing), so it's ready as soon as the row is written.

Source field must be a string

The source field must hold a plain string at write time. Empty strings, nulls, or other types skip the embed and lexical write silently — the record is still written, just without a vector or lexical row.

Setting up an AI provider

Koolbase supports two embedding providers in v1: Google Gemini (free tier, no payment method required) and OpenAI (requires billing). One config per project per provider.

Get a Gemini key at aistudio.google.com/apikey, then register it:

terminal
bash
curl -X POST "https://api.koolbase.com/v1/projects/$PROJECT_ID/ai/providers" \
  -H "Authorization: Bearer $DASHBOARD_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "provider": "gemini",
    "api_key": "AIza...",
    "default_model": "gemini-embedding-001"
  }'

Validation happens inline — the response includes status: "valid" if the key works, or status: "invalid" with a validation_error message. Rate-limit errors (429) return status: "unvalidated"; click Re-test from the dashboard once the limit clears.

Provider keys are encrypted at rest

Stored with AES-256-GCM. The worker decrypts at call time only. The encrypted blob never leaves the server.

Searching: semantic, lexical, hybrid

Koolbase ships three retrieval modes from a single endpoint. Pick the one that matches your query characteristics, or use hybrid as a strong production default.

  • Semantic — pure vector search via HNSW + cosine. Best for fuzzy / conceptual queries where exact words don't have to match.
  • Lexical — pure BM25 over the field's source text via Postgres ts_rank_cd. Best for exact terms, codes, names, acronyms.
  • Hybrid — vector + lexical fused with reciprocal rank fusion (k=60). Generally the strongest default; both rankers vote and the fused score promotes records that score well on either signal.

Flutter

lib/search.dart
Dart
// Semantic (default) — pure vector via HNSW + cosine.
final semantic = await Koolbase.db.collection('articles').searchSemantic(
  field: 'content_embedding',
  queryText: 'how do I configure CI/CD?',
  limit: 10,
);

// Lexical — pure BM25 over the field's source text.
final lexical = await Koolbase.db.collection('articles').searchSemantic(
  field: 'content_embedding',
  queryText: 'CVE-2024-1234',
  mode: KoolbaseSearchMode.lexical,
  limit: 10,
);

// Hybrid — vector + lexical, RRF-fused (k=60).
final hybrid = await Koolbase.db.collection('articles').searchSemantic(
  field: 'content_embedding',
  queryText: 'production deploy pipeline',
  mode: KoolbaseSearchMode.hybrid,
  limit: 10,
);

for (final hit in hybrid.hits) {
  print('${hit.record['title']}  (${hit.distance.toStringAsFixed(3)})');
}

React Native & Web

lib/search.ts
TypeScript
// Semantic (default) — pure vector via HNSW + cosine.
const semantic = await Koolbase.db.searchSemantic({
  collection: 'articles',
  field: 'content_embedding',
  queryText: 'how do I configure CI/CD?',
  limit: 10,
});

// Lexical — pure BM25 over the field's source text.
const lexical = await Koolbase.db.searchSemantic({
  collection: 'articles',
  field: 'content_embedding',
  queryText: 'CVE-2024-1234',
  mode: 'lexical',
  limit: 10,
});

// Hybrid — vector + lexical, RRF-fused (k=60).
const hybrid = await Koolbase.db.searchSemantic({
  collection: 'articles',
  field: 'content_embedding',
  queryText: 'production deploy pipeline',
  mode: 'hybrid',
  limit: 10,
});

for (const hit of hybrid.hits) {
  console.log(`${hit.record.title}  ${hit.distance.toFixed(3)}`);
}

The pre-existing queryVector path still works for semantic mode — useful when you've already encoded the query client-side or want to use a different embedding model for the query than for the corpus. Lexical and hybrid modes require queryText (BM25 has no notion of vector queries).

Picking a mode

If you're unsure, start with hybrid. It combines the best of both rankers and degrades gracefully — a query that's a strong lexical match but a weak semantic match (e.g. an exact product code) still ranks high, and vice versa.

Use lexical when you specifically need keyword behavior (search by SKU, error code, person's name). Use semantic when you specifically want fuzzy meaning-based retrieval and don't want exact-term hits dominating.

Language for lexical indexing

Lexical and hybrid search use a Postgres text-search config to tokenize the source field — the config controls stemming (so running, runs, and ran can all match a query for run) and stop-word handling. Each vector field carries its own lexical_config, defaulting to simple.

Set it from the dashboard (Database → your collection → Vector Fields → the 🌐 Languages icon next to a field) or via the API:

terminal
bash
curl -X PATCH "$API/v1/projects/$PROJECT_ID/collections/$COLLECTION/vector-fields/$FIELD" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"lexical_config": "english"}'

Or with the CLI:

terminal
bash
koolbase vector-fields update articles content_embedding \
  --lexical-config english \
  --project $PROJECT_ID

Supported configs in v1 — pick the one that matches the dominant language of your source data:

ConfigBehavior
simpleDefault. No stemming, no stop words — every token matches literally. Best for codes, names, multilingual mixes.
englishSnowball English stemmer + English stop words. running, runs, ran all match run.
frenchFrench stemmer + French stop words.
germanGerman stemmer + German stop words.
spanishSpanish stemmer + Spanish stop words.
italianItalian stemmer + Italian stop words.
portuguesePortuguese stemmer + Portuguese stop words.
dutchDutch stemmer + Dutch stop words.
russianRussian stemmer + Russian stop words.
arabicArabic stemmer + Arabic stop words.

Changing the config doesn't re-index existing data

New writes after the config change use the new config immediately. Existing lexical rows keep their old tokenization until you run a lexical backfill — without that, search will mix tokens from the old and new configs and matches will be inconsistent. Lexical backfill is idempotent and Postgres-only (no provider charges), so it's safe to run any time.

Filtering weak matches

Pass minSimilarity (0..100) to drop results below a similarity threshold server-side. Saves bandwidth on weak matches and keeps client code simpler — no post-filtering needed.

lib/search.dart
Dart
final result = await Koolbase.db.collection('articles').searchSemantic(
  field: 'content_embedding',
  queryText: 'how do I configure CI/CD?',
  mode: KoolbaseSearchMode.hybrid,
  minSimilarity: 70, // only matches at least 70% similar
  limit: 10,
);
lib/search.ts
TypeScript
const result = await Koolbase.db.searchSemantic({
  collection: 'articles',
  field: 'content_embedding',
  queryText: 'how do I configure CI/CD?',
  mode: 'hybrid',
  minSimilarity: 70,
  limit: 10,
});

Only valid for semantic and hybrid

The server rejects minSimilarity on lexical mode with a 400 validation_error — BM25 rank scores aren't comparable to cosine similarity, and silently ignoring the parameter would produce confusing “I set 80, why did weak results return?” behavior. Values outside 0..100 are also rejected client-side before the request is sent.

Backfilling existing records

Records that pre-date auto-embed or lexical indexing don't have vectors or lexical rows yet. Two backfill paths cover the two indexes — both are idempotent, both are exposed from the dashboard's Vector Fields modal.

Embedding backfill

For a single record, use embedText to queue an embedding job. Returns as soon as the job is queued — typically <100ms. The vector lands within ~1 second once the worker picks it up.

lib/backfill.dart
Dart
// Embed using the record's source_field value (whatever you configured):
await Koolbase.db.collection('articles').embedText(
  recordId: article.id,
  vectorField: 'content_embedding',
);

// Or override with custom text — useful for combining fields:
await Koolbase.db.collection('articles').embedText(
  recordId: article.id,
  vectorField: 'content_embedding',
  text: '${article.title}\n\n${article.summary}',
);

For a full collection at once, hit the admin endpoint (also available from the dashboard's Vector Fields modal via the ✨ wand icon):

terminal
bash
curl -X POST "$API/v1/projects/$PROJECT_ID/collections/$COLLECTION/vector-fields/$FIELD/embed-all" \
  -H "Authorization: Bearer $TOKEN"

Lexical backfill

When you enable hybrid or lexical search on a collection that already has records, lexical rows need to be populated for the pre-existing data. (The write-path hook only covers writes that happen after the hook was wired.) The dashboard's Vector Fields modal has a 📖 book icon next to the ✨ wand for this; the corresponding endpoint:

terminal
bash
curl -X POST "$API/v1/projects/$PROJECT_ID/collections/$COLLECTION/vector-fields/$FIELD/lexical-backfill" \
  -H "Authorization: Bearer $TOKEN"

Idempotent + no provider charges

Lexical backfill is safe to run repeatedly. Records whose source text already matches the stored lexical row are skipped (already_current), so re-runs are cheap. Unlike embed backfill, lexical indexing is handled entirely by Postgres — no provider calls, no charges, no worker queue. Returns synchronously with the count of rows written.

Combining with structured filters

All three modes accept a where filter — e.g. “find the 10 closest articles in the ‘tech’ category”:

lib/search.dart
Dart
final scoped = await Koolbase.db.collection('articles').searchSemantic(
  field: 'content_embedding',
  queryText: 'production deploy pipeline',
  mode: KoolbaseSearchMode.hybrid,
  limit: 10,
  where: {'category': 'tech'},
);

Filters apply after retrieval

The HNSW index ranks by similarity first; where filters the top candidates after. With a very strict filter (matching only a tiny fraction of records) you may get fewer than limit hits — request a higher limit and re-filter client-side if needed.

Supported dimensions

Vector dimensions match the output size of your embedding model. Koolbase supports these dimensions in v1, covering the most common open-source and commercial models:

DimensionsExample models
384sentence-transformers/all-MiniLM-L6-v2
768sentence-transformers/all-mpnet-base-v2, BERT
1024Cohere embed-v3, BGE-large
1536OpenAI text-embedding-3-small, OpenAI text-embedding-ada-002, Gemini gemini-embedding-001

Using OpenAI's text-embedding-3-large

The native 3072-dim output of text-embedding-3-large isn't yet supported — pgvector's HNSW index caps at 2000 dimensions in our current version. Use the model's dimensions=1536 parameter (Matryoshka truncation) for full compatibility; this is the documented approach from OpenAI, not a hack. Full 3072-dim support arrives in a future release alongside a pgvector upgrade.

Behavior & errors

Online-only

Vector operations don't cache locally and aren't queued offline — HNSW similarity search and BM25 ranking have no useful offline semantics, so deferring writes could corrupt your view of what's stored. Calls throw on network failure rather than queuing for sync.

Read rules apply post-search

All three search modes respect the collection's read rule the same way get() does. For owner, scoped, or conditional rules, records are filtered to the caller after the index lookup — so very strict rules may return fewer than limit hits.

Errors to handle

lib/search.dart
Dart
try {
  await Koolbase.db.doc(id).setVector('embedding', vec);
} on KoolbaseVectorDimensionMismatchException catch (e) {
  // vector.length didn't match the field's declared dimension
  showError(e.message);  // includes both expected and actual dims
} on KoolbaseNotFoundException {
  // record_not_found, vector_field_not_found, or vector_not_found
  showError('Record or field is missing.');
} on KoolbasePermissionException {
  // The collection's write rule denied the operation
  showError('Not allowed.');
}

React Native

Same code paths, swap Exception → Error: KoolbaseVectorDimensionMismatchError, KoolbaseNotFoundError, KoolbasePermissionError, all caught with e instanceof ….