You run a streaming site. A user has watched five films, and they liked all of them. You'd like to surface another ten they probably also want to watch, but not just "more of the last thing they clicked." Something that captures the blend of what they're into.

This project walks through the pattern end to end with the bundled 5,000-film movie corpus that ships with veep[samples] (the same dataset the quickstart on /docs uses). Once you've finished, the only line to change to plug in your own catalogue is the source DataFrame.

The trick is one of the things vector databases are uniquely good at: vector arithmetic. Take the embedding of each film a user has watched, average the vectors, renormalize, and use that as the query. The result is a single point in the same 384-dimensional space that represents the user's "taste." Cosine-nearest neighbours to that point are the recommendations.

End-to-end pipeline: a 5-film watch list (Lion King, Toy Story, Finding Nemo, Monsters Inc, Inside Out) produces 5 384-dimensional embeddings, which are averaged and renormalized into a single "taste centroid" used to query the Vector Panda collection. Top-5 results return on-target family films (Legend of the Guardians 0.561, Nim's Island 0.557, Adventures of Elmo 0.554, Gremlins 2 0.548, Zeus and Roxanne 0.537), with the watch list itself excluded via a $nin filter.

What you need

pip install veep[samples,pandas,parquet] numpy
pip install langchain-vectorpanda langchain-huggingface veep[samples,pandas,parquet] numpy

langchain-huggingface (which pulls sentence-transformers and torch) is only needed if you'll do text-search later; this walkthrough is by-vector throughout, so you can skip it if your project doesn't need a text path.

The [samples] extra adds the 5,000-film movie DataFrame. numpy is what we'll use to average vectors. Nothing leaves your machine until you upload to Vector Panda.

Upload the corpus

The bundled DataFrame has six columns: id, title, year, genre, plot, vector. The id column becomes the row key on upsert; the rest become filterable metadata.

from veep import VP, samples

df = samples.dataframe()

vp = VP.from_creds()
vp.collections.create("movie-recs", tier="hot")
vp.vectors.upsert("movie-recs", dataframe=df)

print(f"uploaded {len(df)} films; vector dim = {len(df.iloc[0].vector)}")

The bundled DataFrame ships with pre-computed embeddings, so the upload itself goes through veep directly — LangChain's VectorStore.add_texts is text-first and would re-embed every plot. Wrap the populated collection in VectorPandaStore once the upsert is done and the rest of this walkthrough is pure LangChain.

from veep import VP, samples
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_vectorpanda import VectorPandaStore

df = samples.dataframe()

vp = VP.from_creds()
vp.collections.create("movie-recs", tier="hot")
vp.vectors.upsert("movie-recs", dataframe=df)

# Match the embedding model the bundled DataFrame was built with.
# Used only for text-query paths; similarity_search_by_vector skips it.
embeddings = HuggingFaceEmbeddings(
    model_name="sentence-transformers/all-MiniLM-L6-v2"
)
store = VectorPandaStore(
    collection_name="movie-recs",
    embedding=embeddings,
    client=vp,
)

print(f"uploaded {len(df)} films; vector dim = {len(df.iloc[0].vector)}")

You'll see:

uploaded 5000 films; vector dim = 384

Build a watch list and a taste centroid

Pick five films a fake user has watched recently. We'll use a coherent theme so the recommendations are recognizable: animated family films from the late 90s through 2010s.

import numpy as np

WATCH_LIST = [
    "film-00002",  # The Lion King (1994)
    "film-00004",  # Toy Story (1995)
    "film-00026",  # Finding Nemo (2003)
    "film-00039",  # Monsters, Inc. (2001)
    "film-00042",  # Inside Out (2015)
]

# Look up each watched film's vector from the DataFrame.
# In a real app you'd vp.vectors.fetch() these from the collection, but
# we already have them in memory from the upsert.
watched_vectors = np.stack([
    np.array(df[df.id == fid].iloc[0].vector) for fid in WATCH_LIST
])
print(f"watched_vectors shape: {watched_vectors.shape}")

# Average the five vectors → the "taste centroid."
centroid = watched_vectors.mean(axis=0)

# Renormalize to unit length. Cosine similarity treats vectors as directions,
# so the centroid's magnitude doesn't matter, but the SDK and most embedding
# models work best with unit-length vectors.
centroid = centroid / np.linalg.norm(centroid)
print(f"centroid: {len(centroid)} dims, |v| = {np.linalg.norm(centroid):.3f}")
watched_vectors shape: (5, 384)
centroid: 384 dims, |v| = 1.000

Geometrically, you've taken five points in a 384-dimensional space and found their middle. That middle is the user's "taste": not literally any single watched film, but the direction in space that all five share.

How the taste centroid works: five watched film embeddings (Lion King, Toy Story, Finding Nemo, Monsters Inc, Inside Out) become one point in 384-dimensional vector space via centroid = mean(watched_vectors), then renormalized to unit length for cosine similarity. The centroid captures the blend of the user's taste rather than any single film, and is suitable for nearest-neighbor recommendation queries.

Query with the centroid

Now query the collection using the centroid as the search vector. The $nin filter excludes the five films already on the watch list (otherwise they'd dominate the top results, since they're as close to the centroid as anything).

results = vp.vectors.query(
    "movie-recs",
    vector=centroid.tolist(),
    top_k=5,
    filter={"id": {"$nin": WATCH_LIST}},
)
for r in results:
    m = r.metadata
    print(f"  {r.score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')}, {m.get('genre','?')[:20]})")
results = store.similarity_search_by_vector_with_score(
    centroid.tolist(),
    k=5,
    filter={"id": {"$nin": WATCH_LIST}},
)
for doc, score in results:
    m = doc.metadata
    print(f"  {score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')}, {m.get('genre','?')[:20]})")
  0.5611  Legend of the Guardians: The Owls of Ga'  (2010, fantasy)
  0.5568  Nim's Island                              (2008, adventure)
  0.5535  The Adventures of Elmo in Grouchland      (1999, children's film)
  0.5475  Gremlins 2: The New Batch                 (1990, sci-fi comedy)
  0.5368  Zeus and Roxanne                          (1997, family)

These are real recommendations from a real run. The user's taste profile (Pixar/Disney-flavoured family films) pulled in more family-and-creature stories: an owl-fantasy adventure, a girl-and-her-island story, a Sesame Street film, a creature comedy, and a boy-and-his-dolphin film. The model never saw the word "Pixar" or "kids movie"; it's reading the plot embeddings and picking up on shared themes (small heroes, animals, adventure, warmth).

Combine the centroid with filters

The same centroid, narrowed with filters: the taste centroid (input) plus the always-on $nin watched filter forks into two filter variants. Genre = animated returns Land Before Time II 0.5356, Penguins of Madagascar 0.5349, Dumbo 0.5298, Lion King II 0.5088, Aquamania 0.5047. Year >= 2010 returns a different slice of family/adventure titles. Same centroid, only the filter changes; the database returns items closest to the centroid that also satisfy the metadata predicate.

The same centroid plus a metadata filter scopes recommendations to a slice of the catalogue. Suppose the user wants only animated films:

results = vp.vectors.query(
    "movie-recs",
    vector=centroid.tolist(),
    top_k=5,
    filter={"id": {"$nin": WATCH_LIST}, "genre": "animated"},
)
for r in results:
    m = r.metadata
    print(f"  {r.score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')})")
results = store.similarity_search_by_vector_with_score(
    centroid.tolist(),
    k=5,
    filter={"id": {"$nin": WATCH_LIST}, "genre": "animated"},
)
for doc, score in results:
    m = doc.metadata
    print(f"  {score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')})")
  0.5356  The Land Before Time II: The Great Valle  (1994)
  0.5349  Penguins of Madagascar                    (2014)
  0.5298  Dumbo                                     (1941)
  0.5088  The Lion King II: Simba's Pride           (1998)
  0.5047  Aquamania                                 (1961)

Same centroid, narrower lens. The filter ANDs with the centroid query: the database returns the items closest to the centroid that also satisfy the metadata predicate. You can compose any of $eq, $ne, $in, $nin, $gt, $gte, $lt, $lte across as many fields as you like; everything has to be true for a row to qualify.

A second example: the same user wants something modern (2010 or later):

results = vp.vectors.query(
    "movie-recs",
    vector=centroid.tolist(),
    top_k=5,
    filter={"id": {"$nin": WATCH_LIST}, "year": {"$gte": 2010}},
)
for r in results:
    m = r.metadata
    print(f"  {r.score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')}, {m.get('genre','?')[:20]})")
results = store.similarity_search_by_vector_with_score(
    centroid.tolist(),
    k=5,
    filter={"id": {"$nin": WATCH_LIST}, "year": {"$gte": 2010}},
)
for doc, score in results:
    m = doc.metadata
    print(f"  {score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')}, {m.get('genre','?')[:20]})")

The shape of the call is identical; only the filter changes. This is the part of the design that customers usually want to see early: scoping a vector search to a structured slice is one operation, not two.

A second user, a different taste

To see the centroid trick really pay off, build a different user with a different watch list. This one's into epic fantasy:

WATCH_B = [
    "film-00023",  # The Lord of the Rings: The Fellowship of the Ring (2001)
    "film-00027",  # The Lord of the Rings: The Return of the King (2003)
    "film-00035",  # The Lord of the Rings: The Two Towers (2002)
    "film-00053",  # Harry Potter and the Deathly Hallows: Part 1 (2010)
    "film-00062",  # Harry Potter and the Deathly Hallows: Part 2 (2011)
]

vecs_b = np.stack([
    np.array(df[df.id == fid].iloc[0].vector) for fid in WATCH_B
])
centroid_b = vecs_b.mean(axis=0)
centroid_b = centroid_b / np.linalg.norm(centroid_b)
results = vp.vectors.query(
    "movie-recs",
    vector=centroid_b.tolist(),
    top_k=5,
    filter={"id": {"$nin": WATCH_B}},
)
for r in results:
    m = r.metadata
    print(f"  {r.score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')}, {m.get('genre','?')[:20]})")
results = store.similarity_search_by_vector_with_score(
    centroid_b.tolist(),
    k=5,
    filter={"id": {"$nin": WATCH_B}},
)
for doc, score in results:
    m = doc.metadata
    print(f"  {score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')}, {m.get('genre','?')[:20]})")
  0.5267  The Secret of the Sword                   (1985, animation)
  0.4897  Hercules in the Haunted World             (1961, adventure)
  0.4895  King Arthur: Legend of the Sword          (2017, action, adventure, e)
  0.4770  Smurfs: The Lost Village                  (2017, animation, comedy, f)
  0.4691  Hellboy II: The Golden Army               (2008, action, superhero)

A 1985 sword-fantasy animation, two different epic-quest films (Hercules, King Arthur), a family animation with a quest plot, and a supernatural-action film. The centroid's blending the LOTR axis (long quest, magic, swords, dark forces) with the Harry Potter axis (magic, school, dark forces, friends) and pulling out other "small band on an epic quest against magical evil" stories. None of them were in the watch list and none are obvious sequels; the database is doing real recommendation work.

Why the centroid beats single-item similarity

Why the centroid beats single-item similarity: side-by-side comparison. Left, query by one watched film (Fellowship of the Ring) returns near-duplicates: Two Towers 0.6745, Return of the King 0.6245, Hercules in the Haunted World 0.3982, Secret of the Sword 0.3915, Thor The Dark World 0.3903. Top hits are obvious sequels. Right, query by the centroid of a 5-film fantasy watch list (LOTR + Harry Potter) returns diverse-but-related results: Secret of the Sword 0.5267, Hercules 0.4897, King Arthur 0.4895, Smurfs Lost Village 0.4770, Hellboy II 0.4691. Centroid recommendations surface the user's broader taste, not just the nearest sequel.

It's worth seeing what you'd have got if you'd just queried by one watched film instead of averaging five. The single-film version of "user B's recommendations" looks like this:

single = np.array(df[df.id == "film-00023"].iloc[0].vector)  # Fellowship of the Ring
single = single / np.linalg.norm(single)
results = vp.vectors.query(
    "movie-recs",
    vector=single.tolist(),
    top_k=5,
    filter={"id": {"$ne": "film-00023"}},
)
for r in results:
    m = r.metadata
    print(f"  {r.score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')}, {m.get('genre','?')[:20]})")
results = store.similarity_search_by_vector_with_score(
    single.tolist(),
    k=5,
    filter={"id": {"$ne": "film-00023"}},
)
for doc, score in results:
    m = doc.metadata
    print(f"  {score:.4f}  {m.get('title','?')[:40]:<40}  ({m.get('year','?')}, {m.get('genre','?')[:20]})")
  0.6745  The Lord of the Rings: The Two Towers     (2002, adventure, fantasy)
  0.6245  The Lord of the Rings: The Return of the  (2003, adventure, fantasy)
  0.3982  Hercules in the Haunted World             (1961, adventure)
  0.3915  The Secret of the Sword                   (1985, animation)
  0.3903  Thor: The Dark World                      (2013, superhero)

The top two are the other LOTR films, strong matches by score (0.67, 0.62), but they're sequels to a film the user has already watched. The recommendations only get interesting at position 3, where the score has already dropped to 0.40.

The centroid is a softer query. Scores are flatter (0.47 to 0.53 instead of 0.39 to 0.67), but the first recommendation is already novel rather than being a near-duplicate. That's exactly the trade you want for "here's something else you might enjoy": surface the diverse-but-related, not the obvious.

Now what

You just built a recommendation engine in about 30 lines of Python. The pattern works for anything that's "items embedded in a vector space": products, articles, songs, profile photos, images on a moodboard.

To make it yours:

  1. Replace the bundled DataFrame with your own (id, vector, plus whatever metadata you'd want to filter on later). Embed your items with whatever model fits the domain. sentence-transformers/all-MiniLM-L6-v2 for text is the default that works almost everywhere.
  2. The watch list is whatever your app has as a "positive signal": purchases, likes, time-on-page, completed views, items added to a playlist. You don't need ratings; presence in the list is the signal.
  3. Five items in the centroid is a defensible default, not a magic number. Three works. Twenty works. Past about 50, the centroid drifts toward the population mean and recommendations become bland. If your users have very long histories, weight more recent items higher with a weighted average.
  4. To handle "I liked this and disliked that," subtract the disliked items' centroid from the liked centroid before normalizing. That's the same arithmetic, just signed: it nudges the query away from things the user told you not to recommend.

Read the SDK reference for the full filter operator list and the embeddings 101 concept page for why averaging vectors works as a "taste profile" in the first place.