Skip to content

Working with Vector Databases

Export trained recommendation embeddings to production vector databases for real-time retrieval, similarity search, and hybrid fusion.

rusket provides two functions that auto-detect the database backend from the client object you pass:

  • export_vectors() — export a single embedding matrix
  • export_multi_vectors() — export multiple named embedding matrices (for Qdrant, Meilisearch, Weaviate)

Quick Start

import rusket
from qdrant_client import QdrantClient

model = rusket.ALS(factors=64, iterations=15).fit(interactions)

# Auto-detect backend from client object
client = QdrantClient("localhost", port=6333)
rusket.export_vectors(model.item_factors, client=client, collection_name="items")

Supported Backends

export_vectors() auto-detects backends from these client libraries:

Backend Multi-vector? Client library Install
Qdrant qdrant-client pip install qdrant-client
Meilisearch meilisearch pip install meilisearch
Weaviate (v4) weaviate-client pip install weaviate-client
PostgreSQL (pgvector) psycopg2 pip install psycopg2-binary
ChromaDB chromadb pip install chromadb
Pinecone pinecone-client pip install pinecone-client
Milvus pymilvus pip install pymilvus
Elasticsearch elasticsearch pip install elasticsearch
MongoDB Atlas pymongo pip install pymongo
LanceDB lancedb pip install lancedb
Typesense typesense pip install typesense

All backends are optional — install only the ones you need.


Real-World Examples

1. E-Commerce Product Recommendations → Qdrant

Serve personalised "For You" recommendations from an ALS model via Qdrant's high-performance vector search.

import rusket
import pandas as pd
from qdrant_client import QdrantClient

# ── Train the recommender ──────────────────────────────────────────
purchases = pd.DataFrame({
    "user_id":  [1, 1, 2, 2, 3, 3, 3, 4, 4],
    "item_id":  [101, 102, 101, 103, 102, 103, 104, 101, 104],
})

als = rusket.ALS(factors=64, iterations=15).from_transactions(
    purchases, user_col="user_id", item_col="item_id"
).fit()

# ── Export item embeddings to Qdrant ───────────────────────────────
client = QdrantClient("localhost", port=6333)
rusket.export_vectors(als.item_factors, client=client, collection_name="product_embeddings")

# ── Query at serving time with native SDK ──────────────────────────
user_vector = als.user_factors[0].tolist()
results = client.query_points(
    collection_name="product_embeddings",
    query=user_vector,
    limit=5,
)
print(results)

2. Hybrid Fusion → Meilisearch (Multi-Vector)

Let Meilisearch handle the fusion of collaborative filtering and semantic embeddings at query time. Each document stores separate "cf" and "semantic" embedders.

import rusket
import meilisearch
from sentence_transformers import SentenceTransformer

# ── Train ALS + get semantic embeddings ────────────────────────────
als = rusket.ALS(factors=64, iterations=15).fit(interactions)

encoder = SentenceTransformer("all-MiniLM-L6-v2")
text_vectors = encoder.encode(product_descriptions)  # (n_items, 384)

# ── Fuse into a HybridEmbeddingIndex ──────────────────────────────
hybrid = rusket.HybridEmbeddingIndex(
    cf_embeddings=als.item_factors,
    semantic_embeddings=text_vectors,
)

# ── Export as separate named vectors to Meilisearch ───────────────
client = meilisearch.Client("http://localhost:7700", "masterKey")

# Multi-vector: each document gets _vectors.cf and _vectors.semantic
hybrid.export_vectors(client, mode="multi", collection_name="products")

Meilisearch handles the fusion

With mode="multi", Meilisearch stores CF and semantic vectors separately. At query time, you can weight them differently or use Meilisearch's built-in hybrid search to combine both signals automatically.

3. Multi-Vector Hybrid Search → Qdrant Named Vectors

Qdrant's named vectors let you store multiple embedding spaces per point and query them independently or together.

import rusket
from qdrant_client import QdrantClient

# ── Build hybrid embeddings ────────────────────────────────────────
als = rusket.ALS(factors=64).fit(interactions)
hybrid = rusket.HybridEmbeddingIndex(
    cf_embeddings=als.item_factors,
    semantic_embeddings=text_vectors,
    strategy="weighted_concat",
    alpha=0.6,
)

# ── Export as named vectors ────────────────────────────────────────
client = QdrantClient("localhost")

