@utils/semantic-search (2.0.0)

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

Installation

@utils:registry=
npm config set @utils:registry=
npm install @utils/semantic-search@2.0.0
"@utils/semantic-search": "2.0.0"

About this package

semantic-search

Semantic search over a list of files, as a single Bun CLI. You say which files count — git ls-files, fd, a changed-files list, anything that prints paths — and it handles the part those tools cannot: embedding and retrieval.

Embeddings live in one shared cache keyed by absolute path, content hash and model, so the same file indexed from two projects is embedded once. It is a cache in the strict sense: delete it and the next run rebuilds it.

Local-only by default: transformers.js runs a small ONNX model in-process, so there is no API key and nothing leaves the machine.

Install

The package lives on a Forgejo npm feed, not on npmjs.com, so the @utils scope has to be pointed at it first. Without this step bun add looks up npmjs.com and fails with a 404.

# ~/.npmrc — the `utils` owner is public, so no token is needed
@utils:registry=https://code.olsen.cloud/api/packages/utils/npm/

Bun reads ~/.npmrc; if you would rather keep it in Bun's own config, the equivalent is:

# ~/.bunfig.toml
[install.scopes]
"@utils" = "https://code.olsen.cloud/api/packages/utils/npm/"

Then:

bun add -g @utils/semantic-search

Runs under Bun only — it uses bun:sqlite and Bun.CryptoHasher.

Bun reports "Blocked N postinstalls" on install. That is fine here: the ONNX runtime's CPU binaries ship inside the onnxruntime-node tarball, and its install script only downloads the optional CUDA execution providers for Linux/x64. Allow-list it ("trustedDependencies": ["onnxruntime-node"]) only if you want those.

From a checkout

mise install          # bun + task, pinned
task install          # bun install --frozen-lockfile
task link             # put `semantic-search` on PATH (~/.bun/bin)

Use

git ls-files | semantic-search search "where do we hash passwords" -n 5
git ls-files '*.md' | semantic-search index
fd -e ts | semantic-search search "retry with backoff" --paths-only
git ls-files | semantic-search search "rate limiting" --json | jq -r '.path'
git ls-files | semantic-search search "release process" --no-reconcile
semantic-search config
semantic-search --help

Output is grep-shaped: results on stdout, diagnostics on stderr, and exit codes 0 found / 1 nothing found / 2 error, so it composes in pipelines and && chains. Every command takes --json: index prints one summary object, search prints one object per hit (JSONL), config prints the resolved config, its layer list, and the cache's contents.

src/auth/auth.service.ts:41-68  0.612
    const hash = await scrypt(password, salt, KEY_LENGTH);

Hit paths print relative to your current directory when the file lives under it, absolute when it does not. --json always gives the absolute path, since a machine consumer shares no cwd with you.

Scope: the file list is the interface

Deciding which files count is not this tool's job. There is no directory argument, no --pattern and no built-in ignore list: pipe a list and those are the files, for both index and search.

# Honour .gitignore for free: git already knows which files count
git ls-files '*.md' | semantic-search search "what does the cat say?"

# Only what changed on this branch
git diff --name-only main... | semantic-search index

# Anything that can print paths works — fd, comm, a hand-written list
fd -e ts -E '*.test.ts' | semantic-search search "retry with backoff"

# Files from anywhere, in one scope; nothing has to share a root
{ git ls-files; ls ~/notes/*.md; } | semantic-search search "that idea about queues"

git ls-files is the everyday case, and it is worth being explicit about why: it already knows your .gitignore, your submodules, and your .gitattributes — none of which this tool has any business re-implementing.

With no list on stdin, index and search are errors rather than guesses. An empty list is an error too: it almost always means a pipeline produced nothing, and silently searching zero files would report "no matches" for what is really a broken command.

Relative paths resolve against your current directory, so a list produced anywhere works.

The cache

${XDG_CACHE_HOME:-~/.cache}/semantic-search/index.sqlite

Override it with --cache <path> or $SEMANTIC_SEARCH_CACHE — useful for CI, a test, or a throwaway corpus you do not want mixed into the shared one.

One cache serves every project because nothing in it is project-specific: an entry is keyed by absolute path, content hash and provider:model. Two projects listing the same file share its vectors, and indexing the same file under two models keeps both rather than thrashing between them.

Both commands reconcile the listed files first. Unchanged files cost a stat, changed files are re-embedded, and a listed file that has since been deleted is evicted rather than scored — which is what makes a stale file list safe. The invariant is:

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 now" must never mean "delete". The flip side is that the cache only ever grows on its own; it is a cache, so the remedy is rm.

Performance

Measured on an Apple M4 Pro (12 cores) with the default local model (Xenova/all-MiniLM-L6-v2, 384d), over 100 markdown documents averaging 4 KB — 392 KB and 499 chunks in total.

Cold index, 499 chunks 4.7 s (~9.5 ms/chunk, ~105 chunks/s)
Model load, on its own ~57 ms
Re-index, nothing changed ~60 ms wall (model never loads)
Re-index, 1 of 100 changed ~200 ms wall
Search, cache warm ~150 ms wall

Embedding is the only expensive part, and it is paid once per file version per model. The reconcile pass is stat-first, so an unchanged list never reads a file or loads the model.

Search cost tracks your file list, not the cache: scoring is a brute-force cosine scan over the scoped chunks only. A 500-file scope measures ~18 ms against a 2 GB / 500k-chunk cache, and stays flat as the cache grows. Above roughly 20k chunks in a single scope the dot product starts to dominate, which is the point at which a SIMD kernel or an ANN index would earn its place; IndexStore.search is the one 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 reconciles first by default, so a caller never has to think about freshness. Pass { reconcile: false } to score the cache as-is.

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 — config questions are almost always "which layer won?".

maxFileBytes and the binary check are guard rails, not selectors: a listed file that is enormous or not UTF-8 text is refused rather than embedded badly. Anything 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:25:42 +02:00
5
2026-10-26 04:18:18 +01:00
24 KiB
Assets (1)
Versions (2) View all
2.1.0 2026-09-18
2.0.0 2026-09-18