Search your data
Two levels, both available today with the bundled SQLite (see SQL, Mongo & Redis):
- Level 0 — full-text (FTS5/BM25): pure SQL, no models, index kept in sync by SQL triggers.
- Level 1 — vectors + hybrid: an embedder as a task (
(text) => vector), brute-force cosine, and RRF to fuse both rankings — no weights to calibrate.
-- Doc example: search your data — FTS5/BM25 (level 0, pure SQL) + vectors with an
-- embedder-as-a-task + hybrid RRF fusion. The embedder here is a deterministic fake
-- (bag of hashed words) so the example runs offline; swap `embed` for a real model
-- (API call or local GGUF) without touching anything else — that's the point.
intent: "doc example: FTS5 + vector search with an embedder task, fused with RRF"
require db(":memory:")
db_open(":memory:")
sql_exec("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, description TEXT)")
sql_exec("CREATE TABLE product_vecs (id INTEGER PRIMARY KEY, vec TEXT)")
-- Level 0: FTS5 external-content + triggers keep the index in sync — pure SQL, no models.
sql_exec("CREATE VIRTUAL TABLE products_fts USING fts5(name, description, content='products', content_rowid='id')")
sql_exec("CREATE TRIGGER products_ai AFTER INSERT ON products BEGIN INSERT INTO products_fts(rowid, name, description) VALUES (new.id, new.name, new.description); END")
-- The embedder is a TASK: (text) => vector. Deterministic fake here; a real one is an
-- API call or a local model. ⚠️ SQL triggers can NEVER call a task — keep the write
-- path in ONE task (insert_product) so the vector table can't silently drift.
task embed(t)
let ws be where(split(fold(t), " "), (w) => length(w) > 0)
let hs be apply(ws, (w) => (sha256(w)[0] * 256 + sha256(w)[1]) % 64)
give array(apply(range(64), (i) => count_where(hs, (h) => h == i)))
task cosine(a, b)
let na be sqrt(sum(a * a))
let nb be sqrt(sum(b * b))
give when na == 0 or nb == 0 then 0 otherwise sum(a * b) / (na * nb)
task insert_product(name, description)
sql_exec("INSERT INTO products (name, description) VALUES (?, ?)", [name, description])
let pid be sql("SELECT last_insert_rowid() AS id")[0].id
sql_exec("INSERT INTO product_vecs (id, vec) VALUES (?, ?)", [pid, json_encode(to_list(embed(name + " " + description)))])
give pid
insert_product("Notebook Aurora 14", "notebook economica y liviana ideal para estudiar")
insert_product("Cargador USB-C 65W", "cargador rapido compatible con notebook y celular")
insert_product("Auriculares Nimbus", "auriculares inalambricos con cancelacion de ruido")
-- Hybrid = RRF (reciprocal rank fusion): no weights to calibrate, just ranks.
task rank_of(xs, pid)
let hit be index_of(xs, (x) => x.id == pid)
give when hit == nothing then -1 otherwise hit
task rrf_score(lex, sem, pid)
let l be rank_of(lex, pid)
let s be rank_of(sem, pid)
give (when l >= 0 then 1 / (60 + l) otherwise 0) + (when s >= 0 then 1 / (60 + s) otherwise 0)
task hybrid_search(q, top)
let lex be []
try
set lex to sql("SELECT rowid AS id, bm25(products_fts) AS s FROM products_fts WHERE products_fts MATCH ? ORDER BY s LIMIT 20", [q])
recover err
set lex to []
let qv be embed(q)
let scored be apply(sql("SELECT id, vec FROM product_vecs"), (r) => {"id": r.id, "s": cosine(qv, array(json_decode(r.vec)))})
let sem be slice(sort_by(where(scored, (x) => x.s > 0), (x) => 0 - x.s), 0, 20)
let ids be unique(flatten([apply(lex, (x) => x.id), apply(sem, (x) => x.id)]))
let ranked be slice(sort_by(ids, (i) => 0 - rrf_score(lex, sem, i)), 0, top)
when length(ranked) == 0
give []
let idlist be join(apply(ranked, (i) => text(floor(i))), ",")
let rows be sql("SELECT id, name FROM products WHERE id IN (" + idlist + ")")
give sort_by(rows, (r) => index_of(ranked, r.id))
print("top: " + (hybrid_search("notebook para estudiar", 3)[0])["name"])
test "BM25 alone finds the lexical match"
let hits be sql("SELECT rowid AS id FROM products_fts WHERE products_fts MATCH ? ORDER BY bm25(products_fts)", ["cargador"])
assert_eq((hits[0])["id"], 2)
test "hybrid RRF ranks the right product first"
let top be hybrid_search("notebook para estudiar", 3)
assert_eq((top[0])["name"], "Notebook Aurora 14")
let top2 be hybrid_search("cargador celular", 2)
assert_eq((top2[0])["name"], "Cargador USB-C 65W")
test "an empty query has zero signal on both sides — empty result, not an error"
-- embed("") is the zero vector (cosine 0 everywhere) and FTS5 has nothing to match.
-- Note: a NONSENSE query can still return weak hits with THIS fake embedder (hash
-- buckets collide) — a real embedding model is what makes low-similarity junk rank
-- low; RRF already puts lexical matches first either way.
assert_eq(hybrid_search("", 3), [])
Level 0 — FTS5 with self-maintaining triggers
sql_exec("CREATE VIRTUAL TABLE products_fts USING fts5(name, description, content='products', content_rowid='id')")
sql_exec("CREATE TRIGGER products_ai AFTER INSERT ON products BEGIN INSERT INTO products_fts(rowid, name, description) VALUES (new.id, new.name, new.description); END")
Query with MATCH + bm25() (lower = better, so ORDER BY ascending). Triggers are fine here because FTS5 sync is SQL-only.
FTS5 is SQLite. On Postgres the level-0 equivalent istsvector/tsquery(and pgvector for level 1 — pass a list as?::vector, see SQL, Mongo & Redis); the embedder-as-a-task and RRF fusion below work identically on any engine.
Level 1 — the embedder is a task
task embed(t)
-- swap this body for a real model: an API call, or a local GGUF
...
Anything callable works: http_post to an embeddings API, a local model, or the deterministic fake in the example (which runs offline). Vectors live in a plain table (json_encode(to_list(vec))), similarity is cosine over arrays, top-k is sort_by + slice.
⚠️ The write-path rule: SQL triggers can never call a Synsema task — a trigger cannot re-embed. Keep every write in one task (insert_productembeds and inserts both rows); any directsql_exec("INSERT ...")silently skips vectorization. This is the structural limit of the userland pattern.
⚠️ Fake vs real embedder: the example's hash-bucket embedder is deterministic and offline, but nonsense queries can still collide into weak similarity. A real embedding model is what makes junk rank low. Never mix vectors from different models in one table — same model, same dimension, or re-embed everything.
Hybrid = RRF (reciprocal rank fusion)
Fuse the two rankings by rank, not by score — no calibration:
task rrf_score(lex, sem, pid)
let l be rank_of(lex, pid) -- index_of with a predicate; nothing → not ranked
let s be rank_of(sem, pid)
give (when l >= 0 then 1 / (60 + l) otherwise 0) + (when s >= 0 then 1 / (60 + s) otherwise 0)
Candidates are the union of both lists (unique(flatten([...])) — dedupe by value, first-appearance order), sorted by RRF score. The example's hybrid_search is ~20 lines end-to-end.
Serve it (route "GET /search" → hybrid_search(query.q, 5)) or expose it as an MCP tool — see Your DB as MCP tools.