Skip to main content

Download notebook · View source · Open in Colab

Hybrid Search Examples

Hybrid search combines vector similarity with metadata constraints. A FindDescriptor query can restrict matches by properties, order the selected results, or return aggregates instead of individual descriptors.

These examples use dish-description embeddings with cuisine and calorie properties. They cover KNN search, equality and range filters, sorting, counts, numeric aggregates, and grouping.

Connect to ApertureDB​

ApertureDB Cloud The SDK connects with an API key from your Cloud instance. Obtain the key from Connect > Generate API Key and add it to a .env file in this directory:

APERTUREDB_KEY=your_key_here

Local evaluation: follow the deployment guide, then configure your connection profile.

See client configuration options for all connection methods and server setup options for deployment choices.

%pip install --quiet "aperturedb==0.5.1" "python-dotenv==1.1.1" sentence-transformers pandas

# Encode literal data without interpreting a leading dollar sign as a property.
def literal(value):
if isinstance(value, str) and value.startswith("$"):
return "$" + value
if isinstance(value, (list, tuple)):
return {"array": [literal(item) for item in value]}
if isinstance(value, dict):
if len(value) == 1 and ("date" in value or "_date" in value):
return {"to_date": literal(next(iter(value.values())))}
return {"json": value}
return value
# Option A: ApertureDB Cloud
from dotenv import load_dotenv
load_dotenv() # loads APERTUREDB_KEY from .env into the environment
# For local evaluation, configure the profile from your versioned deployment package.
# https://docs.aperturedata.io/administration/deployment/DockerCompose
from aperturedb.CommonLibrary import create_connector

client = create_connector()
response, _ = client.query([{"GetStatus": {}}])
client.print_last_response()

Load and Ingest the Cookbook Dataset​

all-MiniLM-L6-v2 embeds the dish descriptions. Each descriptor stores cuisine metadata and a generated calorie value for the numeric-filter examples; these values are sample data, not nutritional measurements.

import pandas as pd
import numpy as np
from sentence_transformers import SentenceTransformer

dishes = pd.read_csv(
"https://raw.githubusercontent.com/aperture-data/Cookbook/51b3c3d9f495f8fd77108a270b88fe8ab8cc0004/images.adb.csv"
)
dishes["description"] = dishes["dish_name"] + " - " + dishes["caption"]

model = SentenceTransformer("all-MiniLM-L6-v2")
embeddings = model.encode(dishes["description"].tolist(), normalize_embeddings=True)
print(f"Loaded {len(dishes)} dishes, embedding shape: {embeddings.shape}")
import uuid
SET_NAME = "cookbook_hybrid_" + uuid.uuid4().hex[:12]

client.query([{
"AddDescriptorSet": {
"name": SET_NAME, "dimensions": 384, "engine": "HNSW", "metric": "CS",
}
}])

# Add synthetic calorie counts for the range filter demo
import random
random.seed(42)

for i, row in dishes.iterrows():
emb = embeddings[i].astype("float32")
client.query([{
"AddDescriptor": {
"set": SET_NAME,
"properties": {
"dish_name": row["dish_name"],
"cuisine": row["food_tags"],
"calories": random.randint(300, 900),
},
"if_not_found": ['$dish_name', '==', literal(row['dish_name'])],
}
}], [emb.tobytes()])

print(f"Ingested {len(dishes)} descriptors")

Pattern 1: KNN Only​

FindDescriptor searches for three dishes similar to the query vector without a metadata constraint.

query_text = "spicy curry with rice"
query_emb = model.encode([query_text], normalize_embeddings=True)[0].astype("float32")

response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"k_neighbors": 3,
"distances": True,
"results": {"all_properties": True},
}
}], [query_emb.tobytes()])

print(f"KNN only — top 3 for '{query_text}':")
for e in response[0]["FindDescriptor"].get("entities", []):
print(f" {e['dish_name']:<30} [{e['cuisine']}] score={1-e['_distance']:.3f}")

Pattern 2: KNN + Equality Filter​

The cuisine constraint limits returned descriptors to Indian dishes. ApertureDB evaluates the metadata condition as part of the query.

