mcpField
N
node-sqlite-vec

node-sqlite-vec

github.com/mizchiData & databases41665 installsUpdated 1w ago
Repository

About node-sqlite-vec

A practical guide to combining Node 24+ built-in `node:sqlite` with `sqlite-vec` for vector storage. It is aimed at developers adding vector search or RAG, and documents setup details and pitfalls involving extension loading, row IDs, testing, and TypeScript.

Key features

  1. 01Set up node:sqlite with sqlite-vec
  2. 02Load the SQLite vector extension
  3. 03Bind vec0 row IDs as BigInt
  4. 04Query vector data with KNN
  5. 05Test with Node's built-in test runner

Use cases

  1. 01Add vector search storage to a Node project
  2. 02Build a SQLite-backed RAG data layer
  3. 03Troubleshoot sqlite-vec inserts or extension loading
  4. 04Test directly executed TypeScript database code

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/mizchi/skills --skill node-sqlite-vec

After installing, type/node-sqlite-vecin Claude Code to confirm it loaded.

Bundled filesREADME.md

Details

From the project README

node-sqlite-vec

Recipe for combining Node 24's built-in node:sqlite with the sqlite-vec extension. Each section below is a known pitfall paired with the working incantation — read the Pitfalls section first if you only have a minute.

When to use

  • Adding vector search / RAG storage to a Node 24+ project
  • You want to avoid the native build dance of better-sqlite3
  • TypeScript source that runs directly with --experimental-strip-types

Skip this skill if:

  • Your runtime is Node ≤ 22 (no built-in node:sqlite) — use better-sqlite3
  • You need server-grade concurrency / replication — SQLite is the wrong tool

Pitfalls (read first)

SymptomCauseFix
Failed to load url sqlite (resolved id: sqlite) from vitestvite strips the node: prefix and looks up plain sqlite, which does not existDrop vitest. Use node --test (built-in, no vite layer)
ERR_SQLITE_ERROR: Only integers are allows for primary key values on <table>_vec on insertJS number gets bound as REAL; vec0 virtual tables require strict integer rowidsWrap rowid bindings with BigInt(id)
loadExtension is not a function or "Not authorized to use load_extension"Both extension-load gates must be openednew DatabaseSync(path, { allowExtension: true }) and db.enableLoadExtension(true)
ExperimentalWarning: SQLite is an experimental feature printed every CLI runNode 24 emits this for any node:sqlite import#!/usr/bin/env -S node --no-warnings=ExperimentalWarning in the bin shebang
An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabledtsc rejects import "./x.ts" by default; --experimental-strip-types requires .ts extensionsSet allowImportingTsExtensions: true and rewriteRelativeImportExtensions: true in tsconfig.json

Setup

Dependencies
pnpm add sqlite-vec
pnpm add -D @types/node typescript

No better-sqlite3, no bindings, no node-gyp.

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "allowImportingTsExtensions": true,    // import "./x.ts" allowed in source
    "rewriteRelativeImportExtensions": true, // tsc emits "./x.js" in output
    "esModuleInterop": true,
    "skipLibCheck": true,
    "types": ["node"]
  }
}

rewriteRelativeImportExtensions lets you keep .ts imports in source (so node --experimental-strip-types tests/x.test.ts works) while still producing valid .js output via tsc.

package.json scripts
{
  "scripts": {
    "build": "tsc",
    "test": "node --experimental-strip-types --test --test-reporter=spec tests/*.test.ts",
    "typecheck": "tsc --noEmit"
  }
}

Do not pass --experimental-sqlite. The flag stopped being required in Node v22.13.0 / v23.4.0 — node:sqlite is importable out of the box on every version this skill targets, and the flag is now a no-op. Node still prints an ExperimentalWarning because the module sits at stability 1.2 (release candidate as of Node 25.7); see "Silencing the ExperimentalWarning" below.

--experimental-strip-types is a separate flag and is still needed to run .ts files directly on Node 22; Node 23+ strips types by default.

Open + load extension

// src/db.ts
import { DatabaseSync } from "node:sqlite";
import * as sqliteVec from "sqlite-vec";

export function openDB(path: string) {
  const db = new DatabaseSync(path, { allowExtension: true });
  db.enableLoadExtension(true);
  sqliteVec.load(db);
  db.enableLoadExtension(false); // re-disable after load (defense in depth)
  return db;
}

Both allowExtension: true (constructor) and enableLoadExtension(true) (instance) are required. If either is missing, the load throws.

