Getting Started with AstraeaDB

Vector and Hybrid Search with AstraeaDB in Python

On this page

Turn your movie graph into a recommender by combining its connections with search over embedding vectors.

In the first post, Getting Started with AstraeaDB in Python: A Graph + Vector Database in Minutes, we started a server, connected with AstraeaClient, and built a small movie knowledge graph. A knowledge graph stores facts as nodes joined by labeled connections, so ours recorded who acted in what and which genre a film belongs to. Those facts are exact, but they cannot tell you that two movies simply feel alike. In this post we attach a short list of numbers, called an embedding, to each movie, then use AstraeaDB's vector and hybrid search to answer the question every recommender is really asking: what else feels like this one?

Why vectors live next to the graph

A graph handles exact facts well: Keanu Reeves acted in The Matrix. It struggles with softer questions, though. The idea that "these two movies have a similar mood" is not a connection you can draw by hand, because similarity is a matter of degree rather than a simple yes or no. An embedding captures that degree. It is a list of numbers, also called a vector, that places each item at a point in an imaginary space where items sitting close together are alike. Picture it like placing towns on a map, where neighbors tend to share a climate. AstraeaDB stores these vectors on the nodes and builds a fast search structure over them, an HNSW index (Hierarchical Navigable Small World), right beside the graph. Because both live in one database, a single system can answer "who directed this?" and "which movies are closest in meaning to this one?"

When AstraeaDB compares two vectors, it reports a distance, a single number measuring how far apart they are. The rule to remember is simple: a smaller distance means the two items are more similar.

Recap: connect and rebuild, this time with embeddings

If you followed the first post you already have the graph, but this time each Movie also carries a small embedding for its plot. To keep the example easy to paste and run, we use hand-written vectors of four numbers each, and you can read the four positions as [sci-fi, action, romance, drama]. In a real system you would generate them from each movie's plot text with an embedding model, such as a sentence-transformer or an embeddings service you call over the web. One rule always holds: every vector you store must have the same number of values, a property called its dimensionality.

from astraeadb import AstraeaClient

# Toy "plot" embeddings. Axes ~ [sci-fi, action, romance, drama].
MOVIES = {
"The Matrix":         (1999, [0.90, 0.80, 0.05, 0.20]),
"The Matrix Reloaded":(2003, [0.85, 0.85, 0.05, 0.15]),
"John Wick":          (2014, [0.10, 0.95, 0.05, 0.25]),
"Inception":          (2010, [0.80, 0.55, 0.15, 0.55]),
"Interstellar":       (2014, [0.85, 0.35, 0.25, 0.75]),
"The Notebook":       (2004, [0.05, 0.10, 0.95, 0.80]),
"Forrest Gump":       (1994, [0.10, 0.20, 0.45, 0.90]),
}

# Open a connection and keep it open for the rest of this lesson.
client = AstraeaClient(host="127.0.0.1", port=7687)
client.connect()

# Movies carry the embedding via the create_node `embedding` argument.
movies = {}
for title, (year, vec) in MOVIES.items():
    movies[title] = client.create_node(
        ["Movie"], {"title": title, "year": year}, embedding=vec
    )

# A few people, and who acted in / directed what.
people = {}
for name in ["Keanu Reeves", "Carrie-Anne Moss",
             "Lana Wachowski", "Christopher Nolan"]:
    people[name] = client.create_node(["Person"], {"name": name})

def acted(name, title):
    client.create_edge(people[name], movies[title], "ACTED_IN")

def directed(name, title):
    client.create_edge(people[name], movies[title], "DIRECTED")

acted("Keanu Reeves", "The Matrix")
acted("Keanu Reeves", "The Matrix Reloaded")
acted("Keanu Reeves", "John Wick")
acted("Carrie-Anne Moss", "The Matrix")
acted("Carrie-Anne Moss", "The Matrix Reloaded")
directed("Lana Wachowski", "The Matrix")
directed("Lana Wachowski", "The Matrix Reloaded")
directed("Christopher Nolan", "Inception")
directed("Christopher Nolan", "Interstellar")

print("movies:", len(movies), "people:", len(people))

Everything from here on reuses the client you opened above, so run it in the same session. client, movies, and people all stay in scope.

vector_search: find movies with a similar feel

The most direct kind of recommendation is a nearest-neighbor search: you hand the database a query vector and get back the stored nodes whose vectors are closest to it. Here we ask for movies that suit a sci-fi action night, meaning ones that score high on the first two axes and low on romance and drama:

vibe = [0.90, 0.90, 0.05, 0.10]          # what we're in the mood for
hits = client.vector_search(vibe, k=5)   # list of {node_id, distance, score}

