Semantic Search Over Your Own Content: Nuxt 4 + TypeScript + Nuxt Content + sqlite-vec + Transformers.js

Somebody types "why is my site slow on mobile" into your docs search and gets nothing. The article they wanted is called Optimizing Largest Contentful Paint. It doesn't contain the word "slow". It doesn't contain the word "mobile". Keyword search did exactly what it was built to do, and the person left.
The usual next move is to sign up for something. Algolia, or an OpenAI embeddings key plus a vector database, and now your blog search has a monthly bill, a rate limit, and a network hop between the user and their own content.
None of that is necessary. A 23 MB embedding model runs fine on the same CPU that's already serving your SSR, an 800 KB SQLite extension does the nearest-neighbour math, and the whole index for a mid-sized site is a file you could email to yourself. The interesting part of this build isn't any one of those pieces β it's that four things you're already running or can vendor in turn out to compose into a search engine, and the seams between them are where all the real work is.
Tags: Nuxt 4, Nuxt Content, sqlite-vec, Transformers.js, SQLite FTS5, TypeScript
Time to read: 21 min
What you'll build: search for a Nuxt Content site that returns Optimizing Largest Contentful Paint for "why is my site slow on mobile", still returns exact matches for defineNuxtConfig the way a keyword index would, runs entirely inside your own Nitro process, and rebuilds its index from one authenticated POST.
Why this combination
Four pieces. The reason they fit is that each one hands the next exactly what it needs, in the shape it needs it.
Nuxt Content v3 is the chunker, and this is the part people rebuild by hand for no reason. Embedding models have a short context window β the one we're using truncates at 256 tokens β so you cannot embed a whole article. You have to split it. Content already exposes queryCollectionSearchSections(), which walks the parsed document and returns one section per heading, each carrying its own heading trail. That is a semantically-bounded chunk with breadcrumbs attached, generated from the same parse that renders your pages. Writing your own markdown splitter to get worse chunks is a popular way to spend a weekend.
Transformers.js (@huggingface/transformers) turns those chunks into vectors, in-process, on the CPU, via onnxruntime-node. No API key, no per-token cost, no request leaving the box, and β the underrated one β no drift, because the model file is pinned in your lockfile and will produce byte-identical vectors in two years.
sqlite-vec is the index. It's a loadable SQLite extension that adds vec0 virtual tables and does brute-force KNN over them. Brute force sounds like a limitation until you count: a comparison against a 384-dimension vector is 384 multiply-adds, and a site with 3,000 chunks is a bit over a million float operations per query. That is not a workload. It's a rounding error next to the embedding call that produced the query vector.
FTS5 β SQLite's built-in full-text index β is in there because vector search alone is worse than what you already have for a class of query that matters enormously on a technical site. Ask a semantic index for useAsyncData and it will happily hand you a chunk about useFetch, because those two things genuinely are semantically adjacent. They are also not the same function. Exact-token matching is a real capability and throwing it away is a downgrade. So we run both and fuse the rankings.
What you're deliberately not getting: an ANN index (unnecessary below ~100k chunks, and stable sqlite-vec doesn't have one yet), reranking with a cross-encoder, or a hosted service's typo tolerance and analytics dashboard. This is the tier that covers "my site search is embarrassing and I have no budget."
Prerequisites
- Node.js 20.19+ (Nuxt Content 3.15's floor; 22+ if you want
node:sqlite) - Nuxt 4.x with
@nuxt/contentv3 already rendering your markdown - Comfort with
server/in Nuxt β route handlers,useRuntimeConfig, Nitro plugins - A deploy target that is a long-lived Node process β a VPS, a container, a Fly machine. Read section 11 before you try this on a serverless function; the answer there is "don't", and I'll explain exactly why.
1) Install, and the two config lines that decide whether this builds
npm i @huggingface/transformers sqlite-vec better-sqlite3
Three packages, and each one carries a caveat worth knowing before you're 40 minutes into a broken build.
@huggingface/transformers is a big install. It pulls onnxruntime-node, onnxruntime-web (yes, on a server β it's an unconditional dependency) and sharp. Expect somewhere around 350β400 MB in node_modules. That is a real constraint on some platforms and a non-issue on a container. Also note the package rename: @xenova/transformers is the old name, frozen at 2.17.2 and pinned to an ancient onnxruntime. Anything you find online importing @xenova/* is at least two majors stale.
onnxruntime-node has no darwin/x64 binary. If you're on an Intel Mac, importing the library throws Cannot find module '../bin/napi-v6/darwin/x64/onnxruntime_binding.node'. This is a known, still-open issue. Apple Silicon, Linux x64 and Linux arm64 are all fine. There's a workaround involving hand-copying binaries from onnxruntime-node@1.23.2, but if you have an Intel Mac in the team, decide now whether you're doing that or gating this feature behind a dev flag locally.
sqlite-vec ships prebuilt binaries for exactly five platform triples: darwin-x64, darwin-arm64, linux-x64, linux-arm64, windows-x64. There is no musl build, which means this does not run on Alpine. If your Dockerfile starts with node:22-alpine, it will install cleanly β the platform packages are optionalDependencies, so a missing one fails silently β and then blow up at runtime with Unsupported platform for sqlite-vec. Section 10 has the Dockerfile.
Now the config:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/content'],
compatibilityDate: '2025-07-15',
nitro: {
externals: {
// Native .node binaries cannot be inlined into a bundle. Left to its
// own devices, Nitro will try, and you get a build that succeeds and
// a server that dies on first import.
external: [
'@huggingface/transformers',
'onnxruntime-node',
'sharp',
'better-sqlite3',
'sqlite-vec',
],
},
},
runtimeConfig: {
// Server-only. Both are absolute or root-relative paths/secrets, so they
// must stay out of the public block.
searchIndexPath: './.data/search/index.sqlite',
modelCacheDir: './.cache/models',
reindexToken: '',
},
})
Why externals.external is not optional. @huggingface/transformers does a static import * as ONNX_NODE from 'onnxruntime-node' β not a conditional dynamic import β and that package resolves to a platform-specific .node binary. Bundlers cannot follow that. Marking these external tells Nitro to leave them as plain runtime requires against node_modules, which in turn means your deploy has to ship node_modules, not just .output. If you were relying on Nitro producing a self-contained output directory, this is the point where that stops being true. It's the honest cost of native modules and it applies equally to better-sqlite3.
Why the index is its own file. Nuxt Content has its own SQLite database β .data/content/contents.sqlite in dev, plus a dump it restores at runtime. Do not put your vectors in it. It is regenerated on build, its schema is not yours, and Content is free to change it in a minor. A separate file at .data/search/index.sqlite is one line of config and zero coupling.
2) One shape, in shared/
The Nitro handler produces search hits and the Vue component renders them. Nuxt 4's shared/ directory is the one place both can import from, so the contract lives there and the compiler checks both ends against it:
// shared/search.ts
// The model and its dimension count travel together. Change one and you must
// change the other, and you must rebuild the index β a vec0 column's width is
// baked into the table's DDL and mismatched query vectors are a hard error.
export const EMBEDDING_MODEL = 'Xenova/all-MiniLM-L6-v2'
export const EMBEDDING_DIMS = 384
export interface SearchHit {
/** Page path plus heading anchor, e.g. /articles/2026-08/foo#chunking */
url: string
/** Page title */
title: string
/** Heading trail within the page, outermost first */
headings: string[]
/** Plain text, possibly with <mark> tags from FTS5's snippet() */
snippet: string
/** Fused score. Higher is better. Not a probability, not comparable across queries. */
score: number
/** Which retriever(s) surfaced this chunk. Useful for debugging relevance. */
matched: Array<'vector' | 'keyword'>
}
export interface SearchResponse {
query: string
hits: SearchHit[]
/** Milliseconds spent embedding the query, for the performance section below. */
embedMs: number
}
That matched array looks like a debugging afterthought. Keep it. When someone reports a bad result, the first question is always "did that come from the vector side or the keyword side", and having the answer in the payload saves you an hour every time.
3) The embedder: one model, one instance, one promise
// server/utils/embedder.ts
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers'
import { EMBEDDING_MODEL } from '#shared/search'
let pipelinePromise: Promise<FeatureExtractionPipeline> | null = null
export function getEmbedder(): Promise<FeatureExtractionPipeline> {
if (!pipelinePromise) {
// Must be set before the first pipeline() call β see below for why the
// default location is a trap.
env.cacheDir = useRuntimeConfig().modelCacheDir
// NOT awaited. We cache the *promise*, so two requests arriving during a
// cold start share one model load instead of racing to download 23 MB twice.
pipelinePromise = pipeline('feature-extraction', EMBEDDING_MODEL, {
// In Node the default dtype is fp32. Overriding to q8 takes this model
// from ~90 MB to ~23 MB with a relevance cost you will not be able to
// measure on prose.
dtype: 'q8',
})
}
return pipelinePromise
}
export async function embed(texts: string[]): Promise<Float32Array[]> {
const extractor = await getEmbedder()
// pooling: 'mean' collapses per-token vectors into one per input.
// normalize: true gives unit-length vectors β see section 5 on why that
// matters more than it looks like it does.
const output = await extractor(texts, { pooling: 'mean', normalize: true })
const [rows, dims] = output.dims as [number, number]
return Array.from({ length: rows }, (_, i) =>
// subarray() returns a VIEW onto the batch's shared buffer. Wrapping it in
// a Float32Array constructor copies. Skip the copy and every vector you
// store pins the entire batch in memory until all of them are released.
new Float32Array(output.data.subarray(i * dims, (i + 1) * dims)),
)
}
Four things in there that are load-bearing.
The unawaited promise is the whole point of the singleton. A naive if (!pipe) pipe = await pipeline(...) has a window: two concurrent requests both see null, both start a load. Caching the promise closes it. This is the pattern in Hugging Face's own Node tutorial and it's worth copying exactly.
env.cacheDir defaults to inside node_modules. Specifically node_modules/@huggingface/transformers/.cache/. In a Docker build that directory is either rebuilt or ephemeral, so you re-download the model on every cold start. Point it somewhere you control and, in production, somewhere you've pre-populated.
TRANSFORMERS_CACHE does nothing here. That environment variable is from the Python library. The JS package reads exactly two env vars β HF_TOKEN and TESTING_REMOTELY β and cacheDir is code-only. I've watched someone lose an afternoon to this.
On the model choice. As of Transformers.js v4 the default model for feature-extraction is onnx-community/all-MiniLM-L6-v2-ONNX, which is the same architecture but ships fp32 weights split across an external .onnx_data file β a ~91 MB cold download β and has no q8 export at all, so dtype: 'q8' against it 404s. Xenova/all-MiniLM-L6-v2 has the full dtype ladder in single files. Same 384 dimensions, same quality, a third of the bytes, no external data file to lose in a Docker layer. If you want something newer, mixedbread-ai/mxbai-embed-xsmall-v1 and Snowflake/snowflake-arctic-embed-xs are also 384-dimension and also have q8 exports β but check their model cards first, because some embedding models expect a prefix like Represent this sentence for searching relevant passages: on the query side and not on the document side. Get that asymmetry wrong and your relevance quietly degrades with no error anywhere.
4) The store: three tables, one file
// server/utils/search-db.ts
import { mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import Database from 'better-sqlite3'
import * as sqliteVec from 'sqlite-vec'
import { EMBEDDING_DIMS } from '#shared/search'
let db: Database.Database | null = null
export function useSearchDb(): Database.Database {
if (db) return db
const file = resolve(useRuntimeConfig().searchIndexPath)
mkdirSync(dirname(file), { recursive: true })
db = new Database(file)
// This is literally `db.loadExtension(pathToVec0)`. better-sqlite3 permits
// extension loading unconditionally β there is no allowExtension option
// here. (node:sqlite is the opposite: it needs { allowExtension: true } in
// the constructor and cannot be talked into it afterwards.)
sqliteVec.load(db)
db.pragma('journal_mode = WAL')
db.exec(`
-- Plain table. Source of truth for text, and the join target after KNN.
create table if not exists chunks (
id integer primary key,
url text not null,
path text not null,
title text not null,
headings text not null, -- JSON array
content text not null
);
-- Keyword index. content='chunks' makes this an external content table:
-- FTS5 stores only the index, not a second copy of the text.
create virtual table if not exists chunks_fts using fts5(
headings,
content,
content='chunks',
content_rowid='id',
tokenize='porter unicode61'
);
-- Vector index. No primary key declared, so rowid stays available and we
-- keep it identical to chunks.id. Declaring any PK on a vec0 table makes
-- it WITHOUT ROWID and 'select rowid' starts failing.
create virtual table if not exists vec_chunks using vec0(
embedding float[${EMBEDDING_DIMS}] distance_metric=cosine
);
`)
return db
}
distance_metric=cosine is not the default. vec0 defaults to L2. For normalized embeddings the two produce the same ordering, so this looks cosmetic β but it stops being cosmetic the day someone swaps in a model that doesn't normalize, and cosine is what every embedding model's documentation talks about. Say what you mean.
Keeping vec_chunks.rowid equal to chunks.id is the entire join strategy. vec0 also supports metadata columns (filterable in a KNN query, max 16) and auxiliary +column columns (retrievable but not filterable, max 16), and you could denormalize the whole chunk in there. Don't. You need the plain table for FTS5's external content anyway, so a second copy buys nothing but drift.
On tokenize='porter unicode61'. The Porter stemmer means "optimizing" matches "optimize". On a technical site this is mostly good and occasionally annoying β it will also stem your identifiers. If your content is heavy on API names, unicode61 alone is the more conservative choice.
5) Chunking, courtesy of Nuxt Content
Here's the function that saves you writing a markdown splitter:
// server/utils/chunker.ts
import type { H3Event } from 'h3'
export interface Chunk {
url: string
path: string
title: string
headings: string[]
content: string
}
// all-MiniLM-L6-v2 truncates at 256 tokens. ~4 chars per token for English
// prose gives a soft budget around 1000 characters; the overlap keeps a
// sentence that straddles a boundary retrievable from both sides.
const MAX_CHARS = 1000
const OVERLAP_CHARS = 150
export async function buildChunks(event: H3Event): Promise<Chunk[]> {
const sections = await queryCollectionSearchSections(event, 'content', {
// Code blocks are noise for a sentence-embedding model β they eat the
// token budget and push the prose that explains them out of the window.
// They stay searchable via FTS5 if you'd rather keep them; see the note.
ignoredTags: ['pre', 'code'],
})
const chunks: Chunk[] = []
for (const section of sections) {
const text = section.content?.trim()
if (!text || text.length < 40) continue // headings with no body under them
// section.id is `/path` for the h1 and `/path#anchor` for everything else.
const [path] = section.id.split('#')
for (const part of splitWithOverlap(text, MAX_CHARS, OVERLAP_CHARS)) {
chunks.push({
url: section.id,
path: path!,
title: section.titles[0] ?? section.title,
// The heading trail. Prepending it to the embedded text is the cheap
// trick that makes a chunk called "Gotchas" retrievable, because on
// its own that word means nothing.
headings: section.titles,
content: part,
})
}
}
return chunks
}
function splitWithOverlap(text: string, max: number, overlap: number): string[] {
if (text.length <= max) return [text]
const parts: string[] = []
let start = 0
while (start < text.length) {
let end = Math.min(start + max, text.length)
if (end < text.length) {
// Prefer a sentence boundary, fall back to a space, then to a hard cut.
const window = text.slice(start, end)
const boundary = Math.max(
window.lastIndexOf('. '),
window.lastIndexOf('? '),
window.lastIndexOf('! '),
)
if (boundary > max * 0.5) end = start + boundary + 1
else {
const space = window.lastIndexOf(' ')
if (space > max * 0.5) end = start + space
}
}
parts.push(text.slice(start, end).trim())
if (end >= text.length) break
start = Math.max(end - overlap, start + 1)
}
return parts
}
queryCollectionSearchSections(event, 'content', opts) returns { id, title, titles, level, content } per heading-delimited section, where titles is the heading trail and content is the plain text with markup stripped. Note the event first argument β that's the server-side signature; the client-side one omits it. Content's server utilities need the H3 event to reach the database binding, which is exactly why the indexer in the next section is an HTTP endpoint rather than a build script.
Two judgement calls I'd flag as judgement calls rather than answers:
Dropping code blocks. For a site like this one β where half the value is the code β you can argue it either way. My compromise: exclude them from the embedded text (a 256-token window spent on an import block is a wasted chunk) but they're still reachable via the keyword side, because FTS5 indexes chunks.content, and if you want them there you simply drop 'pre' from ignoredTags and let the vector side ignore what it can't use. Try it both ways on your own corpus; this is a ten-minute experiment, not a principle.
Prepending the heading trail before embedding. Section 6 does this. A chunk under Deployment β Docker β Gotchas embedded as bare text loses every bit of that context. Prepending Deployment > Docker > Gotchas costs a handful of tokens and makes the chunk findable by the words a person would actually type.
6) Building the index
// server/api/search/reindex.post.ts
import { buildChunks, type Chunk } from '../../utils/chunker'
import { embed } from '../../utils/embedder'
import { useSearchDb } from '../../utils/search-db'
const BATCH_SIZE = 32
export default defineEventHandler(async (event) => {
const { reindexToken } = useRuntimeConfig(event)
// No token configured means the endpoint is off, not open. An unauthenticated
// reindex is a trivially abusable CPU sink.
if (!reindexToken || getHeader(event, 'authorization') !== `Bearer ${reindexToken}`) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
}
const started = Date.now()
const db = useSearchDb()
const chunks = await buildChunks(event)
const insertChunk = db.prepare(`
insert into chunks (id, url, path, title, headings, content)
values (?, ?, ?, ?, ?, ?)
`)
const insertFts = db.prepare(`
insert into chunks_fts (rowid, headings, content) values (?, ?, ?)
`)
const insertVec = db.prepare(`
insert into vec_chunks (rowid, embedding) values (?, ?)
`)
const writeBatch = db.transaction((rows: Array<{ chunk: Chunk, id: number, vector: Float32Array }>) => {
for (const { chunk, id, vector } of rows) {
const headings = JSON.stringify(chunk.headings)
insertChunk.run(id, chunk.url, chunk.path, chunk.title, headings, chunk.content)
insertFts.run(id, chunk.headings.join(' '), chunk.content)
// better-sqlite3 binds any TypedArray as a BLOB, and vec0 reads a
// float32 BLOB directly. Pass the Float32Array itself β NOT .buffer,
// which is wrong the moment the view has a non-zero byteOffset.
insertVec.run(id, vector)
}
})
// Wipe first. Three different incantations, which is not an accident:
db.transaction(() => {
db.exec('delete from chunks')
// External content FTS5 tables reject a plain DELETE. 'delete-all' is the
// documented command and the error you get without it is not helpful.
db.exec(`insert into chunks_fts (chunks_fts) values ('delete-all')`)
// vec0 DELETE only became reliable in sqlite-vec 0.1.7 (and 0.1.9 fixed a
// further bug with metadata TEXT over 12 chars). On anything older, drop
// and recreate the table instead.
db.exec('delete from vec_chunks')
})()
let id = 0
for (let i = 0; i < chunks.length; i += BATCH_SIZE) {
const batch = chunks.slice(i, i + BATCH_SIZE)
// Embed the heading trail together with the body β see section 5.
const vectors = await embed(
batch.map(c => `${c.headings.join(' > ')}\n\n${c.content}`),
)
writeBatch(batch.map((chunk, j) => ({ chunk, id: ++id, vector: vectors[j]! })))
}
return {
chunks: chunks.length,
ms: Date.now() - started,
}
})
Why this is an endpoint and not a build script. queryCollectionSearchSections(event, ...) needs the H3 event to get at Content's database. You could reach around it β parse the markdown yourself, or query the SQLite dump directly β and you'd be reimplementing Content's parse pipeline to get slightly worse chunks. An authenticated POST that you fire once after deploy is less code and less to keep in sync. The honest cost is that your deploy pipeline now has a second step:
curl -fsS -X POST https://example.com/api/search/reindex \
-H "Authorization: Bearer $NUXT_REINDEX_TOKEN"
If you'd rather have it fire itself, wrap the body in a function and call it from a Nitro task on a schedule β but note that a Nitro task doesn't have a real H3 event to hand to queryCollectionSearchSections, so you'd be back to $fetch-ing your own endpoint, which is fine and slightly silly.
Why the whole index is rebuilt. Incremental indexing means tracking which files changed, which chunks each file produced last time, and which of those need deleting β three pieces of bookkeeping to avoid re-embedding a few thousand short strings. On a batch of 32, MiniLM-q8 on a modern CPU core chews through a mid-sized site in seconds to low tens of seconds. Do the simple thing until it hurts.
BATCH_SIZE = 32 is a tuning knob, not a magic number. The pipeline pads every input in a batch to the longest one in that batch, so a batch containing one 1000-character chunk and thirty-one 80-character ones wastes most of its compute on padding. If reindex time ever becomes a problem, sorting chunks by length before batching is the highest-leverage twenty minutes available to you.
7) Querying: KNN, FTS5, and fusing the two
This is the section that makes the difference between "I added vector search" and "search got better".
// server/utils/retrieval.ts
import type { Database } from 'better-sqlite3'
/** How many candidates each retriever contributes before fusion. */
const CANDIDATES = 30
export function vectorCandidates(db: Database, queryVector: Float32Array, k = CANDIDATES) {
return db.prepare(`
select rowid as id, distance
from vec_chunks
where embedding match ?
and k = ?
order by distance
`).all(queryVector, k) as Array<{ id: number, distance: number }>
}
export function keywordCandidates(db: Database, query: string, limit = CANDIDATES) {
const match = toFtsQuery(query)
if (!match) return []
return db.prepare(`
select rowid as id, rank
from chunks_fts
where chunks_fts match ?
order by rank
limit ?
`).all(match, limit) as Array<{ id: number, rank: number }>
}
/**
* FTS5's MATCH argument is a query language, not a string. A user typing
* `nuxt-content` or `"` produces a syntax error, which surfaces as a 500 on
* your search endpoint. Quote every token, escape embedded quotes, and add a
* prefix wildcard to the last one so results appear while they're still typing.
*/
function toFtsQuery(input: string): string {
const tokens = input
.toLowerCase()
.split(/[^\p{L}\p{N}_]+/u)
.filter(t => t.length > 1)
if (!tokens.length) return ''
return tokens
.map((t, i) => {
const quoted = `"${t.replace(/"/g, '""')}"`
return i === tokens.length - 1 ? `${quoted}*` : quoted
})
.join(' OR ')
}
Four sqlite-vec constraints you will hit, in the order you'll hit them:
- You must supply
k = ?or aLIMIT, and never both. Omit both and you getA LIMIT or 'k = ?' constraint is required on vec0 knn queries.Supply both and you getOnly LIMIT or 'k =?' can be provided, not bothβ typo and all. I usek =because it works regardless of the host SQLite's version. kcaps at 4096. Above that:k value in knn query too large, provided 4097 and the limit is 4096.distanceonly exists inside a KNN query. Select it from a plainselect * from vec_chunksand every row returnsNULLβ silently, no error, andorder by distancebecomes a no-op. If your results ever look randomly ordered, check that theMATCHis still there.- You cannot
JOINthe vec0 table in the KNN query itself. TheLIMITwon't push down and you'll scan everything. Do the KNN alone, then join β that's why the code above returns bare{ id, distance }and hydration happens separately.
Now the fusion:
// server/utils/fuse.ts
/**
* Reciprocal Rank Fusion. Each retriever contributes 1 / (K + rank) per
* document; the sum is the final score.
*
* The reason this and not a weighted average of the raw scores: cosine
* distance (0..2, lower is better) and BM25 rank (unbounded negative, lower is
* better) are not on the same scale, are not on the same scale *as each other
* across different queries*, and any normalisation you invent will be tuned to
* whatever three queries you happened to test. RRF throws the magnitudes away
* and keeps only the ordering, which is the part that's actually comparable.
*
* K = 60 comes from the original Cormack et al. paper and is the number
* everyone uses. Larger K flattens the contribution of top ranks; smaller K
* lets a single retriever's #1 dominate.
*/
const K = 60
export interface Fused {
id: number
score: number
matched: Array<'vector' | 'keyword'>
}
export function reciprocalRankFusion(
vector: Array<{ id: number }>,
keyword: Array<{ id: number }>,
weights: { vector: number, keyword: number } = { vector: 1, keyword: 1 },
): Fused[] {
const scores = new Map<number, Fused>()
const contribute = (
rows: Array<{ id: number }>,
source: 'vector' | 'keyword',
weight: number,
) => {
rows.forEach((row, index) => {
const existing = scores.get(row.id) ?? { id: row.id, score: 0, matched: [] }
existing.score += weight / (K + index + 1)
existing.matched.push(source)
scores.set(row.id, existing)
})
}
contribute(vector, 'vector', weights.vector)
contribute(keyword, 'keyword', weights.keyword)
return [...scores.values()].sort((a, b) => b.score - a.score)
}
The property that makes RRF worth the twelve lines: a document found by both retrievers gets both contributions and floats to the top automatically. You don't need a rule for "boost things that match twice" β it falls out of the arithmetic. And a document that only one retriever found still ranks, which is what preserves the exact-identifier behaviour that pure vector search loses.
The endpoint that ties it together:
// server/api/search.get.ts
import { embed } from '../utils/embedder'
import { useSearchDb } from '../utils/search-db'
import { vectorCandidates, keywordCandidates } from '../utils/retrieval'
import { reciprocalRankFusion } from '../utils/fuse'
import type { SearchHit, SearchResponse } from '#shared/search'
export default defineCachedEventHandler(async (event): Promise<SearchResponse> => {
const query = (getQuery(event).q as string | undefined)?.trim() ?? ''
const limit = Math.min(Number(getQuery(event).limit) || 10, 25)
if (query.length < 2) return { query, hits: [], embedMs: 0 }
const db = useSearchDb()
const t0 = performance.now()
const [queryVector] = await embed([query])
const embedMs = Math.round(performance.now() - t0)
const vector = vectorCandidates(db, queryVector!)
const keyword = keywordCandidates(db, query)
const fused = reciprocalRankFusion(vector, keyword).slice(0, limit)
if (!fused.length) return { query, hits: [], embedMs }
const ids = fused.map(f => f.id)
const placeholders = ids.map(() => '?').join(',')
const rows = db.prepare(`
select
c.id, c.url, c.title, c.headings, c.content,
-- snippet() only produces marks for rows the FTS query matched; for
-- vector-only hits it returns the leading text, which is what we want
-- as a fallback anyway.
snippet(chunks_fts, 1, '<mark>', '</mark>', 'β¦', 20) as snippet
from chunks c
join chunks_fts on chunks_fts.rowid = c.id
where c.id in (${placeholders})
`).all(...ids) as Array<Record<string, string | number>>
const byId = new Map(rows.map(r => [r.id as number, r]))
const hits: SearchHit[] = fused.flatMap((f) => {
const row = byId.get(f.id)
if (!row) return []
return [{
url: row.url as string,
title: row.title as string,
headings: JSON.parse(row.headings as string) as string[],
snippet: (row.snippet as string) || (row.content as string).slice(0, 200),
score: f.score,
matched: [...new Set(f.matched)],
}]
})
return { query, hits, embedMs }
}, {
// Query strings repeat far more than you'd think, and the expensive half of
// this handler is deterministic given the same query and index.
maxAge: 60 * 10,
name: 'search',
getKey: event => `${getQuery(event).q}:${getQuery(event).limit ?? 10}`,
})
One caveat on that snippet() call, because it's a genuine trap: joining chunks_fts without a MATCH in the same statement means FTS5 has no query to highlight against, and snippet() degrades to returning the beginning of the column. That's an acceptable fallback and it's what the code above relies on β but if you want real highlighting on every hit, you need to run the hydration query with the FTS match and left-join the vector-only ids separately. I've kept the simpler version because a leading-text snippet for a vector-only hit is not obviously worse than a highlighted one.
8) The client side
// app/composables/useSemanticSearch.ts
import type { SearchResponse } from '#shared/search'
export function useSemanticSearch() {
const query = ref('')
const pending = ref(false)
const results = shallowRef<SearchResponse | null>(null)
let timer: ReturnType<typeof setTimeout> | undefined
let controller: AbortController | undefined
watch(query, (value) => {
clearTimeout(timer)
// Every keystroke is a CPU-bound embed on the server. Debounce hard β
// this is not a database index lookup and it should not be treated like one.
timer = setTimeout(async () => {
const q = value.trim()
if (q.length < 2) {
results.value = null
return
}
// Cancel the in-flight request so a slow early query cannot land after
// a fast later one and overwrite it.
controller?.abort()
controller = new AbortController()
pending.value = true
try {
results.value = await $fetch<SearchResponse>('/api/search', {
query: { q },
signal: controller.signal,
})
}
catch (error) {
if ((error as Error)?.name !== 'AbortError') throw error
}
finally {
pending.value = false
}
}, 250)
})
onScopeDispose(() => {
clearTimeout(timer)
controller?.abort()
})
return { query, pending, results }
}
<!-- app/components/SearchBox.vue -->
<script setup lang="ts">
const { query, pending, results } = useSemanticSearch()
</script>
<template>
<div class="relative">
<input
v-model="query"
type="search"
placeholder="Search β try a question, not a keyword"
class="w-full rounded-xl border border-white/10 bg-slate-900/60 px-4 py-3 text-sm outline-none focus:border-emerald-400/60"
>
<p v-if="pending" class="mt-2 text-xs text-slate-400">
Searchingβ¦
</p>
<ul v-else-if="results?.hits.length" class="mt-4 space-y-3">
<li v-for="hit in results.hits" :key="hit.url">
<NuxtLink :to="hit.url" class="block rounded-xl bg-white/5 p-4 hover:bg-white/10">
<p class="text-xs text-slate-400">
{{ hit.headings.join(' βΊ ') }}
</p>
<p class="font-medium">
{{ hit.title }}
</p>
<!-- snippet() emits <mark> tags. This is server-generated from your
own indexed content, not user input β but if you ever index
user-submitted text, sanitise before you do this. -->
<p class="mt-1 text-sm text-slate-300" v-html="hit.snippet" />
<span
v-if="hit.matched.length === 2"
class="mt-2 inline-block rounded bg-emerald-400/10 px-2 py-0.5 text-[11px] text-emerald-300"
>keyword + meaning</span>
</NuxtLink>
</li>
</ul>
<p v-else-if="results" class="mt-4 text-sm text-slate-400">
Nothing found for β{{ results.query }}β.
</p>
</div>
</template>
The 250ms debounce plus AbortController matters more here than in a normal typeahead. Each request costs a model forward pass on your server's CPU; without the debounce, one person typing a ten-character query fires ten of them, and ten users typing at once is a load spike you'd rather not have discovered in production.
9) Warming the model
Cold start is the sharpest edge in this whole build. The first request after boot pays for reading ~23 MB of weights and constructing an ONNX session β hundreds of milliseconds at best, a full model download if the cache is empty. Move that off the user:
// server/plugins/warm-search.ts
import { getEmbedder } from '../utils/embedder'
export default defineNitroPlugin(() => {
// Dev reloads constantly; warming on every restart is just noise.
if (import.meta.dev) return
// Not awaited β a plugin that blocks here blocks the whole server's boot.
getEmbedder()
.then(() => console.log('[search] embedder ready'))
.catch(error => console.error('[search] embedder warmup failed', error))
})
Note it's fire-and-forget. defineNitroPlugin will happily await an async plugin and hold up startup, which turns a slow first request into a slow first deploy β no better, and it fails your health check.
10) Deploying it
# Debian slim, NOT alpine. sqlite-vec publishes no musl binary; onnxruntime-node
# and better-sqlite3 are also glibc-only in their prebuilt form.
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Bake the model into the image. Without this, the first cold start in
# production downloads 23 MB β and does it again on every new container.
RUN node -e "\
const { pipeline, env } = require('@huggingface/transformers'); \
env.cacheDir = '/app/.cache/models'; \
pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { dtype: 'q8' }) \
.then(() => console.log('model cached')); \
"
FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
ENV NUXT_MODEL_CACHE_DIR=/app/.cache/models
ENV NUXT_SEARCH_INDEX_PATH=/data/search/index.sqlite
# node_modules ships too β the native modules are external to the bundle and
# cannot be inlined. See section 1.
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/.output ./.output
COPY --from=build /app/.cache/models ./.cache/models
# The index lives on a volume so a redeploy doesn't discard it. You still want
# to reindex after deploy β content changed β but a container restart shouldn't
# leave search dead until someone notices.
VOLUME ["/data"]
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
NUXT_MODEL_CACHE_DIR and NUXT_SEARCH_INDEX_PATH map onto the runtimeConfig keys from section 1 through Nitro's standard NUXT_-prefixed override convention β modelCacheDir becomes NUXT_MODEL_CACHE_DIR, and so on. Set NUXT_REINDEX_TOKEN the same way, from your secrets store.
If you're not using Docker, the same three rules apply: glibc, node_modules present next to .output, model cache on persistent disk. A PM2 and nginx setup works fine β just make sure the process user can write both the cache and the index directory.
11) What this doesn't do, and when to stop
Serverless is the wrong home for this. Not "harder" β wrong. Around 400 MB of node_modules blows past Lambda's 250 MB unzipped limit before you've written a line of app code, and even where the size fits, every cold start reloads the ONNX session and re-reads the index from a filesystem that may not persist. Nuxt Content's own serverless presets already force its database to /tmp for exactly this reason. If serverless is non-negotiable, the shape that works is: embed at build time with a hosted API, store vectors in Turso or D1, and query them from the function. Different article, different tradeoffs, and it comes with the API key you were trying to avoid.
Brute force has a ceiling, and it's higher than you think. Stable sqlite-vec (0.1.9 at time of writing) has no ANN index β DiskANN is in the 0.1.10 alphas, not in a release. Every query compares against every vector. At a few thousand chunks that's genuinely nothing. At a hundred thousand you'll feel it, and the escape hatches in order of effort are: binary quantization with vec_quantize_binary() (store a bit[384] column, over-fetch on it, re-rank the survivors against the float column β 32Γ less data to scan), then partition keys if your corpus shards naturally by locale or product, then a real vector database.
sqlite-vec is pre-1.0 and says so. The docs page carries the words "expect breaking changes." Pin the version. The project also went quiet for a stretch before the 0.1.7 "sqlite-vec is back" release, which is worth knowing when you're deciding how much to build on it. If the platform matrix is your problem specifically β Alpine, Windows ARM β @photostructure/sqlite-vec is a maintained fork with wider prebuilds and a near drop-in API.
MiniLM is a small, English, 2021-vintage model. It will not do multilingual well, it truncates at 256 tokens, and it has no idea what your product is called. All three are fine for site search over English prose and all three are reasons to reach for something bigger the moment they aren't. Since the dimension count is a constant in shared/search.ts and the table DDL reads it, swapping models is: change two lines, delete the index file, reindex.
There's no relevance measurement here at all. That's the biggest gap and the least glamorous fix. Write down twenty real queries and the answer you'd want for each, keep them in a JSON file, and run them through the endpoint after any change to chunk size, model, or fusion weights. Without it, every tuning decision you make is vibes β including the ones in this article.
Where this leaves you
One extra SQLite file, three tables, about 250 lines of server code, and a search box that answers "why is my site slow on mobile" with the LCP article. No API key, no third-party service in the request path, no per-query cost, and the whole thing runs on the CPU that was already sitting mostly idle between SSR renders.
The piece I'd encourage you to actually keep is the fusion, not the vectors. Semantic search on its own is a different set of failures from keyword search, not a smaller one β it finds the article about slow mobile pages and then confidently hands you useFetch when you asked for useAsyncData. Twelve lines of Reciprocal Rank Fusion is what turns two flawed retrievers into one that's better than either, and it's the part of this build that transfers to every other search problem you'll ever have.





