Skip to content

Semantic search in Node / TypeScript

The same app as the Python, .NET and Java guides. Eight notes, and a search for “where did I put my keys?” that returns the note about the spare front-door set — which doesn’t contain the word “key” at all.

Terminal window
mkdir notes-search && cd notes-search
npm init -y
npm install openai
npm install -D typescript tsx @types/node

tsx runs TypeScript directly with no build step, which is what you want while experimenting. Set "type": "module" in your package.json so the import syntax below works.

Terminal window
export OPENAI_API_KEY="sk-..."
notesSearch.ts
import OpenAI from "openai";
const NOTES = [
"The spare set for the front door is in the kitchen drawer.",
"The dog needs his flea treatment on the 3rd.",
"Mum's birthday is in April, she likes tulips.",
"Renew the car insurance before it lapses.",
"The standing desk needs a 4mm hex key to assemble.",
"Pizza dough: 500g flour, 300ml water, rest overnight.",
"The wifi password is taped under the router.",
"Physio said ice the knee for twenty minutes.",
];
const client = new OpenAI(); // picks up OPENAI_API_KEY from the environment
const MODEL = "text-embedding-3-small";
/** Sends the whole batch in one request, returns unit-length vectors. */
async function embed(texts: string[]): Promise<number[][]> {
const response = await client.embeddings.create({ model: MODEL, input: texts });
return response.data.map((item) => normalize(item.embedding));
}
/** Scale to length 1, so a dot product gives cosine similarity directly. */
function normalize(vector: number[]): number[] {
const magnitude = Math.hypot(...vector);
return vector.map((value) => value / magnitude);
}
function dot(a: number[], b: number[]): number {
let total = 0;
for (let i = 0; i < a.length; i++) total += a[i] * b[i];
return total;
}
async function main() {
// Embed the notes once, up front.
const noteVectors = await embed(NOTES);
for (const question of ["where did I put my keys?", "what do I cook on Friday?"]) {
console.log(`\nQ: ${question}`);
const [queryVector] = await embed([question]);
const ranked = NOTES.map((note, i) => ({ note, score: dot(noteVectors[i], queryVector) }))
.sort((a, b) => b.score - a.score)
.slice(0, 3);
for (const { note, score } of ranked) {
console.log(` ${score.toFixed(3)} ${note}`);
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

Run it:

Terminal window
npx tsx notesSearch.ts
Q: where did I put my keys?
0.412 The spare set for the front door is in the kitchen drawer.
0.287 The standing desk needs a 4mm hex key to assemble.
0.169 The wifi password is taped under the router.
Q: what do I cook on Friday?
0.447 Pizza dough: 500g flour, 300ml water, rest overnight.
0.192 The dog needs his flea treatment on the 3rd.
0.161 Renew the car insurance before it lapses.

The scores above are illustrative rather than captured from a run, and they shift as models are updated. What matters is the ordering: the note about keys wins a search for keys without sharing a single word with it.

  1. new OpenAI() — reads OPENAI_API_KEY from process.env automatically. Construct it once at module scope rather than per request; it holds a connection pool, and rebuilding it on every call throws that away.

  2. client.embeddings.create({ input: texts })input takes a string or an array of strings. Pass the array. Responses come back in the order you sent them, so response.data[3] is your fourth note.

  3. response.data.map((item) => item.embedding) — each embedding is a plain number[]. No wrapper type to unpack, which makes JavaScript the most direct of the four languages here.

  4. Math.hypot(...vector) — computes the square root of the sum of squares, which is exactly the vector’s length. It’s also more numerically careful than doing it by hand, since it avoids overflow on large values.

    One catch worth knowing: the spread operator passes every element as a separate argument, and engines cap how many arguments a call can take. At 1,536 dimensions you’re comfortably fine. If you ever push into tens of thousands of dimensions, swap it for a reduce.

  5. dot(a, b) — the search. A plain indexed for loop, not reduce, because this runs on every query against every document and V8 optimises the simple loop far better.

  6. .sort((a, b) => b.score - a.score).slice(0, 3) — descending, top three. Note sort mutates, which is harmless here because map already gave us a fresh array. Worth remembering when you refactor.

ERR_MODULE_NOT_FOUND or “Cannot use import statement outside a module” Your package.json is missing "type": "module". Add it. Alternatively rename the file to .mts, or switch the imports to require. This is Node’s module system being Node’s module system and has nothing to do with embeddings.

401 Incorrect API key provided The key isn’t in process.env where the SDK expects it. If you’re using a .env file, remember Node doesn’t read those on its own — either npm install dotenv and call dotenv.config() before constructing the client, or use Node 20.6+’s built-in --env-file=.env flag.

It works locally and 401s in your serverless deployment Vercel, Netlify, Lambda and friends don’t inherit your laptop’s environment. Set the variable in the platform’s dashboard, and redeploy — most of them only apply new environment variables at build time, so setting it without redeploying changes nothing.

If you’d rather not sign up, Transformers.js runs embedding models locally in Node or the browser:

Terminal window
npm install @huggingface/transformers
import { pipeline } from "@huggingface/transformers";
const extractor = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2");
const output = await extractor("The spare set is in the kitchen drawer.", {
pooling: "mean",
normalize: true,
});
const vector = Array.from(output.data); // 384 numbers, already unit length

normalize: true does the same job as our normalize function, so you can drop it. Quality is a step below the API models, but it’s free, private, and works offline — a good fit for a prototype or anything handling sensitive text.