Skip to main content

llm_kernel/embedding/
pgvector.rs

1//! pgvector `AsyncVectorIndex` — PostgreSQL + the `pgvector` extension.
2//!
3//! `PgVectorIndex` implements [`AsyncVectorIndex`] over a `sqlx::PgPool`,
4//! mirroring `qdrant`/`elastic` (async remote vector backend). Vectors live in
5//! a `{table}(id BIGINT PK, vec vector)` relation; search uses the cosine
6//! distance operator `<=>`. Requires `CREATE EXTENSION vector` in the target DB
7//! (the table + HNSW index are created automatically by [`PgVectorIndex::new`]).
8
9use 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
18/// PostgreSQL vector index backed by the `pgvector` extension.
19///
20/// All operations are async over a shared `PgPool` (MVCC, connection-pooled).
21/// The relation named `table` holds `(id BIGINT PK, vec vector(N))` rows.
22pub struct PgVectorIndex {
23    pool: PgPool,
24    table: String,
25    dim: usize,
26}
27
28impl PgVectorIndex {
29    /// Connect to `url` (libpq connstring / `postgresql://…`), create the
30    /// vector table + HNSW cosine index if missing, and return a ready index.
31    ///
32    /// `dim` is enforced by the fixed `vector(dim)` column; vectors whose
33    /// length differs from `dim` are rejected by pgvector on insert.
34    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    /// `CREATE TABLE IF NOT EXISTS {table} (id BIGINT PK, vec vector)` + HNSW
51    /// cosine index. Idempotent.
52    async fn init_schema(&self) -> Result<()> {
53        // Identifier is caller-controlled (not user input at runtime) and
54        // validated in `new`, so `format!` is acceptable here. PG cannot bind
55        // identifiers. Callers pass a fixed, validated table name.
56        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        // Map u64 → i64 up front: pgvector stores BIGINT (i64), so values
88        // above i64::MAX cannot be represented and must be rejected rather
89        // than silently wrapped. Single batched INSERT → one round trip.
90        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        // cosine distance <=> : 0 (동일) .. 2 (반대). score = 1 - distance.
121        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
186/// `u64` external ID → PG `BIGINT` (`i64`). IDs exceeding `i64::MAX` cannot
187/// be stored in a BIGINT column — reject rather than silently wrap.
188fn to_pg_id(id: u64) -> Result<i64> {
189    i64::try_from(id).map_err(|_| KernelError::Embedding(format!("id {id} exceeds BIGINT range")))
190}
191
192/// Validate that `table` is a plain, safe SQL identifier (ASCII alphanumeric +
193/// `_`, starting with a letter or `_`). It is interpolated into DDL/DML via
194/// `format!` (PG cannot bind identifiers), so reject anything that could break
195/// out of the identifier context.
196fn 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    /// `LLMKERNEL_PG_URL` 미설정 시 자동 skip (graph-pg pg.rs 패턴).
214    fn pg_url() -> Option<String> {
215        std::env::var("LLMKERNEL_PG_URL").ok()
216    }
217
218    /// add → search → search_filtered → remove 라운드트립. HNSW는 대량 데이터에서
219    /// 정확도가 보장되지만 소규모 테스트에선 정확 매칭이 간헐적일 수 있어
220    /// 여기선 id/회수 위주로 검증.
221    #[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        // nearest to [1,0,0] → id 10 우선
240        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        // filtered: allow [20,30] → 10 제외
245        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        // cleanup
256        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        // valid identifiers accepted
265        assert!(validate_table_name("lk_test_1").is_ok());
266        assert!(validate_table_name("_vec").is_ok());
267        // rejected — would break out of identifier context
268        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}