1use async_trait::async_trait;
14use sqlx::PgPool;
15use sqlx::QueryBuilder;
16use sqlx::postgres::PgPoolOptions;
17
18use crate::embedding::vector_index::SearchHit;
19use crate::error::{KernelError, Result};
20
21#[derive(sqlx::FromRow)]
23struct ScoreRow {
24 id: i64,
25 score: f64,
26}
27
28fn vec_literal(v: &[f32]) -> String {
31 let mut s = String::from("[");
32 for (i, f) in v.iter().enumerate() {
33 if i > 0 {
34 s.push(',');
35 }
36 s.push_str(&f.to_string());
37 }
38 s.push(']');
39 s
40}
41
42#[derive(Debug, Clone, Default)]
47pub struct PgVectorOpts {
48 pub half: bool,
50 pub hnsw_m: Option<u32>,
53 pub hnsw_ef_construction: Option<u32>,
56 pub hnsw_ef_search: Option<u32>,
60}
61
62pub struct PgVectorIndex {
68 pool: PgPool,
69 table: String,
70 dim: usize,
71 half: bool,
73 hnsw_m: Option<u32>,
74 hnsw_ef_construction: Option<u32>,
75}
76
77impl PgVectorIndex {
78 pub async fn new(url: &str, table: &str, dim: usize) -> Result<Self> {
86 Self::new_with_opts(url, table, dim, PgVectorOpts::default()).await
87 }
88
89 pub async fn new_halfvec(url: &str, table: &str, dim: usize) -> Result<Self> {
94 Self::new_with_opts(
95 url,
96 table,
97 dim,
98 PgVectorOpts {
99 half: true,
100 ..Default::default()
101 },
102 )
103 .await
104 }
105
106 pub async fn new_with_opts(
113 url: &str,
114 table: &str,
115 dim: usize,
116 opts: PgVectorOpts,
117 ) -> Result<Self> {
118 validate_table_name(table)?;
119 let mut pool_opts = PgPoolOptions::new().max_connections(8);
120 if let Some(ef) = opts.hnsw_ef_search {
121 pool_opts = pool_opts.after_connect(move |conn, _meta| {
123 Box::pin(async move {
124 sqlx::query(&format!("SET hnsw.ef_search = {ef}"))
125 .execute(conn)
126 .await?;
127 Ok(())
128 })
129 });
130 }
131 let pool = pool_opts
132 .connect(url)
133 .await
134 .map_err(|e| KernelError::Embedding(format!("pgvector connect: {e}")))?;
135 let idx = Self {
136 pool,
137 table: table.to_string(),
138 dim,
139 half: opts.half,
140 hnsw_m: opts.hnsw_m,
141 hnsw_ef_construction: opts.hnsw_ef_construction,
142 };
143 idx.init_schema().await?;
144 Ok(idx)
145 }
146
147 fn sql_type(&self) -> &'static str {
149 if self.half { "halfvec" } else { "vector" }
150 }
151
152 fn ops_class(&self) -> &'static str {
154 if self.half {
155 "halfvec_cosine_ops"
156 } else {
157 "vector_cosine_ops"
158 }
159 }
160
161 async fn init_schema(&self) -> Result<()> {
164 let ty = self.sql_type();
168 let ops = self.ops_class();
169 let with = hnsw_with_clause(self.hnsw_m, self.hnsw_ef_construction);
170 sqlx::query(&format!(
171 "CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, vec {}({}) NOT NULL)",
172 self.table, ty, self.dim
173 ))
174 .execute(&self.pool)
175 .await
176 .map_err(|e| KernelError::Embedding(format!("pgvector create table: {e}")))?;
177 sqlx::query(&format!(
178 "CREATE INDEX IF NOT EXISTS idx_{}_vec ON {} USING hnsw (vec {}){}",
179 self.table, self.table, ops, with
180 ))
181 .execute(&self.pool)
182 .await
183 .map_err(|e| KernelError::Embedding(format!("pgvector hnsw index: {e}")))?;
184 Ok(())
185 }
186
187 pub fn pool(&self) -> &PgPool {
195 &self.pool
196 }
197
198 pub async fn remove_in_tx(&self, tx: &mut sqlx::PgConnection, ids: &[u64]) -> Result<()> {
206 if ids.is_empty() {
207 return Ok(());
208 }
209 let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
210 sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
211 .bind(&ids)
212 .execute(tx)
213 .await
214 .map_err(|e| KernelError::Embedding(format!("pgvector remove_in_tx: {e}")))?;
215 Ok(())
216 }
217}
218
219#[async_trait]
220impl crate::embedding::AsyncVectorIndex for PgVectorIndex {
221 async fn add(&self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()> {
222 if vectors.len() != ids.len() {
223 return Err(KernelError::Embedding(format!(
224 "vectors.len() ({}) must equal ids.len() ({})",
225 vectors.len(),
226 ids.len()
227 )));
228 }
229 if vectors.is_empty() {
230 return Ok(());
231 }
232 let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
236
237 const ROWS_PER_CHUNK: usize = 16_000;
242 for chunk in vectors
243 .chunks(ROWS_PER_CHUNK)
244 .zip(pg_ids.chunks(ROWS_PER_CHUNK))
245 {
246 let (chunk_vecs, chunk_ids) = chunk;
247 let mut q = QueryBuilder::new("INSERT INTO ");
248 q.push(self.table.as_str());
249 q.push(" (id, vec) VALUES ");
250 for (i, (v, &id)) in chunk_vecs.iter().zip(chunk_ids.iter()).enumerate() {
254 if i > 0 {
255 q.push(", ");
256 }
257 q.push("(");
258 q.push_bind(id);
259 q.push(", ");
260 q.push_bind(vec_literal(v));
261 q.push("::");
262 q.push(self.sql_type());
263 q.push(")");
264 }
265 q.push(" ON CONFLICT (id) DO UPDATE SET vec = EXCLUDED.vec");
266 q.build()
267 .execute(&self.pool)
268 .await
269 .map_err(|e| KernelError::Embedding(format!("pgvector add: {e}")))?;
270 }
271 Ok(())
272 }
273
274 async fn remove(&self, ids: &[u64]) -> Result<()> {
275 if ids.is_empty() {
276 return Ok(());
277 }
278 let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
279 sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
280 .bind(&ids)
281 .execute(&self.pool)
282 .await
283 .map_err(|e| KernelError::Embedding(format!("pgvector remove: {e}")))?;
284 Ok(())
285 }
286
287 async fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
288 let q = vec_literal(query);
289 let ty = self.sql_type();
290 let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
292 "SELECT id, 1 - (vec <=> $1::{ty}) AS score FROM {} ORDER BY vec <=> $1::{ty} LIMIT $2",
293 self.table
294 ))
295 .bind(q)
296 .bind(k as i64)
297 .fetch_all(&self.pool)
298 .await
299 .map_err(|e| KernelError::Embedding(format!("pgvector search: {e}")))?;
300 Ok(rows
301 .into_iter()
302 .map(|r| SearchHit {
303 id: r.id as u64,
304 score: r.score as f32,
305 })
306 .collect())
307 }
308
309 async fn search_filtered(
310 &self,
311 query: &[f32],
312 k: usize,
313 allowlist: &[u64],
314 ) -> Result<Vec<SearchHit>> {
315 if allowlist.is_empty() {
316 return Ok(Vec::new());
317 }
318 let q = vec_literal(query);
319 let ty = self.sql_type();
320 let allow: Vec<i64> = allowlist
321 .iter()
322 .map(|&i| to_pg_id(i))
323 .collect::<Result<_>>()?;
324 let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
325 "SELECT id, 1 - (vec <=> $1::{ty}) AS score FROM {} WHERE id = ANY($2) \
326 ORDER BY vec <=> $1::{ty} LIMIT $3",
327 self.table
328 ))
329 .bind(q)
330 .bind(&allow)
331 .bind(k as i64)
332 .fetch_all(&self.pool)
333 .await
334 .map_err(|e| KernelError::Embedding(format!("pgvector search_filtered: {e}")))?;
335 Ok(rows
336 .into_iter()
337 .map(|r| SearchHit {
338 id: r.id as u64,
339 score: r.score as f32,
340 })
341 .collect())
342 }
343
344 async fn len(&self) -> Result<usize> {
345 let n: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM {}", self.table))
346 .fetch_one(&self.pool)
347 .await
348 .map_err(|e| KernelError::Embedding(format!("pgvector len: {e}")))?;
349 Ok(n as usize)
350 }
351
352 fn dim(&self) -> usize {
353 self.dim
354 }
355}
356
357fn hnsw_with_clause(m: Option<u32>, ef_construction: Option<u32>) -> String {
361 let mut parts: Vec<String> = Vec::new();
362 if let Some(m) = m {
363 parts.push(format!("m = {m}"));
364 }
365 if let Some(ef) = ef_construction {
366 parts.push(format!("ef_construction = {ef}"));
367 }
368 if parts.is_empty() {
369 String::new()
370 } else {
371 format!(" WITH ({})", parts.join(", "))
372 }
373}
374
375fn to_pg_id(id: u64) -> Result<i64> {
378 i64::try_from(id).map_err(|_| KernelError::Embedding(format!("id {id} exceeds BIGINT range")))
379}
380
381fn validate_table_name(table: &str) -> Result<()> {
386 let mut chars = table.chars();
387 let first_ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_');
388 let valid = first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
389 if !valid {
390 return Err(KernelError::Embedding(format!(
391 "invalid table identifier: {table:?}"
392 )));
393 }
394 Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use crate::embedding::AsyncVectorIndex;
401
402 fn pg_url() -> Option<String> {
404 std::env::var("LLMKERNEL_PG_URL").ok()
405 }
406
407 #[tokio::test]
411 async fn roundtrip_add_search_remove() {
412 let Some(url) = pg_url() else {
413 eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
414 return;
415 };
416 let table = format!("lk_test_{}", line!());
417 let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
418
419 let vecs = vec![
420 vec![1.0, 0.0, 0.0],
421 vec![0.0, 1.0, 0.0],
422 vec![0.0, 0.0, 1.0],
423 ];
424 let ids = vec![10u64, 20, 30];
425 idx.add(&vecs, &ids).await.expect("add");
426 assert_eq!(idx.len().await.unwrap(), 3);
427
428 let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
430 assert_eq!(hits.len(), 1);
431 assert_eq!(hits[0].id, 10);
432
433 let hits = idx
435 .search_filtered(&[1.0, 0.0, 0.0], 1, &[20, 30])
436 .await
437 .unwrap();
438 assert_eq!(hits.len(), 1);
439 assert_ne!(hits[0].id, 10);
440
441 idx.remove(&[10]).await.unwrap();
442 assert_eq!(idx.len().await.unwrap(), 2);
443
444 sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
446 .execute(&idx.pool)
447 .await
448 .ok();
449 }
450
451 #[tokio::test]
454 async fn roundtrip_halfvec() {
455 let Some(url) = pg_url() else {
456 eprintln!("skip pgvector halfvec test: LLMKERNEL_PG_URL unset");
457 return;
458 };
459 let table = format!("lk_test_hv_{}", line!());
460 let idx = PgVectorIndex::new_halfvec(&url, &table, 3)
461 .await
462 .expect("new_halfvec");
463
464 let vecs = vec![
465 vec![1.0, 0.0, 0.0],
466 vec![0.0, 1.0, 0.0],
467 vec![0.0, 0.0, 1.0],
468 ];
469 let ids = vec![10u64, 20, 30];
470 idx.add(&vecs, &ids).await.expect("add");
471 assert_eq!(idx.len().await.unwrap(), 3);
472
473 let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
475 assert_eq!(hits.len(), 1);
476 assert_eq!(hits[0].id, 10);
477
478 idx.remove(&[10]).await.unwrap();
479 assert_eq!(idx.len().await.unwrap(), 2);
480
481 sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
483 .execute(&idx.pool)
484 .await
485 .ok();
486 }
487
488 #[tokio::test]
491 async fn remove_in_tx_atomic_delete() {
492 let Some(url) = pg_url() else {
493 eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
494 return;
495 };
496 let table = format!("lk_txtx_{}", line!());
497 let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
498 idx.add(&[vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]], &[11, 12])
499 .await
500 .expect("add");
501 assert_eq!(idx.len().await.unwrap(), 2);
502
503 let mut tx = idx.pool().begin().await.expect("begin tx");
505 idx.remove_in_tx(&mut tx, &[11])
506 .await
507 .expect("remove_in_tx");
508 tx.commit().await.expect("commit");
509 assert_eq!(idx.len().await.unwrap(), 1);
510
511 let mut tx2 = idx.pool().begin().await.expect("begin tx2");
513 idx.remove_in_tx(&mut tx2, &[12])
514 .await
515 .expect("remove_in_tx2");
516 tx2.rollback().await.expect("rollback");
517 assert_eq!(idx.len().await.unwrap(), 1);
518
519 sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
521 .execute(idx.pool())
522 .await
523 .ok();
524 }
525
526 #[test]
527 fn rejects_invalid_table_name() {
528 assert!(validate_table_name("lk_test_1").is_ok());
530 assert!(validate_table_name("_vec").is_ok());
531 assert!(validate_table_name("").is_err());
533 assert!(validate_table_name("1bad").is_err());
534 assert!(validate_table_name("rm; DROP").is_err());
535 assert!(validate_table_name("weird\"name").is_err());
536 assert!(validate_table_name("sch.tbl").is_err());
537 }
538
539 #[test]
540 fn hnsw_with_clause_variants() {
541 assert_eq!(hnsw_with_clause(None, None), "");
542 assert_eq!(hnsw_with_clause(Some(32), None), " WITH (m = 32)");
543 assert_eq!(
544 hnsw_with_clause(None, Some(200)),
545 " WITH (ef_construction = 200)"
546 );
547 assert_eq!(
548 hnsw_with_clause(Some(32), Some(200)),
549 " WITH (m = 32, ef_construction = 200)"
550 );
551 }
552
553 #[test]
556 fn default_opts_are_full_precision_and_untuned() {
557 let o = PgVectorOpts::default();
558 assert!(!o.half);
559 assert!(o.hnsw_m.is_none());
560 assert!(o.hnsw_ef_construction.is_none());
561 assert!(o.hnsw_ef_search.is_none());
562 }
563
564 #[test]
565 fn rejects_overflowing_id() {
566 assert_eq!(to_pg_id(0).unwrap(), 0);
567 assert_eq!(to_pg_id(42).unwrap(), 42);
568 assert_eq!(to_pg_id(i64::MAX as u64).unwrap(), i64::MAX);
569 assert!(to_pg_id((i64::MAX as u64) + 1).is_err());
570 assert!(to_pg_id(u64::MAX).is_err());
571 }
572}