# Each point gets two vectors: "cf" (64-d) and "semantic" (384-d)
hybrid.export_vectors(client, mode="multi", collection_name="hybrid_products")

# ── Query with different strategies using native Qdrant SDK ───────
from qdrant_client.models import NamedVector

# Query CF space only (behavioural similarity)
cf_results = client.query_points(
    collection_name="hybrid_products",
    query=NamedVector(name="cf", vector=user_cf_vector),
    limit=10,
)

# Query semantic space only (content similarity)
sem_results = client.query_points(
    collection_name="hybrid_products",
    query=NamedVector(name="semantic", vector=query_text_vector),
    limit=10,
)

4. Production Pipeline → PostgreSQL (pgvector)

For teams already running PostgreSQL, pgvector lets you add vector search without a separate service.

import rusket
import psycopg2

# ── Connect to your existing PostgreSQL database ──────────────────
conn = psycopg2.connect(
    host="localhost", dbname="myapp", user="api_user", password="secret"
)

# ── Train and export ──────────────────────────────────────────────
model = rusket.ALS(factors=32).fit(interactions)
rusket.export_vectors(model.item_factors, client=conn, collection_name="item_embeddings")

# ── Query with SQL! ───────────────────────────────────────────────
cursor = conn.cursor()
user_vec = model.user_factors[42].tolist()
cursor.execute("""
    SELECT id, payload
    FROM item_embeddings
    ORDER BY embedding <=> %s::vector
    LIMIT 10
""", (user_vec,))
results = cursor.fetchall()

5. ChromaDB (Local Dev / Prototyping)

ChromaDB is perfect for local development and prototyping — zero infrastructure needed.

import rusket
import chromadb

# ── In-memory for development ─────────────────────────────────────
client = chromadb.Client()
model = rusket.BPR(factors=64).fit(interactions)
rusket.export_vectors(model.item_factors, client=client, collection_name="recommendations")

# ── Query with native ChromaDB SDK ────────────────────────────────
collection = client.get_collection("recommendations")
results = collection.query(
    query_embeddings=[model.user_factors[0].tolist()],
    n_results=10,
)

6. Weaviate Named Vectors (v4)

Weaviate v4's named vector support enables multi-modal search across different embedding spaces.

import rusket
import weaviate

# ── Connect to Weaviate ───────────────────────────────────────────
client = weaviate.connect_to_local()  # or weaviate.connect_to_wcs(...)

# ── Multi-vector export ───────────────────────────────────────────
hybrid = rusket.HybridEmbeddingIndex(
    cf_embeddings=als.item_factors,
    semantic_embeddings=text_vectors,
)
hybrid.export_vectors(client, mode="multi", collection_name="Products")

7. Batch Export with Metadata (Qdrant)

export_vectors() supports payloads for attaching rich metadata to each vector:

import rusket
import pandas as pd
from qdrant_client import QdrantClient

catalog = pd.DataFrame({
    "item_id": [1, 2, 3, 4, 5],
    "name":    ["Laptop", "Mouse", "Keyboard", "Monitor", "Webcam"],
    "price":   [999.99, 29.99, 79.99, 449.99, 89.99],
    "category": ["Electronics", "Accessories", "Accessories", "Electronics", "Accessories"],
    "in_stock": [True, True, False, True, True],
})

model = rusket.ALS(factors=64).fit(interactions)
client = QdrantClient("localhost")

rusket.export_vectors(
    model.item_factors,
    client=client,
    collection_name="products",
    ids=catalog["item_id"].tolist(),
    payloads=catalog.drop(columns="item_id").to_dict("records"),
)

# ── Filter at query time using native Qdrant SDK ─────────────────
from qdrant_client.models import Filter, FieldCondition, MatchValue

results = client.query_points(
    collection_name="products",
    query=user_vector,
    query_filter=Filter(
        must=[
            FieldCondition(key="in_stock", match=MatchValue(value=True)),
            FieldCondition(key="category", match=MatchValue(value="Electronics")),
        ]
    ),
    limit=5,
)

8. End-to-End: Train → Fuse → Export → Serve

A complete production pipeline from training to serving:

import rusket
from qdrant_client import QdrantClient
from sentence_transformers import SentenceTransformer