Verify:

const row = db.prepare("SELECT vec_version() AS v").get();
console.log(row.v); // → "v0.1.9"

Schema: vec0 virtual table

const EMBEDDING_DIM = 1024;
db.exec(`
  CREATE TABLE IF NOT EXISTS items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    body TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  );
`);
db.exec(
  `CREATE VIRTUAL TABLE IF NOT EXISTS item_vec USING vec0(embedding FLOAT[${EMBEDDING_DIM}]);`,
);

Keep the regular table (items) and the vector table (item_vec) joined by id ↔ rowid.

Insert: the BigInt rowid trap

const insertItem = db.prepare(
  `INSERT INTO items (body) VALUES (?) RETURNING id`,
);
const insertVec = db.prepare(
  `INSERT INTO item_vec (rowid, embedding) VALUES (?, ?)`,
);

function add(body: string, embedding: Float32Array): number {
  const { id } = insertItem.get(body) as { id: number };

  // ❌ insertVec.run(id, blob)
  //    → ERR_SQLITE_ERROR: Only integers are allows for primary key values on item_vec
  //
  // ✅ Wrap with BigInt to force INTEGER affinity:
  insertVec.run(
    BigInt(id),
    new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength),
  );
  return id;
}

Apply the same BigInt(id) wrap to every vec0 binding: DELETE FROM item_vec WHERE rowid = ?, etc. The regular items table does not need this — only vec0 virtual tables enforce strict integer rowids.

The embedding goes in as a raw little-endian float buffer (Uint8Array view over a Float32Array).

KNN query

const sql = `
  SELECT i.id, i.body, v.distance
  FROM item_vec AS v
  JOIN items AS i ON i.id = v.rowid
  WHERE v.embedding MATCH ? AND v.k = ?
  ORDER BY v.distance ASC
  LIMIT ?
`;
const blob = new Uint8Array(query.buffer, query.byteOffset, query.byteLength);
const rows = db.prepare(sql).all(blob, k, k);

v.k = ? pre-filters to the top-k inside vec0; the outer LIMIT is the same number for clarity. When you also need a non-vector filter (e.g. WHERE kind = 'X'), pull more candidates via a larger v.k so the secondary filter does not starve.

Testing: do NOT use vitest

vitest depends on vite, and vite's resolver strips the node: prefix when resolving imports. node:sqlite has no plain-name fallback, so vitest fails before any test runs:

Error: Failed to load url sqlite (resolved id: sqlite). Does the file exist?

Use Node's built-in test runner instead:

// tests/db.test.ts
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDB } from "../src/db.ts";

let dir: string;
let db: ReturnType<typeof openDB>;

beforeEach(() => {
  dir = mkdtempSync(join(tmpdir(), "spike-"));
  db = openDB(join(dir, "test.db"));
});
afterEach(() => {
  db.close();
  rmSync(dir, { recursive: true, force: true });
});

describe("vec0", () => {
  it("loads sqlite-vec", () => {
    const row = db.prepare("SELECT vec_version() AS v").get() as { v: string };
    assert.match(row.v, /^v\d+\.\d+\.\d+/);
  });
});
node --experimental-strip-types --test tests/*.test.ts

Real DB, no mocks. node:test outputs TAP by default; pass --test-reporter=spec for the readable form.

CLI shebang

#!/usr/bin/env -S node --no-warnings=ExperimentalWarning
import { DatabaseSync } from "node:sqlite";
// ...

env -S splits the rest of the shebang on whitespace so you can pass flags. Without --no-warnings=ExperimentalWarning, every CLI invocation prints:

(node:12345) ExperimentalWarning: SQLite is an experimental feature and might change at any time

This becomes annoying in scripts that capture stderr. Suppress it at the entry point only — keep it visible during development if you want.

Stability timeline

Nodenode:sqlite status
v22.5.0Added, behind --experimental-sqlite
v22.13.0 / v23.4.0Flag no longer required; still experimental (warning stays)
v25.7.0+Stability 1.2 — release candidate; warning still emitted

Practical consequences today (Node 24 LTS "Krypton", Node 26 current):

  • Drop --experimental-sqlite from every script — it does nothing
  • Keep --no-warnings=ExperimentalWarning while the module is pre-1.0; remove it once the stability index reaches 2
  • Everything else (BigInt rowids, vitest avoidance, tsconfig flags) is independent of stability and continues to apply

References

Comments

Markdown supported