ESC
Type to search...
S
Soli Docs

Search: Vector, Fulltext & Geo

Declare search indexes in the model class body — vector (HNSW ANN), fulltext, geospatial, and plain secondary indexes — and query them with similar, search, near, and within.

SoliDB only. Vector (HNSW), fulltext, and geo indexes are SoliDB engine features. On the SQL adapters (postgres, mysql, sqlite) these declarations and queries raise rather than degrade silently — pgvector is a separate, deferred design. See SQL document backends.

Index DSL

class Article < Model
  vector_index "embedding", dimension: 1536, metric: "cosine"
  fulltext_index "title", "body"
  index "email", unique: true
end

class Store < Model
  geo_index "location"    # field holds { "lat": ..., "lon": ... }
end
Declaration Description
vector_index field, dimension:, metric: HNSW vector index for ANN search on an embedding field. Optional: m:, ef_construction:, quantization:, name:.
fulltext_index field, ... Fulltext index over one or more fields; powers Model.search.
geo_index field Geospatial index; the field holds a { "lat": ..., "lon": ... } hash. Powers near / within.
index field_or_fields, options? Secondary index. A field name or an array (compound). Options: unique:, type: ("persistent" default, or "hash" / "fulltext" / "bloom" / "cuckoo"), name: (defaults to idx_<collection>_<fields>).

How indexes get created (sync strategy)

Declarations are metadata-only at load — declaring an index doesn't talk to the database. In dev, the server ensures the declared indexes exist at boot. In production, run soli db:indexes [folder] (new CLI command) or create them in migrations — migrations remain the recommended production DDL path; soli db:indexes is the DSL reconciler:

soli db:indexes           # reconcile declared indexes for the current app
soli db:indexes ./myapp   # or point at a project folder

There's also an internal __sync_model_indexes() builtin for scripts and tests.

Migration-side equivalents

  • db.create_index(collection, name, fields, { "unique": ..., "type": ... }) for secondary and fulltext indexes (type: "fulltext").
  • db.create_vector_index(collection, name, field, dimension, options) / db.drop_vector_index(collection, name) for vector indexes — options is a metric string ("cosine") or a hash with metric and quantization.
  • Geo indexes have no migration helper yet — dev boot or soli db:indexes creates them.

With a vector_index declared on the field, .similar() pushes the search down to the database's HNSW index (approximate nearest neighbor):

Article.similar("query text", "embedding", 5)      # embeds client-side, then ANN search
Article.similar([0.1, 0.2, ...], "embedding", 5)   # vector literal — no embedding call

# Chained filters: ANN candidates first, then your filter
Article.where({ "published": true }).similar("q", "embedding", 5)

# Escape hatch: force the old exact client-side cosine path
Article.similar("q", "embedding", 5, { "exact": true })
  • Text queries are embedded client-side (requires SOLI_EMBEDDING_API_KEY — see the embedding configuration). Pass a vector literal to skip the embedding call entirely.
  • Results carry a _similarity_score field.
  • Without a vector_index declaration, .similar() behaves exactly as before — the historical client-side cosine path is unchanged.

ANN honesty notes

  • HNSW results are approximate — ordering can differ from exact cosine similarity, especially among close scores.
  • With chained filters, the database returns ANN candidates first (4×k, capped at 400) and your filters are applied after candidate selection — so fewer than k rows may come back.
  • { "exact": true } is the escape hatch: exact client-side cosine over the filtered rows, at fetch-everything cost.
  • Raw-SDBQL equivalent with a server-side metadata filter (applied after ANN candidate selection): VECTOR_SEARCH(coll, index, @vec, k, { filter: { tenant: "acme" }, overfetch: 4 }) via db.query(...) — returns { doc, score } rows.

Generating embeddings (embed / embed_batch)

.similar("text", ...) embeds the query for you, but you still need to embed the documents you store. embed is the write-side counterpart — it returns the vector for a string so you can persist it on the record:

class Article < Model
  vector_index "embedding", dimension: 1536, metric: "cosine"

  before_save fn() {
    this.embedding = embed(this.title + "\n" + this.body)
  }
end
  • embed(text) → Array<Float> — one embedding vector.
  • embed_batch(texts) → Array<Array<Float>> — one request for many texts, returned in input order. Use it to back-fill embeddings over an existing collection instead of one call per row.
articles = Article.where({ "embedding": null }).all
vectors  = embed_batch(articles.map(fn(a) a.title))
articles.each_with_index(fn(article, i) {
  article.embedding = vectors[i]
  article.save()
})