# ═══════════════════════════════════════════════════════════════════
# STEP 1: Train the recommender
# ═══════════════════════════════════════════════════════════════════
als = rusket.ALS(factors=64, iterations=15, alpha=40.0).from_transactions(
    purchase_log, user_col="user_id", item_col="item_id", rating_col="quantity"
).fit()

# ═══════════════════════════════════════════════════════════════════
# STEP 2: Generate semantic embeddings
# ═══════════════════════════════════════════════════════════════════
encoder = SentenceTransformer("all-MiniLM-L6-v2")
text_vectors = encoder.encode(
    [f"{row['name']} {row['description']}" for _, row in products.iterrows()]
)

# ═══════════════════════════════════════════════════════════════════
# STEP 3: Fuse into hybrid space
# ═══════════════════════════════════════════════════════════════════
hybrid = rusket.HybridEmbeddingIndex(
    cf_embeddings=als.item_factors,
    semantic_embeddings=text_vectors,
    strategy="weighted_concat",
    alpha=0.6,  # 60% collaborative, 40% semantic
)

# ═══════════════════════════════════════════════════════════════════
# STEP 4: Export to vector DB
# ═══════════════════════════════════════════════════════════════════
client = QdrantClient("qdrant.mycompany.com", port=6333, api_key="...")

# Option A: Single fused vector (simpler, works everywhere)
hybrid.export_vectors(client, collection_name="product_recs")

# Option B: Separate named vectors (DB handles fusion at query time)
hybrid.export_vectors(client, mode="multi", collection_name="product_recs_multi")

# ═══════════════════════════════════════════════════════════════════
# STEP 5: Serve recommendations via your API
# ═══════════════════════════════════════════════════════════════════
def get_recommendations(user_id: int, n: int = 10) -> list[dict]:
    """FastAPI endpoint handler."""
    user_vector = als.user_factors[user_id].tolist()
    results = client.query_points(
        collection_name="product_recs",
        query=user_vector,
        limit=n,
    )
    return [{"id": r.id, "score": r.score, **r.payload} for r in results.points]

Serving Recommendations from Vector Databases

Once you've exported embeddings, you need to retrieve recommendations at serving time. This section shows production-ready patterns using the native SDKs of Qdrant, Meilisearch, and PostgreSQL (pgvector).

Qdrant — Full Recommendation Serving

Personalised "For You" Recommendations

from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue, Range
import rusket

# ── Setup (one-time) ──────────────────────────────────────────────
als = rusket.ALS(factors=64, iterations=15).from_transactions(
    purchases, user_col="user_id", item_col="item_id"
).fit()

client = QdrantClient("localhost", port=6333)
rusket.export_vectors(als.item_factors, client=client, collection_name="items")

# ── Recommend for a user ──────────────────────────────────────────
def recommend_for_user(
    user_id: int, n: int = 10, category: str | None = None
) -> list[dict]:
    """Get top-N recommendations for a user, optionally filtered by category."""
    user_vector = als.user_factors[user_id].tolist()

    # Build optional filters
    filters = []
    if category:
        filters.append(
            FieldCondition(key="category", match=MatchValue(value=category))
        )

    results = client.query_points(
        collection_name="items",
        query=user_vector,
        query_filter=Filter(must=filters) if filters else None,
        limit=n,
        with_payload=True,
    )
    return [
        {"id": r.id, "score": r.score, **r.payload}
        for r in results.points
    ]

# Usage
recs = recommend_for_user(user_id=42, n=10, category="Electronics")

"Similar Items" (Item-to-Item)

def similar_items(item_id: int, n: int = 10) -> list[dict]:
    """Find items similar to a given item using its embedding."""
    item_vector = als.item_factors[item_id].tolist()

    results = client.query_points(
        collection_name="items",
        query=item_vector,
        limit=n + 1,  # +1 to exclude the item itself
        with_payload=True,
    )
    # Filter out the query item
    return [
        {"id": r.id, "score": r.score, **r.payload}
        for r in results.points if r.id != item_id
    ][:n]

similar = similar_items(item_id=101, n=5)
# → [{"id": 104, "score": 0.95, "name": "Monitor", ...}, ...]

Hybrid Named Vector Search (CF + Semantic)

from qdrant_client.models import Prefetch, FusionQuery, Fusion

