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 —optionsis a metric string ("cosine") or a hash withmetricandquantization.- Geo indexes have no migration helper yet — dev boot or
soli db:indexescreates them.
Vector Search (similar)
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_scorefield. - Without a
vector_indexdeclaration,.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
krows 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 })viadb.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.
Fulltext Search (search)
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 |
Geo Search (near / within)
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, andwithinbypass 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
| Option | Default | Description |
|---|---|---|
| via | — | Required edge model class |
| direction | out | out, in, or any |
| depth | 1 | Int or [min, max] |
| seed_k | 5 | ANN seed count |
| limit | 10 | Final 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
| Option | Default | Description |
|---|---|---|
| field | embedding | Vector-index field to search |
| text_field | content | Field whose text builds the context |
| k | 5 | Rows retrieved as context |
| system | RAG prompt | System 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 probescontent/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{}.
Hybrid Search (not exposed yet)
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.