Skip to content

Multimodal Search

pgVectorDB's multimodal system lets you attach multiple independent embeddings to a single document — one for text, one for price, one for category, one for recency, and so on. During search, scores from all spaces are weighted and fused into a single ranked list.

This eliminates the need for post-retrieval re-ranking on structured data and enables semantic search over mixed data types (text + numbers + categories) natively in PostgreSQL.


Concept: What is a Space?

A Space defines one embedding channel on a document. Each space has:

  • A name (used as a column name: embedding_{name})
  • A field that maps to a key in document.metadata or document.page_content
  • A weight that controls its relative influence during search
  • An encoder that converts the field value into a dense vector
from pgvectordb.spaces import TextSpace, NumberSpace, CategorySpace, RecencySpace

All Available Spaces

Space Input Type Best For
TextSpace str (text content) Semantic text similarity
NumberSpace float / int Numeric fields like price, rating, views
CategorySpace str (category label) Categorical fields like city, product type
RecencySpace datetime / Unix timestamp Time-based relevance (newer = better)

Step 1: Define Your Spaces

TextSpace

Encodes text using your configured embedding model. If dimensions=0, it auto-detects from the model at register_spaces() time.

from pgvectordb.spaces import TextSpace

text_space = TextSpace(
    name="description",   # Column: embedding_description
    field="content",      # Maps to document.page_content
    dimensions=384,       # Or 0 to auto-detect
    weight=1.0
)

NumberSpace

Encodes numeric values into a vector using a configurable mode:

from pgvectordb.spaces import NumberSpace, NumberMode

price_space = NumberSpace(
    name="price",
    field="price",           # Key in document.metadata
    min_value=0,
    max_value=1_000_000,
    mode=NumberMode.NORMALIZED,   # See table below
    weight=0.3
)
NumberMode Behavior
NORMALIZED Linearly scales value to [0, 1] range
MINIMUM Encodes "smaller is better" preference
MAXIMUM Encodes "larger is better" preference

CategorySpace

Encodes categorical labels as one-hot-style dense vectors:

from pgvectordb.spaces import CategorySpace

city_space = CategorySpace(
    name="city",
    field="city",                    # Key in document.metadata
    categories=["NYC", "LA", "Chicago", "Houston"],
    weight=0.2
)

RecencySpace

Encodes timestamps so more recent documents score higher:

from pgvectordb.spaces import RecencySpace, TimeUnit

recency_space = RecencySpace(
    name="recency",
    field="published_at",    # Key in document.metadata (datetime or Unix timestamp)
    time_unit=TimeUnit.DAYS,
    weight=0.1
)
TimeUnit Granularity
SECONDS Fine-grained
MINUTES Medium
HOURS Medium-coarse
DAYS Coarse (most common)

Step 2: Register Spaces

Call register_spaces() on your initialized pgVectorDB instance:

from pgvectordb import pgVectorDB
from pgvectordb.spaces import TextSpace, NumberSpace, CategorySpace

db = pgVectorDB(
    collection_name="real_estate",
    embedding_model=embeddings,
    connection_string="postgresql+asyncpg://user:pass@localhost/db"
)
await db.initialize()

spaces = [
    TextSpace(name="description", field="content", weight=0.5),
    NumberSpace(name="price",   field="price",   min_value=0, max_value=5_000_000, weight=0.3),
    CategorySpace(name="city",  field="city",    categories=["NYC", "LA", "Chicago"], weight=0.2),
]
db.register_spaces(spaces)

Note

register_spaces() is synchronous. It validates the space list and auto-detects TextSpace dimensions from the embedding model.


Step 3: Add Documents

Use add_documents_multimodal() instead of add_documents(). It automatically extracts the right field for each space and populates all embedding columns.

from langchain_core.documents import Document

docs = [
    Document(
        page_content="Spacious 2BR with skyline views, modern kitchen",
        metadata={"price": 850_000, "city": "NYC", "bedrooms": 2}
    ),
    Document(
        page_content="Cozy studio in the heart of downtown",
        metadata={"price": 320_000, "city": "Chicago", "bedrooms": 0}
    ),
    Document(
        page_content="Luxury penthouse, private terrace, doorman building",
        metadata={"price": 3_200_000, "city": "NYC", "bedrooms": 4}
    ),
]