response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"k_neighbors": 3,
"constraints": ['$cuisine', '==', 'Indian'],
"distances": True,
"results": {"all_properties": True},
}
}], [query_emb.tobytes()])

print(f"KNN + cuisine==Indian — top 3 for '{query_text}':")
for e in response[0]["FindDescriptor"].get("entities", []):
print(f" {e['dish_name']:<30} [{e['cuisine']}] score={1-e['_distance']:.3f}")

Pattern 3: KNN + Range Filter​

The numeric constraint limits returned descriptors to dishes with calories below 600.

response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"k_neighbors": 5,
"constraints": ['$calories', '<', 600],
"distances": True,
"results": {"all_properties": True},
}
}], [query_emb.tobytes()])

print(f"KNN + calories<600 — top 5 for '{query_text}':")
for e in response[0]["FindDescriptor"].get("entities", []):
print(f" {e['dish_name']:<30} calories={e['calories']} score={1-e['_distance']:.3f}")

Pattern 4: Sort KNN Results by Property​

The query selects five similar dishes, then orders those results by calories, lowest first. The property sort changes their display order, not the vector used for the similarity search.

response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"k_neighbors": 5,
"sort": "calories", # sort top-5 KNN results ascending by calories
"distances": True,
"results": {"all_properties": True},
}
}], [query_emb.tobytes()])

print(f"KNN top-5 sorted by calories:")
for e in response[0]["FindDescriptor"].get("entities", []):
print(f" {e['dish_name']:<30} calories={e['calories']} score={1-e['_distance']:.3f}")

Pattern 5: Count​

count: True in results requests a count without returning individual entities. The examples count Indian dishes across the set and matches for a constrained KNN query.

# Count all Indian dishes
response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"constraints": ['$cuisine', '==', 'Indian'],
"results": {"count": True},
}
}])

print(f"Indian dishes in set: {response[0]['FindDescriptor']['count']}")

# Count how many of the top-10 similar dishes are under 500 calories
response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"k_neighbors": 10,
"constraints": ['$calories', '<', 500],
"results": {"count": True},
}
}], [query_emb.tobytes()])

print(f"Top-10 similar dishes with <500 cal: {response[0]['FindDescriptor']['count']}")

Pattern 6: Average, Min, Max​

The results fields average, min, and max aggregate a numeric property over the matches. The response uses _avg, _min, and _max for these values. The examples compare cuisine-wide statistics with an aggregate over KNN results.

import numpy as np

# Aggregate stats on all Indian dishes
response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"constraints": ['$cuisine', '==', 'Indian'],
"results": {"average": "calories", "min": "calories", "max": "calories"},
}
}])

fd = response[0]["FindDescriptor"]
print(f"Indian dishes — avg cal: {fd['_avg']['calories']:.0f}, "
f"min: {fd['_min']['calories']}, max: {fd['_max']['calories']}")

# Average calories of the top-5 KNN results
response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"k_neighbors": 5,
"results": {"average": "calories"},
}
}], [query_emb.tobytes()])

print(f"Avg calories of top-5 similar dishes: "
f"{response[0]['FindDescriptor']['_avg']['calories']:.0f}")

Pattern 7: Group By (Facets)​

group collects matches by property value. The response contains groups, with fields such as _group_count and _group_avg, rather than individual entities. These examples return counts and average calories per cuisine.

# Count dishes per cuisine across the whole set
response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"results": {"group": ["cuisine"]},
}
}])

print("Dishes per cuisine:")
for g in response[0]["FindDescriptor"].get("groups", []):
print(f" {g['cuisine']:<15} count={g['_group_count']}")

print()

# Average calories per cuisine
response, _ = client.query([{
"FindDescriptor": {
"set": SET_NAME,
"results": {"group": ["cuisine"], "average": "calories"},
}
}])

print("Average calories per cuisine:")
for g in response[0]["FindDescriptor"].get("groups", []):
avg = g.get("_group_avg", {}).get("calories", "n/a")
print(f" {g['cuisine']:<15} avg_cal={avg:.0f}")

Cleanup​

Cleanup removes the descriptor set created for this run.

client.query([{"DeleteDescriptorSet": {"with_name": SET_NAME}}])
client.print_last_response()

What's Next​