def hybrid_recommend(
    user_id: int, query_text: str, n: int = 10
) -> list[dict]:
    """Combine CF and semantic signals at query time using Qdrant's fusion."""
    user_cf_vector = als.user_factors[user_id].tolist()
    text_vector = encoder.encode(query_text).tolist()

    # Reciprocal Rank Fusion across both vector spaces
    results = client.query_points(
        collection_name="hybrid_items",
        prefetch=[
            Prefetch(query=user_cf_vector, using="cf", limit=50),
            Prefetch(query=text_vector, using="semantic", limit=50),
        ],
        query=FusionQuery(fusion=Fusion.RRF),  # Reciprocal Rank Fusion
        limit=n,
        with_payload=True,
    )
    return [{"id": r.id, "score": r.score, **r.payload} for r in results.points]

# "Show me laptops this user would like"
recs = hybrid_recommend(user_id=42, query_text="lightweight laptop for travel")

FastAPI Integration

from fastapi import FastAPI, Query
from qdrant_client import QdrantClient
import rusket

app = FastAPI()
client = QdrantClient("localhost")
model = rusket.load_model("trained_als.pkl")

@app.get("/recommendations/{user_id}")
async def get_recommendations(
    user_id: int,
    n: int = Query(default=10, le=100),
    category: str | None = None,
):
    user_vector = model.user_factors[user_id].tolist()

    filters = []
    if category:
        from qdrant_client.models import Filter, FieldCondition, MatchValue
        filters.append(FieldCondition(key="category", match=MatchValue(value=category)))

    results = client.query_points(
        collection_name="product_embeddings",
        query=user_vector,
        query_filter=Filter(must=filters) if filters else None,
        limit=n,
        with_payload=True,
    )
    return {
        "user_id": user_id,
        "recommendations": [
            {"item_id": r.id, "score": round(r.score, 4), **r.payload}
            for r in results.points
        ],
    }

@app.get("/similar/{item_id}")
async def get_similar(item_id: int, n: int = Query(default=5, le=50)):
    item_vector = model.item_factors[item_id].tolist()
    results = client.query_points(
        collection_name="product_embeddings",
        query=item_vector,
        limit=n + 1,
        with_payload=True,
    )
    return {
        "item_id": item_id,
        "similar": [
            {"item_id": r.id, "score": round(r.score, 4), **r.payload}
            for r in results.points if r.id != item_id
        ][:n],
    }

Meilisearch — Hybrid Search Recommendations

Meilisearch excels at combining keyword search with vector similarity in a single query — perfect for e-commerce where users type product queries and you want to boost results with collaborative filtering signals.

Setup: Export with Product Metadata

import rusket
import meilisearch
from rusket import MeilisearchVectorStore

als = rusket.ALS(factors=64).fit(interactions)
client = meilisearch.Client("http://localhost:7700", "masterKey")

# Configure searchable and filterable attributes
index = client.index("products")
index.update_settings({
    "searchableAttributes": ["name", "description", "category"],
    "filterableAttributes": ["category", "price", "in_stock"],
    "sortableAttributes": ["price", "popularity"],
})

# Export with rich metadata
store = MeilisearchVectorStore(client)
store.upload(
    als.item_factors,
    collection_name="products",
    ids=product_ids,
    payloads=[
        {
            "name": row["name"],
            "description": row["description"],
            "category": row["category"],
            "price": row["price"],
            "in_stock": row["in_stock"],
            "popularity": row["sales_count"],
        }
        for _, row in catalog.iterrows()
    ],
)

Personalised Recommendations (Pure Vector)

def recommend_for_user(user_id: int, n: int = 10, category: str | None = None):
    """Pure vector search — find items closest to user's taste."""
    user_vector = als.user_factors[user_id].tolist()

    index = client.index("products")
    results = index.search(
        "",  # empty query = pure vector search
        opt_params={
            "vector": user_vector,
            "limit": n,
            "filter": f'category = "{category}"' if category else None,
            "attributesToRetrieve": ["name", "price", "category"],
        },
    )
    return results["hits"]

recs = recommend_for_user(42, category="Electronics")
# → [{"name": "Laptop Pro", "price": 1299, "_rankingScore": 0.95}, ...]

Hybrid Search: Keyword + CF Embeddings

