Examples · Web Development
Search your site by meaning
Twelve articles of a school blog become vectors with bge-m3, saved once in a JSON file. A question becomes a vector too, and cosine similarity in plain JavaScript finds the closest article, also when the question shares no words with it. A small test set measures it against a keyword search.
Intermediate · 40 min
The idea
A keyword search finds the articles that contain your words. A visitor who asks «Dove si mangia a mezzogiorno?» finds nothing, because the article says «pranzo» and «mensa». A visitor who asks in English finds nothing on an Italian site.
An embedding model turns a text into a list of numbers, a vector: texts with a similar meaning get similar vectors, even in different languages. To search:
- turn every article into a vector, once, and save the vectors in a file;
- turn the question into a vector;
- compute how close it is to every article (cosine similarity) and take the closest.
No model writes anything here: the numbers come from bge-m3, and your code decides. A test set, questions with the article you expect, tells you whether the search is good enough.
What you need
A key and the openai package, as in Your first API call in JavaScript. The model is bge-m3, on POST /v1/embeddings. It works, though it is not listed in /v1/models.
The articles
Save them as articles.json: twelve short posts of a school blog, in Italian.
[
{ "id": "open-day", "title": "Open day sabato 15 novembre",
"text": "Sabato 15 novembre, dalle 9 alle 13, apriamo i laboratori delle sedi di Busto Arsizio e Legnano. Studenti e docenti mostrano i progetti dei corsi e rispondono alle domande delle famiglie. Non serve prenotare." },
{ "id": "treno", "title": "Come arrivare in treno",
"text": "La sede di Busto Arsizio è a dieci minuti a piedi dalla stazione FS, sulla linea Trenord Milano-Gallarate. A Legnano la stazione è ancora più vicina. Nel cortile c'è un parcheggio per le biciclette." },
{ "id": "borse", "title": "Borse di studio 2026/27",
"text": "Regione Lombardia finanzia borse di studio fino a 2.500 euro per chi ha un ISEE sotto i 30.000 euro. La domanda si presenta online entro il 31 ottobre; la segreteria aiuta a compilarla." },
{ "id": "pranzo", "title": "Pranzo e pausa",
"text": "La scuola non ha una mensa, ma il bar di fronte alla sede di Legnano fa un menù a 7 euro per chi mostra il badge. In aula 3 ci sono un microonde e un frigorifero." },
{ "id": "stage", "title": "Lo stage in azienda",
"text": "Al secondo anno ogni studente passa 800 ore in un'azienda del territorio. Il tutor aiuta a scegliere tra le aziende partner: software house, system integrator e reparti IT di aziende manifatturiere." },
{ "id": "lab-iot", "title": "Il nuovo laboratorio IoT di Legnano",
"text": "Venti postazioni con ESP32, Raspberry Pi, sensori di temperatura e qualità dell'aria, e una stampante 3D. Il laboratorio è aperto anche il venerdì pomeriggio per i progetti personali." },
{ "id": "hackathon", "title": "Hackathon di primavera",
"text": "24 ore per costruire un'app, in squadre di quattro. Il tema di quest'anno è la mobilità sostenibile in città. Alla squadra vincitrice un portatile per ciascuno e uno stage estivo." },
{ "id": "wifi", "title": "Wi-Fi e account",
"text": "La rete della scuola si chiama ITS-Studenti e si entra con l'account del registro elettronico. Se hai dimenticato la password, la reimposti dal portale con il codice che arriva via SMS." },
{ "id": "inglese", "title": "Inglese tecnico",
"text": "Ogni corso ha 60 ore di inglese tecnico. A fine anno si può sostenere in sede l'esame per la certificazione B2, a prezzo ridotto." },
{ "id": "esame", "title": "L'esame finale",
"text": "Il diploma di tecnico superiore si ottiene con un esame davanti a una commissione esterna: una prova scritta, una prova pratica e la presentazione del progetto svolto in azienda." },
{ "id": "davide", "title": "Dove sono ora: Davide",
"text": "Davide Colombo si è diplomato nel 2024 in Web Development. Oggi è sviluppatore full stack in un'agenzia di Varese: Laravel, Vue e molti caffè. «Lo stage è stato il mio primo colloquio.»" },
{ "id": "phishing", "title": "Attenzione alle email false",
"text": "Arrivano email che sembrano della segreteria e chiedono di confermare l'account su un sito esterno. La segreteria non chiede mai la password per email: cancellale e avvisa il tecnico." }
]
The program
Save it as search.mjs, next to articles.json:
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://api.ai.itsincom.org/v1", apiKey: process.env.API_KEY });
const articles = JSON.parse(readFileSync("articles.json", "utf8"));
async function embed(texts) {
const answer = await client.embeddings.create({ model: "bge-m3", input: texts });
// One vector per text, all of the same length: check it before you save anything.
if (answer.data.length !== texts.length || answer.data.some((d) => d.embedding.length !== 1024)) {
throw new Error("Unexpected answer from /v1/embeddings");
}
return answer.data.sort((a, b) => a.index - b.index).map((d) => d.embedding);
}
// Vectors are computed once and kept in vectors.json, with a hash of the text they come from:
// change an article and only its vector is computed again.
const cache = existsSync("vectors.json") ? JSON.parse(readFileSync("vectors.json", "utf8")) : {};
const hashOf = (a) => createHash("sha256").update(a.title + "\n" + a.text).digest("hex");
const missing = articles.filter((a) => cache[a.id]?.hash !== hashOf(a));
if (missing.length > 0) {
const vectors = await embed(missing.map((a) => a.title + "\n" + a.text));
missing.forEach((a, i) => (cache[a.id] = { hash: hashOf(a), vector: vectors[i] }));
writeFileSync("vectors.json", JSON.stringify(cache));
}
console.log(`${missing.length} vectors computed, ${articles.length - missing.length} read from vectors.json`);
function cosine(a, b) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
const words = (s) => new Set(s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length >= 4));
function byKeyword(query) {
const q = words(query);
const scored = articles.map((a) => {
const w = words(a.title + " " + a.text);
return { id: a.id, score: [...q].filter((x) => w.has(x)).length };
});
return scored.sort((x, y) => y.score - x.score)[0];
}
function byMeaning(queryVector) {
const scored = articles.map((a) => ({ id: a.id, score: cosine(queryVector, cache[a.id].vector) }));
return scored.sort((x, y) => y.score - x.score).slice(0, 2); // the best two
}
// Your test set: each question, and the article a person would expect. null means «nothing here».
const tests = [
["Quando posso venire a vedere la scuola?", "open-day"],
["How do I get there by train from Milan?", "treno"],
["Non ho abbastanza soldi per pagare la retta", "borse"],
["Dove si mangia a mezzogiorno?", "pranzo"],
["I forgot my password", "wifi"],
["laboratorio con ESP32 e sensori", "lab-iot"],
["Do former students find a job?", "davide"],
["Ricetta della pasta alla carbonara", null],
];
const MIN_SCORE = 0.40; // below this, «no article found»: chosen after looking at the scores of this test set
const queryVectors = await embed(tests.map(([q]) => q));
let rightMeaning = 0, rightKeyword = 0;
tests.forEach(([query, expected], i) => {
const [m, second] = byMeaning(queryVectors[i]);
const k = byKeyword(query);
const meaning = m.score >= MIN_SCORE ? m.id : null;
const keyword = k.score > 0 ? k.id : null;
if (meaning === expected) rightMeaning++;
if (keyword === expected) rightKeyword++;
console.log(`\n${query} (expected: ${expected ?? "nothing"})`);
const low = m.score < MIN_SCORE ? " (too low: nothing)" : "";
console.log(` meaning: ${m.id} ${m.score.toFixed(3)}, then ${second.id} ${second.score.toFixed(3)}${low}` +
` ${meaning === expected ? "ok" : "WRONG"}`);
console.log(` keyword: ${keyword ?? "nothing"} (words in common: ${k.score}) ${keyword === expected ? "ok" : "WRONG"}`);
});
console.log(`\nby meaning: ${rightMeaning}/${tests.length} right, by keyword: ${rightKeyword}/${tests.length} right`);
Run it with node search.mjs. The first time it makes two calls: one for the twelve articles, one for the eight questions.
What you should see
12 vectors computed, 0 read from vectors.json
Quando posso venire a vedere la scuola? (expected: open-day)
meaning: open-day 0.580, then pranzo 0.449 ok
keyword: pranzo (words in common: 1) WRONG
How do I get there by train from Milan? (expected: treno)
meaning: treno 0.525, then hackathon 0.381 ok
keyword: nothing (words in common: 0) WRONG
Non ho abbastanza soldi per pagare la retta (expected: borse)
meaning: pranzo 0.480, then borse 0.451 WRONG
keyword: nothing (words in common: 0) WRONG
Dove si mangia a mezzogiorno? (expected: pranzo)
meaning: pranzo 0.481, then treno 0.358 ok
keyword: davide (words in common: 1) WRONG
…
Do former students find a job? (expected: davide)
meaning: stage 0.449, then esame 0.409 WRONG
keyword: nothing (words in common: 0) WRONG
Ricetta della pasta alla carbonara (expected: nothing)
meaning: pranzo 0.331, then treno 0.308 (too low: nothing) ok
keyword: pranzo (words in common: 1) WRONG
by meaning: 6/8 right, by keyword: 2/8 right
From the second run on, the first line is 0 vectors computed, 12 read from vectors.json, and only the questions are sent. We ran it five times: the same answers and the same scores every time. (During our tests one score moved in the third decimal, from 0.651 to 0.652, when the vectors were computed again.)
What the comparison shows
- No words in common. «Quando posso venire a vedere la scuola?» shares no word with the open day article, and the search by meaning finds it. The keyword search finds «scuola» in the lunch article, and answers that.
- Another language. The English questions about the train and the password find Italian articles. The keyword search finds the password only because “password” is the same word in both languages.
- Short words are not meaning. The keyword search answered «davide» to the lunch question because of «Dove sono ora», and «pranzo» to the carbonara because of «alla». Real keyword searches drop words like these (stop words), but «treno» will never match “train”.
- Both do well with exact words. «laboratorio con ESP32 e sensori» is easy for both.
What the search by meaning got wrong
- «Non ho abbastanza soldi per pagare la retta» came closer to the lunch article (0.480) than to the scholarships (0.451), which came second. The scholarship article never says what the money is for: there is no «retta» in it. When we asked «Aiuti economici per chi non può pagare», the scholarships came first, with 0.472.
- “Do former students find a job?” found the internship article, not Davide's story. Davide's article never says «lavoro» or «ex studente»: it says «sviluppatore», «agenzia», «colloquio». Not absurd, but not what a person expects.
This is why the test set is in the program. Similarity is a number, not an answer: the right article was second in one case, and a wrong article scored 0.449 in another. Every time you change the articles, the model or MIN_SCORE, run the tests again and look at the count.
Why MIN_SCORE
There is always a closest article, even for a carbonara recipe. Without a minimum score, your site answers every question with something. We looked at our scores: the right answers were between 0.48 and 0.65, the carbonara 0.331. So 0.40. It is not a universal number: other texts, another model or another language give other scores. Measure it on your own test set.
Good to know
- Vectors are big. Twelve short articles made a
vectors.jsonof 260 KB: 1,024 numbers each. For a few hundred articles a JSON file is fine. For thousands, use a database that can search vectors, such as PostgreSQL with pgvector. - The same model for articles and questions. A vector from
bge-m3can only be compared with another vector frombge-m3. If you change model, deletevectors.json. - The hash keeps the file honest. Without it, you edit an article and the search keeps using the old vector. We changed one sentence of the open day:
1 vectors computed, 11 read from vectors.json. - Long articles. A vector describes the whole text. For a long page, cut it into paragraphs and give each paragraph its own vector.
Exercise
- Add a stop word list to
words(): «dove», «alla», «della», «quando». How many keyword answers change? - Add a sentence to the scholarship article: «Aiuta a pagare la retta del corso.» Run it: only one vector is computed. Is the third question right now? Is that fair, or did you just copy the question into the article?
- Add three questions of your own to the test set, one in English. Write the expected answer before you run them.
- Make it a web page: a small Node server with
GET /search?q=…that returns the best article as JSON, and a page with a search box that calls it. The key stays on the server.
