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.

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
viaRequired 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