Skip to content

Commit 601f297

Browse files
committed
feat: native vector search support (paradedb/paradedb#5685)
1 parent 94ce5fc commit 601f297

11 files changed

Lines changed: 765 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format
44

55
## Unreleased
66

7+
### Added
8+
9+
- Native vector search support: a `Vector(n)` column type, `VectorField` for declaring pgvector columns with a distance metric (`l2`, `cosine`, `ip`) inside BM25 indexes, and `vector.l2_distance` / `vector.cosine_distance` / `vector.inner_product` query expressions for Top-K ordering. Alembic autogenerate round-trips BM25 indexes containing vector opclasses without churn.
10+
711
## [0.8.0] - 2026-07-14
812

913
### Changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,48 @@ The official [SQLAlchemy](https://www.sqlalchemy.org/) integration for [ParadeDB
4747
| ParadeDB | 0.22.0+ |
4848
| PostgreSQL | 15+ (with ParadeDB extension) |
4949

50+
## Vector Search
51+
52+
ParadeDB indexes pgvector `vector` columns directly inside BM25 indexes, so no pgvector ORM library is needed. Declare a `vector(n)` column with the built-in `Vector` type, add it to the BM25 index with `VectorField` and a distance metric, and order by the matching distance function:
53+
54+
```python
55+
from sqlalchemy import Index, select
56+
from paradedb.sqlalchemy import search, vector
57+
from paradedb.sqlalchemy.indexing import BM25Field, VectorField
58+
from paradedb.sqlalchemy.vector import Vector
59+
60+
61+
class Product(Base):
62+
__tablename__ = "products"
63+
64+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
65+
description: Mapped[str] = mapped_column(Text, nullable=False)
66+
embedding: Mapped[list[float]] = mapped_column(Vector(3), nullable=False)
67+
68+
69+
Index(
70+
"products_bm25_idx",
71+
BM25Field(Product.id),
72+
BM25Field(Product.description),
73+
VectorField(Product.embedding, metric="l2"), # metric: "l2" (default), "cosine", or "ip"
74+
postgresql_using="bm25",
75+
postgresql_with={"key_field": "id"},
76+
)
77+
78+
# Top-K query: a @@@ predicate (e.g. search.all) and a LIMIT are required
79+
# for index pushdown. A pure vector query uses the match-all predicate.
80+
stmt = (
81+
select(Product.id)
82+
.where(search.all(Product.id))
83+
.order_by(vector.l2_distance(Product.embedding, [1.0, 0.0, 0.0]))
84+
.limit(10)
85+
)
86+
```
87+
88+
The ORDER BY distance function must match the index metric: `vector.l2_distance` (`<->`) with `metric="l2"`, `vector.cosine_distance` (`<=>`) with `metric="cosine"`, and `vector.inner_product` (`<#>`) with `metric="ip"`. A mismatched pair still returns correct results but silently loses Top-K index pushdown.
89+
90+
Vector search requires a ParadeDB build with vector-in-bm25 support and the `vector` (pgvector) extension installed.
91+
5092
## Examples
5193

5294
- [Quick Start](examples/quickstart/quickstart.py)
@@ -55,6 +97,7 @@ The official [SQLAlchemy](https://www.sqlalchemy.org/) integration for [ParadeDB
5597
- [More Like This](examples/more_like_this/more_like_this.py)
5698
- [Hybrid Search (RRF)](examples/hybrid_rrf/hybrid_rrf.py)
5799
- [RAG](examples/rag/rag.py)
100+
- [Vector Search](examples/vector_search/vector_search.py)
58101

59102
## Contributing
60103

examples/vector_search/setup.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
import os
4+
5+
from sqlalchemy import Index, Integer, Text, create_engine, text
6+
from sqlalchemy.engine import Engine
7+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
8+
9+
from paradedb.sqlalchemy import indexing
10+
from paradedb.sqlalchemy.vector import Vector
11+
12+
13+
PRODUCT_ROWS = [
14+
{"id": 1, "description": "Sleek running shoes for daily training", "embedding": [1, 0, 0]},
15+
{"id": 2, "description": "Trail running shoes with durable grip", "embedding": [0.9, 0.1, 0]},
16+
{"id": 3, "description": "Wireless noise-canceling headphones", "embedding": [0, 1, 0]},
17+
{"id": 4, "description": "Artistic ceramic vase", "embedding": [0, 0, 1]},
18+
]
19+
20+
21+
class Base(DeclarativeBase):
22+
pass
23+
24+
25+
class Product(Base):
26+
__tablename__ = "vector_products"
27+
28+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
29+
description: Mapped[str] = mapped_column(Text, nullable=False)
30+
embedding: Mapped[list[float]] = mapped_column(Vector(3), nullable=False)
31+
32+
33+
Index(
34+
"vector_products_bm25_idx",
35+
indexing.BM25Field(Product.id),
36+
indexing.BM25Field(Product.description),
37+
indexing.VectorField(Product.embedding, metric="l2"),
38+
postgresql_using="bm25",
39+
postgresql_with={"key_field": "id"},
40+
)
41+
42+
43+
def engine_from_env() -> Engine:
44+
dsn = os.getenv("DATABASE_URL", "postgresql+psycopg://postgres:postgres@localhost:5443/postgres")
45+
return create_engine(dsn)
46+
47+
48+
def setup_database(engine: Engine) -> None:
49+
with engine.begin() as conn:
50+
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
51+
Base.metadata.drop_all(engine)
52+
Base.metadata.create_all(engine)
53+
with Session(engine) as session:
54+
session.add_all(Product(**row) for row in PRODUCT_ROWS)
55+
session.commit()
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Top-K vector search over a BM25 index.
2+
3+
Requires a ParadeDB build with vector-in-bm25 support (pg_search branch
4+
``mvp/vector-search``). The ORDER BY metric must match the index opclass
5+
metric, the ``@@@`` predicate (here ``search.all``) is mandatory, and a
6+
LIMIT is required for Top-K index pushdown.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from sqlalchemy import select
12+
from sqlalchemy.orm import Session
13+
14+
from paradedb.sqlalchemy import search, vector
15+
from setup import Product, engine_from_env, setup_database
16+
17+
18+
def main() -> None:
19+
engine = engine_from_env()
20+
setup_database(engine)
21+
22+
query_embedding = [1.0, 0.0, 0.0]
23+
24+
stmt = (
25+
select(Product.id, Product.description)
26+
.where(search.all(Product.id))
27+
.order_by(vector.l2_distance(Product.embedding, query_embedding))
28+
.limit(2)
29+
)
30+
31+
with Session(engine) as session:
32+
for row in session.execute(stmt):
33+
print(dict(row._mapping))
34+
35+
36+
if __name__ == "__main__":
37+
main()

paradedb/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
paradedb_verify_index,
77
)
88
from .sqlalchemy.facets import with_rows
9-
from .sqlalchemy.indexing import BM25Field, assert_indexed, describe
9+
from .sqlalchemy.indexing import BM25Field, VectorField, assert_indexed, describe
1010
from .sqlalchemy.tokenizer import Tokenizer
11+
from .sqlalchemy.vector import Vector, cosine_distance, inner_product, l2_distance
1112
from .sqlalchemy import tokenizer
1213
from .sqlalchemy.pdb import agg, alias, score, snippet, snippet_positions, snippets
1314
from .sqlalchemy.search import (
@@ -34,12 +35,17 @@
3435
"BM25Field",
3536
"ProximityExpr",
3637
"Tokenizer",
38+
"Vector",
39+
"VectorField",
3740
"agg",
3841
"alias",
3942
"all",
4043
"assert_indexed",
44+
"cosine_distance",
4145
"describe",
4246
"exists",
47+
"inner_product",
48+
"l2_distance",
4349
"match_all",
4450
"match_any",
4551
"more_like_this",

paradedb/sqlalchemy/__init__.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1-
from . import diagnostics, expr, facets, indexing, tokenizer, inspect, pdb, search, select_with
1+
from . import diagnostics, expr, facets, indexing, tokenizer, inspect, pdb, search, select_with, vector
22

3-
__all__ = ["diagnostics", "expr", "facets", "indexing", "tokenizer", "inspect", "pdb", "search", "select_with"]
3+
__all__ = [
4+
"diagnostics",
5+
"expr",
6+
"facets",
7+
"indexing",
8+
"tokenizer",
9+
"inspect",
10+
"pdb",
11+
"search",
12+
"select_with",
13+
"vector",
14+
]

paradedb/sqlalchemy/alembic.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ def _autogen_bm25_meta_indexes(
231231
def _autogen_bm25_db_indexes(conn, effective_schemas: set[str]) -> dict[tuple[str, str], dict]:
232232
"""Return {(schema, index_name): {table_name, expressions, key_field, where}} from pg_indexes."""
233233
from .indexing import (
234+
_extract_bm25_field_list,
234235
_extract_key_field,
236+
_extract_trailing_opclass,
235237
_extract_where_clause,
236238
_introspect_bm25_index_rows,
237239
_normalize_reloption_value,
@@ -251,7 +253,16 @@ def _autogen_bm25_db_indexes(conn, effective_schemas: set[str]) -> dict[tuple[st
251253
"where": _extract_where_clause(str(row["indexdef"])),
252254
},
253255
)
254-
index_entry["expressions"].append(str(row["keydef"]))
256+
# pg_get_indexdef(oid, colno, true) omits opclasses, so recover any
257+
# vector opclass from the full index definition's field list.
258+
expression = str(row["keydef"])
259+
field_list = _extract_bm25_field_list(str(row["indexdef"]))
260+
position = len(index_entry["expressions"])
261+
if position < len(field_list) and _extract_trailing_opclass(expression) is None:
262+
opclass = _extract_trailing_opclass(field_list[position])
263+
if opclass is not None:
264+
expression = f"{expression} {opclass}"
265+
index_entry["expressions"].append(expression)
255266
if not index_entry["key_field"]:
256267
index_entry["key_field"] = _extract_key_field(str(row["indexdef"])) or ""
257268
return result
@@ -285,9 +296,14 @@ def _strip_match(match: re.Match[str]) -> str:
285296
return qualifier_re.sub(_strip_match, expr)
286297

287298

299+
_DEFAULT_VECTOR_OPCLASS_RE = re.compile(r"\s+vector_l2_ops\s*$")
300+
301+
288302
def _normalize_bm25_expression(expr: str) -> str:
289303
"""Normalize BM25 expression text to reduce false-positive autogen churn."""
290-
normalized = "".join(expr.split())
304+
# vector_l2_ops is the default opclass, so Postgres omits it from indexdef.
305+
normalized = _DEFAULT_VECTOR_OPCLASS_RE.sub("", expr)
306+
normalized = "".join(normalized.split())
291307
normalized = normalized.replace('"', "")
292308
normalized = normalized.replace("::text", "")
293309
return _strip_non_pdb_qualifiers(normalized)

paradedb/sqlalchemy/indexing.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from ._select_introspection import has_limit, has_order_by
1717
from ._pdb_cast import PDBCast
18+
from .vector import VECTOR_OPCLASSES
1819
from .errors import (
1920
DuplicateTokenizerAliasError,
2021
FieldNotIndexedError,
@@ -58,6 +59,36 @@ def _compile_bm25_field_default(element: BM25Field, compiler, **kw: Any) -> str:
5859
raise CompileError("BM25Field is only supported for PostgreSQL dialects")
5960

6061

62+
class VectorField(BM25Field):
63+
"""Represents a pgvector column in a ParadeDB BM25 index with a distance metric opclass."""
64+
65+
inherit_cache = True
66+
_traverse_internals = [
67+
("expr", InternalTraversal.dp_clauseelement),
68+
("metric", InternalTraversal.dp_string),
69+
]
70+
71+
def __init__(self, expr: ClauseElement, *, metric: str = "l2") -> None:
72+
if metric not in VECTOR_OPCLASSES:
73+
raise InvalidArgumentError(f"metric must be one of: {', '.join(sorted(VECTOR_OPCLASSES))}")
74+
super().__init__(expr)
75+
self.metric = metric
76+
77+
@property
78+
def opclass(self) -> str:
79+
return VECTOR_OPCLASSES[self.metric]
80+
81+
82+
@compiles(VectorField, "postgresql")
83+
def _compile_vector_field(element: VectorField, compiler, **kw: Any) -> str:
84+
return f"{compiler.process(element.expr, **kw)} {element.opclass}"
85+
86+
87+
@compiles(VectorField)
88+
def _compile_vector_field_default(element: VectorField, compiler, **kw: Any) -> str:
89+
raise CompileError("VectorField is only supported for PostgreSQL dialects")
90+
91+
6192
def _is_bm25_index(index: Index) -> bool:
6293
using = index.dialect_options["postgresql"].get("using")
6394
return bool(using and str(using).lower() == "bm25")
@@ -110,6 +141,8 @@ def validate_bm25_index(index: Index) -> None:
110141
raise InvalidKeyFieldError(f"BM25 key_field '{key_field}' must be the first indexed BM25Field")
111142
if first_field.tokenizer is not None:
112143
raise InvalidKeyFieldError(f"BM25 key_field '{key_field}' must be untokenized")
144+
if isinstance(first_field, VectorField):
145+
raise InvalidKeyFieldError(f"BM25 key_field '{key_field}' cannot be a VectorField")
113146

114147

115148
@event.listens_for(Index, "before_create")
@@ -128,6 +161,7 @@ class IndexMeta:
128161

129162

130163
_KEY_FIELD_RE = re.compile(r"key_field\s*=\s*'?\"?([^'\",)\s]+)\"?'?", re.IGNORECASE)
164+
_VECTOR_OPCLASS_TAIL_RE = re.compile(r"\s(vector_(?:l2|cosine|ip)_ops)\s*$")
131165
_ALIAS_RE = re.compile(r"alias\s*=\s*([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE)
132166
_TOKENIZER_NAME_RE = re.compile(r"::pdb\.([A-Za-z_][A-Za-z0-9_]*)", re.IGNORECASE)
133167
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@@ -271,6 +305,13 @@ def _extract_where_clause(indexdef: str) -> str | None:
271305
return None
272306

273307

308+
def _extract_trailing_opclass(field_expr: str) -> str | None:
309+
"""Return the trailing vector opclass token from an index field expression, e.g.
310+
``vector_cosine_ops`` from ``vec vector_cosine_ops``. Returns ``None`` for plain fields."""
311+
match = _VECTOR_OPCLASS_TAIL_RE.search(field_expr)
312+
return match.group(1) if match else None
313+
314+
274315
def _extract_alias(index_expr: str) -> str | None:
275316
match = _ALIAS_RE.search(index_expr)
276317
if match:

0 commit comments

Comments
 (0)