Search documents by meaning
Ask a question in plain language and get the closest documents back — even when they share no keywords with it. Install dirsql-plugin-embeddings and semantic search becomes plain SQL: the plugin's embed() function turns text into vectors, sqlite-vec's vec_distance_cosine() measures distance, and ORDER BY … LIMIT does the ranking. No config, no API keys, no services — the model runs locally.
Suppose short notes live in notes/*.md:
notes/pasta.md # boiling spaghetti, olive oil, garlic
notes/branches.md # git feature branches and pull requests
notes/tomatoes.md # planting tomato seedlings after the last frostThe one-liner
The plugin package is also its own command. Give it a corpus glob and a question, and it prints the closest paths, ranked by distance:
uvx dirsql-plugin-embeddings 'notes/*.md' "how do I cook pasta?" -k 3- The corpus glob is required, and always first: the plugin never picks a default corpus, so you always say exactly which files are in scope. A bare glob is fine — the command normalizes it to the
./-relative form the SQL layer requires. - The question is the second positional.
-k/--limit(both spellings, default 10) is the number of results — it is exactly the SQLLIMITof the generated query; there is no other cutoff.--model <id>switches the embedding model (model story).
The first-ever run downloads the model (on the order of a hundred megabytes, with progress on stderr); after that it loads from the local cache. Results print one path<TAB>distance line per match, closest first.
The SQL behind it
The one-liner generates and runs ordinary dirsql SQL, and you can write it yourself when you want more than ranked paths — a different projection, a join, a WHERE clause, a subset of a JSON file's content:
uvx --with dirsql-plugin-embeddings dirsql "
SELECT path,
vec_distance_cosine(emb, embed('how do I cook pasta?')) AS distance
FROM (SELECT path, embed(content) AS emb FROM './notes/*.md')
WHERE emb IS NOT NULL
ORDER BY distance
LIMIT 3"[{"path":"notes/pasta.md","distance":0.315},{"path":"notes/tomatoes.md","distance":0.881},{"path":"notes/branches.md","distance":0.92}]Neither "cook" nor any other keyword needs to appear in pasta.md — the distance ranking is doing the work.
Reading the query inside-out:
- The subquery scans the path-table
'./notes/*.md'and embeds each file'scontent— only the files the glob matches are ever read or embedded. In hand-written SQL the./prefix is required; only the one-liner normalizes a bare glob for you. embed('how do I cook pasta?')embeds the question once (the function is deterministic, so SQLite reuses the value across rows).WHERE emb IS NOT NULLdrops the files that could not be embedded. A file that is unreadable or not valid UTF-8 hasNULLcontent, so its embedding and its distance are NULL too — and SQLite sorts NULLs first ascending, so without this line the unrankable files take the top-k slots.vec_distance_cosine(...)computes cosine distance between the two vectors;ORDER BY distance LIMIT 3keeps the three nearest.
Structured files compose with SQL's JSON operators — embed one field instead of the whole file:
SELECT path
FROM (SELECT path, embed(content ->> 'abstract') AS emb
FROM './papers/**/metadata.json')
WHERE emb IS NOT NULL
ORDER BY vec_distance_cosine(emb, embed('local private models'))
LIMIT 10Top-k is LIMIT k
If you know sqlite-vec you may reach for its MATCH … AND k = 10 idiom. That syntax belongs to sqlite-vec's vec0 virtual table, which dirsql does not use — dirsql tables are per-file row tables. For plain expressions, sqlite-vec's own documented pattern is exactly what this guide uses: ORDER BY vec_distance_cosine(...) LIMIT k.
Repeat runs are cheap
Computed vectors are cached on disk, keyed on content and model (vector cache) — re-running a search over unchanged files skips the model entirely and re-embeds only what changed. And the plugin costs nothing when idle: a query that never calls embed() spawns no worker and loads no model (zero cost when unused).
How embed() gets into SQL
The plugin ships a config fragment declaring embed() via [[dirsql.function]], which the uvx/pip launcher discovers automatically. The same mechanism is open to your own configs and plugins — any external command that speaks the worker protocol can back a SQL function. To build one, see Write a plugin.