-
-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathhybrid_rrf.py
More file actions
executable file
·160 lines (129 loc) · 5.23 KB
/
Copy pathhybrid_rrf.py
File metadata and controls
executable file
·160 lines (129 loc) · 5.23 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#!/usr/bin/env python
"""Hybrid search example using BM25 + vector search with Reciprocal Rank Fusion.
Uses a single SQL query with CTEs to combine BM25 full-text search and vector
similarity search, fused via Reciprocal Rank Fusion (RRF).
No raw SQL - built entirely with Django ORM + django-cte + pgvector + django-paradedb.
Reference: https://www.paradedb.com/blog/hybrid-search-in-postgresql-the-missing-manual
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from common import MockItemWithEmbedding as MockItem
from django.db.models import F, FloatField, Sum, Window
from django.db.models.expressions import ExpressionWrapper, Value
from django.db.models.functions import Cast, RowNumber
from django_cte import CTE, with_cte
from pgvector.django import CosineDistance
from paradedb.functions import Score
from paradedb.search import MatchAll, ParadeDB
if MockItem is None:
raise ImportError(
"pgvector is required for this example. Install the example extras with "
"'uv sync --extra examples'."
)
def get_query_embedding(text: str) -> list[float]:
"""Get pre-computed embedding for query text."""
sys.path.insert(0, str(Path(__file__).resolve().parent))
from setup import QUERY_EMBEDDINGS
return QUERY_EMBEDDINGS[text]
def _rrf_score(k: float) -> ExpressionWrapper:
"""Build the RRF score expression: 1.0 / (k + rank)."""
return ExpressionWrapper(
Value(1.0, output_field=FloatField())
/ (Value(k, output_field=FloatField()) + Cast(F("rank"), FloatField())),
output_field=FloatField(),
)
def hybrid_search(
query: str,
query_embedding: list[float],
*,
top_k: int = 20,
rrf_k: int = 60,
limit: int = 5,
) -> list:
"""Single-query hybrid search combining BM25 + vector via RRF.
Builds one SQL statement with four CTEs:
1. fulltext - BM25 ranked results via ParadeDB
2. semantic - vector similarity ranked results via pgvector
3. rrf - UNION ALL of RRF score contributions
4. rrf_scores - aggregated RRF scores per item
Args:
query: Text query for BM25 full-text search.
query_embedding: Vector embedding for similarity search.
top_k: Number of candidates from each ranker.
rrf_k: RRF constant (typically 60).
limit: Number of final results to return.
"""
# CTE 1: BM25 full-text search with ROW_NUMBER rank
fulltext_qs = (
MockItem.objects.filter(description=ParadeDB(MatchAll(query)))
.annotate(bm25=Score())
.annotate(rank=Window(expression=RowNumber(), order_by=F("bm25").desc()))
.order_by("-bm25")
.values("id", "rank")[:top_k]
)
fulltext_cte = CTE(fulltext_qs, name="fulltext")
# CTE 2: Vector similarity search with ROW_NUMBER rank
semantic_qs = (
MockItem.objects.filter(embedding__isnull=False)
.annotate(distance=CosineDistance("embedding", query_embedding))
.annotate(rank=Window(expression=RowNumber(), order_by=F("distance").asc()))
.order_by("distance")
.values("id", "rank")[:top_k]
)
semantic_cte = CTE(semantic_qs, name="semantic")
# CTE 3: UNION ALL of RRF contributions from each ranker
rrf_expr = _rrf_score(float(rrf_k))
fulltext_rrf = (
fulltext_cte.queryset().annotate(score=rrf_expr).values("id", "score")
)
semantic_rrf = (
semantic_cte.queryset().annotate(score=rrf_expr).values("id", "score")
)
rrf_cte = CTE(fulltext_rrf.union(semantic_rrf, all=True), name="rrf")
# CTE 4: Aggregate RRF scores per item
rrf_scores_qs = (
rrf_cte.queryset()
.values("id")
.annotate(rrf_score=Sum("score"))
.order_by("-rrf_score")
)[:limit]
rrf_scores_cte = CTE(rrf_scores_qs, name="rrf_scores")
# Final SELECT: join aggregated scores back to model for full fields
final_qs = with_cte(
fulltext_cte,
semantic_cte,
rrf_cte,
rrf_scores_cte,
select=rrf_scores_cte.join(MockItem, id=rrf_scores_cte.col.id)
.annotate(rrf_score=rrf_scores_cte.col.rrf_score)
.order_by("-rrf_score"),
)
return list(final_qs)
def display_results(query: str, results: list) -> None:
"""Display hybrid search results."""
print(f"\n{'=' * 70}")
print(f"Query: '{query}'")
print("=" * 70)
for i, item in enumerate(results, 1):
print(f" {i}. {item.description[:60]:<60} (RRF: {item.rrf_score:.4f})")
def demo(query: str) -> None:
"""Run hybrid search demo for a query."""
query_embedding = get_query_embedding(query)
results = hybrid_search(query, query_embedding)
display_results(query, results)
if __name__ == "__main__":
print("=" * 70)
print("Hybrid Search with Reciprocal Rank Fusion (RRF)")
print("=" * 70)
print("\nSingle-query CTE: BM25 (keyword) + Vector (semantic)")
print("RRF formula: score = sum(1 / (k + rank)) across all rankings")
sys.path.insert(0, str(Path(__file__).resolve().parent))
from setup import setup
setup()
demo("running shoes")
demo("footwear for exercise")
demo("wireless earbuds")
print("\n" + "=" * 70)
print("All results produced by a single SQL query per search.")
print("=" * 70)