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 —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.