@ai/semantic-search (0.2.0)
Installation
@ai:registry=npm config set @ai:registry=npm install @ai/semantic-search@0.2.0"@ai/semantic-search": "0.2.0"About this package
semantic-search
Semantic search over a directory, as a single Bun CLI. Embeddings live in a
sqlite file next to the content (<dir>/.semantic-index.sqlite); every command
reconciles that index against the tree before it does anything else.
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
From the Forgejo npm feed (the ai owner is public, so no install auth):
bun add -g @ai/semantic-search
Runs under Bun only — it uses bun:sqlite, Bun.Glob 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.
The first search in a process pays ~2.4 s to load the model; after that a search over an unchanged tree is ~60 ms.
From a checkout
mise install # bun + task, pinned
task install # bun install --frozen-lockfile
task link # put `semantic-search` on PATH (~/.bun/bin)
Use
semantic-search search . "where do we hash passwords" -n 5
git ls-files '*.ts' | semantic-search search . "retry with backoff"
semantic-search search . "release process" --paths-only
semantic-search search . "rate limiting" --json | jq -r '.path'
semantic-search search . "rate limiting" --no-reconcile # index as-is, offline
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 index's contents.
src/auth/auth.service.ts:41-68 0.612
const hash = await scrypt(password, salt, KEY_LENGTH);
With no file list piped in, the scope is the whole directory minus binaries,
oversized files, and the exclude list (node_modules, dist, lockfiles,
source maps, …).
Scoping with a file list
Deciding which files count is not this tool's job — it owns embedding and
retrieval. There is no --pattern flag: pipe a list of files 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"
The directory argument stays required (defaulting to .): it names the index,
which the file list deliberately does not. Paths resolve against your current
directory, so a list produced anywhere works; files outside the index directory
are skipped with a reason rather than silently rebased.
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.
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({
directory: './docs',
paths: await listMarkdownFiles(), // optional; omit to index the whole directory
config: { limit: 5 },
});
const hits = await index.search('how are passwords hashed');
// ^ [{ path, startLine, endLine, score, text }, …]
index.close();
search reconciles before scoring unless you pass { reconcile: false }, so a
long-lived process never serves hits for files that changed underneath it. The
embedding model is loaded lazily and at most once per index, and never at all if
nothing needs embedding.
openIndex takes the same file-list scope as the CLI — paths: string[],
resolved against cwd (default: the process cwd) and clipped to directory:
const index = await openIndex({ directory: '.', paths: gitTrackedMarkdownFiles });
It also takes embedder, a factory for your own Embedder
({ provider, model, dims, embedDocuments, embedQuery }) — useful to reuse a
model you already have loaded, or to test without one. reconcile(), files(),
stats() and close() round out the surface.
Reconciliation
Running it on every command — rather than only on index — is a deliberate
choice: a stale hit pointing at deleted or rewritten text is worse than a
slightly slower search, and the cheap path really is cheap.
| State on disk | Cost |
|---|---|
| unchanged (same size + mtime) | one stat; model is never loaded |
| touched, identical bytes | one read + sha256; no embedding |
| new or changed | read, chunk, embed, write |
| file gone | entry and its chunks deleted |
| exists but outside the scope | left untouched |
The last row is what lets one index serve many different file lists over the same tree: "not matched now" never means "delete".
Configuration
Layered, lowest first:
configs/default.json— shipped defaults (provider, model, chunking, excludes)~/.config/semantic-search/config.json— per-user overrides ($SEMANTIC_SEARCH_CONFIGor$XDG_CONFIG_HOMEmove it;config --initwrites it)- CLI flags —
--provider,--model,--limit
semantic-search config . prints the resolved values, which layers contributed,
and what the directory's index currently holds.
For a hosted model, point it at any OpenAI-compatible /embeddings endpoint:
{ "provider": "openai", "model": "text-embedding-3-small", "baseUrl": "https://api.openai.com/v1" }
The key comes from $OPENAI_API_KEY by default; apiKeyEnv renames that
variable, and setting it to "" sends no Authorization header at all — which
is what self-hosted servers (llama.cpp, Ollama, vLLM) want:
{
"provider": "openai",
"model": "qwen3-embedding-8b",
"baseUrl": "https://llama.olsen.cloud/v1",
"apiKeyEnv": "",
"queryPrefix": "Instruct: Given a search query, retrieve relevant passages\nQuery: "
}
queryPrefix/documentPrefix exist for the asymmetric checkpoints (e5, bge,
Qwen3) that are trained to see an instruction on the query side only.
Vectors from different models are never mixed: searching with a model the index does not hold is an error that names what it does hold, and switching models re-embeds the tree.
Chunking
Documents rarely fit in one embedding — most checkpoints cap out around 512
tokens, and a whole-file vector averages away whatever made the file findable.
So files are split into a line-aligned sliding window (chunkChars, default
1200, with overlapChars of carry-over), one vector per chunk, and a hit cites
path:start-end. Overlap keeps a sentence that straddles a boundary reachable
from both sides; a single line wider than the window is split rather than
silently truncated by the tokenizer.
Design
Small modules, each with one job and a narrow interface:
| Module | Job |
|---|---|
src/scan |
glob + stat + "is this text", no content decisions |
src/chunk |
line-aligned sliding window, so hits cite path:start-end |
src/embed |
one Embedder seam, adapters for local and OpenAI-compatible |
src/store |
sqlite: schema, transactions, vector layout, cosine scan |
src/reconcile |
the cheap-out ladder: stat → hash → embed |
src/commands |
CLI-shaped adapters over the above |
Search is an exact brute-force cosine scan, streamed one row at a time. That is
the right trade up to ~10^5 chunks; IndexStore.search is the single place an
ANN index would go.
Agent skill
skills/semantic-search/ documents the tool for coding agents — when to reach
for it instead of grep, how to read the scores, and the gotchas. Install it
with the Skills CLI:
npx skills https://code.olsen.cloud/ai/semantic-search.git
Pass the full clone URL — a bare <owner>/<repo> makes the Skills CLI look on
GitHub, where this repo does not exist.
Development
Every command is a task target, and CI calls the same ones — the shared
definitions come from ci/ci's workflows/bun.yml via a remote include, so
task ci:quality reproduces the PR gate byte-for-byte.
task check # tsc --noEmit
task lint # eslint
task test # bun test
task ci:quality # check + lint + test --coverage (what PRs run)
task ci:compliance # dependency license gate
task publish:check # print the publish tarball contents
There is no compiled-binary target: bun build --compile embeds
onnxruntime-node's .node file but not the libonnxruntime dylib it links
against, so the local provider dies at startup. The package ships TypeScript
and runs under Bun.
CI / releases
Woodpecker on the homelab Forgejo. Workflows are thin — all work is task:
| Workflow | Trigger | Does |
|---|---|---|
quality.yaml |
push, PR, manual | task ci:quality |
compliance.yaml |
push, PR, manual | task ci:compliance + Trivy fs scan |
draft-release.yaml |
push to main |
upserts a draft release from conventional commits |
release.yaml |
tag | task ci:quality, then publishes to the npm feed |
renovate.yaml |
cron, manual | self-hosted Renovate |
Releases are human-gated: pushes to main only keep a draft release
up to date (next version + grouped notes, from feat:/fix:/! commits).
Publishing that draft in the Forgejo UI creates the tag, and the tag is what
publishes the package. The tag is the version source — package.json#version
stays 0.0.0 in git and scripts/publish-npm.sh sets it at publish time, so
nothing pushes a chore commit back to main.
The license gate runs against the full install tree; Taskfile.yml documents
the two entries added to the default allowlist and why.
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 |