Skip to content

Commit 5bdd6a9

Browse files
committed
feat: support the paradedb index access method (paradedb/paradedb#5706)
1 parent 94ce5fc commit 5bdd6a9

18 files changed

Lines changed: 575 additions & 185 deletions

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+
- Support for the `paradedb` index access method, the primary name on pg_search 0.25.0+ (`bm25` remains a permanent alias). `op.create_bm25_index` and `op.drop_bm25_index` emit `USING paradedb` by default and accept an `am` option (`"paradedb"` or `"bm25"`); `Index(postgresql_using=...)`, validation, introspection, and Alembic autogenerate recognize ParadeDB indexes under either name without producing migration churn. New `indexing.server_index_access_method(engine)` helper probes which name the server supports.
10+
711
## [0.8.0] - 2026-07-14
812

913
### Changed

examples/autocomplete/setup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class Product(Base):
3232
rating: Mapped[int] = mapped_column(Integer, nullable=False)
3333

3434

35-
Index(
35+
SEARCH_INDEX = Index(
3636
"products_autocomplete_bm25_idx",
3737
indexing.BM25Field(Product.id),
3838
indexing.BM25Field(Product.description),
@@ -42,7 +42,7 @@ class Product(Base):
4242
),
4343
indexing.BM25Field(Product.category, tokenizer=tokenizer.literal()),
4444
indexing.BM25Field(Product.rating),
45-
postgresql_using="bm25",
45+
postgresql_using="paradedb",
4646
postgresql_with={"key_field": "id"},
4747
)
4848

@@ -53,6 +53,8 @@ def engine_from_env() -> Engine:
5353

5454

5555
def setup_database(engine: Engine) -> None:
56+
# pg_search <= 0.24.x only exposes the backwards-compatible bm25 alias.
57+
SEARCH_INDEX.dialect_options["postgresql"]["using"] = indexing.server_index_access_method(engine)
5658
Base.metadata.drop_all(engine)
5759
Base.metadata.create_all(engine)
5860
with Session(engine) as session:

examples/faceted_search/setup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ class Product(Base):
3232
rating: Mapped[int] = mapped_column(Integer, nullable=False)
3333

3434

35-
Index(
35+
SEARCH_INDEX = Index(
3636
"products_facets_bm25_idx",
3737
indexing.BM25Field(Product.id),
3838
indexing.BM25Field(Product.description),
3939
indexing.BM25Field(Product.category, tokenizer=tokenizer.literal()),
4040
indexing.BM25Field(Product.rating),
41-
postgresql_using="bm25",
41+
postgresql_using="paradedb",
4242
postgresql_with={"key_field": "id"},
4343
)
4444

@@ -49,6 +49,8 @@ def engine_from_env() -> Engine:
4949

5050

5151
def setup_database(engine: Engine) -> None:
52+
# pg_search <= 0.24.x only exposes the backwards-compatible bm25 alias.
53+
SEARCH_INDEX.dialect_options["postgresql"]["using"] = indexing.server_index_access_method(engine)
5254
Base.metadata.drop_all(engine)
5355
Base.metadata.create_all(engine)
5456
with Session(engine) as session:

examples/hybrid_rrf/setup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ class Product(Base):
3232
rating: Mapped[int] = mapped_column(Integer, nullable=False)
3333

3434

35-
Index(
35+
SEARCH_INDEX = Index(
3636
"products_hybrid_rrf_bm25_idx",
3737
indexing.BM25Field(Product.id),
3838
indexing.BM25Field(Product.description),
3939
indexing.BM25Field(Product.category, tokenizer=tokenizer.literal()),
4040
indexing.BM25Field(Product.rating),
41-
postgresql_using="bm25",
41+
postgresql_using="paradedb",
4242
postgresql_with={"key_field": "id"},
4343
)
4444

@@ -49,6 +49,8 @@ def engine_from_env() -> Engine:
4949

5050

5151
def setup_database(engine: Engine) -> None:
52+
# pg_search <= 0.24.x only exposes the backwards-compatible bm25 alias.
53+
SEARCH_INDEX.dialect_options["postgresql"]["using"] = indexing.server_index_access_method(engine)
5254
Base.metadata.drop_all(engine)
5355
Base.metadata.create_all(engine)
5456
with Session(engine) as session:

examples/more_like_this/setup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ class Product(Base):
3232
rating: Mapped[int] = mapped_column(Integer, nullable=False)
3333

3434

35-
Index(
35+
SEARCH_INDEX = Index(
3636
"products_mlt_bm25_idx",
3737
indexing.BM25Field(Product.id),
3838
indexing.BM25Field(Product.description),
3939
indexing.BM25Field(Product.category, tokenizer=tokenizer.literal()),
4040
indexing.BM25Field(Product.rating),
41-
postgresql_using="bm25",
41+
postgresql_using="paradedb",
4242
postgresql_with={"key_field": "id"},
4343
)
4444

@@ -49,6 +49,8 @@ def engine_from_env() -> Engine:
4949

5050

5151
def setup_database(engine: Engine) -> None:
52+
# pg_search <= 0.24.x only exposes the backwards-compatible bm25 alias.
53+
SEARCH_INDEX.dialect_options["postgresql"]["using"] = indexing.server_index_access_method(engine)
5254
Base.metadata.drop_all(engine)
5355
Base.metadata.create_all(engine)
5456
with Session(engine) as session:

examples/quickstart/setup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ class Product(Base):
3232
rating: Mapped[int] = mapped_column(Integer, nullable=False)
3333

3434

35-
Index(
35+
SEARCH_INDEX = Index(
3636
"products_bm25_idx",
3737
indexing.BM25Field(Product.id),
3838
indexing.BM25Field(Product.description),
3939
indexing.BM25Field(Product.category, tokenizer=tokenizer.literal()),
4040
indexing.BM25Field(Product.rating),
41-
postgresql_using="bm25",
41+
postgresql_using="paradedb",
4242
postgresql_with={"key_field": "id"},
4343
)
4444

@@ -49,6 +49,8 @@ def engine_from_env() -> Engine:
4949

5050

5151
def setup_database(engine: Engine) -> None:
52+
# pg_search <= 0.24.x only exposes the backwards-compatible bm25 alias.
53+
SEARCH_INDEX.dialect_options["postgresql"]["using"] = indexing.server_index_access_method(engine)
5254
Base.metadata.drop_all(engine)
5355
Base.metadata.create_all(engine)
5456
with Session(engine) as session:

examples/rag/setup.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ class Document(Base):
2727
content: Mapped[str] = mapped_column(Text, nullable=False)
2828

2929

30-
Index(
30+
SEARCH_INDEX = Index(
3131
"documents_bm25_idx",
3232
indexing.BM25Field(Document.id),
3333
indexing.BM25Field(Document.content),
34-
postgresql_using="bm25",
34+
postgresql_using="paradedb",
3535
postgresql_with={"key_field": "id"},
3636
)
3737

@@ -42,6 +42,8 @@ def engine_from_env() -> Engine:
4242

4343

4444
def setup_database(engine: Engine) -> None:
45+
# pg_search <= 0.24.x only exposes the backwards-compatible bm25 alias.
46+
SEARCH_INDEX.dialect_options["postgresql"]["using"] = indexing.server_index_access_method(engine)
4547
Base.metadata.drop_all(engine)
4648
Base.metadata.create_all(engine)
4749
with Session(engine) as session:

paradedb/sqlalchemy/alembic.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from sqlalchemy.dialects import postgresql
1010
from sqlalchemy.sql.elements import ClauseElement
1111

12+
from .indexing import DEFAULT_INDEX_ACCESS_METHOD, validate_index_access_method
13+
1214

1315
def _quote_ident(name: str) -> str:
1416
return '"' + name.replace('"', '""') + '"'
@@ -35,13 +37,15 @@ def __init__(
3537
*,
3638
table_schema: str | None = None,
3739
where: str | None = None,
40+
am: str = DEFAULT_INDEX_ACCESS_METHOD,
3841
) -> None:
3942
self.index_name = index_name
4043
self.table_name = table_name
4144
self.expressions = expressions
4245
self.key_field = key_field
4346
self.table_schema = table_schema
4447
self.where = where
48+
self.am = validate_index_access_method(am)
4549

4650
@classmethod
4751
def create_bm25_index(
@@ -54,6 +58,7 @@ def create_bm25_index(
5458
key_field: str,
5559
table_schema: str | None = None,
5660
where: str | None = None,
61+
am: str = DEFAULT_INDEX_ACCESS_METHOD,
5762
) -> MigrateOperation:
5863
return operations.invoke(
5964
cls(
@@ -63,11 +68,12 @@ def create_bm25_index(
6368
key_field,
6469
table_schema=table_schema,
6570
where=where,
71+
am=am,
6672
)
6773
)
6874

6975
def reverse(self) -> MigrateOperation:
70-
return DropBM25IndexOp(index_name=self.index_name, if_exists=True, schema=self.table_schema)
76+
return DropBM25IndexOp(index_name=self.index_name, if_exists=True, schema=self.table_schema, am=self.am)
7177

7278

7379
@Operations.implementation_for(CreateBM25IndexOp)
@@ -76,7 +82,7 @@ def _create_bm25_index_impl(operations: Operations, operation: CreateBM25IndexOp
7682
sql = (
7783
f"CREATE INDEX {_quote_ident(operation.index_name)} "
7884
f"ON {_quote_qualified(operation.table_schema, operation.table_name)} "
79-
f"USING bm25 ({expressions_sql}) WITH (key_field={_quote_literal(operation.key_field)})"
85+
f"USING {operation.am} ({expressions_sql}) WITH (key_field={_quote_literal(operation.key_field)})"
8086
)
8187
if operation.where is not None:
8288
sql += f" WHERE {operation.where}"
@@ -95,6 +101,8 @@ def _render_create_bm25_index_op(autogen_context, op: CreateBM25IndexOp) -> str:
95101
parts.append(f"table_schema={op.table_schema!r}")
96102
if op.where is not None:
97103
parts.append(f"where={op.where!r}")
104+
if op.am != DEFAULT_INDEX_ACCESS_METHOD:
105+
parts.append(f"am={op.am!r}")
98106
return f"op.create_bm25_index({', '.join(parts)})"
99107

100108

@@ -110,6 +118,7 @@ def __init__(
110118
expressions: list[str] | None = None,
111119
key_field: str | None = None,
112120
where: str | None = None,
121+
am: str = DEFAULT_INDEX_ACCESS_METHOD,
113122
) -> None:
114123
self.index_name = index_name
115124
self.if_exists = if_exists
@@ -118,6 +127,7 @@ def __init__(
118127
self.expressions = expressions
119128
self.key_field = key_field
120129
self.where = where
130+
self.am = validate_index_access_method(am)
121131

122132
@classmethod
123133
def drop_bm25_index(
@@ -131,6 +141,7 @@ def drop_bm25_index(
131141
expressions: list[str] | None = None,
132142
key_field: str | None = None,
133143
where: str | None = None,
144+
am: str = DEFAULT_INDEX_ACCESS_METHOD,
134145
) -> MigrateOperation:
135146
return operations.invoke(
136147
cls(
@@ -141,6 +152,7 @@ def drop_bm25_index(
141152
expressions=expressions,
142153
key_field=key_field,
143154
where=where,
155+
am=am,
144156
)
145157
)
146158

@@ -155,6 +167,7 @@ def reverse(self) -> MigrateOperation:
155167
key_field=self.key_field,
156168
table_schema=self.schema,
157169
where=self.where,
170+
am=self.am,
158171
)
159172

160173

@@ -177,6 +190,8 @@ def _render_drop_bm25_index_op(autogen_context, op: DropBM25IndexOp) -> str:
177190
parts.append(f"key_field={op.key_field!r}")
178191
if op.where is not None:
179192
parts.append(f"where={op.where!r}")
193+
if op.am != DEFAULT_INDEX_ACCESS_METHOD:
194+
parts.append(f"am={op.am!r}")
180195
return f"op.drop_bm25_index({', '.join(parts)})"
181196

182197

@@ -229,7 +244,7 @@ def _autogen_bm25_meta_indexes(
229244

230245

231246
def _autogen_bm25_db_indexes(conn, effective_schemas: set[str]) -> dict[tuple[str, str], dict]:
232-
"""Return {(schema, index_name): {table_name, expressions, key_field, where}} from pg_indexes."""
247+
"""Return {(schema, index_name): {table_name, expressions, key_field, where, am}} from pg_indexes."""
233248
from .indexing import (
234249
_extract_key_field,
235250
_extract_where_clause,
@@ -249,6 +264,7 @@ def _autogen_bm25_db_indexes(conn, effective_schemas: set[str]) -> dict[tuple[st
249264
"expressions": [],
250265
"key_field": _normalize_reloption_value(row["key_field"]) or "",
251266
"where": _extract_where_clause(str(row["indexdef"])),
267+
"am": str(row["amname"]),
252268
},
253269
)
254270
index_entry["expressions"].append(str(row["keydef"]))
@@ -447,6 +463,7 @@ def _compare_bm25_indexes(autogen_context, upgrade_ops, schemas) -> PriorityDisp
447463
expressions=db["expressions"],
448464
key_field=db["key_field"],
449465
where=db.get("where"),
466+
am=db["am"],
450467
)
451468
)
452469

@@ -467,11 +484,15 @@ def _compare_bm25_indexes(autogen_context, upgrade_ops, schemas) -> PriorityDisp
467484
key_field=key_field,
468485
table_schema=key[0],
469486
where=meta_where,
487+
am=validate_index_access_method(index.dialect_options["postgresql"].get("using")),
470488
)
471489

472490
if key not in db_bm25:
473491
upgrade_ops.ops.append(create_op)
474492
else:
493+
# The AM name is intentionally not compared: paradedb and bm25 are
494+
# aliases for the same access method, so an AM-only difference must
495+
# not produce migration churn.
475496
db = db_bm25[key]
476497
expressions_changed = _normalized_expression_list(db["expressions"]) != _normalized_expression_list(
477498
expressions
@@ -488,6 +509,7 @@ def _compare_bm25_indexes(autogen_context, upgrade_ops, schemas) -> PriorityDisp
488509
expressions=db["expressions"],
489510
key_field=db["key_field"],
490511
where=db.get("where"),
512+
am=db["am"],
491513
)
492514
)
493515
upgrade_ops.ops.append(create_op)

paradedb/sqlalchemy/indexing.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,33 @@ def _compile_bm25_field_default(element: BM25Field, compiler, **kw: Any) -> str:
5858
raise CompileError("BM25Field is only supported for PostgreSQL dialects")
5959

6060

61+
DEFAULT_INDEX_ACCESS_METHOD = "paradedb"
62+
INDEX_ACCESS_METHODS = ("paradedb", "bm25")
63+
64+
65+
def validate_index_access_method(am: str) -> str:
66+
"""Normalize *am* and raise :exc:`InvalidArgumentError` unless it is a ParadeDB access method."""
67+
normalized = str(am).lower()
68+
if normalized not in INDEX_ACCESS_METHODS:
69+
raise InvalidArgumentError(f"index access method must be one of: {', '.join(INDEX_ACCESS_METHODS)}")
70+
return normalized
71+
72+
73+
def server_index_access_method(bind: Engine | Any) -> str:
74+
"""Return ``"paradedb"`` when the server exposes the ``paradedb`` index access method
75+
(pg_search 0.25.0+), otherwise the backwards-compatible ``"bm25"`` alias."""
76+
query = text("SELECT 1 FROM pg_am WHERE amname = 'paradedb'")
77+
if isinstance(bind, Engine):
78+
with bind.connect() as conn:
79+
row = conn.execute(query).first()
80+
else:
81+
row = bind.execute(query).first()
82+
return "paradedb" if row is not None else "bm25"
83+
84+
6185
def _is_bm25_index(index: Index) -> bool:
6286
using = index.dialect_options["postgresql"].get("using")
63-
return bool(using and str(using).lower() == "bm25")
87+
return bool(using) and str(using).lower() in INDEX_ACCESS_METHODS
6488

6589

6690
def _bm25_field_name(field: BM25Field) -> str | None:
@@ -169,7 +193,7 @@ def _split_top_level_csv(expr: str) -> list[str]:
169193

170194

171195
def _extract_bm25_field_list(indexdef: str) -> list[str]:
172-
marker = re.search(r"USING\s+bm25\s*\(", indexdef, re.IGNORECASE)
196+
marker = re.search(r"USING\s+(?:bm25|paradedb)\s*\(", indexdef, re.IGNORECASE)
173197
if marker is None:
174198
return []
175199

@@ -308,6 +332,7 @@ def _introspect_bm25_index_rows(conn, *, schema_name: str, table_name: str | Non
308332
ns.nspname AS schemaname,
309333
tbl.relname AS tablename,
310334
idx.relname AS indexname,
335+
am.amname AS amname,
311336
pg_get_indexdef(idx.oid) AS indexdef,
312337
split_part(opt.opt, '=', 2) AS key_field,
313338
key_ord.ord::int AS ordinality,
@@ -317,6 +342,7 @@ def _introspect_bm25_index_rows(conn, *, schema_name: str, table_name: str | Non
317342
JOIN pg_namespace AS ns ON ns.oid = idx.relnamespace
318343
JOIN pg_index AS i ON i.indexrelid = idx.oid
319344
JOIN pg_class AS tbl ON tbl.oid = i.indrelid
345+
JOIN pg_am AS am ON am.oid = idx.relam
320346
LEFT JOIN LATERAL (
321347
SELECT opt
322348
FROM unnest(COALESCE(idx.reloptions, ARRAY[]::text[])) AS opt
@@ -329,7 +355,7 @@ def _introspect_bm25_index_rows(conn, *, schema_name: str, table_name: str | Non
329355
AND attr.attnum = key_ord.attnum
330356
WHERE ns.nspname = :schema_name
331357
AND (CAST(:table_name AS text) IS NULL OR tbl.relname = CAST(:table_name AS text))
332-
AND pg_get_indexdef(idx.oid) ILIKE '%USING bm25%'
358+
AND am.amname IN ('bm25', 'paradedb')
333359
ORDER BY idx.relname, key_ord.ord
334360
"""
335361
),

0 commit comments

Comments
 (0)