@utils/semantic-search (2.1.0)
Installation
@utils:registry=npm config set @utils:registry=npm install @utils/semantic-search@2.1.0"@utils/semantic-search": "2.1.0"About this package
semantic-search
Search your files by meaning instead of by string match. Pipe in a list of files, ask a question in plain language, get back the passages that answer it.
$ git ls-files | semantic-search "where do we hash passwords"
src/auth/auth.service.ts:41-68 0.612
const hash = await scrypt(password, salt, KEY_LENGTH);
Everything runs locally: a small ONNX model does the embedding in-process, so there is no API key and nothing leaves the machine.
Is this what you want?
Probably yes if you want grep for concepts — hunting through notes or a codebase where you remember the idea but not the wording, or pulling the few relevant files together to hand to an LLM.
Probably no if you want a search service. There is no daemon and no server; it is a CLI that reads a file list and exits. Scoring is a brute-force scan over whatever you piped in, which is fast for a few thousand files and starts to drag somewhere north of 20k chunks in one query.
Install
The package lives on a Forgejo npm feed rather than npmjs.com, so point the
@utils scope at it first — otherwise bun add looks up npmjs.com and 404s.
# ~/.npmrc — the `utils` owner is public, so no token needed
@utils:registry=https://code.olsen.cloud/api/packages/utils/npm/
bun add -g @utils/semantic-search
Bun only: it uses bun:sqlite and Bun.CryptoHasher. From a checkout it is
mise install && task install && task link.
You will see "Blocked N postinstalls" on install. That is fine — the ONNX CPU binaries ship inside the tarball, and the install script only fetches the optional CUDA providers for Linux/x64.
Using it
git ls-files | semantic-search "how do we retry failed jobs"
git ls-files '*.md' | semantic-search "that idea about queues" -n 5
fd -e ts | semantic-search "rate limiting" --paths-only
git ls-files | semantic-search "release process" --json | jq -r '.path'
search is the default command, so you rarely type it. Anything that is not
index, search or config is treated as the query.
Output is grep-shaped — results on stdout, chatter on stderr, exit 0 found /
1 nothing found / 2 error — so it drops into pipelines and && chains.
Paths print relative to your current directory when the file is under it. With
--json you get one object per hit and always an absolute path, since whatever
is reading it does not share your cwd.
semantic-search config prints what resolved and where it came from, which is
the other thing you will occasionally want.
The file list is the interface
Deciding which files count is not this tool's job, so there is no directory
argument, no --pattern and no ignore list. You pipe in paths and those are the
files, for both index and search.
# git already knows your .gitignore, submodules and .gitattributes
git ls-files '*.md' | semantic-search "what does the cat say?"
# only what changed on this branch
git diff --name-only main... | semantic-search "did we document this?"
# files from anywhere, in one scope; nothing has to share a root
{ git ls-files; ls ~/notes/*.md; } | semantic-search "that idea about queues"
An empty list is an error, not an empty result — it nearly always means the
pipeline upstream broke, and answering "no matches" for a scope that was never
searched is the wrong lie to tell. When empty genuinely is a valid state (a
changed-files list on a commit that touched none), say so with --allow-empty:
index then does nothing and exits 0, search finds nothing and exits 1.
Cheap hybrid: grep first, meaning second
Embedding is the only expensive part, and you pay it per file. So the cheapest way to search a big repo is to let a text tool throw most of it away first and spend the embeddings on what survives:
# coarse pass: files that mention the word at all
# fine pass: which of them actually mean what you meant
git ls-files '*.ts' \
| xargs grep -il 'retry' \
| semantic-search "give up after a few failed attempts"
grep -l prints matching filenames, which is exactly the shape the pipe wants.
Flip it to grep -Li to search everything that doesn't mention the word —
handy when you suspect the concept got implemented under a different name.
Neither half gets there alone. Grep will never find backoff from "give up
after a few failed attempts", and a whole-repo semantic pass burns a lot of
embedding to answer one question. Narrowing first also stays warm: the cache is
keyed per file, so those files are already embedded next time, whatever list
they arrive in.
If grep matches nothing the pipeline comes out empty, and you get an error rather than a quiet "no matches".
The cache
${XDG_CACHE_HOME:-~/.cache}/semantic-search/index.sqlite
One cache for every project, because nothing in it is project-specific: entries
are keyed by absolute path, content hash and provider:model. Two projects
listing the same file embed it once, and switching models keeps both sets rather
than thrashing. Override the location with --cache or $SEMANTIC_SEARCH_CACHE
for CI or a throwaway corpus.
It is a cache in the strict sense — delete it and the next run rebuilds it.
Both commands index the listed files before doing anything else. Unchanged files cost a stat, changed files are re-embedded, and a listed file that has since been deleted gets evicted rather than scored, which is what makes a stale file list safe. The invariant:
for every path in the list, the cache holds either current embeddings for it, or nothing for it.
Files outside the list are never touched. One cache serves many different
lists, so "not in scope right now" must never mean "delete". The flip side is
that it only ever grows on its own, and the remedy is rm.
Warming it ahead of time
Because search indexes what it needs anyway, you mostly never touch this. The
index command exists for when you would rather pay that cost early than inside
a query — before a flight, in a post-checkout hook, or as a CI step:
git ls-files '*.md' | semantic-search index
Going the other way, --only-index skips the indexing pass and scores what is
already there, which is what you want offline or when you know nothing has
moved. (This flag used to be called --no-reconcile; the old name still works.)
Speed
Apple M4 Pro, default local model (Xenova/all-MiniLM-L6-v2, 384d), 100 markdown
docs averaging 4 KB — 392 KB and 499 chunks.
| Cold index, 499 chunks | 4.7 s (~105 chunks/s) |
| Re-index, nothing changed | ~60 ms (model never loads) |
| Re-index, 1 of 100 changed | ~200 ms |
| Search, cache warm | ~150 ms |
Embedding is paid once per file version per model. The freshness check is stat-first, so an unchanged list never reads a file or loads the model.
Search cost tracks your file list, not the cache: a 500-file scope measures
~18 ms against a 2 GB cache and stays flat as the cache grows. Past ~20k chunks
in one scope the dot product starts to dominate, which is where a SIMD kernel or
an ANN index would earn its place. IndexStore.search is the seam that would
change.
As a library
The CLI is a formatting layer over one exported function, so anything it does is available to a program — a RAG retrieval step, an editor plugin, a batch job:
import { openIndex } from '@utils/semantic-search';
const index = await openIndex({
paths: await listMarkdownFiles(), // required: you own file selection
config: { limit: 5 }, // optional: patch the resolved config
// cachePath: './scratch.sqlite' // optional: isolate from the shared cache
});
const hits = await index.search('how are passwords hashed');
index.close();
search indexes first by default, so callers never have to think about
freshness; pass { reconcile: false } to score the cache as-is, or
{ allowEmpty: true } to openIndex if an empty paths list is expected.
openIndex also exposes reconcile(), files(), stats() and the resolved
config. readCacheStats() reports whole-cache totals with no file list
involved, which is what semantic-search config prints.
Configuration
configs/default.json < ~/.config/semantic-search/config.json < CLI flags
Override the middle layer with $SEMANTIC_SEARCH_CONFIG. semantic-search config prints what was resolved and which layers contributed, because config
questions are almost always "which layer won?".
--provider openai --model text-embedding-3-small points at any
OpenAI-compatible /embeddings endpoint, reading the key from $OPENAI_API_KEY
— or from no header at all when apiKeyEnv is "", which is how you reach a
local llama.cpp, Ollama or vLLM server.
maxFileBytes and the binary check are guard rails, not selectors: a listed file
that is enormous or isn't UTF-8 text gets refused rather than embedded badly.
Everything else about which files count is yours to decide, upstream of the pipe.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @huggingface/transformers | ^3.8.1 |
| zod | ^4.1.13 |
Development dependencies
| ID | Version |
|---|---|
| @eslint/eslintrc | ^3.3.1 |
| @eslint/js | ^9.39.1 |
| @types/bun | ^1.4.0 |
| eslint | ^9.39.1 |
| eslint-config-prettier | ^10.1.8 |
| eslint-plugin-import | ^2.32.0 |
| eslint-plugin-prettier | ^5.5.5 |
| prettier | ^3.6.2 |
| typescript | ^5.9.3 |
| typescript-eslint | ^8.46.4 |