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
- Hybrid Search page: constraint operators reference
- Bulk Embeddings: ingest large numbers of embeddings with ParallelLoader
- Building RAG Pipelines: use hybrid search as the retrieval step in a RAG chain
- FindDescriptor reference: full list of parameters
- Results reference: count, average, sum, min, max, group