@utils/semantic-search (2.1.0)

Published 2026-09-18 08:54:35 +02:00 by morten-olsen in utils/semantic-search

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
Details
npm
2026-09-18 08:54:35 +02:00
6
2026-11-08 03:57:04 +01:00
latest
26 KiB
Assets (1)
Versions (2) View all
2.1.0 2026-09-18
2.0.0 2026-09-18