I asked the seven-fact index Has he worked in healthcare? Mazecare and PrimaKu ranked first at 0.664 and 0.652 — but KiriminAja (logistics UI) still landed third at 0.627. That third hit is the most important lesson in this post: top-k gives you the best scores you have, not a guarantee every row belongs.
This is part 4 of Learning AI Engineering in Public.
One-sentence answer: Semantic search embeds your documents and your question, then ranks by meaning — not by exact keywords.
The idea
In part 3, cosine similarity compared two sentences. Here I keep seven short facts about myself as documents, embed them once, embed each question, score every document, sort highest first, and keep the top three.
Analogy time:
- Embeddings are still GPS coordinates for meaning.
- Top-k is like walking into a library and picking the 3 most relevant books for your question — not the whole shelf.
- A threshold later (part 5) is like a bouncer at the door: below the score, you do not get in.
The code lives in search.ts. Below is that logic, trimmed into sections, not rewritten.
Seven fact documents
const documents = [
"Yeremia builds PrimaKu's web platform — clinician-facing UI and integrations on a pediatric health product whose consumer app has 1M+ downloads.",
"On Mazecare, Yeremia shipped FHIR-based clinic flows and AI-assisted clinical notes.",
"At KiriminAja, Yeremia built high-volume logistics UI using Web Workers and virtualization.",
"Yeremia's main frontend stack is Next.js, React 19, TypeScript, and Tailwind CSS.",
"Yeremia deploys with Docker, GitHub Actions CI/CD, and Vercel.",
"Yeremia is building a construction project platform in Indonesia with his brother.",
"Yeremia is currently learning RAG, embeddings, and AI agents.",
];What this block does: it is the whole knowledge base for the demo — PrimaKu, Mazecare (FHIR + AI notes), KiriminAja, the Next.js stack, deployment (Docker / GitHub Actions / Vercel), the construction platform, and what I am learning now. Small on purpose, so every score is easy to inspect.
Setup, cosine similarity, and task types
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const EMBEDDING_MODEL = "gemini-embedding-001";
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
async function embed(texts: string[], taskType: string): Promise<number[][]> {
const response = await ai.models.embedContent({
model: EMBEDDING_MODEL,
contents: texts,
config: { taskType },
});
return response.embeddings!.map((embedding) => embedding.values!);
}What this block does:
EMBEDDING_MODELpinsgemini-embedding-001so the model string is not scattered.cosineSimilarityis the same idea as part 3: dot product divided by the product of the norms. Higher means more aligned on the meaning map.embedsends one or more strings and returns one vector per string.taskTypeis the important new knob.
Why two task types:
- Documents use
RETRIEVAL_DOCUMENT. - Questions use
RETRIEVAL_QUERY.
gemini-embedding-001 is optimized differently for those two roles. A document is "what lives in the index." A query is "what someone is asking." Using the matching task type puts both sides into the space the model expects for retrieval.
Embed documents once, then search
const documentVectors = await embed(documents, "RETRIEVAL_DOCUMENT");
async function search(query: string, topK = 3) {
const [queryVector] = await embed([query], "RETRIEVAL_QUERY");
return documents
.map((text, index) => ({
text,
score: cosineSimilarity(queryVector, documentVectors[index]),
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}What this block does:
documentVectorsruns once at startup. The seven facts are embedded asRETRIEVAL_DOCUMENTand reused for every question. That is cheaper and faster than re-embedding the whole set on each search.searchembeds the question asRETRIEVAL_QUERY.- It pairs each document text with its cosine score against the question.
sortputs the highest score first.slice(0, topK)keeps only the top 3 — the three most relevant library books.
Running the three queries
const queries = [
"Has he worked in healthcare?",
"What does he use for deployment?",
"What is a good rendang recipe?",
];
for (const query of queries) {
console.log(`\nQuery: ${query}`);
for (const result of await search(query)) {
console.log(` ${result.score.toFixed(3)} ${result.text}`);
}
}What this block does: it prints each question, then the top-3 documents with scores rounded to three decimals. That is how I captured the real outputs below.
Real outputs
"Has he worked in healthcare?"
| Rank | Score | Document |
|---|---|---|
| 1 | 0.664 | Mazecare (FHIR + AI clinical notes) |
| 2 | 0.652 | PrimaKu (pediatric health app) |
| 3 | 0.627 | KiriminAja (logistics UI) |
Mazecare's line never says "health" — it mentions FHIR-based clinic flows and AI-assisted clinical notes — yet it ranked first. The question said "healthcare." PrimaKu's blurb does include "health" (pediatric health product). All three hits are search by meaning, not a keyword match on the question text.
KiriminAja also landed in the top 3 at 0.627. Logistics is not healthcare. The score is still high enough to appear. Remember that from part 3: unrelated text can sit around the middle of the scale. Top-k returns the best of what you have, not a guarantee of relevance.
"What does he use for deployment?"
The deployment document scored 0.741 and ranked first. Docker, GitHub Actions, and Vercel are exactly what that question asks for, so the high score is accurate for this tiny dataset.
"What is a good rendang recipe?"
The top score was only 0.550. Nothing in the seven facts is about cooking. The meaning map has no nearby recipe document, so every score stays low. That gap is useful: it is a signal that the question is off-topic for this corpus.
The threshold lesson (and why evals matter)
A threshold around 0.6 looks like a clean line between relevant and irrelevant in this run:
- Healthcare hits are above 0.6.
- Rendang tops out at 0.550.
But KiriminAja scored 0.627 on the healthcare question and would pass a 0.6 bouncer even though it is not relevant. So a threshold alone is not enough.
These numbers are specific to this dataset of seven sentences. Another corpus, another embedding model, or longer documents would shift the scale. That is why evals are needed: you measure real questions against real documents before you trust a cutoff in production.
What to remember
- Embed documents once with
RETRIEVAL_DOCUMENT, embed each question withRETRIEVAL_QUERY, score with cosine similarity, sort, take top-k — this demo keeps 3 hits per question. - Mazecare shows meaning over keywords (no "health" in the doc, still first on a healthcare ask); deployment at 0.741 and off-topic rendang at 0.550 bracket how far scores spread on this corpus.
- My live /lab chatbot uses the same retrieval pattern on real site content, with top-5 chunks and a cutoff tuned to 0.45 for that index — proof the number moves with the dataset (here I used top-3 and ~0.6 as a mental line).
- Top-k and a single threshold both need evals on your own Q&A pairs; KiriminAja at 0.627 is the concrete example of why.