@ai/semantic-search (1.0.0)
Installation
@ai:registry=npm config set @ai:registry=npm install @ai/semantic-search@1.0.0"@ai/semantic-search": "1.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 @ai scope
has to be pointed at it first. Without this step bun add looks up npmjs.com
and fails with a 404.
# ~/.npmrc — the `ai` owner is public, so no token is needed
@ai:registry=https://code.olsen.cloud/api/packages/ai/npm/
Bun reads ~/.npmrc; if you would rather keep it in Bun's own config, the
equivalent is:
# ~/.bunfig.toml
[install.scopes]
"@ai" = "https://code.olsen.cloud/api/packages/ai/npm/"
Then:
bun add -g @ai/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 '@ai/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 |