FastAPI x GenAI

Cosine Similarity

Last post promised a proper way to measure "near" on a map with 1536 axes. If you did the lab, you have already measured nearness once: straight-line distance between movie points, and it worked fine there. Today we see why real systems measure something else entirely, and why the something else has such an intimidating name for such a small idea.

The problem with rulers

So far we treated a vector as a point on a map. The more useful picture: an arrow from zero to that point. An arrow has two properties, direction and length, and here is the insight the whole post hangs on: in embedding space, direction carries the meaning, length mostly carries volume.

"I love pizza" and a three-page essay about pizza point the same way. The essay's arrow is just longer, it says the same thing louder. Put a ruler between the two arrow tips and the gap looks huge, so distance declares them unrelated. Wrong verdict. The ruler got distracted by loudness.

Measure the angle instead

If direction is what matters, compare directions: measure the angle between the arrows and ignore the lengths. Cosine turns that angle into a tidy score. Angle of 0 degrees means same direction, score 1. Ninety degrees means nothing in common, score 0. Fully opposite directions score -1, the geometric version of an argument.

That is the entire concept. Cosine similarity is literally "the cosine of the angle between the arrows". The name sounds like a final exam, the idea fits in a sentence.

Shorter than its name

import mathdef cosine_similarity(a, b):    dot = sum(x * y for x, y in zip(a, b))    mag_a = math.sqrt(sum(x * x for x in a))    mag_b = math.sqrt(sum(x * x for x in b))    return dot / (mag_a * mag_b)

The top line multiplies the arrows position by position and adds it up, which rewards pointing the same way. The two mag lines compute each arrow's length, and dividing by them cancels loudness out of the score. And notice what the function never asks: how many dimensions there are. zip chews through 2 numbers or 1536 with the same five lines.

Point it at the toy map from the Vectors post:

words = {    "fastapi":      [0.9, 0.1],    "docker":       [0.8, 0.0],    "biryani":      [0.1, 0.9],    "pasta":        [0.0, 0.8],    "recipe api":   [0.7, 0.6],}print(cosine_similarity(words["fastapi"], words["docker"]))      # 0.994print(cosine_similarity(words["fastapi"], words["biryani"]))     # 0.220print(cosine_similarity(words["recipe api"], words["fastapi"]))  # 0.826print(cosine_similarity(words["recipe api"], words["biryani"]))  # 0.731

The numbers agree with the picture: "docker" and "fastapi" point almost the same way, "biryani" barely overlaps with either, and "recipe api" scores decently against both camps. Its identity crisis from last post now has digits. 📐

One habit to build early: with real embeddings you will not see clean scores like 0.994, and the absolute values shift from model to model. Do not stare at a lone score asking "is 0.61 good?" Compare candidates and take the highest. Ranking is trustworthy, raw thresholds need tuning.

Back to our Blog

Ranking is a three-liner now:

def most_similar(query_vec, library):    scores = {name: cosine_similarity(query_vec, vec) for name, vec in library.items()}    return max(scores, key=scores.get)print(most_similar(words["recipe api"], {k: v for k, v in words.items() if k != "recipe api"}))

This tiny function is the engine we will bolt into the Blog app: embed every post once, embed the reader's question, return the post with the highest score. That is semantic search, minus one missing ingredient.

Because our vectors are still handmade. Nobody is going to score fifty blog posts on little axes by hand, we need the model that turns any sentence into those 1536 honest numbers automatically. Embeddings, next post. After that, most_similar stops being a toy. 🙂