ashe0042 commited on
Commit
a02cc42
·
1 Parent(s): 177a6eb

Retrieval layer: dense search + BM25 + RRF combiner (smoke test verified)

Browse files
Files changed (1) hide show
  1. src/retrieval.py +154 -0
src/retrieval.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Retrieval only: dense (pgvector cosine), BM25 (Postgres FTS), and RRF combiner.
3
+ No RAG pipeline here — that's a separate stage.
4
+ """
5
+
6
+ import os
7
+
8
+ import numpy as np
9
+ from dotenv import load_dotenv
10
+ from openai import OpenAI
11
+ from pgvector.psycopg2 import register_vector
12
+
13
+ from db import get_connection
14
+
15
+ load_dotenv()
16
+
17
+ EMBEDDING_MODEL = "text-embedding-3-large"
18
+ EMBEDDING_DIMENSIONS = 3072
19
+
20
+ client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
21
+
22
+
23
+ def embed_query(query: str) -> list[float]:
24
+ response = client.embeddings.create(
25
+ model=EMBEDDING_MODEL,
26
+ input=[query],
27
+ dimensions=EMBEDDING_DIMENSIONS,
28
+ )
29
+ return np.array(response.data[0].embedding)
30
+
31
+
32
+ def dense_search(
33
+ query_embedding: list[float],
34
+ source_filter: list[str] | None = None,
35
+ top_k: int = 20,
36
+ ) -> list[dict]:
37
+ conn = get_connection()
38
+ register_vector(conn)
39
+
40
+ sql = """
41
+ SELECT id, source, paragraph_id, text, 1 - (embedding <=> %s) AS score
42
+ FROM corpus_chunks
43
+ WHERE embedding IS NOT NULL
44
+ AND paragraph_id NOT LIKE %s
45
+ """
46
+ params: list = [query_embedding, "%_SCHEDULE"]
47
+
48
+ if source_filter:
49
+ sql += " AND source = ANY(%s)"
50
+ params.append(source_filter)
51
+
52
+ sql += " ORDER BY embedding <=> %s LIMIT %s"
53
+ params.extend([query_embedding, top_k])
54
+
55
+ with conn.cursor() as cur:
56
+ cur.execute(sql, params)
57
+ rows = cur.fetchall()
58
+
59
+ conn.close()
60
+
61
+ return [
62
+ {"id": row[0], "source": row[1], "paragraph_id": row[2], "text": row[3], "score": row[4]}
63
+ for row in rows
64
+ ]
65
+
66
+
67
+ def bm25_search(
68
+ query: str,
69
+ source_filter: list[str] | None = None,
70
+ top_k: int = 20,
71
+ ) -> list[dict]:
72
+ conn = get_connection()
73
+
74
+ sql = """
75
+ SELECT id, source, paragraph_id, text,
76
+ ts_rank(to_tsvector('english', text), plainto_tsquery('english', %s)) AS score
77
+ FROM corpus_chunks
78
+ WHERE to_tsvector('english', text) @@ plainto_tsquery('english', %s)
79
+ AND paragraph_id NOT LIKE %s
80
+ """
81
+ params: list = [query, query, "%_SCHEDULE"]
82
+
83
+ if source_filter:
84
+ sql += " AND source = ANY(%s)"
85
+ params.append(source_filter)
86
+
87
+ sql += " ORDER BY score DESC LIMIT %s"
88
+ params.append(top_k)
89
+
90
+ with conn.cursor() as cur:
91
+ cur.execute(sql, params)
92
+ rows = cur.fetchall()
93
+
94
+ conn.close()
95
+
96
+ return [
97
+ {"id": row[0], "source": row[1], "paragraph_id": row[2], "text": row[3], "score": row[4]}
98
+ for row in rows
99
+ ]
100
+
101
+
102
+ def rrf_search(
103
+ query: str,
104
+ query_embedding: list[float],
105
+ source_filter: list[str] | None = None,
106
+ top_k: int = 5,
107
+ k: int = 60,
108
+ ) -> list[dict]:
109
+ dense_results = dense_search(query_embedding, source_filter=source_filter, top_k=20)
110
+ bm25_results = bm25_search(query, source_filter=source_filter, top_k=20)
111
+
112
+ dense_ranks = {row["id"]: i + 1 for i, row in enumerate(dense_results)}
113
+ bm25_ranks = {row["id"]: i + 1 for i, row in enumerate(bm25_results)}
114
+
115
+ chunks_by_id = {row["id"]: row for row in dense_results}
116
+ for row in bm25_results:
117
+ chunks_by_id.setdefault(row["id"], row)
118
+
119
+ rrf_scores = {}
120
+ for chunk_id in chunks_by_id:
121
+ score = 0.0
122
+ if chunk_id in dense_ranks:
123
+ score += 1 / (k + dense_ranks[chunk_id])
124
+ if chunk_id in bm25_ranks:
125
+ score += 1 / (k + bm25_ranks[chunk_id])
126
+ rrf_scores[chunk_id] = score
127
+
128
+ ranked_ids = sorted(rrf_scores, key=lambda cid: rrf_scores[cid], reverse=True)[:top_k]
129
+
130
+ return [
131
+ {
132
+ "id": chunk_id,
133
+ "source": chunks_by_id[chunk_id]["source"],
134
+ "paragraph_id": chunks_by_id[chunk_id]["paragraph_id"],
135
+ "text": chunks_by_id[chunk_id]["text"],
136
+ "rrf_score": rrf_scores[chunk_id],
137
+ "dense_rank": dense_ranks.get(chunk_id),
138
+ "bm25_rank": bm25_ranks.get(chunk_id),
139
+ }
140
+ for chunk_id in ranked_ids
141
+ ]
142
+
143
+
144
+ if __name__ == "__main__":
145
+ smoke_query = "What are the general obligations of a financial services licensee?"
146
+
147
+ query_embedding = embed_query(smoke_query)
148
+ results = rrf_search(smoke_query, query_embedding, top_k=5)
149
+
150
+ for rank, result in enumerate(results, start=1):
151
+ print(
152
+ f"{rank}. [{result['source']}] {result['paragraph_id']} "
153
+ f"(rrf={result['rrf_score']:.5f}) {result['text'][:150]!r}"
154
+ )