id_to_title = {nid: t for t, nid in movies.items()}
for h in hits:
    title = id_to_title.get(h["node_id"], f"node {h['node_id']}")
    print(f"{title:22s} distance={h['distance']:.4f}")

You will see the Matrix films and John Wick rise to the top, while The Notebook falls to the bottom, which is exactly the ordering our axes encode. Each result carries a node_id, a distance, and a score. The score field is a legacy alias of distance: it holds the very same number and is kept only so older code keeps working. Do not read it as "higher score is better." Here, a lower number is better.

A common variation is "find more movies like this one." Instead of writing a query vector by hand, you use an existing movie's own embedding as the query. A node is always closest to itself, so you simply skip the first result:

query_vec = MOVIES["The Matrix"][1]      # The Matrix's own embedding
similar = client.vector_search(query_vec, k=4)
similar = [h for h in similar if h["node_id"] != movies["The Matrix"]]
print("More like The Matrix:",
      [id_to_title[h["node_id"]] for h in similar])

hybrid_search: blend graph connections with vector similarity

Plain vector search ignores everything the graph already knows. A good recommender should respect connections as well. If you are browsing an actor's page, movies near that actor in the graph are usually more relevant than a random film that happens to have a similar embedding. Hybrid search does exactly this, and "hybrid" just means it mixes two kinds of information. It starts from an anchor node, the node you want results to stay near, explores outward up to max_hops steps away (one hop being one connection you follow), and ranks the candidates by combining two signals:

The alpha argument is the dial between the two signals. Setting alpha=0 uses graph proximity alone, alpha=1 uses vector similarity alone, and 0.5 gives an even mix. Below we anchor on Keanu Reeves and ask for sci-fi-leaning recommendations near him:

scifi = [0.95, 0.50, 0.05, 0.20]
blended = client.hybrid_search(
    anchor=people["Keanu Reeves"],
    query_vector=scifi,
    max_hops=3,
    k=5,
    alpha=0.5,          # half graph proximity, half vibe
)
for h in blended:       # list of {node_id, score}
    label = id_to_title.get(h["node_id"], f"node {h['node_id']}")
    print(f"{label:22s} score={h['score']:.4f}")

Try adjusting alpha. Push it toward 0 and the results stay close to Keanu's actual filmography no matter the query. Push it toward 1 and the graph fades into the background, leaving something close to a plain vector_search. That single setting lets you tune how much to trust the connections versus the content, without rewriting the query itself.

semantic_neighbors and semantic_walk: steer toward a concept

Sometimes you do not want the closest match overall. Instead, you want to move through the graph in the direction of a particular idea. You give AstraeaDB a concept vector, which is just an embedding that stands for the theme you are chasing, and it ranks or steps through neighboring nodes by how well each points toward that theme. Moving from node to node along connections like this is called traversing the graph.

semantic_neighbors ranks the immediate neighbors of a node, meaning the nodes one connection away, by how close they sit to a concept. Starting from Inception, we can ask which of its connected nodes lean most toward thoughtful science fiction:

cerebral_scifi = [0.90, 0.30, 0.10, 0.60]
near = client.semantic_neighbors(
    movies["Inception"], cerebral_scifi,
    direction="outgoing", k=5,
)                                  # list of {node_id, distance}
print("Neighbors of Inception, by concept:", near)

semantic_walk goes further. From a starting node it takes up to max_hops steps, and at each step it moves to whichever neighbor best matches the concept. This is a greedy path, meaning it always takes the best-looking next step rather than planning the whole route ahead, and it is guided by meaning instead of by connection type alone:

path = client.semantic_walk(
    movies["The Matrix"], cerebral_scifi, max_hops=3,
)
print("Concept walk from The Matrix:", path)

Think of these two methods as the exploring half of a recommender. vector_search answers "what is most similar overall?", hybrid_search answers "what is relevant given where I already am?", and the semantic walk answers "if I keep leaning into this theme, where do I end up?" All three read from the same vectors and the same graph, so there is no separate vector store to keep in sync.

What's next

With these five methods you have built the core of a recommender: nearest-neighbor lookups, a tunable blend of graph and vector signals, and concept-guided exploration. In the final post, Graph Algorithms, Time-Travel, and GraphRAG with AstraeaDB in Python, we will add User nodes and RATED edges. We will rank the most influential people and movies with PageRank, an algorithm that scores each node by how many well-connected nodes point to it. We will group similar nodes into genre-like communities with Louvain, a method that finds clusters whose members are tightly linked. We will query the graph as it looked on a past date using time-travel traversals, which ask questions about an earlier state of the data. We will finish by feeding a small piece of the graph into GraphRAG, short for graph retrieval-augmented generation, which hands relevant graph facts to a language model so it can answer questions in plain English.

When you are finished with this lesson, close the connection you opened at the start:

client.close()