Articles  /  2026-09-25

How to build your own three-letter agency with InsightFace and Atlas Local vector search

DISCLAIMER: I am a Senior Software Engineer at MongoDB and worked on MongoDB Atlas Local. All opinions and views expressed in this post are my own.

Six people in a bar, each face in a green box with a name above it

Each of those green boxes is a MongoDB query result.

The database has never seen a pixel. It holds 100 documents, each one a first name, a last name and 512 floating-point numbers, and those 512 numbers are enough to identify every single face in this picture.

Most people, me included, meet Atlas Vector Search through RAG: take some text, embed it, and let LangChain find it again. That is very cool, and it made me wonder: what else can be turned into vectors and searched the same way? Faces, for one. I have wanted my own face database since the first time I saw one on television, so this is me building my own three-letter agency, on a laptop, in a marimo notebook.

(Everything below is for learning. Do not go out and violate people's privacy with it. And if you are an actual three-letter agency, please create the cluster in the cloud.)

What is a face to a database?

import cv2
import insightface
import numpy as np

app = insightface.app.FaceAnalysis("buffalo_l")
app.prepare(ctx_id=0)

faces = app.get(cv2.imread("_dataset/img001.jpg"))
face = faces[0]
print(len(faces), "face found")
print("embedding:       ", face.embedding.shape, "numbers, length", round(float(face.embedding_norm), 1))
print("normed_embedding:", face.normed_embedding.shape, "numbers, length", round(float(np.linalg.norm(face.normed_embedding)), 1))
print("first six:       ", face.normed_embedding[:6].round(4))

InsightFace does the seeing, in two steps. buffalo_l is its default model pack: a detector (SCRFD-10GF) that finds every face in the photo and five landmarks on each (eyes, nose, mouth corners), and a recogniser (a ResNet50 trained on WebFace600K) that takes the face, rotated and cropped so the landmarks sit where the network expects them, and turns it into 512 floats. The detector exists because the recogniser was trained on faces at a known position, not on bars with people in them. The pack is 326 MB and downloads itself into ~/.insightface/models the first time the line above runs, so the first run is slower than every run after it. (ctx_id=0 means "do not force the CPU". InsightFace takes CUDA or CoreML if onnxruntime has them and otherwise the CPU, silently. My XPS 13 has no graphics card, so it's using the CPU, and a portrait takes it about 1.6 seconds. On a machine with a graphics card this should run way faster.)

1 face found
embedding:        (512,) numbers, length 20.1
normed_embedding: (512,) numbers, length 1.0
first six:        [-0.0138 -0.0106  0.016  -0.0011  0.0047 -0.0483]

This gives us an embedding for the face. But be careful: don't use embedding as it comes out. It has to be normalised before it can be compared with another one, and that is what normed_embedding is: the same 512 numbers, scaled to length 1. That is the version we store. (app.get also gives each face its bounding box, for the green box later.)

aligned cropSCRFD detectorenrol: insert_onephotolookup: $vectorSearchArcFace ResNet50512 floats, unit length

Where do the vectors live?

In a real Atlas deployment, running in Docker on the laptop, started from Python:

from atlas_local import LocalDeployment
import pymongo

deployment = LocalDeployment.get_or_create(name="image-recognition")
deployment.start()
client = pymongo.MongoClient(deployment.connection_string())

atlas-local-lib-py drives the Atlas Local container, which runs MongoDB and the search process side by side, so $vectorSearch works against mongodb://127.0.0.1:32768/?directConnection=true exactly as it does in the cloud. get_or_create reuses a deployment with matching options or creates one, so the cell can be re-run without thinking about it. (No Docker handy? A free Atlas cluster does the same job; it allows three indexes, search or vector, and this post needs one.)

Then the database, and the two indexes that make it an agency:

import time
from pymongo.errors import CollectionInvalid
from pymongo.operations import SearchIndexModel

db = client.face_recognition

# 1. the collection has to exist before a search index can attach to it
try:
    db.create_collection("people")
except CollectionInvalid:
    pass
people = db["people"]

# 2. one document per person
people.create_index(
    [("first_name", 1), ("last_name", 1)], name="name_idx", unique=True
)

# 3. the vector index
if "face_vector_index" not in {i["name"] for i in people.list_search_indexes()}:
    people.create_search_index(
        model=SearchIndexModel(
            definition={
                "fields": [
                    {
                        "type": "vector",
                        "path": "face_embedding",
                        "numDimensions": 512,
                        "similarity": "dotProduct",
                    }
                ]
            },
            name="face_vector_index",
            type="vectorSearch",
        )
    )

# 4. wait until it can be queried
while True:
    info = next(iter(people.list_search_indexes("face_vector_index")), None)
    if info and info.get("queryable"):
        break
    print(".", end="")
    time.sleep(3)
print("vector index ready")
.vector index ready

(One dot: on an empty collection the index went from PENDING to READY inside the first three-second sleep.)

TIP: MongoDB creates databases and collections on first write, so most code never calls create_collection. A search index is different: it attaches to a collection that exists. Skip step 1 on a fresh database and step 3 raises OperationFailure:

Error retrieving collection UUID and view info :: caused by :: Collection 'face_recognition.people' does not exist.

So create the collection, then the index. CollectionInvalid is what a second run gets, and it is fine.

Step 2 is a plain unique index on the name pair. It is what makes enrolment idempotent later, and it is the only thing stopping my agency from filing the same person twice.

Step 3 is the index this post is about. numDimensions has to match the model (512 here; the limit is 8192). similarity is a choice, and it deserves its own section.

Step 4 polls list_search_indexes until queryable is true. A search index is built by the search process, not inline with the command, so the command returns before the index can answer.

Why dotProduct and not cosine?

MongoDB offers three similarity functions for a vector index:

  1. euclidean: straight-line distance between the vector ends.
  2. cosine: the angle between the vectors, magnitude ignored.
  3. dotProduct: like cosine, but magnitude counts.

Face embeddings are compared by angle, so option 1 is out. Between 2 and 3, the docs are direct: "To measure cosine similarity, we recommend that you normalize your vectors and use dotProduct instead", with the condition that "you must normalize the vector to unit length at index-time and query-time". We already have unit-length vectors (that is what normed_embedding is), so dotProduct gives the cosine and, in the docs' words, "consumes less computational resources than cosine".

The score that comes back is not the cosine. For cosine, and so for dotProduct on unit-length vectors, it is

score = (1 + cosine(v1, v2)) / 2

so that it lands in 0..1:

cosinescore
-1 (opposite)0
0 (perpendicular)0.5
1 (same direction)1

Face recognition thresholds are published as cosines, so the lookup below converts in both directions: threshold to score before the query, score to cosine after it.

Enrolling 100 people

The dataset is 100 people who do not exist. I generated the faces with Higgsfield, one portrait per person, each with a JSON file for the name:

_dataset/img001.jpg
_dataset/img001.json   {"first_name": "Dalia", "last_name": "Novak", ...}
_dataset/img002.jpg
_dataset/img002.json
...

Enrolment is a loop over the JSON files:

import json, os

for entry in os.scandir("./_dataset"):
    if entry.is_file() and entry.path.endswith(".json"):
        with open(entry.path) as meta_file:
            meta = json.load(meta_file)
        first_name, last_name = meta["first_name"], meta["last_name"]

        if people.find_one(
            {"first_name": first_name, "last_name": last_name},
            projection={"_id": 1},
        ) is None:
            print(f"preparing {first_name} {last_name} embeddings")
            img = cv2.imread(entry.path.replace(".json", ".jpg"))
            faces = app.get(img)  # one face per portrait, in this dataset

            people.insert_one({
                "first_name": first_name,
                "last_name": last_name,
                # numpy float32 -> Python float, which is what pymongo encodes
                "face_embedding": [float(x) for x in faces[0].normed_embedding],
            })
            print(f"inserted {first_name} {last_name} embeddings in db")
        else:
            print(f"skipping {first_name} {last_name}, already in db")
preparing Dalia Novak embeddings
inserted Dalia Novak embeddings in db
preparing Hiroshi Sharma embeddings
inserted Hiroshi Sharma embeddings in db
...
preparing Mateo Haddad embeddings
inserted Mateo Haddad embeddings in db

100 people in 164 seconds on my CPU, almost all of it in app.get. Run the cell again and it prints skipping 100 times in a tenth of a second: the find_one on the name pair skips everyone already filed, with the unique index from step 2 underneath in case two files share a name. One portrait per person is what this dataset has; an agency with several photos of the same person would store several documents and let the name index go.

The stored document is a name and the 512 numbers. Printing the first one, with the embedding cut to three numbers so it fits on a line:

print(people.find_one({}, {"_id": 0, "face_embedding": {"$slice": 3}}))
{'first_name': 'Dalia', 'last_name': 'Novak', 'face_embedding': [-0.013844465836882591, -0.010554011911153793, 0.016035884618759155]}

Who is this?

Now the group picture, also generated. Six people, all of them in the files. Detect the faces, then ask the database about each one:

lookup_img = cv2.imread("group_picture.jpg")
lookup_faces = app.get(lookup_img)

def lookup_face(face) -> str | None:
    COS_THRESHOLD = 0.30
    SCORE_THRESHOLD = (COS_THRESHOLD + 1) / 2   # 0.65

    pipeline = [
        {
            "$vectorSearch": {
                "index": "face_vector_index",
                "path": "face_embedding",
                "queryVector": face.normed_embedding.tolist(),
                "limit": 1,
                "exact": True,
            }
        },
        {"$addFields": {"score": {"$meta": "vectorSearchScore"}}},
        {"$match": {"score": {"$gte": SCORE_THRESHOLD}}},
        {
            "$project": {
                "_id": 0,
                "first_name": 1,
                "last_name": 1,
                "cos": {"$subtract": [{"$multiply": ["$score", 2]}, 1]},
            }
        },
    ]

    result = next(iter(people.aggregate(pipeline)), None)
    if result is None:
        return None
    return f"{result['first_name']} {result['last_name']}"

$vectorSearch has to be the first stage. It takes the query vector, the index name, how many neighbours to return, and one decision: how to search.

  1. Approximate nearest neighbours, the default. The index is an HNSW graph, and numCandidates says how far to walk it (the docs suggest at least 20 times limit). This is the setting for a collection you cannot afford to scan.
  2. Exact nearest neighbours, exact: True. Every vector in the index is compared to the query. The docs call it "computationally intensive", which at 100 documents is a rounding error: each lookup below took under 15 ms.
  3. A flat index instead of HNSW: a non-graph index that does not approximate.

I picked 2. It is the same index and one flag, so when the agency grows past what a scan can handle, the flag comes off and numCandidates goes on, and nothing else changes. (I checked: ANN with numCandidates: 20 returned the same person with the same score.)

The rest of the pipeline is bookkeeping. $meta: "vectorSearchScore" exposes the score, $match drops anything below the threshold, and $project turns the score back into a cosine so the number I print is the number the literature uses. With limit: 1 the result is either the nearest person or nothing.

To see how sure the database is, I asked it for the two nearest people per face instead of one, without the threshold, and printed the cosine of both:

for i, face in enumerate(lookup_faces):
    best, second = people.aggregate([
        {"$vectorSearch": {"index": "face_vector_index", "path": "face_embedding",
                           "queryVector": face.normed_embedding.tolist(), "limit": 2, "exact": True}},
        {"$project": {"_id": 0, "first_name": 1, "last_name": 1,
                      "cos": {"$subtract": [{"$multiply": [{"$meta": "vectorSearchScore"}, 2]}, 1]}}},
    ])
    name = f"{best['first_name']} {best['last_name']}"
    runner_up = f"{second['first_name']} {second['last_name']}"
    print(f"face {i}: {name:<16} cos={best['cos']:.3f}   next: {runner_up:<15} {second['cos']:.3f}")
face 0: Ethan Aziz       cos=0.485   next: Rosa Nakagawa   0.212
face 1: Jamal Toure      cos=0.592   next: Daniel Ocampo   0.180
face 2: Freya Holm       cos=0.370   next: Layla Holm      0.147
face 3: Valentina Ricci  cos=0.494   next: Finn Ocampo     0.143
face 4: Mateo Haddad     cos=0.525   next: Kiara Iqbal     0.173
face 5: Yara O'Brien     cos=0.602   next: Petra Ferreira  0.198

Six for six, and the right answer is never close to the wrong one. Across the whole set, the nearest two different people are Beatriz Diallo and Sienna Okafor at cosine 0.264, so anything between 0.27 and 0.36 separates everyone in my files. The threshold is what turns "nearest" into "known", and I owe you the story of mine.

The first version of this notebook used real photos and a threshold of 0.38. Where did 0.38 come from? InsightFace's guide puts typical thresholds for its models between 0.30 and 0.45 and says to recompute for your own population. 0.38 is the middle of that range. I tried it, it worked, and I gave it no further thought.

Then I swapped the real photos for generated ones and Freya Holm disappeared. Look at face 2: cosine 0.370. Same woman, same generator, different photo, and the model is less sure about her than it had been about the real photos. Generated faces are a population of their own, and the guide had already told me to recompute. So: 0.30 for this dataset, and a red box instead of a name for anyone under it.

The same group picture at threshold 0.38: five green boxes with names, and one red box

Drawing the answer

import marimo as mo

annotated = lookup_img.copy()

for face in lookup_faces:
    found_face = lookup_face(face)
    is_found = found_face is not None
    rectangle_color = (0, 255, 0) if is_found else (0, 0, 255)

    x1, y1, x2, y2 = face.bbox.astype(int)
    cv2.rectangle(annotated, (x1, y1), (x2, y2), rectangle_color, 2)

    if is_found:
        # keep the label on screen if the box is near the top edge
        text_y = y1 - 10 if y1 - 10 > 20 else y2 + 25
        cv2.putText(
            annotated, found_face, (x1, text_y),
            cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, cv2.LINE_AA
        )

mo.image(cv2.imencode(".png", annotated)[1].tobytes())

Every detected face gets a box; only the ones the database vouches for get a name. The colours look backwards because OpenCV orders channels as BGR, so (0, 0, 255) is red. (In the notebook the picture comes from a mo.ui.file upload widget rather than a path, and cv2.imdecode turns the uploaded bytes into the same array cv2.imread would have given.)

That is the whole agency

One Docker container, one collection, two indexes, and one aggregation pipeline.

I started this to learn something about vector search, and the honest report is that the search was the easy part. InsightFace does the seeing, MongoDB does the remembering, and the query that finds a face is the same $vectorSearch that finds a paragraph. The notebook is at github.com/jeroenvervaeke/atlas-local-face-recognition; uv run marimo edit face-recognition.py opens it, and the first cell after the imports starts the database.

Now I want to know what else has a nearest neighbour. Songs? Handwriting? The bugs in my issue tracker? If you have embedded something that is not text, tell me about it on LinkedIn.