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::sparse::SparseVector;
19use crate::embedding::vector_index::SearchHit;
20use crate::error::{KernelError, Result};
21
22/// 검색 결과 행(id + cosine 유사도) — 튜플 대신 구조체로 sqlx `FromRow` 안정 매핑.
23#[derive(sqlx::FromRow)]
24struct ScoreRow {
25    id: i64,
26    score: f64,
27}
28
29/// f32 슬라이스 → pgvector 문자열 리터럴 `[1,2,3]` (text → vector 입력 캐스트).
30/// `pgvector::Vector`의 sqlx `Type` 바인드가 의존 환경에 따라 충돌해 문자열로 회피.
31fn vec_literal(v: &[f32]) -> String {
32    let mut s = String::from("[");
33    for (i, f) in v.iter().enumerate() {
34        if i > 0 {
35            s.push(',');
36        }
37        s.push_str(&f.to_string());
38    }
39    s.push(']');
40    s
41}
42
43/// Storage precision + HNSW tuning for [`PgVectorIndex`].
44///
45/// All fields default to pgvector's own defaults, so `PgVectorOpts::default()`
46/// is equivalent to [`PgVectorIndex::new`].
47#[derive(Debug, Clone, Default)]
48pub struct PgVectorOpts {
49    /// Store `halfvec` (float16) instead of `vector` (float32) — ~half the RAM.
50    pub half: bool,
51    /// HNSW `m` (edges per node). `None` → pgvector default (16). Higher `m`
52    /// raises recall and index size.
53    pub hnsw_m: Option<u32>,
54    /// HNSW `ef_construction` (build-time candidate list). `None` → default
55    /// (64). Higher values build a better graph, more slowly.
56    pub hnsw_ef_construction: Option<u32>,
57    /// `hnsw.ef_search` (query-time candidate list), applied to every pooled
58    /// connection. `None` → default (40). Raise it for higher recall on large
59    /// indexes; it is the main recall/latency knob at query time.
60    pub hnsw_ef_search: Option<u32>,
61}
62
63/// PostgreSQL vector index backed by the `pgvector` extension.
64///
65/// All operations are async over a shared `PgPool` (MVCC, connection-pooled).
66/// The relation named `table` holds `(id BIGINT PK, vec <type>(N))` rows where
67/// `<type>` is `vector` (float32) or `halfvec` (float16) per the `half` flag.
68pub struct PgVectorIndex {
69    pool: PgPool,
70    table: String,
71    dim: usize,
72    /// `true` → `halfvec` (float16, ~half RAM, recall 손실 ~0). `false` → `vector` (float32).
73    half: bool,
74    hnsw_m: Option<u32>,
75    hnsw_ef_construction: Option<u32>,
76}
77
78impl PgVectorIndex {
79    /// Connect to `url` (libpq connstring / `postgresql://…`), create the
80    /// vector table + HNSW cosine index if missing, and return a ready index.
81    ///
82    /// `dim` is enforced by the fixed `vector(dim)` column; vectors whose
83    /// length differs from `dim` are rejected by pgvector on insert. Uses
84    /// full-precision `vector` (float32). For half-precision see
85    /// [`new_halfvec`](Self::new_halfvec).
86    pub async fn new(url: &str, table: &str, dim: usize) -> Result<Self> {
87        Self::new_with_opts(url, table, dim, PgVectorOpts::default()).await
88    }
89
90    /// Half-precision variant — stores `halfvec` (float16): **~half the RAM** of
91    /// `vector` with negligible recall loss for cosine similarity. Requires the
92    /// `pgvector` extension ≥ 0.6 (`halfvec` type + `halfvec_cosine_ops`).
93    /// `dim` ≤ 4000 (vs 2000 for `vector`).
94    pub async fn new_halfvec(url: &str, table: &str, dim: usize) -> Result<Self> {
95        Self::new_with_opts(
96            url,
97            table,
98            dim,
99            PgVectorOpts {
100                half: true,
101                ..Default::default()
102            },
103        )
104        .await
105    }
106
107    /// Full form — pick storage precision and HNSW tuning via [`PgVectorOpts`].
108    ///
109    /// `hnsw_m` / `hnsw_ef_construction` are applied to the `CREATE INDEX` (they
110    /// only affect a *newly* created index — an existing one keeps its build
111    /// parameters). `hnsw_ef_search` is applied to every pooled connection and
112    /// therefore takes effect immediately for queries.
113    pub async fn new_with_opts(
114        url: &str,
115        table: &str,
116        dim: usize,
117        opts: PgVectorOpts,
118    ) -> Result<Self> {
119        validate_table_name(table)?;
120        let mut pool_opts = PgPoolOptions::new().max_connections(8);
121        if let Some(ef) = opts.hnsw_ef_search {
122            // `ef` is a `u32`, so the interpolation has no injection surface.
123            pool_opts = pool_opts.after_connect(move |conn, _meta| {
124                Box::pin(async move {
125                    sqlx::query(&format!("SET hnsw.ef_search = {ef}"))
126                        .execute(conn)
127                        .await?;
128                    Ok(())
129                })
130            });
131        }
132        let pool = pool_opts
133            .connect(url)
134            .await
135            .map_err(|e| KernelError::Embedding(format!("pgvector connect: {e}")))?;
136        let idx = Self {
137            pool,
138            table: table.to_string(),
139            dim,
140            half: opts.half,
141            hnsw_m: opts.hnsw_m,
142            hnsw_ef_construction: opts.hnsw_ef_construction,
143        };
144        idx.init_schema().await?;
145        Ok(idx)
146    }
147
148    /// SQL vector type name for the active precision (`vector` | `halfvec`).
149    fn sql_type(&self) -> &'static str {
150        if self.half { "halfvec" } else { "vector" }
151    }
152
153    /// HNSW ops class for the active precision.
154    fn ops_class(&self) -> &'static str {
155        if self.half {
156            "halfvec_cosine_ops"
157        } else {
158            "vector_cosine_ops"
159        }
160    }
161
162    /// `CREATE TABLE IF NOT EXISTS {table} (id BIGINT PK, vec {vector|halfvec}(N))`
163    /// + matching HNSW cosine index, per the active precision. Idempotent.
164    async fn init_schema(&self) -> Result<()> {
165        // Identifier is caller-controlled (not user input at runtime) and
166        // validated in `new_with_opts`, so `format!` is acceptable here. PG cannot
167        // bind identifiers. Callers pass a fixed, validated table name.
168        let ty = self.sql_type();
169        let ops = self.ops_class();
170        let with = hnsw_with_clause(self.hnsw_m, self.hnsw_ef_construction);
171        sqlx::query(&format!(
172            "CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, vec {}({}) NOT NULL)",
173            self.table, ty, self.dim
174        ))
175        .execute(&self.pool)
176        .await
177        .map_err(|e| KernelError::Embedding(format!("pgvector create table: {e}")))?;
178        sqlx::query(&format!(
179            "CREATE INDEX IF NOT EXISTS idx_{}_vec ON {} USING hnsw (vec {}){}",
180            self.table, self.table, ops, with
181        ))
182        .execute(&self.pool)
183        .await
184        .map_err(|e| KernelError::Embedding(format!("pgvector hnsw index: {e}")))?;
185        Ok(())
186    }
187
188    /// Underlying connection pool.
189    ///
190    /// Callers that need **cross-table transactional consistency** — e.g. pruning
191    /// a law's chunks plus their vectors atomically in one transaction — can
192    /// `pool().begin()` and run their own DML on the same pool the index uses.
193    /// This preserves the table-name encapsulation while letting the caller
194    /// coordinate a multi-statement transaction.
195    pub fn pool(&self) -> &PgPool {
196        &self.pool
197    }
198
199    /// Remove vectors by external IDs **within a caller-provided transaction**.
200    ///
201    /// Enables atomic cross-table deletes: begin a tx on [`pool`](Self::pool),
202    /// delete related rows in other relations (chunks, edges, …), call this with
203    /// the same `&mut PgConnection`, then `commit()`. A failure anywhere rolls
204    /// back the whole set — no orphaned vectors or chunks. The table name stays
205    /// encapsulated (`format!` over the validated identifier).
206    pub async fn remove_in_tx(&self, tx: &mut sqlx::PgConnection, ids: &[u64]) -> Result<()> {
207        if ids.is_empty() {
208            return Ok(());
209        }
210        let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
211        sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
212            .bind(&ids)
213            .execute(tx)
214            .await
215            .map_err(|e| KernelError::Embedding(format!("pgvector remove_in_tx: {e}")))?;
216        Ok(())
217    }
218}
219
220#[async_trait]
221impl crate::embedding::AsyncVectorIndex for PgVectorIndex {
222    async fn add(&self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()> {
223        if vectors.len() != ids.len() {
224            return Err(KernelError::Embedding(format!(
225                "vectors.len() ({}) must equal ids.len() ({})",
226                vectors.len(),
227                ids.len()
228            )));
229        }
230        if vectors.is_empty() {
231            return Ok(());
232        }
233        // Map u64 → i64 up front: pgvector stores BIGINT (i64), so values
234        // above i64::MAX cannot be represented and must be rejected rather
235        // than silently wrapped.
236        let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
237
238        // Each row binds 2 params (id, vec literal). PostgreSQL caps bound
239        // parameters at u16::MAX (65535), so a single INSERT over ~32k vectors
240        // overflows it. Chunk well under the limit; each chunk is its own
241        // round trip but shares one prepared-statement shape.
242        const ROWS_PER_CHUNK: usize = 16_000;
243        for chunk in vectors
244            .chunks(ROWS_PER_CHUNK)
245            .zip(pg_ids.chunks(ROWS_PER_CHUNK))
246        {
247            let (chunk_vecs, chunk_ids) = chunk;
248            let mut q = QueryBuilder::new("INSERT INTO ");
249            q.push(self.table.as_str());
250            q.push(" (id, vec) VALUES ");
251            // pgvector `vec` 컬럼은 text 리터럴 입력 시 `::vector` 캐스트가 필수다.
252            // `push_values` 는 값별 캐스트를 붙일 수 없어 수동으로 VALUES 튜플을 조립한다
253            // (캐스트 누락 시 "column vec is of type vector but expression is of type text").
254            for (i, (v, &id)) in chunk_vecs.iter().zip(chunk_ids.iter()).enumerate() {
255                if i > 0 {
256                    q.push(", ");
257                }
258                q.push("(");
259                q.push_bind(id);
260                q.push(", ");
261                q.push_bind(vec_literal(v));
262                q.push("::");
263                q.push(self.sql_type());
264                q.push(")");
265            }
266            q.push(" ON CONFLICT (id) DO UPDATE SET vec = EXCLUDED.vec");
267            q.build()
268                .execute(&self.pool)
269                .await
270                .map_err(|e| KernelError::Embedding(format!("pgvector add: {e}")))?;
271        }
272        Ok(())
273    }
274
275    async fn remove(&self, ids: &[u64]) -> Result<()> {
276        if ids.is_empty() {
277            return Ok(());
278        }
279        let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
280        sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
281            .bind(&ids)
282            .execute(&self.pool)
283            .await
284            .map_err(|e| KernelError::Embedding(format!("pgvector remove: {e}")))?;
285        Ok(())
286    }
287
288    async fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
289        let q = vec_literal(query);
290        let ty = self.sql_type();
291        // cosine distance <=> : 0 (동일) .. 2 (반대). score = 1 - distance.
292        // `ORDER BY ..., id` keeps ties deterministic so the per-branch rank order
293        // that RRF feeds on is stable across branches (pgvector's own tie order
294        // is otherwise index-scan dependent and unstable).
295        let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
296            "SELECT id, 1 - (vec <=> $1::{ty}) AS score FROM {} \
297             ORDER BY vec <=> $1::{ty}, id LIMIT $2",
298            self.table
299        ))
300        .bind(q)
301        .bind(k as i64)
302        .fetch_all(&self.pool)
303        .await
304        .map_err(|e| KernelError::Embedding(format!("pgvector search: {e}")))?;
305        Ok(rows
306            .into_iter()
307            .map(|r| SearchHit {
308                id: r.id as u64,
309                score: r.score as f32,
310            })
311            .collect())
312    }
313
314    async fn search_filtered(
315        &self,
316        query: &[f32],
317        k: usize,
318        allowlist: &[u64],
319    ) -> Result<Vec<SearchHit>> {
320        if allowlist.is_empty() {
321            return Ok(Vec::new());
322        }
323        let q = vec_literal(query);
324        let ty = self.sql_type();
325        let allow: Vec<i64> = allowlist
326            .iter()
327            .map(|&i| to_pg_id(i))
328            .collect::<Result<_>>()?;
329        let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
330            "SELECT id, 1 - (vec <=> $1::{ty}) AS score FROM {} WHERE id = ANY($2) \
331             ORDER BY vec <=> $1::{ty}, id LIMIT $3",
332            self.table
333        ))
334        .bind(q)
335        .bind(&allow)
336        .bind(k as i64)
337        .fetch_all(&self.pool)
338        .await
339        .map_err(|e| KernelError::Embedding(format!("pgvector search_filtered: {e}")))?;
340        Ok(rows
341            .into_iter()
342            .map(|r| SearchHit {
343                id: r.id as u64,
344                score: r.score as f32,
345            })
346            .collect())
347    }
348
349    async fn len(&self) -> Result<usize> {
350        let n: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM {}", self.table))
351            .fetch_one(&self.pool)
352            .await
353            .map_err(|e| KernelError::Embedding(format!("pgvector len: {e}")))?;
354        Ok(n as usize)
355    }
356
357    fn dim(&self) -> usize {
358        self.dim
359    }
360}
361
362/// `SparseVector` → pgvector `sparsevec` literal `{i:v,…}/dim`.
363///
364/// pgvector's text form uses **1-based** indices while models emit 0-based
365/// vocabulary positions, so every index is shifted by one on the way out.
366///
367/// Weights use `f32::to_string`, which yields the shortest string that
368/// round-trips back to the same `f32` (Rust's `Display` guarantee). pgvector
369/// parses the literal as `float8` (f64), so no extra precision is lost in
370/// transit beyond what `f32` already discards. The same holds for dense
371/// [`vec_literal`].
372fn sparse_literal(v: &SparseVector, dim: usize) -> String {
373    let mut s = String::from("{");
374    for (n, (idx, val)) in v.indices().iter().zip(v.values()).enumerate() {
375        if n > 0 {
376            s.push(',');
377        }
378        s.push_str(&(u64::from(*idx) + 1).to_string());
379        s.push(':');
380        s.push_str(&val.to_string());
381    }
382    s.push('}');
383    s.push('/');
384    s.push_str(&dim.to_string());
385    s
386}
387
388/// PostgreSQL **sparse** vector index — the lexical half of hybrid retrieval.
389///
390/// Stores `sparsevec(N)` rows (`N` = vocabulary size, e.g. 250 002 for BGE-M3's
391/// XLM-R vocabulary) and ranks by inner product, which is how learned-lexical
392/// weights (BGE-M3 sparse, SPLADE) are scored. Deliberately *not* an
393/// [`AsyncVectorIndex`](crate::embedding::AsyncVectorIndex) — that trait speaks
394/// dense `Vec<f32>`. Pair it with a [`PgVectorIndex`] and combine the two
395/// result lists with [`Fusion`](crate::embedding::Fusion).
396///
397/// Requires the `pgvector` extension ≥ 0.7 (`sparsevec` + `sparsevec_ip_ops`).
398/// pgvector caps the number of non-zero elements it will index, so prune long
399/// lexical vectors with [`SparseVector::prune_top_k`] before inserting.
400pub struct PgSparseVectorIndex {
401    pool: PgPool,
402    table: String,
403    dim: usize,
404}
405
406impl PgSparseVectorIndex {
407    /// Connect and create the `sparsevec` table + HNSW inner-product index.
408    ///
409    /// `dim` is the vocabulary size and is enforced by the `sparsevec(dim)`
410    /// column; vectors carrying an index ≥ `dim` are rejected on insert.
411    pub async fn new(url: &str, table: &str, dim: usize) -> Result<Self> {
412        validate_table_name(table)?;
413        let pool = PgPoolOptions::new()
414            .max_connections(8)
415            .connect(url)
416            .await
417            .map_err(|e| KernelError::Embedding(format!("pgvector sparse connect: {e}")))?;
418        let idx = Self {
419            pool,
420            table: table.to_string(),
421            dim,
422        };
423        sqlx::query(&format!(
424            "CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, vec sparsevec({}) NOT NULL)",
425            idx.table, idx.dim
426        ))
427        .execute(&idx.pool)
428        .await
429        .map_err(|e| KernelError::Embedding(format!("pgvector sparse create table: {e}")))?;
430        sqlx::query(&format!(
431            "CREATE INDEX IF NOT EXISTS idx_{}_vec ON {} USING hnsw (vec sparsevec_ip_ops)",
432            idx.table, idx.table
433        ))
434        .execute(&idx.pool)
435        .await
436        .map_err(|e| KernelError::Embedding(format!("pgvector sparse hnsw index: {e}")))?;
437        Ok(idx)
438    }
439
440    /// Vocabulary size of the indexed vectors.
441    pub fn dim(&self) -> usize {
442        self.dim
443    }
444
445    /// Underlying connection pool — see [`PgVectorIndex::pool`].
446    pub fn pool(&self) -> &PgPool {
447        &self.pool
448    }
449
450    /// Upsert sparse vectors by external ID.
451    pub async fn add(&self, vectors: &[SparseVector], ids: &[u64]) -> Result<()> {
452        if vectors.len() != ids.len() {
453            return Err(KernelError::Embedding(format!(
454                "vectors.len() ({}) must equal ids.len() ({})",
455                vectors.len(),
456                ids.len()
457            )));
458        }
459        if vectors.is_empty() {
460            return Ok(());
461        }
462        let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
463        let mut q = QueryBuilder::new("INSERT INTO ");
464        q.push(self.table.as_str());
465        q.push(" (id, vec) VALUES ");
466        for (i, (v, &id)) in vectors.iter().zip(pg_ids.iter()).enumerate() {
467            if i > 0 {
468                q.push(", ");
469            }
470            q.push("(");
471            q.push_bind(id);
472            q.push(", ");
473            q.push_bind(sparse_literal(v, self.dim));
474            q.push("::sparsevec)");
475        }
476        q.push(" ON CONFLICT (id) DO UPDATE SET vec = EXCLUDED.vec");
477        q.build()
478            .execute(&self.pool)
479            .await
480            .map_err(|e| KernelError::Embedding(format!("pgvector sparse add: {e}")))?;
481        Ok(())
482    }
483
484    /// Top-`k` by inner product. An empty query matches nothing by definition
485    /// (its inner product with every row is zero), so it short-circuits.
486    pub async fn search(&self, query: &SparseVector, k: usize) -> Result<Vec<SearchHit>> {
487        if query.is_empty() {
488            return Ok(Vec::new());
489        }
490        let q = sparse_literal(query, self.dim);
491        // `<#>` yields the *negative* inner product, so ascending order is most
492        // similar first; negate it back into a positive similarity score. The
493        // `id` tie-break mirrors the dense index so RRF sees stable ranks.
494        let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
495            "SELECT id, -(vec <#> $1::sparsevec) AS score FROM {} \
496             ORDER BY vec <#> $1::sparsevec, id LIMIT $2",
497            self.table
498        ))
499        .bind(q)
500        .bind(k as i64)
501        .fetch_all(&self.pool)
502        .await
503        .map_err(|e| KernelError::Embedding(format!("pgvector sparse search: {e}")))?;
504        Ok(rows
505            .into_iter()
506            .map(|r| SearchHit {
507                id: r.id as u64,
508                score: r.score as f32,
509            })
510            .collect())
511    }
512
513    /// Top-`k` by inner product, restricted to `allowlist`.
514    pub async fn search_filtered(
515        &self,
516        query: &SparseVector,
517        k: usize,
518        allowlist: &[u64],
519    ) -> Result<Vec<SearchHit>> {
520        if query.is_empty() || allowlist.is_empty() {
521            return Ok(Vec::new());
522        }
523        let q = sparse_literal(query, self.dim);
524        let allow: Vec<i64> = allowlist
525            .iter()
526            .map(|&i| to_pg_id(i))
527            .collect::<Result<_>>()?;
528        let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
529            "SELECT id, -(vec <#> $1::sparsevec) AS score FROM {} WHERE id = ANY($2) \
530             ORDER BY vec <#> $1::sparsevec, id LIMIT $3",
531            self.table
532        ))
533        .bind(q)
534        .bind(&allow)
535        .bind(k as i64)
536        .fetch_all(&self.pool)
537        .await
538        .map_err(|e| KernelError::Embedding(format!("pgvector sparse search_filtered: {e}")))?;
539        Ok(rows
540            .into_iter()
541            .map(|r| SearchHit {
542                id: r.id as u64,
543                score: r.score as f32,
544            })
545            .collect())
546    }
547
548    /// Remove vectors by external ID.
549    pub async fn remove(&self, ids: &[u64]) -> Result<()> {
550        if ids.is_empty() {
551            return Ok(());
552        }
553        let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
554        sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
555            .bind(&ids)
556            .execute(&self.pool)
557            .await
558            .map_err(|e| KernelError::Embedding(format!("pgvector sparse remove: {e}")))?;
559        Ok(())
560    }
561
562    /// Remove within a caller-provided transaction — see
563    /// [`PgVectorIndex::remove_in_tx`]. Lets a dense index, a sparse index and
564    /// the caller's own tables be pruned atomically together.
565    pub async fn remove_in_tx(&self, tx: &mut sqlx::PgConnection, ids: &[u64]) -> Result<()> {
566        if ids.is_empty() {
567            return Ok(());
568        }
569        let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
570        sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
571            .bind(&ids)
572            .execute(tx)
573            .await
574            .map_err(|e| KernelError::Embedding(format!("pgvector sparse remove_in_tx: {e}")))?;
575        Ok(())
576    }
577
578    /// Number of stored vectors.
579    pub async fn len(&self) -> Result<usize> {
580        let n: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM {}", self.table))
581            .fetch_one(&self.pool)
582            .await
583            .map_err(|e| KernelError::Embedding(format!("pgvector sparse len: {e}")))?;
584        Ok(n as usize)
585    }
586
587    /// Whether the index holds no vectors.
588    pub async fn is_empty(&self) -> Result<bool> {
589        Ok(self.len().await? == 0)
590    }
591}
592
593/// ` WITH (m = …, ef_construction = …)` for the HNSW index, or `""` when both
594/// are unset (pgvector defaults: `m = 16`, `ef_construction = 64`). Values are
595/// `u32`, so the interpolation has no injection surface.
596fn hnsw_with_clause(m: Option<u32>, ef_construction: Option<u32>) -> String {
597    let mut parts: Vec<String> = Vec::new();
598    if let Some(m) = m {
599        parts.push(format!("m = {m}"));
600    }
601    if let Some(ef) = ef_construction {
602        parts.push(format!("ef_construction = {ef}"));
603    }
604    if parts.is_empty() {
605        String::new()
606    } else {
607        format!(" WITH ({})", parts.join(", "))
608    }
609}
610
611/// `u64` external ID → PG `BIGINT` (`i64`). IDs exceeding `i64::MAX` cannot
612/// be stored in a BIGINT column — reject rather than silently wrap.
613fn to_pg_id(id: u64) -> Result<i64> {
614    i64::try_from(id).map_err(|_| KernelError::Embedding(format!("id {id} exceeds BIGINT range")))
615}
616
617/// Validate that `table` is a plain, safe SQL identifier (ASCII alphanumeric +
618/// `_`, starting with a letter or `_`). It is interpolated into DDL/DML via
619/// `format!` (PG cannot bind identifiers), so reject anything that could break
620/// out of the identifier context.
621fn validate_table_name(table: &str) -> Result<()> {
622    let mut chars = table.chars();
623    let first_ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_');
624    let valid = first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
625    if !valid {
626        return Err(KernelError::Embedding(format!(
627            "invalid table identifier: {table:?}"
628        )));
629    }
630    Ok(())
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::embedding::AsyncVectorIndex;
637
638    /// `LLMKERNEL_PG_URL` 미설정 시 자동 skip (graph-pg pg.rs 패턴).
639    fn pg_url() -> Option<String> {
640        std::env::var("LLMKERNEL_PG_URL").ok()
641    }
642
643    /// add → search → search_filtered → remove 라운드트립. HNSW는 대량 데이터에서
644    /// 정확도가 보장되지만 소규모 테스트에선 정확 매칭이 간헐적일 수 있어
645    /// 여기선 id/회수 위주로 검증.
646    #[tokio::test]
647    async fn roundtrip_add_search_remove() {
648        let Some(url) = pg_url() else {
649            eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
650            return;
651        };
652        let table = format!("lk_test_{}", line!());
653        let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
654
655        let vecs = vec![
656            vec![1.0, 0.0, 0.0],
657            vec![0.0, 1.0, 0.0],
658            vec![0.0, 0.0, 1.0],
659        ];
660        let ids = vec![10u64, 20, 30];
661        idx.add(&vecs, &ids).await.expect("add");
662        assert_eq!(idx.len().await.unwrap(), 3);
663
664        // nearest to [1,0,0] → id 10 우선
665        let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
666        assert_eq!(hits.len(), 1);
667        assert_eq!(hits[0].id, 10);
668
669        // filtered: allow [20,30] → 10 제외
670        let hits = idx
671            .search_filtered(&[1.0, 0.0, 0.0], 1, &[20, 30])
672            .await
673            .unwrap();
674        assert_eq!(hits.len(), 1);
675        assert_ne!(hits[0].id, 10);
676
677        idx.remove(&[10]).await.unwrap();
678        assert_eq!(idx.len().await.unwrap(), 2);
679
680        // cleanup
681        sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
682            .execute(&idx.pool)
683            .await
684            .ok();
685    }
686
687    /// halfvec(float16) 변형 라운드트립 — `new_halfvec` 로 halfvec 컬럼/인덱스 생성 후
688    /// add/search/remove 가 float32 경로와 동일하게 동작하는지. pgvector ≥ 0.6 필요.
689    #[tokio::test]
690    async fn roundtrip_halfvec() {
691        let Some(url) = pg_url() else {
692            eprintln!("skip pgvector halfvec test: LLMKERNEL_PG_URL unset");
693            return;
694        };
695        let table = format!("lk_test_hv_{}", line!());
696        let idx = PgVectorIndex::new_halfvec(&url, &table, 3)
697            .await
698            .expect("new_halfvec");
699
700        let vecs = vec![
701            vec![1.0, 0.0, 0.0],
702            vec![0.0, 1.0, 0.0],
703            vec![0.0, 0.0, 1.0],
704        ];
705        let ids = vec![10u64, 20, 30];
706        idx.add(&vecs, &ids).await.expect("add");
707        assert_eq!(idx.len().await.unwrap(), 3);
708
709        // nearest to [1,0,0] → id 10 (halfvec cosine 도 동일 순위)
710        let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
711        assert_eq!(hits.len(), 1);
712        assert_eq!(hits[0].id, 10);
713
714        idx.remove(&[10]).await.unwrap();
715        assert_eq!(idx.len().await.unwrap(), 2);
716
717        // cleanup
718        sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
719            .execute(&idx.pool)
720            .await
721            .ok();
722    }
723
724    /// `remove_in_tx`: 같은 풀에서 시작한 트랜잭션 내 삭제가 원자 반영되는지 검증.
725    /// klr prune(chunks + vectors 단일 tx)의 전제 — 커밋 전 롤백 시 벡터 보존 확인.
726    #[tokio::test]
727    async fn remove_in_tx_atomic_delete() {
728        let Some(url) = pg_url() else {
729            eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
730            return;
731        };
732        let table = format!("lk_txtx_{}", line!());
733        let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
734        idx.add(&[vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]], &[11, 12])
735            .await
736            .expect("add");
737        assert_eq!(idx.len().await.unwrap(), 2);
738
739        // 커밋 경로: tx 내 remove → commit → 삭제 반영
740        let mut tx = idx.pool().begin().await.expect("begin tx");
741        idx.remove_in_tx(&mut tx, &[11])
742            .await
743            .expect("remove_in_tx");
744        tx.commit().await.expect("commit");
745        assert_eq!(idx.len().await.unwrap(), 1);
746
747        // 롤백 경로: tx 내 remove → rollback → 벡터 보존(원자성)
748        let mut tx2 = idx.pool().begin().await.expect("begin tx2");
749        idx.remove_in_tx(&mut tx2, &[12])
750            .await
751            .expect("remove_in_tx2");
752        tx2.rollback().await.expect("rollback");
753        assert_eq!(idx.len().await.unwrap(), 1);
754
755        // cleanup
756        sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
757            .execute(idx.pool())
758            .await
759            .ok();
760    }
761
762    #[test]
763    fn rejects_invalid_table_name() {
764        // valid identifiers accepted
765        assert!(validate_table_name("lk_test_1").is_ok());
766        assert!(validate_table_name("_vec").is_ok());
767        // rejected — would break out of identifier context
768        assert!(validate_table_name("").is_err());
769        assert!(validate_table_name("1bad").is_err());
770        assert!(validate_table_name("rm; DROP").is_err());
771        assert!(validate_table_name("weird\"name").is_err());
772        assert!(validate_table_name("sch.tbl").is_err());
773    }
774
775    /// pgvector's `sparsevec` text form is 1-based; model indices are 0-based.
776    #[test]
777    fn sparse_literal_is_one_based() {
778        let sv = SparseVector::new(vec![0, 2], vec![0.5, 0.25]).unwrap();
779        assert_eq!(sparse_literal(&sv, 5), "{1:0.5,3:0.25}/5");
780    }
781
782    #[test]
783    fn sparse_literal_handles_empty() {
784        assert_eq!(sparse_literal(&SparseVector::default(), 5), "{}/5");
785    }
786
787    /// Sparse index roundtrip: inner-product ranking, empty-query
788    /// short-circuit, and delete. Requires pgvector ≥ 0.7 for `sparsevec`.
789    #[tokio::test]
790    async fn roundtrip_sparse() {
791        let Some(url) = pg_url() else {
792            eprintln!("skip pgvector sparse test: LLMKERNEL_PG_URL unset");
793            return;
794        };
795        let table = format!("lk_test_sp_{}", line!());
796        let idx = PgSparseVectorIndex::new(&url, &table, 10)
797            .await
798            .expect("new sparse");
799
800        let a = SparseVector::new(vec![0, 3], vec![1.0, 0.5]).unwrap();
801        let b = SparseVector::new(vec![3, 7], vec![0.5, 1.0]).unwrap();
802        idx.add(&[a.clone(), b], &[10, 20]).await.expect("add");
803        assert_eq!(idx.len().await.unwrap(), 2);
804
805        // Querying with `a` itself must rank id 10 first (largest dot product).
806        let hits = idx.search(&a, 1).await.unwrap();
807        assert_eq!(hits.len(), 1);
808        assert_eq!(hits[0].id, 10);
809
810        // An all-zero query has zero inner product with everything.
811        let empty = idx.search(&SparseVector::default(), 5).await.unwrap();
812        assert!(empty.is_empty());
813
814        idx.remove(&[10]).await.unwrap();
815        assert_eq!(idx.len().await.unwrap(), 1);
816
817        sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
818            .execute(idx.pool())
819            .await
820            .ok();
821    }
822
823    #[test]
824    fn hnsw_with_clause_variants() {
825        assert_eq!(hnsw_with_clause(None, None), "");
826        assert_eq!(hnsw_with_clause(Some(32), None), " WITH (m = 32)");
827        assert_eq!(
828            hnsw_with_clause(None, Some(200)),
829            " WITH (ef_construction = 200)"
830        );
831        assert_eq!(
832            hnsw_with_clause(Some(32), Some(200)),
833            " WITH (m = 32, ef_construction = 200)"
834        );
835    }
836
837    /// `PgVectorOpts::default()` must stay behaviourally identical to `new`
838    /// (full precision, pgvector's own HNSW defaults).
839    #[test]
840    fn default_opts_are_full_precision_and_untuned() {
841        let o = PgVectorOpts::default();
842        assert!(!o.half);
843        assert!(o.hnsw_m.is_none());
844        assert!(o.hnsw_ef_construction.is_none());
845        assert!(o.hnsw_ef_search.is_none());
846    }
847
848    #[test]
849    fn rejects_overflowing_id() {
850        assert_eq!(to_pg_id(0).unwrap(), 0);
851        assert_eq!(to_pg_id(42).unwrap(), 42);
852        assert_eq!(to_pg_id(i64::MAX as u64).unwrap(), i64::MAX);
853        assert!(to_pg_id((i64::MAX as u64) + 1).is_err());
854        assert!(to_pg_id(u64::MAX).is_err());
855    }
856}