1use async_trait::async_trait;
14use sqlx::PgPool;
15use sqlx::QueryBuilder;
16use sqlx::postgres::PgPoolOptions;
17
18use crate::embedding::sparse::SparseVector;
19use crate::embedding::vector_index::SearchHit;
20use crate::error::{KernelError, Result};
21
22#[derive(sqlx::FromRow)]
24struct ScoreRow {
25 id: i64,
26 score: f64,
27}
28
29#[derive(Debug, Clone, Copy)]
40pub struct SqlFilter<'a> {
41 pub predicate: &'a str,
43 pub binds: &'a [&'a str],
45}
46
47async fn fetch_where(
50 pool: &PgPool,
51 sql: &str,
52 q: String,
53 k: usize,
54 filter: &SqlFilter<'_>,
55 what: &str,
56) -> Result<Vec<SearchHit>> {
57 let mut query = sqlx::query_as::<_, ScoreRow>(sql).bind(q).bind(k as i64);
58 for b in filter.binds {
59 query = query.bind(*b);
60 }
61 let rows = query
62 .fetch_all(pool)
63 .await
64 .map_err(|e| KernelError::Embedding(format!("{what}: {e}")))?;
65 Ok(rows
66 .into_iter()
67 .map(|r| SearchHit {
68 id: r.id as u64,
69 score: r.score as f32,
70 })
71 .collect())
72}
73
74fn vec_literal(v: &[f32]) -> String {
77 let mut s = String::from("[");
78 for (i, f) in v.iter().enumerate() {
79 if i > 0 {
80 s.push(',');
81 }
82 s.push_str(&f.to_string());
83 }
84 s.push(']');
85 s
86}
87
88#[derive(Debug, Clone, Default)]
93pub struct PgVectorOpts {
94 pub half: bool,
96 pub hnsw_m: Option<u32>,
99 pub hnsw_ef_construction: Option<u32>,
102 pub hnsw_ef_search: Option<u32>,
106}
107
108pub struct PgVectorIndex {
114 pool: PgPool,
115 table: String,
116 dim: usize,
117 half: bool,
119 hnsw_m: Option<u32>,
120 hnsw_ef_construction: Option<u32>,
121}
122
123impl PgVectorIndex {
124 pub async fn new(url: &str, table: &str, dim: usize) -> Result<Self> {
132 Self::new_with_opts(url, table, dim, PgVectorOpts::default()).await
133 }
134
135 pub async fn new_halfvec(url: &str, table: &str, dim: usize) -> Result<Self> {
140 Self::new_with_opts(
141 url,
142 table,
143 dim,
144 PgVectorOpts {
145 half: true,
146 ..Default::default()
147 },
148 )
149 .await
150 }
151
152 pub async fn new_with_opts(
159 url: &str,
160 table: &str,
161 dim: usize,
162 opts: PgVectorOpts,
163 ) -> Result<Self> {
164 validate_table_name(table)?;
165 let mut pool_opts = PgPoolOptions::new().max_connections(8);
166 if let Some(ef) = opts.hnsw_ef_search {
167 pool_opts = pool_opts.after_connect(move |conn, _meta| {
169 Box::pin(async move {
170 sqlx::query(&format!("SET hnsw.ef_search = {ef}"))
171 .execute(conn)
172 .await?;
173 Ok(())
174 })
175 });
176 }
177 let pool = pool_opts
178 .connect(url)
179 .await
180 .map_err(|e| KernelError::Embedding(format!("pgvector connect: {e}")))?;
181 let idx = Self {
182 pool,
183 table: table.to_string(),
184 dim,
185 half: opts.half,
186 hnsw_m: opts.hnsw_m,
187 hnsw_ef_construction: opts.hnsw_ef_construction,
188 };
189 idx.init_schema().await?;
190 Ok(idx)
191 }
192
193 fn sql_type(&self) -> &'static str {
195 if self.half { "halfvec" } else { "vector" }
196 }
197
198 fn ops_class(&self) -> &'static str {
200 if self.half {
201 "halfvec_cosine_ops"
202 } else {
203 "vector_cosine_ops"
204 }
205 }
206
207 async fn init_schema(&self) -> Result<()> {
210 let ty = self.sql_type();
214 let ops = self.ops_class();
215 let with = hnsw_with_clause(self.hnsw_m, self.hnsw_ef_construction);
216 sqlx::query(&format!(
217 "CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, vec {}({}) NOT NULL)",
218 self.table, ty, self.dim
219 ))
220 .execute(&self.pool)
221 .await
222 .map_err(|e| KernelError::Embedding(format!("pgvector create table: {e}")))?;
223 sqlx::query(&format!(
224 "CREATE INDEX IF NOT EXISTS idx_{}_vec ON {} USING hnsw (vec {}){}",
225 self.table, self.table, ops, with
226 ))
227 .execute(&self.pool)
228 .await
229 .map_err(|e| KernelError::Embedding(format!("pgvector hnsw index: {e}")))?;
230 Ok(())
231 }
232
233 pub fn pool(&self) -> &PgPool {
241 &self.pool
242 }
243
244 pub async fn remove_in_tx(&self, tx: &mut sqlx::PgConnection, ids: &[u64]) -> Result<()> {
252 if ids.is_empty() {
253 return Ok(());
254 }
255 let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
256 sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
257 .bind(&ids)
258 .execute(tx)
259 .await
260 .map_err(|e| KernelError::Embedding(format!("pgvector remove_in_tx: {e}")))?;
261 Ok(())
262 }
263
264 pub async fn search_where(
269 &self,
270 query: &[f32],
271 k: usize,
272 filter: &SqlFilter<'_>,
273 ) -> Result<Vec<SearchHit>> {
274 let ty = self.sql_type();
275 let sql = format!(
276 "SELECT v.id, 1 - (v.vec <=> $1::{ty}) AS score FROM {} v WHERE ({}) \
277 ORDER BY v.vec <=> $1::{ty}, v.id LIMIT $2",
278 self.table, filter.predicate
279 );
280 fetch_where(
281 &self.pool,
282 &sql,
283 vec_literal(query),
284 k,
285 filter,
286 "pgvector search_where",
287 )
288 .await
289 }
290}
291
292#[async_trait]
293impl crate::embedding::AsyncVectorIndex for PgVectorIndex {
294 async fn add(&self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()> {
295 if vectors.len() != ids.len() {
296 return Err(KernelError::Embedding(format!(
297 "vectors.len() ({}) must equal ids.len() ({})",
298 vectors.len(),
299 ids.len()
300 )));
301 }
302 if vectors.is_empty() {
303 return Ok(());
304 }
305 let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
309
310 const ROWS_PER_CHUNK: usize = 16_000;
315 for chunk in vectors
316 .chunks(ROWS_PER_CHUNK)
317 .zip(pg_ids.chunks(ROWS_PER_CHUNK))
318 {
319 let (chunk_vecs, chunk_ids) = chunk;
320 let mut q = QueryBuilder::new("INSERT INTO ");
321 q.push(self.table.as_str());
322 q.push(" (id, vec) VALUES ");
323 for (i, (v, &id)) in chunk_vecs.iter().zip(chunk_ids.iter()).enumerate() {
327 if i > 0 {
328 q.push(", ");
329 }
330 q.push("(");
331 q.push_bind(id);
332 q.push(", ");
333 q.push_bind(vec_literal(v));
334 q.push("::");
335 q.push(self.sql_type());
336 q.push(")");
337 }
338 q.push(" ON CONFLICT (id) DO UPDATE SET vec = EXCLUDED.vec");
339 q.build()
340 .execute(&self.pool)
341 .await
342 .map_err(|e| KernelError::Embedding(format!("pgvector add: {e}")))?;
343 }
344 Ok(())
345 }
346
347 async fn remove(&self, ids: &[u64]) -> Result<()> {
348 if ids.is_empty() {
349 return Ok(());
350 }
351 let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
352 sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
353 .bind(&ids)
354 .execute(&self.pool)
355 .await
356 .map_err(|e| KernelError::Embedding(format!("pgvector remove: {e}")))?;
357 Ok(())
358 }
359
360 async fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
361 let q = vec_literal(query);
362 let ty = self.sql_type();
363 let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
368 "SELECT id, 1 - (vec <=> $1::{ty}) AS score FROM {} \
369 ORDER BY vec <=> $1::{ty}, id LIMIT $2",
370 self.table
371 ))
372 .bind(q)
373 .bind(k as i64)
374 .fetch_all(&self.pool)
375 .await
376 .map_err(|e| KernelError::Embedding(format!("pgvector search: {e}")))?;
377 Ok(rows
378 .into_iter()
379 .map(|r| SearchHit {
380 id: r.id as u64,
381 score: r.score as f32,
382 })
383 .collect())
384 }
385
386 async fn search_filtered(
387 &self,
388 query: &[f32],
389 k: usize,
390 allowlist: &[u64],
391 ) -> Result<Vec<SearchHit>> {
392 if allowlist.is_empty() {
393 return Ok(Vec::new());
394 }
395 let q = vec_literal(query);
396 let ty = self.sql_type();
397 let allow: Vec<i64> = allowlist
398 .iter()
399 .map(|&i| to_pg_id(i))
400 .collect::<Result<_>>()?;
401 let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
402 "SELECT id, 1 - (vec <=> $1::{ty}) AS score FROM {} WHERE id = ANY($2) \
403 ORDER BY vec <=> $1::{ty}, id LIMIT $3",
404 self.table
405 ))
406 .bind(q)
407 .bind(&allow)
408 .bind(k as i64)
409 .fetch_all(&self.pool)
410 .await
411 .map_err(|e| KernelError::Embedding(format!("pgvector search_filtered: {e}")))?;
412 Ok(rows
413 .into_iter()
414 .map(|r| SearchHit {
415 id: r.id as u64,
416 score: r.score as f32,
417 })
418 .collect())
419 }
420
421 async fn len(&self) -> Result<usize> {
422 let n: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM {}", self.table))
423 .fetch_one(&self.pool)
424 .await
425 .map_err(|e| KernelError::Embedding(format!("pgvector len: {e}")))?;
426 Ok(n as usize)
427 }
428
429 fn dim(&self) -> usize {
430 self.dim
431 }
432}
433
434fn sparse_literal(v: &SparseVector, dim: usize) -> String {
445 let mut s = String::from("{");
446 for (n, (idx, val)) in v.indices().iter().zip(v.values()).enumerate() {
447 if n > 0 {
448 s.push(',');
449 }
450 s.push_str(&(u64::from(*idx) + 1).to_string());
451 s.push(':');
452 s.push_str(&val.to_string());
453 }
454 s.push('}');
455 s.push('/');
456 s.push_str(&dim.to_string());
457 s
458}
459
460pub struct PgSparseVectorIndex {
473 pool: PgPool,
474 table: String,
475 dim: usize,
476}
477
478impl PgSparseVectorIndex {
479 pub async fn new(url: &str, table: &str, dim: usize) -> Result<Self> {
484 validate_table_name(table)?;
485 let pool = PgPoolOptions::new()
486 .max_connections(8)
487 .connect(url)
488 .await
489 .map_err(|e| KernelError::Embedding(format!("pgvector sparse connect: {e}")))?;
490 let idx = Self {
491 pool,
492 table: table.to_string(),
493 dim,
494 };
495 sqlx::query(&format!(
496 "CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, vec sparsevec({}) NOT NULL)",
497 idx.table, idx.dim
498 ))
499 .execute(&idx.pool)
500 .await
501 .map_err(|e| KernelError::Embedding(format!("pgvector sparse create table: {e}")))?;
502 sqlx::query(&format!(
503 "CREATE INDEX IF NOT EXISTS idx_{}_vec ON {} USING hnsw (vec sparsevec_ip_ops)",
504 idx.table, idx.table
505 ))
506 .execute(&idx.pool)
507 .await
508 .map_err(|e| KernelError::Embedding(format!("pgvector sparse hnsw index: {e}")))?;
509 Ok(idx)
510 }
511
512 pub fn dim(&self) -> usize {
514 self.dim
515 }
516
517 pub fn pool(&self) -> &PgPool {
519 &self.pool
520 }
521
522 pub async fn add(&self, vectors: &[SparseVector], ids: &[u64]) -> Result<()> {
524 if vectors.len() != ids.len() {
525 return Err(KernelError::Embedding(format!(
526 "vectors.len() ({}) must equal ids.len() ({})",
527 vectors.len(),
528 ids.len()
529 )));
530 }
531 if vectors.is_empty() {
532 return Ok(());
533 }
534 let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
535 let mut q = QueryBuilder::new("INSERT INTO ");
536 q.push(self.table.as_str());
537 q.push(" (id, vec) VALUES ");
538 for (i, (v, &id)) in vectors.iter().zip(pg_ids.iter()).enumerate() {
539 if i > 0 {
540 q.push(", ");
541 }
542 q.push("(");
543 q.push_bind(id);
544 q.push(", ");
545 q.push_bind(sparse_literal(v, self.dim));
546 q.push("::sparsevec)");
547 }
548 q.push(" ON CONFLICT (id) DO UPDATE SET vec = EXCLUDED.vec");
549 q.build()
550 .execute(&self.pool)
551 .await
552 .map_err(|e| KernelError::Embedding(format!("pgvector sparse add: {e}")))?;
553 Ok(())
554 }
555
556 pub async fn search(&self, query: &SparseVector, k: usize) -> Result<Vec<SearchHit>> {
559 if query.is_empty() {
560 return Ok(Vec::new());
561 }
562 let q = sparse_literal(query, self.dim);
563 let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
567 "SELECT id, -(vec <#> $1::sparsevec) AS score FROM {} \
568 ORDER BY vec <#> $1::sparsevec, id LIMIT $2",
569 self.table
570 ))
571 .bind(q)
572 .bind(k as i64)
573 .fetch_all(&self.pool)
574 .await
575 .map_err(|e| KernelError::Embedding(format!("pgvector sparse search: {e}")))?;
576 Ok(rows
577 .into_iter()
578 .map(|r| SearchHit {
579 id: r.id as u64,
580 score: r.score as f32,
581 })
582 .collect())
583 }
584
585 pub async fn search_filtered(
587 &self,
588 query: &SparseVector,
589 k: usize,
590 allowlist: &[u64],
591 ) -> Result<Vec<SearchHit>> {
592 if query.is_empty() || allowlist.is_empty() {
593 return Ok(Vec::new());
594 }
595 let q = sparse_literal(query, self.dim);
596 let allow: Vec<i64> = allowlist
597 .iter()
598 .map(|&i| to_pg_id(i))
599 .collect::<Result<_>>()?;
600 let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
601 "SELECT id, -(vec <#> $1::sparsevec) AS score FROM {} WHERE id = ANY($2) \
602 ORDER BY vec <#> $1::sparsevec, id LIMIT $3",
603 self.table
604 ))
605 .bind(q)
606 .bind(&allow)
607 .bind(k as i64)
608 .fetch_all(&self.pool)
609 .await
610 .map_err(|e| KernelError::Embedding(format!("pgvector sparse search_filtered: {e}")))?;
611 Ok(rows
612 .into_iter()
613 .map(|r| SearchHit {
614 id: r.id as u64,
615 score: r.score as f32,
616 })
617 .collect())
618 }
619
620 pub async fn search_where(
623 &self,
624 query: &SparseVector,
625 k: usize,
626 filter: &SqlFilter<'_>,
627 ) -> Result<Vec<SearchHit>> {
628 if query.is_empty() {
629 return Ok(Vec::new());
630 }
631 let sql = format!(
632 "SELECT v.id, -(v.vec <#> $1::sparsevec) AS score FROM {} v WHERE ({}) \
633 ORDER BY v.vec <#> $1::sparsevec, v.id LIMIT $2",
634 self.table, filter.predicate
635 );
636 fetch_where(
637 &self.pool,
638 &sql,
639 sparse_literal(query, self.dim),
640 k,
641 filter,
642 "pgvector sparse search_where",
643 )
644 .await
645 }
646
647 pub async fn remove(&self, ids: &[u64]) -> Result<()> {
649 if ids.is_empty() {
650 return Ok(());
651 }
652 let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
653 sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
654 .bind(&ids)
655 .execute(&self.pool)
656 .await
657 .map_err(|e| KernelError::Embedding(format!("pgvector sparse remove: {e}")))?;
658 Ok(())
659 }
660
661 pub async fn remove_in_tx(&self, tx: &mut sqlx::PgConnection, ids: &[u64]) -> Result<()> {
665 if ids.is_empty() {
666 return Ok(());
667 }
668 let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
669 sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
670 .bind(&ids)
671 .execute(tx)
672 .await
673 .map_err(|e| KernelError::Embedding(format!("pgvector sparse remove_in_tx: {e}")))?;
674 Ok(())
675 }
676
677 pub async fn len(&self) -> Result<usize> {
679 let n: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM {}", self.table))
680 .fetch_one(&self.pool)
681 .await
682 .map_err(|e| KernelError::Embedding(format!("pgvector sparse len: {e}")))?;
683 Ok(n as usize)
684 }
685
686 pub async fn is_empty(&self) -> Result<bool> {
688 Ok(self.len().await? == 0)
689 }
690}
691
692fn hnsw_with_clause(m: Option<u32>, ef_construction: Option<u32>) -> String {
696 let mut parts: Vec<String> = Vec::new();
697 if let Some(m) = m {
698 parts.push(format!("m = {m}"));
699 }
700 if let Some(ef) = ef_construction {
701 parts.push(format!("ef_construction = {ef}"));
702 }
703 if parts.is_empty() {
704 String::new()
705 } else {
706 format!(" WITH ({})", parts.join(", "))
707 }
708}
709
710fn to_pg_id(id: u64) -> Result<i64> {
713 i64::try_from(id).map_err(|_| KernelError::Embedding(format!("id {id} exceeds BIGINT range")))
714}
715
716fn validate_table_name(table: &str) -> Result<()> {
721 let mut chars = table.chars();
722 let first_ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_');
723 let valid = first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
724 if !valid {
725 return Err(KernelError::Embedding(format!(
726 "invalid table identifier: {table:?}"
727 )));
728 }
729 Ok(())
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use crate::embedding::AsyncVectorIndex;
736
737 fn pg_url() -> Option<String> {
739 std::env::var("LLMKERNEL_PG_URL").ok()
740 }
741
742 #[tokio::test]
746 async fn roundtrip_add_search_remove() {
747 let Some(url) = pg_url() else {
748 eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
749 return;
750 };
751 let table = format!("lk_test_{}", line!());
752 let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
753
754 let vecs = vec![
755 vec![1.0, 0.0, 0.0],
756 vec![0.0, 1.0, 0.0],
757 vec![0.0, 0.0, 1.0],
758 ];
759 let ids = vec![10u64, 20, 30];
760 idx.add(&vecs, &ids).await.expect("add");
761 assert_eq!(idx.len().await.unwrap(), 3);
762
763 let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
765 assert_eq!(hits.len(), 1);
766 assert_eq!(hits[0].id, 10);
767
768 let hits = idx
770 .search_filtered(&[1.0, 0.0, 0.0], 1, &[20, 30])
771 .await
772 .unwrap();
773 assert_eq!(hits.len(), 1);
774 assert_ne!(hits[0].id, 10);
775
776 idx.remove(&[10]).await.unwrap();
777 assert_eq!(idx.len().await.unwrap(), 2);
778
779 sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
781 .execute(&idx.pool)
782 .await
783 .ok();
784 }
785
786 #[tokio::test]
789 async fn roundtrip_halfvec() {
790 let Some(url) = pg_url() else {
791 eprintln!("skip pgvector halfvec test: LLMKERNEL_PG_URL unset");
792 return;
793 };
794 let table = format!("lk_test_hv_{}", line!());
795 let idx = PgVectorIndex::new_halfvec(&url, &table, 3)
796 .await
797 .expect("new_halfvec");
798
799 let vecs = vec![
800 vec![1.0, 0.0, 0.0],
801 vec![0.0, 1.0, 0.0],
802 vec![0.0, 0.0, 1.0],
803 ];
804 let ids = vec![10u64, 20, 30];
805 idx.add(&vecs, &ids).await.expect("add");
806 assert_eq!(idx.len().await.unwrap(), 3);
807
808 let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
810 assert_eq!(hits.len(), 1);
811 assert_eq!(hits[0].id, 10);
812
813 idx.remove(&[10]).await.unwrap();
814 assert_eq!(idx.len().await.unwrap(), 2);
815
816 sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
818 .execute(&idx.pool)
819 .await
820 .ok();
821 }
822
823 #[tokio::test]
826 async fn remove_in_tx_atomic_delete() {
827 let Some(url) = pg_url() else {
828 eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
829 return;
830 };
831 let table = format!("lk_txtx_{}", line!());
832 let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
833 idx.add(&[vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]], &[11, 12])
834 .await
835 .expect("add");
836 assert_eq!(idx.len().await.unwrap(), 2);
837
838 let mut tx = idx.pool().begin().await.expect("begin tx");
840 idx.remove_in_tx(&mut tx, &[11])
841 .await
842 .expect("remove_in_tx");
843 tx.commit().await.expect("commit");
844 assert_eq!(idx.len().await.unwrap(), 1);
845
846 let mut tx2 = idx.pool().begin().await.expect("begin tx2");
848 idx.remove_in_tx(&mut tx2, &[12])
849 .await
850 .expect("remove_in_tx2");
851 tx2.rollback().await.expect("rollback");
852 assert_eq!(idx.len().await.unwrap(), 1);
853
854 sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
856 .execute(idx.pool())
857 .await
858 .ok();
859 }
860
861 #[test]
862 fn rejects_invalid_table_name() {
863 assert!(validate_table_name("lk_test_1").is_ok());
865 assert!(validate_table_name("_vec").is_ok());
866 assert!(validate_table_name("").is_err());
868 assert!(validate_table_name("1bad").is_err());
869 assert!(validate_table_name("rm; DROP").is_err());
870 assert!(validate_table_name("weird\"name").is_err());
871 assert!(validate_table_name("sch.tbl").is_err());
872 }
873
874 #[test]
876 fn sparse_literal_is_one_based() {
877 let sv = SparseVector::new(vec![0, 2], vec![0.5, 0.25]).unwrap();
878 assert_eq!(sparse_literal(&sv, 5), "{1:0.5,3:0.25}/5");
879 }
880
881 #[test]
882 fn sparse_literal_handles_empty() {
883 assert_eq!(sparse_literal(&SparseVector::default(), 5), "{}/5");
884 }
885
886 #[tokio::test]
889 async fn roundtrip_sparse() {
890 let Some(url) = pg_url() else {
891 eprintln!("skip pgvector sparse test: LLMKERNEL_PG_URL unset");
892 return;
893 };
894 let table = format!("lk_test_sp_{}", line!());
895 let idx = PgSparseVectorIndex::new(&url, &table, 10)
896 .await
897 .expect("new sparse");
898
899 let a = SparseVector::new(vec![0, 3], vec![1.0, 0.5]).unwrap();
900 let b = SparseVector::new(vec![3, 7], vec![0.5, 1.0]).unwrap();
901 idx.add(&[a.clone(), b], &[10, 20]).await.expect("add");
902 assert_eq!(idx.len().await.unwrap(), 2);
903
904 let hits = idx.search(&a, 1).await.unwrap();
906 assert_eq!(hits.len(), 1);
907 assert_eq!(hits[0].id, 10);
908
909 let empty = idx.search(&SparseVector::default(), 5).await.unwrap();
911 assert!(empty.is_empty());
912
913 idx.remove(&[10]).await.unwrap();
914 assert_eq!(idx.len().await.unwrap(), 1);
915
916 sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
917 .execute(idx.pool())
918 .await
919 .ok();
920 }
921
922 #[tokio::test]
925 async fn search_where_joins_side_table() {
926 let Some(url) = pg_url() else {
927 eprintln!("skip pgvector search_where test: LLMKERNEL_PG_URL unset");
928 return;
929 };
930 let table = format!("lk_test_sw_{}", line!());
931 let sp_table = format!("{table}_sp");
932 let tags = format!("{table}_tags");
933 let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
934 let sp = PgSparseVectorIndex::new(&url, &sp_table, 10)
935 .await
936 .expect("new sparse");
937 idx.add(
938 &[
939 vec![1.0, 0.0, 0.0],
940 vec![0.9, 0.1, 0.0],
941 vec![0.0, 1.0, 0.0],
942 ],
943 &[10, 20, 30],
944 )
945 .await
946 .expect("add");
947 let a = SparseVector::new(vec![0, 3], vec![1.0, 0.5]).unwrap();
948 let b = SparseVector::new(vec![0, 7], vec![0.5, 1.0]).unwrap();
949 sp.add(&[a.clone(), b], &[10, 20])
950 .await
951 .expect("add sparse");
952 sqlx::query(&format!(
953 "CREATE TABLE {tags} (id BIGINT PRIMARY KEY, tag TEXT NOT NULL)"
954 ))
955 .execute(idx.pool())
956 .await
957 .expect("create tags");
958 sqlx::query(&format!(
959 "INSERT INTO {tags} VALUES (10, 'x'), (20, 'y'), (30, 'y')"
960 ))
961 .execute(idx.pool())
962 .await
963 .expect("insert tags");
964
965 let pred = format!("EXISTS (SELECT 1 FROM {tags} t WHERE t.id = v.id AND t.tag = $3)");
966 let filter = SqlFilter {
967 predicate: &pred,
968 binds: &["y"],
969 };
970 let hits = idx
972 .search_where(&[1.0, 0.0, 0.0], 2, &filter)
973 .await
974 .unwrap();
975 assert_eq!(hits.iter().map(|h| h.id).collect::<Vec<_>>(), vec![20, 30]);
976 let hits = sp.search_where(&a, 5, &filter).await.unwrap();
977 assert_eq!(hits.iter().map(|h| h.id).collect::<Vec<_>>(), vec![20]);
978 assert!(
979 sp.search_where(&SparseVector::default(), 5, &filter)
980 .await
981 .unwrap()
982 .is_empty()
983 );
984
985 for t in [&tags, &table, &sp_table] {
986 sqlx::query(&format!("DROP TABLE IF EXISTS {t}"))
987 .execute(idx.pool())
988 .await
989 .ok();
990 }
991 }
992
993 #[test]
994 fn hnsw_with_clause_variants() {
995 assert_eq!(hnsw_with_clause(None, None), "");
996 assert_eq!(hnsw_with_clause(Some(32), None), " WITH (m = 32)");
997 assert_eq!(
998 hnsw_with_clause(None, Some(200)),
999 " WITH (ef_construction = 200)"
1000 );
1001 assert_eq!(
1002 hnsw_with_clause(Some(32), Some(200)),
1003 " WITH (m = 32, ef_construction = 200)"
1004 );
1005 }
1006
1007 #[test]
1010 fn default_opts_are_full_precision_and_untuned() {
1011 let o = PgVectorOpts::default();
1012 assert!(!o.half);
1013 assert!(o.hnsw_m.is_none());
1014 assert!(o.hnsw_ef_construction.is_none());
1015 assert!(o.hnsw_ef_search.is_none());
1016 }
1017
1018 #[test]
1019 fn rejects_overflowing_id() {
1020 assert_eq!(to_pg_id(0).unwrap(), 0);
1021 assert_eq!(to_pg_id(42).unwrap(), 42);
1022 assert_eq!(to_pg_id(i64::MAX as u64).unwrap(), i64::MAX);
1023 assert!(to_pg_id((i64::MAX as u64) + 1).is_err());
1024 assert!(to_pg_id(u64::MAX).is_err());
1025 }
1026}