def hybrid_search(
    user_id: int, query: str, n: int = 10, category: str | None = None
):
    """Combine text search with CF vector for relevance + personalisation."""
    user_vector = als.user_factors[user_id].tolist()

    index = client.index("products")
    results = index.search(
        query,  # keyword query ("wireless mouse", "laptop bag", etc.)
        opt_params={
            "vector": user_vector,  # personalisation signal
            "hybrid": {
                "semanticRatio": 0.5,  # 50% keyword, 50% vector
            },
            "limit": n,
            "filter": f'in_stock = true AND category = "{category}"' if category else "in_stock = true",
            "attributesToRetrieve": ["name", "price", "category", "description"],
        },
    )
    return results["hits"]

# User 42 searches "wireless keyboard" → results personalised to their taste
hits = hybrid_search(42, "wireless keyboard", category="Accessories")

Multi-Embedder Search (CF + Semantic)

from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer("all-MiniLM-L6-v2")

# ── Export multi-vector (done once) ───────────────────────────────
hybrid = rusket.HybridEmbeddingIndex(
    cf_embeddings=als.item_factors,
    semantic_embeddings=text_vectors,
)
hybrid.export_vectors(client, mode="multi", collection_name="products_multi")

# ── Query with specific embedder ─────────────────────────────────
def search_by_description(query_text: str, n: int = 10):
    """Search using semantic similarity to product descriptions."""
    query_vector = encoder.encode(query_text).tolist()

    index = client.index("products_multi")
    results = index.search(
        "",
        opt_params={
            "vector": query_vector,
            "hybridEmbedder": "semantic",  # use the semantic embedder
            "limit": n,
        },
    )
    return results["hits"]

def recommend_collaborative(user_id: int, n: int = 10):
    """Recommend using pure CF signal."""
    user_vector = als.user_factors[user_id].tolist()

    index = client.index("products_multi")
    results = index.search(
        "",
        opt_params={
            "vector": user_vector,
            "hybridEmbedder": "cf",  # use the CF embedder
            "limit": n,
        },
    )
    return results["hits"]

Faceted Recommendations with Meilisearch

def recommend_with_facets(user_id: int, n: int = 20):
    """Get recommendations grouped by category for a discovery page."""
    user_vector = als.user_factors[user_id].tolist()

    index = client.index("products")
    results = index.search(
        "",
        opt_params={
            "vector": user_vector,
            "limit": n,
            "facets": ["category"],
            "filter": "in_stock = true",
            "attributesToRetrieve": ["name", "price", "category"],
        },
    )

    # Group by category
    from collections import defaultdict
    by_category = defaultdict(list)
    for hit in results["hits"]:
        by_category[hit["category"]].append(hit)

    return {
        "facets": results.get("facetDistribution", {}),
        "by_category": dict(by_category),
    }

# Returns: {"facets": {"category": {"Electronics": 8, "Accessories": 12}},
#           "by_category": {"Electronics": [...], "Accessories": [...]}}

PostgreSQL (pgvector) — SQL-Native Recommendations

pgvector lets you serve recommendations using plain SQL — no extra infrastructure, no client libraries, just your existing PostgreSQL database.

Setup: Create the Embeddings Table

import rusket
import psycopg2

als = rusket.ALS(factors=64).fit(interactions)

conn = psycopg2.connect(
    host="localhost", dbname="myapp", user="api_user", password="secret"
)

# rusket's export handles this automatically, but here's the manual SQL:
cursor = conn.cursor()
cursor.execute("CREATE EXTENSION IF NOT EXISTS vector")
cursor.execute("""
    CREATE TABLE IF NOT EXISTS item_embeddings (
        id          INTEGER PRIMARY KEY,
        embedding   vector(64),
        name        TEXT,
        category    TEXT,
        price       NUMERIC(10,2),
        in_stock    BOOLEAN DEFAULT TRUE
    )
""")

# Create an IVFFlat index for fast approximate search
cursor.execute("""
    CREATE INDEX IF NOT EXISTS idx_items_embedding
    ON item_embeddings
    USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100)
""")
conn.commit()

# Export embeddings
from rusket import PgVectorStore
store = PgVectorStore(conn)
store.upload(als.item_factors, collection_name="item_embeddings", ids=item_ids)

Personalised Recommendations (SQL)

