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 <type>)` 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//!
9//! Two precisions: [`new`](PgVectorIndex::new) → `vector` (float32, dim ≤ 2000),
10//! [`new_halfvec`](PgVectorIndex::new_halfvec) → `halfvec` (float16, ~half RAM,
11//! dim ≤ 4000; needs pgvector ≥ 0.6).
12
13use 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/// 검색 결과 행(id + cosine 유사도) — 튜플 대신 구조체로 sqlx `FromRow` 안정 매핑.
22#[derive(sqlx::FromRow)]
23struct ScoreRow {
24    id: i64,
25    score: f64,
26}
27
28/// f32 슬라이스 → pgvector 문자열 리터럴 `[1,2,3]` (text → vector 입력 캐스트).
29/// `pgvector::Vector`의 sqlx `Type` 바인드가 의존 환경에 따라 충돌해 문자열로 회피.
30fn 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/// Storage precision + HNSW tuning for [`PgVectorIndex`].
43///
44/// All fields default to pgvector's own defaults, so `PgVectorOpts::default()`
45/// is equivalent to [`PgVectorIndex::new`].
46#[derive(Debug, Clone, Default)]
47pub struct PgVectorOpts {
48    /// Store `halfvec` (float16) instead of `vector` (float32) — ~half the RAM.
49    pub half: bool,
50    /// HNSW `m` (edges per node). `None` → pgvector default (16). Higher `m`
51    /// raises recall and index size.
52    pub hnsw_m: Option<u32>,
53    /// HNSW `ef_construction` (build-time candidate list). `None` → default
54    /// (64). Higher values build a better graph, more slowly.
55    pub hnsw_ef_construction: Option<u32>,
56    /// `hnsw.ef_search` (query-time candidate list), applied to every pooled
57    /// connection. `None` → default (40). Raise it for higher recall on large
58    /// indexes; it is the main recall/latency knob at query time.
59    pub hnsw_ef_search: Option<u32>,
60}
61
62/// PostgreSQL vector index backed by the `pgvector` extension.
63///
64/// All operations are async over a shared `PgPool` (MVCC, connection-pooled).
65/// The relation named `table` holds `(id BIGINT PK, vec <type>(N))` rows where
66/// `<type>` is `vector` (float32) or `halfvec` (float16) per the `half` flag.
67pub struct PgVectorIndex {
68    pool: PgPool,
69    table: String,
70    dim: usize,
71    /// `true` → `halfvec` (float16, ~half RAM, recall 손실 ~0). `false` → `vector` (float32).
72    half: bool,
73    hnsw_m: Option<u32>,
74    hnsw_ef_construction: Option<u32>,
75}
76
77impl PgVectorIndex {
78    /// Connect to `url` (libpq connstring / `postgresql://…`), create the
79    /// vector table + HNSW cosine index if missing, and return a ready index.
80    ///
81    /// `dim` is enforced by the fixed `vector(dim)` column; vectors whose
82    /// length differs from `dim` are rejected by pgvector on insert. Uses
83    /// full-precision `vector` (float32). For half-precision see
84    /// [`new_halfvec`](Self::new_halfvec).
85    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    /// Half-precision variant — stores `halfvec` (float16): **~half the RAM** of
90    /// `vector` with negligible recall loss for cosine similarity. Requires the
91    /// `pgvector` extension ≥ 0.6 (`halfvec` type + `halfvec_cosine_ops`).
92    /// `dim` ≤ 4000 (vs 2000 for `vector`).
93    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    /// Full form — pick storage precision and HNSW tuning via [`PgVectorOpts`].
107    ///
108    /// `hnsw_m` / `hnsw_ef_construction` are applied to the `CREATE INDEX` (they
109    /// only affect a *newly* created index — an existing one keeps its build
110    /// parameters). `hnsw_ef_search` is applied to every pooled connection and
111    /// therefore takes effect immediately for queries.
112    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            // `ef` is a `u32`, so the interpolation has no injection surface.
122            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    /// SQL vector type name for the active precision (`vector` | `halfvec`).
148    fn sql_type(&self) -> &'static str {
149        if self.half { "halfvec" } else { "vector" }
150    }
151
152    /// HNSW ops class for the active precision.
153    fn ops_class(&self) -> &'static str {
154        if self.half {
155            "halfvec_cosine_ops"
156        } else {
157            "vector_cosine_ops"
158        }
159    }
160
161    /// `CREATE TABLE IF NOT EXISTS {table} (id BIGINT PK, vec {vector|halfvec}(N))`
162    /// + matching HNSW cosine index, per the active precision. Idempotent.
163    async fn init_schema(&self) -> Result<()> {
164        // Identifier is caller-controlled (not user input at runtime) and
165        // validated in `new_with_opts`, so `format!` is acceptable here. PG cannot
166        // bind identifiers. Callers pass a fixed, validated table name.
167        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    /// Underlying connection pool.
188    ///
189    /// Callers that need **cross-table transactional consistency** — e.g. pruning
190    /// a law's chunks plus their vectors atomically in one transaction — can
191    /// `pool().begin()` and run their own DML on the same pool the index uses.
192    /// This preserves the table-name encapsulation while letting the caller
193    /// coordinate a multi-statement transaction.
194    pub fn pool(&self) -> &PgPool {
195        &self.pool
196    }
197
198    /// Remove vectors by external IDs **within a caller-provided transaction**.
199    ///
200    /// Enables atomic cross-table deletes: begin a tx on [`pool`](Self::pool),
201    /// delete related rows in other relations (chunks, edges, …), call this with
202    /// the same `&mut PgConnection`, then `commit()`. A failure anywhere rolls
203    /// back the whole set — no orphaned vectors or chunks. The table name stays
204    /// encapsulated (`format!` over the validated identifier).
205    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        // Map u64 → i64 up front: pgvector stores BIGINT (i64), so values
233        // above i64::MAX cannot be represented and must be rejected rather
234        // than silently wrapped.
235        let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
236
237        // Each row binds 2 params (id, vec literal). PostgreSQL caps bound
238        // parameters at u16::MAX (65535), so a single INSERT over ~32k vectors
239        // overflows it. Chunk well under the limit; each chunk is its own
240        // round trip but shares one prepared-statement shape.
241        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            // pgvector `vec` 컬럼은 text 리터럴 입력 시 `::vector` 캐스트가 필수다.
251            // `push_values` 는 값별 캐스트를 붙일 수 없어 수동으로 VALUES 튜플을 조립한다
252            // (캐스트 누락 시 "column vec is of type vector but expression is of type text").
253            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        // cosine distance <=> : 0 (동일) .. 2 (반대). score = 1 - distance.
291        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
357/// ` WITH (m = …, ef_construction = …)` for the HNSW index, or `""` when both
358/// are unset (pgvector defaults: `m = 16`, `ef_construction = 64`). Values are
359/// `u32`, so the interpolation has no injection surface.
360fn 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
375/// `u64` external ID → PG `BIGINT` (`i64`). IDs exceeding `i64::MAX` cannot
376/// be stored in a BIGINT column — reject rather than silently wrap.
377fn to_pg_id(id: u64) -> Result<i64> {
378    i64::try_from(id).map_err(|_| KernelError::Embedding(format!("id {id} exceeds BIGINT range")))
379}
380
381/// Validate that `table` is a plain, safe SQL identifier (ASCII alphanumeric +
382/// `_`, starting with a letter or `_`). It is interpolated into DDL/DML via
383/// `format!` (PG cannot bind identifiers), so reject anything that could break
384/// out of the identifier context.
385fn 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    /// `LLMKERNEL_PG_URL` 미설정 시 자동 skip (graph-pg pg.rs 패턴).
403    fn pg_url() -> Option<String> {
404        std::env::var("LLMKERNEL_PG_URL").ok()
405    }
406
407    /// add → search → search_filtered → remove 라운드트립. HNSW는 대량 데이터에서
408    /// 정확도가 보장되지만 소규모 테스트에선 정확 매칭이 간헐적일 수 있어
409    /// 여기선 id/회수 위주로 검증.
410    #[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        // nearest to [1,0,0] → id 10 우선
429        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        // filtered: allow [20,30] → 10 제외
434        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        // cleanup
445        sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
446            .execute(&idx.pool)
447            .await
448            .ok();
449    }
450
451    /// halfvec(float16) 변형 라운드트립 — `new_halfvec` 로 halfvec 컬럼/인덱스 생성 후
452    /// add/search/remove 가 float32 경로와 동일하게 동작하는지. pgvector ≥ 0.6 필요.
453    #[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        // nearest to [1,0,0] → id 10 (halfvec cosine 도 동일 순위)
474        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        // cleanup
482        sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
483            .execute(&idx.pool)
484            .await
485            .ok();
486    }
487
488    /// `remove_in_tx`: 같은 풀에서 시작한 트랜잭션 내 삭제가 원자 반영되는지 검증.
489    /// klr prune(chunks + vectors 단일 tx)의 전제 — 커밋 전 롤백 시 벡터 보존 확인.
490    #[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        // 커밋 경로: tx 내 remove → commit → 삭제 반영
504        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        // 롤백 경로: tx 내 remove → rollback → 벡터 보존(원자성)
512        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        // cleanup
520        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        // valid identifiers accepted
529        assert!(validate_table_name("lk_test_1").is_ok());
530        assert!(validate_table_name("_vec").is_ok());
531        // rejected — would break out of identifier context
532        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    /// `PgVectorOpts::default()` must stay behaviourally identical to `new`
554    /// (full precision, pgvector's own HNSW defaults).
555    #[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}