llm_kernel/embedding/
pgvector.rs1use async_trait::async_trait;
10use sqlx::PgPool;
11use sqlx::QueryBuilder;
12use sqlx::postgres::PgPoolOptions;
13
14use crate::embedding::vector_index::SearchHit;
15use crate::error::{KernelError, Result};
16
17#[derive(sqlx::FromRow)]
19struct ScoreRow {
20 id: i64,
21 score: f64,
22}
23
24fn vec_literal(v: &[f32]) -> String {
27 let mut s = String::from("[");
28 for (i, f) in v.iter().enumerate() {
29 if i > 0 {
30 s.push(',');
31 }
32 s.push_str(&f.to_string());
33 }
34 s.push(']');
35 s
36}
37
38pub struct PgVectorIndex {
43 pool: PgPool,
44 table: String,
45 dim: usize,
46}
47
48impl PgVectorIndex {
49 pub async fn new(url: &str, table: &str, dim: usize) -> Result<Self> {
55 validate_table_name(table)?;
56 let pool = PgPoolOptions::new()
57 .max_connections(8)
58 .connect(url)
59 .await
60 .map_err(|e| KernelError::Embedding(format!("pgvector connect: {e}")))?;
61 let idx = Self {
62 pool,
63 table: table.to_string(),
64 dim,
65 };
66 idx.init_schema().await?;
67 Ok(idx)
68 }
69
70 async fn init_schema(&self) -> Result<()> {
73 sqlx::query(&format!(
77 "CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, vec vector({}) NOT NULL)",
78 self.table, self.dim
79 ))
80 .execute(&self.pool)
81 .await
82 .map_err(|e| KernelError::Embedding(format!("pgvector create table: {e}")))?;
83 sqlx::query(&format!(
84 "CREATE INDEX IF NOT EXISTS idx_{}_vec ON {} USING hnsw (vec vector_cosine_ops)",
85 self.table, self.table
86 ))
87 .execute(&self.pool)
88 .await
89 .map_err(|e| KernelError::Embedding(format!("pgvector hnsw index: {e}")))?;
90 Ok(())
91 }
92}
93
94#[async_trait]
95impl crate::embedding::AsyncVectorIndex for PgVectorIndex {
96 async fn add(&self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()> {
97 if vectors.len() != ids.len() {
98 return Err(KernelError::Embedding(format!(
99 "vectors.len() ({}) must equal ids.len() ({})",
100 vectors.len(),
101 ids.len()
102 )));
103 }
104 if vectors.is_empty() {
105 return Ok(());
106 }
107 let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
111 let mut q = QueryBuilder::new("INSERT INTO ");
112 q.push(self.table.as_str());
113 q.push(" (id, vec) ");
114 q.push_values(vectors.iter().zip(pg_ids.iter()), |mut b, (v, &id)| {
115 b.push_bind(id).push_bind(vec_literal(v));
116 });
117 q.push(" ON CONFLICT (id) DO UPDATE SET vec = EXCLUDED.vec");
118 q.build()
119 .execute(&self.pool)
120 .await
121 .map_err(|e| KernelError::Embedding(format!("pgvector add: {e}")))?;
122 Ok(())
123 }
124
125 async fn remove(&self, ids: &[u64]) -> Result<()> {
126 if ids.is_empty() {
127 return Ok(());
128 }
129 let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
130 sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
131 .bind(&ids)
132 .execute(&self.pool)
133 .await
134 .map_err(|e| KernelError::Embedding(format!("pgvector remove: {e}")))?;
135 Ok(())
136 }
137
138 async fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
139 let q = vec_literal(query);
140 let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
142 "SELECT id, 1 - (vec <=> $1::vector) AS score FROM {} ORDER BY vec <=> $1::vector LIMIT $2",
143 self.table
144 ))
145 .bind(q)
146 .bind(k as i64)
147 .fetch_all(&self.pool)
148 .await
149 .map_err(|e| KernelError::Embedding(format!("pgvector search: {e}")))?;
150 Ok(rows
151 .into_iter()
152 .map(|r| SearchHit {
153 id: r.id as u64,
154 score: r.score as f32,
155 })
156 .collect())
157 }
158
159 async fn search_filtered(
160 &self,
161 query: &[f32],
162 k: usize,
163 allowlist: &[u64],
164 ) -> Result<Vec<SearchHit>> {
165 if allowlist.is_empty() {
166 return Ok(Vec::new());
167 }
168 let q = vec_literal(query);
169 let allow: Vec<i64> = allowlist
170 .iter()
171 .map(|&i| to_pg_id(i))
172 .collect::<Result<_>>()?;
173 let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
174 "SELECT id, 1 - (vec <=> $1::vector) AS score FROM {} WHERE id = ANY($2) \
175 ORDER BY vec <=> $1::vector LIMIT $3",
176 self.table
177 ))
178 .bind(q)
179 .bind(&allow)
180 .bind(k as i64)
181 .fetch_all(&self.pool)
182 .await
183 .map_err(|e| KernelError::Embedding(format!("pgvector search_filtered: {e}")))?;
184 Ok(rows
185 .into_iter()
186 .map(|r| SearchHit {
187 id: r.id as u64,
188 score: r.score as f32,
189 })
190 .collect())
191 }
192
193 async fn len(&self) -> Result<usize> {
194 let n: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM {}", self.table))
195 .fetch_one(&self.pool)
196 .await
197 .map_err(|e| KernelError::Embedding(format!("pgvector len: {e}")))?;
198 Ok(n as usize)
199 }
200
201 fn dim(&self) -> usize {
202 self.dim
203 }
204}
205
206fn to_pg_id(id: u64) -> Result<i64> {
209 i64::try_from(id).map_err(|_| KernelError::Embedding(format!("id {id} exceeds BIGINT range")))
210}
211
212fn validate_table_name(table: &str) -> Result<()> {
217 let mut chars = table.chars();
218 let first_ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_');
219 let valid = first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
220 if !valid {
221 return Err(KernelError::Embedding(format!(
222 "invalid table identifier: {table:?}"
223 )));
224 }
225 Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::embedding::AsyncVectorIndex;
232
233 fn pg_url() -> Option<String> {
235 std::env::var("LLMKERNEL_PG_URL").ok()
236 }
237
238 #[tokio::test]
242 async fn roundtrip_add_search_remove() {
243 let Some(url) = pg_url() else {
244 eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
245 return;
246 };
247 let table = format!("lk_test_{}", line!());
248 let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
249
250 let vecs = vec![
251 vec![1.0, 0.0, 0.0],
252 vec![0.0, 1.0, 0.0],
253 vec![0.0, 0.0, 1.0],
254 ];
255 let ids = vec![10u64, 20, 30];
256 idx.add(&vecs, &ids).await.expect("add");
257 assert_eq!(idx.len().await.unwrap(), 3);
258
259 let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
261 assert_eq!(hits.len(), 1);
262 assert_eq!(hits[0].id, 10);
263
264 let hits = idx
266 .search_filtered(&[1.0, 0.0, 0.0], 1, &[20, 30])
267 .await
268 .unwrap();
269 assert_eq!(hits.len(), 1);
270 assert_ne!(hits[0].id, 10);
271
272 idx.remove(&[10]).await.unwrap();
273 assert_eq!(idx.len().await.unwrap(), 2);
274
275 sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
277 .execute(&idx.pool)
278 .await
279 .ok();
280 }
281
282 #[test]
283 fn rejects_invalid_table_name() {
284 assert!(validate_table_name("lk_test_1").is_ok());
286 assert!(validate_table_name("_vec").is_ok());
287 assert!(validate_table_name("").is_err());
289 assert!(validate_table_name("1bad").is_err());
290 assert!(validate_table_name("rm; DROP").is_err());
291 assert!(validate_table_name("weird\"name").is_err());
292 assert!(validate_table_name("sch.tbl").is_err());
293 }
294
295 #[test]
296 fn rejects_overflowing_id() {
297 assert_eq!(to_pg_id(0).unwrap(), 0);
298 assert_eq!(to_pg_id(42).unwrap(), 42);
299 assert_eq!(to_pg_id(i64::MAX as u64).unwrap(), i64::MAX);
300 assert!(to_pg_id((i64::MAX as u64) + 1).is_err());
301 assert!(to_pg_id(u64::MAX).is_err());
302 }
303}