ai

@ai/semantic-search (0.2.0)

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

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:

  1. configs/default.json — shipped defaults (provider, model, chunking, excludes)
  2. ~/.config/semantic-search/config.json — per-user overrides ($SEMANTIC_SEARCH_CONFIG or $XDG_CONFIG_HOME move it; config --init writes it)
  3. 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
Details
npm
2026-09-17 19:54:08 +02:00
4
2026-10-25 20:44:51 +01:00
24 KiB
Assets (1)
Versions (3) View all
1.0.0 2026-09-17
0.2.0 2026-09-17
0.1.0 2026-09-17