def recommend_for_user(
    conn, user_id: int, n: int = 10, category: str | None = None
) -> list[dict]:
    """Get top-N recommendations using cosine distance."""
    user_vec = als.user_factors[user_id].tolist()
    cursor = conn.cursor()

    if category:
        cursor.execute("""
            SELECT id, name, category, price,
                   1 - (embedding <=> %s::vector) AS score
            FROM item_embeddings
            WHERE in_stock = TRUE AND category = %s
            ORDER BY embedding <=> %s::vector
            LIMIT %s
        """, (user_vec, category, user_vec, n))
    else:
        cursor.execute("""
            SELECT id, name, category, price,
                   1 - (embedding <=> %s::vector) AS score
            FROM item_embeddings
            WHERE in_stock = TRUE
            ORDER BY embedding <=> %s::vector
            LIMIT %s
        """, (user_vec, user_vec, n))

    columns = ["id", "name", "category", "price", "score"]
    return [dict(zip(columns, row)) for row in cursor.fetchall()]

recs = recommend_for_user(conn, user_id=42, category="Electronics")

Similar Items (SQL)

def similar_items(conn, item_id: int, n: int = 10) -> list[dict]:
    """Find similar items using item embedding similarity."""
    cursor = conn.cursor()
    cursor.execute("""
        WITH target AS (
            SELECT embedding FROM item_embeddings WHERE id = %s
        )
        SELECT ie.id, ie.name, ie.category, ie.price,
               1 - (ie.embedding <=> t.embedding) AS similarity
        FROM item_embeddings ie, target t
        WHERE ie.id != %s AND ie.in_stock = TRUE
        ORDER BY ie.embedding <=> t.embedding
        LIMIT %s
    """, (item_id, item_id, n))

    columns = ["id", "name", "category", "price", "similarity"]
    return [dict(zip(columns, row)) for row in cursor.fetchall()]

similar = similar_items(conn, item_id=101, n=5)
# → [{"id": 104, "name": "Monitor", "similarity": 0.93, ...}]

"Customers Who Bought X Also Bought Y" (SQL)

def also_bought(conn, cart_item_ids: list[int], n: int = 5) -> list[dict]:
    """Average the embeddings of items in cart, find nearest neighbours."""
    cursor = conn.cursor()

    # Average the embeddings of cart items to create a "session vector"
    cursor.execute("""
        WITH cart_avg AS (
            SELECT AVG(embedding) AS centroid
            FROM item_embeddings
            WHERE id = ANY(%s)
        )
        SELECT ie.id, ie.name, ie.category, ie.price,
               1 - (ie.embedding <=> ca.centroid) AS relevance
        FROM item_embeddings ie, cart_avg ca
        WHERE ie.id != ALL(%s) AND ie.in_stock = TRUE
        ORDER BY ie.embedding <=> ca.centroid
        LIMIT %s
    """, (cart_item_ids, cart_item_ids, n))

    columns = ["id", "name", "category", "price", "relevance"]
    return [dict(zip(columns, row)) for row in cursor.fetchall()]

# User has Laptop + Mouse in cart
cross_sells = also_bought(conn, cart_item_ids=[101, 102], n=3)
# → [{"id": 103, "name": "Keyboard", "relevance": 0.87}, ...]

Category-Scoped Diversity (SQL)

def diverse_recommendations(
    conn, user_id: int, per_category: int = 3, max_categories: int = 5
) -> list[dict]:
    """Get top-N per category for a discovery feed — avoids category bubbles."""
    user_vec = als.user_factors[user_id].tolist()
    cursor = conn.cursor()

    cursor.execute("""
        WITH ranked AS (
            SELECT id, name, category, price,
                   1 - (embedding <=> %s::vector) AS score,
                   ROW_NUMBER() OVER (
                       PARTITION BY category
                       ORDER BY embedding <=> %s::vector
                   ) AS rank_in_cat
            FROM item_embeddings
            WHERE in_stock = TRUE
        )
        SELECT id, name, category, price, score
        FROM ranked
        WHERE rank_in_cat <= %s
        ORDER BY score DESC
        LIMIT %s
    """, (user_vec, user_vec, per_category, per_category * max_categories))

    columns = ["id", "name", "category", "price", "score"]
    return [dict(zip(columns, row)) for row in cursor.fetchall()]

# Returns top-3 items from each of the 5 best-matching categories
diverse = diverse_recommendations(conn, user_id=42)

FastAPI + pgvector

