Skip to content
Fondazione ITS INCOM

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:

  1. turn every article into a vector, once, and save the vectors in a file;
  2. turn the question into a vector;
  3. 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

What the search by meaning got wrong

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

Exercise

All examples