Both use the same SOLI_EMBEDDING_* configuration as .similar(); they raise if SOLI_EMBEDDING_API_KEY is unset. Keys and endpoints live in the environment, not in app code — one place to review where text is sent.

Server-side alternative: auto-embeddings

Instead of embedding documents in app code, declare embedding_source on the vector index and let SolidB embed inserted text itself — on a background worker, off the write path, so bulk and driver inserts never block. Configure it on the index (embedding_source, embedding_provider, embedding_model) via a migration or the vector-index API; provider keys live in the database _env. Use it when you'd rather not run a before_save embed(...) hook on every model.

Requires a fulltext_index covering the field(s). Results are ranked; each carries _search_score:

results = Article.search("database indexing")
results[0]._search_score

# Fuzzy, field-scoped, highlighted
results = Article.search("phne", { "field": "title", "distance": 1, "limit": 5, "highlight": true })
results[0]._highlighted
Option Description
field Restrict the search to one indexed field
distance Fuzzy matching: maximum edit distance
limit Maximum number of results
highlight Adds a _highlighted field with match markup

Requires a geo_index. near sorts by distance and adds _distance (meters); within returns everything inside a radius (meters):

nearby = Store.near(48.85, 2.35, { "limit": 5 })   # each result has ._distance
inside = Store.within(48.85, 2.35, 2000.0)         # radius: 2 km

Pipeline notes (fulltext / geo)

  • search, near, and within bypass the SDBQL query pipeline — they are eager and return an array of model instances immediately; there is no chaining (.where(...), .order(...) don't compose with them).
  • On soft-delete models, deleted rows are dropped client-side after the index lookup — which can shrink a limit-ed result set.

Graph-augmented retrieval (graph_rag)

When documents are connected by edge models, seed with ANN on the vector_index, expand each hit through via: EdgeModel, then re-rank the union. Results carry _similarity_score, _graph_seed, and _graph_hops.

Product.graph_rag("wireless running gear", {
  "via": CompatibleWith,
  "direction": "any",
  "depth": 1,
  "seed_k": 3,
  "limit": 10
})

# Or compose: rank traversal reach by meaning
product.traverse(CompatibleWith).similar("accessories", "embedding", 5).all
OptionDefaultDescription
via—Required edge model class
directionoutout, in, or any
depth1Int or [min, max]
seed_k5ANN seed count
limit10Final result cap

One-call RAG (rag)

Model.rag(question) is retrieval-augmented generation in one call: it embeds the question, ANN-searches the vector_index for the top-k rows, builds an LLM context from each row's text field, and returns the generated answer plus the source rows.

result = Article.rag("How do I rotate the signing key?")
result["answer"]    # the LLM's answer, grounded in your data
result["sources"]   # the Article instances used as context
OptionDefaultDescription
fieldembeddingVector-index field to search
text_fieldcontentField whose text builds the context
k5Rows retrieved as context
systemRAG promptSystem prompt for the answer

Needs a vector_index (retrieval), embeddings (SOLI_EMBEDDING_API_KEY), and an LLM (SOLI_LLM_API_KEY/SOLI_LLM_URL). For a streamed answer, pair retrieval with out.llm_stream inside an sse block.

Reranking (rerank)

rerank(query, rows[, { field:, limit: }]) reorders an array of already-retrieved records by how many query tokens each one's text contains — most relevant first. It's pure and offline (no LLM, no server round-trip), so it's a cheap second pass after similar / graph_rag when you want to bias the order toward a phrase.

rows = Article.similar("vector databases", "embedding", 20)
top5 = rerank("hnsw index tuning", rows, { "field": "content", "limit": 5 })
  • field — which field to rank on. Omitted, it probes content / text / summary / body / title.
  • limit — keep only the top N after reordering.
  • Ties keep their original order, so a no-signal query returns the input unchanged.

For LLM-based reranking (a model reorders the candidates) or a stored retrieve→rerank pipeline, drop to raw SDBQL: SolidB's RERANK(query, docs, { mode: "llm" }) and RAG_PIPELINE(name, @vec) via db.query(...) / @sdbql{}.

Combined vector + fulltext ranking is not exposed through the ORM — the database's HTTP endpoint for it is still a stub. The raw-SDBQL escape hatch is SolidB's HYBRID_SEARCH function via db.query(...) / @sdbql{}; see the SolidB Hybrid Search docs for its signature and tuning.

Next Steps