from fastapi import FastAPI, Depends, Query
import psycopg2
from psycopg2.pool import ThreadedConnectionPool

app = FastAPI()
pool = ThreadedConnectionPool(
    minconn=2, maxconn=10,
    host="localhost", dbname="myapp", user="api_user", password="secret"
)
model = rusket.load_model("trained_als.pkl")

def get_db():
    conn = pool.getconn()
    try:
        yield conn
    finally:
        pool.putconn(conn)

@app.get("/api/recommendations/{user_id}")
async def recommendations(
    user_id: int,
    n: int = Query(default=10, le=100),
    category: str | None = None,
    conn=Depends(get_db),
):
    user_vec = model.user_factors[user_id].tolist()
    cursor = conn.cursor()

    sql = """
        SELECT id, name, category, price,
               1 - (embedding <=> %s::vector) AS score
        FROM item_embeddings
        WHERE in_stock = TRUE
    """
    params = [user_vec]

    if category:
        sql += " AND category = %s"
        params.append(category)

    sql += " ORDER BY embedding <=> %s::vector LIMIT %s"
    params.extend([user_vec, n])

    cursor.execute(sql, params)
    columns = ["id", "name", "category", "price", "score"]

    return {
        "user_id": user_id,
        "recommendations": [dict(zip(columns, row)) for row in cursor.fetchall()],
    }

@app.get("/api/similar/{item_id}")
async def similar(
    item_id: int,
    n: int = Query(default=5, le=50),
    conn=Depends(get_db),
):
    cursor = conn.cursor()
    cursor.execute("""
        WITH target AS (SELECT embedding FROM item_embeddings WHERE id = %s)
        SELECT ie.id, ie.name, ie.category, ie.price,
               1 - (ie.embedding <=> t.embedding) AS similarity
        FROM item_embeddings ie, target t
        WHERE ie.id != %s AND ie.in_stock = TRUE
        ORDER BY ie.embedding <=> t.embedding
        LIMIT %s
    """, (item_id, item_id, n))

    columns = ["id", "name", "category", "price", "similarity"]
    return {
        "item_id": item_id,
        "similar": [dict(zip(columns, row)) for row in cursor.fetchall()],
    }

pgvector distance operators

Operator Distance Best for
<=> Cosine distance L2-normalised embeddings (default)
<-> L2 (Euclidean) distance Raw embeddings
<#> Inner product (negative) When magnitude matters

Use <=> (cosine) for rusket embeddings — HybridEmbeddingIndex returns L2-normalised vectors automatically.

pgvector index types

Index Speed Recall Build time Best for
IVFFlat Fast ~95% Minutes < 1M vectors
HNSW Fastest ~99% Slower Any scale, production
-- IVFFlat (faster to build, good for smaller datasets)
CREATE INDEX ON item_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

-- HNSW (better recall, recommended for production)
CREATE INDEX ON item_embeddings USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 200);

Multi-Vector Export

Databases like Qdrant, Meilisearch, and Weaviate support multiple vectors per document. This lets the database handle fusion at query time instead of pre-computing a single fused vector.

When to use multi-vector

Approach Pros Cons
Single fused vector Simple, works with all backends Must re-export when changing fusion weights
Multi-vector (DB-side) Change weights at query time, A/B test fusion strategies Requires Qdrant/Meilisearch/Weaviate

API

Via HybridEmbeddingIndex:

# Fused (default) — single vector per item
hybrid.export_vectors(client, collection_name="items")

# Multi — separate named CF + semantic vectors
hybrid.export_vectors(client, mode="multi", collection_name="items")

Via export_multi_vectors() function:

rusket.export_multi_vectors(
    {"cf": als.item_factors, "semantic": text_vectors},
    client=qdrant_client,
    collection_name="items",
)

Tips & Best Practices

Normalise for cosine similarity

Most vector DBs default to cosine distance. Use distance="Cosine" in Qdrant or ensure your vectors are L2-normalised. HybridEmbeddingIndex.named_embeddings returns normalised vectors automatically.

Batch size tuning

The default batch_size=1000 is good for most cases. For very large exports (millions of vectors), increase to 5000–10000 to reduce HTTP round-trips.

Multi-vector compatibility

Only Qdrant, Meilisearch, and Weaviate (v4) support multi-vector storage. Other backends will raise NotImplementedError if you call export_multi_vectors().