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/// Row filter for `search_where` on [`PgVectorIndex`] and [`PgSparseVectorIndex`]:
30/// a SQL boolean expression evaluated per candidate row, so the allowed set never
31/// leaves the database. Prefer it to `search_filtered` when the allowed IDs are
32/// many: an allowlist is fetched client-side and bound as one array per query.
33///
34/// `predicate` is interpolated verbatim, so it must be **trusted SQL written by
35/// the caller, never user input**. It sees the vector row as `v` (`v.id`,
36/// `v.vec`) and the text values of `binds` as `$3`, `$4`, ... (`$1` is the query
37/// vector, `$2` the limit). Example: `EXISTS (SELECT 1 FROM chunks c WHERE
38/// c.id = v.id AND c.category = $3)` with `binds: &["statute"]`.
39#[derive(Debug, Clone, Copy)]
40pub struct SqlFilter<'a> {
41    /// Trusted SQL boolean expression over the vector row `v`.
42    pub predicate: &'a str,
43    /// Text values bound as `$3`, `$4`, ... in `predicate`.
44    pub binds: &'a [&'a str],
45}
46
47/// Shared body of the `search_where` methods. `sql` uses `$1` (query literal)
48/// and `$2` (limit); `filter.binds` follow as `$3..`.
49async 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
74/// f32 슬라이스 → pgvector 문자열 리터럴 `[1,2,3]` (text → vector 입력 캐스트).
75/// `pgvector::Vector`의 sqlx `Type` 바인드가 의존 환경에 따라 충돌해 문자열로 회피.
76fn 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/// Storage precision + HNSW tuning for [`PgVectorIndex`].
89///
90/// All fields default to pgvector's own defaults, so `PgVectorOpts::default()`
91/// is equivalent to [`PgVectorIndex::new`].
92#[derive(Debug, Clone, Default)]
93pub struct PgVectorOpts {
94    /// Store `halfvec` (float16) instead of `vector` (float32) — ~half the RAM.
95    pub half: bool,
96    /// HNSW `m` (edges per node). `None` → pgvector default (16). Higher `m`
97    /// raises recall and index size.
98    pub hnsw_m: Option<u32>,
99    /// HNSW `ef_construction` (build-time candidate list). `None` → default
100    /// (64). Higher values build a better graph, more slowly.
101    pub hnsw_ef_construction: Option<u32>,
102    /// `hnsw.ef_search` (query-time candidate list), applied to every pooled
103    /// connection. `None` → default (40). Raise it for higher recall on large
104    /// indexes; it is the main recall/latency knob at query time.
105    pub hnsw_ef_search: Option<u32>,
106}
107
108/// PostgreSQL vector index backed by the `pgvector` extension.
109///
110/// All operations are async over a shared `PgPool` (MVCC, connection-pooled).
111/// The relation named `table` holds `(id BIGINT PK, vec <type>(N))` rows where
112/// `<type>` is `vector` (float32) or `halfvec` (float16) per the `half` flag.
113pub struct PgVectorIndex {
114    pool: PgPool,
115    table: String,
116    dim: usize,
117    /// `true` → `halfvec` (float16, ~half RAM, recall 손실 ~0). `false` → `vector` (float32).
118    half: bool,
119    hnsw_m: Option<u32>,
120    hnsw_ef_construction: Option<u32>,
121}
122
123impl PgVectorIndex {
124    /// Connect to `url` (libpq connstring / `postgresql://…`), create the
125    /// vector table + HNSW cosine index if missing, and return a ready index.
126    ///
127    /// `dim` is enforced by the fixed `vector(dim)` column; vectors whose
128    /// length differs from `dim` are rejected by pgvector on insert. Uses
129    /// full-precision `vector` (float32). For half-precision see
130    /// [`new_halfvec`](Self::new_halfvec).
131    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    /// Half-precision variant — stores `halfvec` (float16): **~half the RAM** of
136    /// `vector` with negligible recall loss for cosine similarity. Requires the
137    /// `pgvector` extension ≥ 0.6 (`halfvec` type + `halfvec_cosine_ops`).
138    /// `dim` ≤ 4000 (vs 2000 for `vector`).
139    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    /// Full form — pick storage precision and HNSW tuning via [`PgVectorOpts`].
153    ///
154    /// `hnsw_m` / `hnsw_ef_construction` are applied to the `CREATE INDEX` (they
155    /// only affect a *newly* created index — an existing one keeps its build
156    /// parameters). `hnsw_ef_search` is applied to every pooled connection and
157    /// therefore takes effect immediately for queries.
158    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            // `ef` is a `u32`, so the interpolation has no injection surface.
168            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    /// SQL vector type name for the active precision (`vector` | `halfvec`).
194    fn sql_type(&self) -> &'static str {
195        if self.half { "halfvec" } else { "vector" }
196    }
197
198    /// HNSW ops class for the active precision.
199    fn ops_class(&self) -> &'static str {
200        if self.half {
201            "halfvec_cosine_ops"
202        } else {
203            "vector_cosine_ops"
204        }
205    }
206
207    /// `CREATE TABLE IF NOT EXISTS {table} (id BIGINT PK, vec {vector|halfvec}(N))`
208    /// + matching HNSW cosine index, per the active precision. Idempotent.
209    async fn init_schema(&self) -> Result<()> {
210        // Identifier is caller-controlled (not user input at runtime) and
211        // validated in `new_with_opts`, so `format!` is acceptable here. PG cannot
212        // bind identifiers. Callers pass a fixed, validated table name.
213        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    /// Underlying connection pool.
234    ///
235    /// Callers that need **cross-table transactional consistency** — e.g. pruning
236    /// a law's chunks plus their vectors atomically in one transaction — can
237    /// `pool().begin()` and run their own DML on the same pool the index uses.
238    /// This preserves the table-name encapsulation while letting the caller
239    /// coordinate a multi-statement transaction.
240    pub fn pool(&self) -> &PgPool {
241        &self.pool
242    }
243
244    /// Remove vectors by external IDs **within a caller-provided transaction**.
245    ///
246    /// Enables atomic cross-table deletes: begin a tx on [`pool`](Self::pool),
247    /// delete related rows in other relations (chunks, edges, …), call this with
248    /// the same `&mut PgConnection`, then `commit()`. A failure anywhere rolls
249    /// back the whole set — no orphaned vectors or chunks. The table name stays
250    /// encapsulated (`format!` over the validated identifier).
251    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    /// Top-`k` by cosine similarity among rows matching `filter`, evaluated
265    /// inside PostgreSQL (see [`SqlFilter`]). With pgvector >= 0.8
266    /// `hnsw.iterative_scan` enabled the HNSW scan continues until `k` rows pass;
267    /// without it a selective predicate can return fewer than `k`.
268    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        // Map u64 → i64 up front: pgvector stores BIGINT (i64), so values
306        // above i64::MAX cannot be represented and must be rejected rather
307        // than silently wrapped.
308        let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
309
310        // Each row binds 2 params (id, vec literal). PostgreSQL caps bound
311        // parameters at u16::MAX (65535), so a single INSERT over ~32k vectors
312        // overflows it. Chunk well under the limit; each chunk is its own
313        // round trip but shares one prepared-statement shape.
314        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            // pgvector `vec` 컬럼은 text 리터럴 입력 시 `::vector` 캐스트가 필수다.
324            // `push_values` 는 값별 캐스트를 붙일 수 없어 수동으로 VALUES 튜플을 조립한다
325            // (캐스트 누락 시 "column vec is of type vector but expression is of type text").
326            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        // cosine distance <=> : 0 (동일) .. 2 (반대). score = 1 - distance.
364        // `ORDER BY ..., id` keeps ties deterministic so the per-branch rank order
365        // that RRF feeds on is stable across branches (pgvector's own tie order
366        // is otherwise index-scan dependent and unstable).
367        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
434/// `SparseVector` → pgvector `sparsevec` literal `{i:v,…}/dim`.
435///
436/// pgvector's text form uses **1-based** indices while models emit 0-based
437/// vocabulary positions, so every index is shifted by one on the way out.
438///
439/// Weights use `f32::to_string`, which yields the shortest string that
440/// round-trips back to the same `f32` (Rust's `Display` guarantee). pgvector
441/// parses the literal as `float8` (f64), so no extra precision is lost in
442/// transit beyond what `f32` already discards. The same holds for dense
443/// [`vec_literal`].
444fn 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
460/// PostgreSQL **sparse** vector index — the lexical half of hybrid retrieval.
461///
462/// Stores `sparsevec(N)` rows (`N` = vocabulary size, e.g. 250 002 for BGE-M3's
463/// XLM-R vocabulary) and ranks by inner product, which is how learned-lexical
464/// weights (BGE-M3 sparse, SPLADE) are scored. Deliberately *not* an
465/// [`AsyncVectorIndex`](crate::embedding::AsyncVectorIndex) — that trait speaks
466/// dense `Vec<f32>`. Pair it with a [`PgVectorIndex`] and combine the two
467/// result lists with [`Fusion`](crate::embedding::Fusion).
468///
469/// Requires the `pgvector` extension ≥ 0.7 (`sparsevec` + `sparsevec_ip_ops`).
470/// pgvector caps the number of non-zero elements it will index, so prune long
471/// lexical vectors with [`SparseVector::prune_top_k`] before inserting.
472pub struct PgSparseVectorIndex {
473    pool: PgPool,
474    table: String,
475    dim: usize,
476}
477
478impl PgSparseVectorIndex {
479    /// Connect and create the `sparsevec` table + HNSW inner-product index.
480    ///
481    /// `dim` is the vocabulary size and is enforced by the `sparsevec(dim)`
482    /// column; vectors carrying an index ≥ `dim` are rejected on insert.
483    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    /// Vocabulary size of the indexed vectors.
513    pub fn dim(&self) -> usize {
514        self.dim
515    }
516
517    /// Underlying connection pool — see [`PgVectorIndex::pool`].
518    pub fn pool(&self) -> &PgPool {
519        &self.pool
520    }
521
522    /// Upsert sparse vectors by external ID.
523    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    /// Top-`k` by inner product. An empty query matches nothing by definition
557    /// (its inner product with every row is zero), so it short-circuits.
558    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        // `<#>` yields the *negative* inner product, so ascending order is most
564        // similar first; negate it back into a positive similarity score. The
565        // `id` tie-break mirrors the dense index so RRF sees stable ranks.
566        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    /// Top-`k` by inner product, restricted to `allowlist`.
586    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    /// Top-`k` by inner product among rows matching `filter`, evaluated inside
621    /// PostgreSQL (see [`SqlFilter`]). An empty query matches nothing.
622    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    /// Remove vectors by external ID.
648    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    /// Remove within a caller-provided transaction — see
662    /// [`PgVectorIndex::remove_in_tx`]. Lets a dense index, a sparse index and
663    /// the caller's own tables be pruned atomically together.
664    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    /// Number of stored vectors.
678    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    /// Whether the index holds no vectors.
687    pub async fn is_empty(&self) -> Result<bool> {
688        Ok(self.len().await? == 0)
689    }
690}
691
692/// ` WITH (m = …, ef_construction = …)` for the HNSW index, or `""` when both
693/// are unset (pgvector defaults: `m = 16`, `ef_construction = 64`). Values are
694/// `u32`, so the interpolation has no injection surface.
695fn 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
710/// `u64` external ID → PG `BIGINT` (`i64`). IDs exceeding `i64::MAX` cannot
711/// be stored in a BIGINT column — reject rather than silently wrap.
712fn to_pg_id(id: u64) -> Result<i64> {
713    i64::try_from(id).map_err(|_| KernelError::Embedding(format!("id {id} exceeds BIGINT range")))
714}
715
716/// Validate that `table` is a plain, safe SQL identifier (ASCII alphanumeric +
717/// `_`, starting with a letter or `_`). It is interpolated into DDL/DML via
718/// `format!` (PG cannot bind identifiers), so reject anything that could break
719/// out of the identifier context.
720fn 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    /// `LLMKERNEL_PG_URL` 미설정 시 자동 skip (graph-pg pg.rs 패턴).
738    fn pg_url() -> Option<String> {
739        std::env::var("LLMKERNEL_PG_URL").ok()
740    }
741
742    /// add → search → search_filtered → remove 라운드트립. HNSW는 대량 데이터에서
743    /// 정확도가 보장되지만 소규모 테스트에선 정확 매칭이 간헐적일 수 있어
744    /// 여기선 id/회수 위주로 검증.
745    #[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        // nearest to [1,0,0] → id 10 우선
764        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        // filtered: allow [20,30] → 10 제외
769        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        // cleanup
780        sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
781            .execute(&idx.pool)
782            .await
783            .ok();
784    }
785
786    /// halfvec(float16) 변형 라운드트립 — `new_halfvec` 로 halfvec 컬럼/인덱스 생성 후
787    /// add/search/remove 가 float32 경로와 동일하게 동작하는지. pgvector ≥ 0.6 필요.
788    #[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        // nearest to [1,0,0] → id 10 (halfvec cosine 도 동일 순위)
809        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        // cleanup
817        sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
818            .execute(&idx.pool)
819            .await
820            .ok();
821    }
822
823    /// `remove_in_tx`: 같은 풀에서 시작한 트랜잭션 내 삭제가 원자 반영되는지 검증.
824    /// klr prune(chunks + vectors 단일 tx)의 전제 — 커밋 전 롤백 시 벡터 보존 확인.
825    #[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        // 커밋 경로: tx 내 remove → commit → 삭제 반영
839        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        // 롤백 경로: tx 내 remove → rollback → 벡터 보존(원자성)
847        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        // cleanup
855        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        // valid identifiers accepted
864        assert!(validate_table_name("lk_test_1").is_ok());
865        assert!(validate_table_name("_vec").is_ok());
866        // rejected — would break out of identifier context
867        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    /// pgvector's `sparsevec` text form is 1-based; model indices are 0-based.
875    #[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    /// Sparse index roundtrip: inner-product ranking, empty-query
887    /// short-circuit, and delete. Requires pgvector ≥ 0.7 for `sparsevec`.
888    #[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        // Querying with `a` itself must rank id 10 first (largest dot product).
905        let hits = idx.search(&a, 1).await.unwrap();
906        assert_eq!(hits.len(), 1);
907        assert_eq!(hits[0].id, 10);
908
909        // An all-zero query has zero inner product with everything.
910        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    /// `search_where`: the predicate joins a side table inside PG, and text binds
923    /// start at `$3`. Dense and sparse indexes share the same filter.
924    #[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        // Nearest to [1,0,0] is 10, but it is tagged x: 20 must come first.
971        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    /// `PgVectorOpts::default()` must stay behaviourally identical to `new`
1008    /// (full precision, pgvector's own HNSW defaults).
1009    #[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}