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