doc_ids = await db.add_documents_multimodal(docs, batch_size=100, show_progress=True)
print(f"Added {len(doc_ids)} documents")

Under the hood, this creates an embedding_{name} column for each space (if it doesn't exist) and populates all columns in a single INSERT ... ON CONFLICT DO UPDATE.


Step 4: Build Multimodal Indexes

Create a vector index on each space's embedding column:

from pgvectordb import DistanceMetric

index_map = await db.build_multimodal_index(
    metric=DistanceMetric.COSINE,
    m=16,
    ef_construction=64
)

for space_name, index_name in index_map.items():
    print(f"  {space_name}: {index_name}")
# description: idx_real_estate_description
# price:       idx_real_estate_price
# city:        idx_real_estate_city

Step 5: Search Across All Spaces

The core method. Query each space independently and fuse results by weighted distance:

results = await db.multimodal_search(
    query_params={
        "description": "modern downtown apartment with views",  # TextSpace
        "price": 500_000,                                        # NumberSpace
        "city": "NYC",                                           # CategorySpace
    },
    weights={
        "description": 0.5,
        "price":       0.3,
        "city":        0.2,
    },
    k=10,
    filter={"bedrooms": {"$gte": 1}},   # Optional pre-filter
)

for r in results:
    print(f"Score: {r['score']:.4f} | {r['content'][:60]}")
    print(f"  Price: ${r['metadata'].get('price'):,} | City: {r['metadata'].get('city')}")

Weight Strategy

Weights are automatically normalized to sum to 1.0. Start with equal weights, then adjust based on evaluation results. Use RAGEvaluator to measure the impact of weight changes.

Fuses multimodal vector search with BM25/FTS keyword search for even richer relevance:

results = await db.multimodal_hybrid_search(
    query_params={
        "description": "cozy apartment near park",
        "price": 350_000,
    },
    weights={"description": 0.7, "price": 0.3},
    keyword_weight=0.25,   # 25% keyword, 75% multimodal vector
    k=10,
)

Full End-to-End Example

import asyncio
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.documents import Document
from pgvectordb import pgVectorDB, DistanceMetric
from pgvectordb.spaces import TextSpace, NumberSpace, CategorySpace

async def main():
    embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")

    db = pgVectorDB(
        collection_name="listings",
        embedding_model=embeddings,
        connection_string="postgresql+asyncpg://user:pass@localhost/db"
    )
    await db.initialize()

    # 1. Register spaces
    db.register_spaces([
        TextSpace(name="description", field="content",  weight=0.5),
        NumberSpace(name="price",     field="price",
                    min_value=0, max_value=5_000_000,   weight=0.3),
        CategorySpace(name="city",    field="city",
                      categories=["NYC", "LA", "Chicago"], weight=0.2),
    ])

    # 2. Add documents
    docs = [
        Document(page_content="Modern 2BR, open floor plan",
                 metadata={"price": 750_000, "city": "NYC"}),
        Document(page_content="Cozy studio, great light",
                 metadata={"price": 280_000, "city": "Chicago"}),
        Document(page_content="Luxury penthouse with terrace",
                 metadata={"price": 4_500_000, "city": "NYC"}),
    ]
    await db.add_documents_multimodal(docs)

    # 3. Build indexes
    await db.build_multimodal_index(metric=DistanceMetric.COSINE)

    # 4. Search
    results = await db.multimodal_search(
        query_params={"description": "bright open apartment", "price": 600_000, "city": "NYC"},
        weights={"description": 0.5, "price": 0.3, "city": 0.2},
        k=5
    )

    for r in results:
        print(f"{r['score']:.4f} | {r['content']}")

asyncio.run(main())

Monitoring Multimodal Indexes

stats = await db.get_multimodal_index_stats()

for space_name, info in stats.items():
    print(f"\n{space_name}:")
    print(f"  Column:     {info['column']}")
    print(f"  Dimensions: {info['dimensions']}")
    print(f"  Index:      {info['index_name']} ({'exists' if info['index_exists'] else 'missing'})")
    print(f"  Size:       {info['index_size']}")