fix: restore libSQL vector search with dynamic dimensions (#1393)

* fix: restore libSQL vector search with dynamic embedding dimensions (#655)

The V9 migration dropped the libsql_vector_idx and changed
memory_chunks.embedding from F32_BLOB(1536) to BLOB, but the
documented brute-force cosine fallback was never implemented.
hybrid_search silently returned empty vector results — search was
FTS5-only on libSQL.

Add ensure_vector_index() which dynamically creates the vector index
with the correct F32_BLOB(N) dimension, inferred from EMBEDDING_DIMENSION
/ EMBEDDING_MODEL env vars during run_migrations(). Uses _migrations
version=0 as a metadata row to track the current dimension (no-op if
unchanged, rebuilds table on dimension change).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* style: move safety comments above multi-line assertions for rustfmt stability

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* refactor: remove unnecessary safety comments from test code

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address review comments from PR #1393 [skip-regression-check]

- Share model→dimension mapping via config::embeddings::default_dimension_for_model()
  instead of duplicating the match table (zmanian, Copilot)
- Add dimension bounds check (1..=65536) to prevent overflow (zmanian, Copilot)
- DROP stale memory_chunks_new before CREATE to handle crashed previous attempts
  (zmanian, Copilot)
- Use plain INSERT instead of INSERT OR IGNORE to surface constraint errors
  (Copilot)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: add missing builder field to AgentDeps in telegram routing test [skip-regression-check]

The self-repair builder field was added to AgentDeps in #712 but this
test was not updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address zmanian's second review on PR #1393

- Add tracing::info when resolve_embedding_dimension returns None (#2)
- Document connection scoping for transaction safety (#1)
- Document _rowid preservation for FTS5 consistency (#4)
- Document precondition that migrations must run first (#5)
- Note F32_BLOB dimension enforcement in insert_chunk (#3)
- Add unit tests for resolve_embedding_dimension (#6)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-19 20:51:37 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8920322589
commit 8526cde1be
7 changed files with 494 additions and 20 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ impl Default for EmbeddingsConfig {
/// Infer the embedding dimension from a well-known model name.
///
/// Falls back to 1536 (OpenAI text-embedding-3-small default) for unknown models.
fn default_dimension_for_model(model: &str) -> usize {
pub(crate) fn default_dimension_for_model(model: &str) -> usize {
match model {
"text-embedding-3-small" => 1536,
"text-embedding-3-large" => 3072,
+1 -1
View File
@@ -9,7 +9,7 @@ mod agent;
mod builder;
mod channels;
mod database;
mod embeddings;
pub(crate) mod embeddings;
mod heartbeat;
pub(crate) mod helpers;
mod hygiene;
+3 -3
View File
@@ -75,7 +75,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
| Numeric/Decimal | `NUMERIC` | `TEXT` (preserves `rust_decimal` precision) |
| Arrays | `TEXT[]` | `TEXT` (JSON-encoded array) |
| Booleans | `BOOLEAN` | `INTEGER` (0/1) |
| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(1536)` via `libsql_vector_idx` |
| Vector embeddings | `VECTOR` (any dim, V9 removed fixed 1536) | `F32_BLOB(N)` via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index`) |
| Full-text search | `tsvector` + `ts_rank_cd` | FTS5 virtual table + sync triggers |
| JSON path update | `jsonb_set(col, '{key}', val)` | `json_patch(col, '{"key": val}')` |
| PL/pgSQL | Functions | Triggers (no stored procs in SQLite) |
@@ -90,7 +90,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
**Timestamp write format:** Always write timestamps with `fmt_ts(dt)` (RFC 3339, millisecond precision). Read with `get_ts()` / `get_opt_ts()` which handle legacy naive formats too.
**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL still uses `F32_BLOB(1536)` — if you use a different-dimension embedding model, the libSQL schema needs updating too.
**Vector dimension:** PostgreSQL V9 migration changed the column to unbounded `vector` (removing the HNSW index). libSQL dynamically creates `F32_BLOB(N)` with the correct dimension via `ensure_vector_index()` during `run_migrations()`, reading `EMBEDDING_DIMENSION` / `EMBEDDING_MODEL` from env vars.
**Connection per operation:** `LibSqlBackend::connect()` creates a fresh connection for every operation, sets `PRAGMA busy_timeout = 5000`, and closes it when the `Connection` is dropped. This is intentional — the libSQL SDK does not offer a pool. Avoid holding connections open across `await` points.
@@ -134,7 +134,7 @@ The `Database` supertrait is composed of seven sub-traits. Leaf consumers can de
- **Settings reload** — `Config::from_db` skipped (requires `Store`)
- **No incremental migrations** — schema is idempotent CREATE IF NOT EXISTS; no ALTER TABLE support; column additions require a new versioned approach
- **No encryption at rest** — only secrets (API tokens) are AES-256-GCM encrypted; all other data is plaintext SQLite
- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; however, the vector index is fixed at `F32_BLOB(1536)` while PostgreSQL switched to unbounded `vector` in V9
- **Hybrid search** — both FTS5 and vector search (`libsql_vector_idx`) are implemented; `ensure_vector_index()` dynamically creates the index with the correct `F32_BLOB(N)` dimension from env vars during `run_migrations()`
- **Write serialization** — WAL mode allows concurrent readers but only one writer at a time; busy timeout is 5 s, which may cause timeouts under high write concurrency
## Running Locally with libSQL
+8
View File
@@ -341,6 +341,14 @@ impl Database for LibSqlBackend {
.map_err(|e| DatabaseError::Migration(format!("libSQL migration failed: {}", e)))?;
// Apply incremental migrations (V9+) tracked in _migrations table.
libsql_migrations::run_incremental(&conn).await?;
// Set up vector index if embeddings are configured.
// This dynamically creates a libsql_vector_idx on memory_chunks.embedding
// with the correct F32_BLOB(N) dimension inferred from env vars.
if let Some(dimension) = workspace::resolve_embedding_dimension() {
self.ensure_vector_index(dimension).await?;
}
Ok(())
}
}
+474 -7
View File
@@ -11,7 +11,7 @@ use super::{
row_to_memory_document,
};
use crate::db::WorkspaceStore;
use crate::error::WorkspaceError;
use crate::error::{DatabaseError, WorkspaceError};
use crate::workspace::{
MemoryChunk, MemoryDocument, RankedResult, SearchConfig, SearchResult, WorkspaceEntry,
fuse_results,
@@ -19,6 +19,227 @@ use crate::workspace::{
use chrono::Utc;
/// Resolve the embedding dimension from environment variables.
///
/// Reads `EMBEDDING_ENABLED`, `EMBEDDING_DIMENSION`, and `EMBEDDING_MODEL`
/// from env vars. Returns `None` if embeddings are disabled.
///
/// Note: this only reads env vars, not persisted `Settings`, because it runs
/// during `run_migrations()` before the full config stack is available. Users
/// who configure embeddings via the settings UI must also set
/// `EMBEDDING_ENABLED=true` in their environment for the vector index to be
/// created. The model→dimension mapping is shared with `EmbeddingsConfig` via
/// `default_dimension_for_model()`.
pub(crate) fn resolve_embedding_dimension() -> Option<usize> {
let enabled = std::env::var("EMBEDDING_ENABLED")
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
if !enabled {
tracing::info!("Vector index setup skipped (EMBEDDING_ENABLED not set in env)");
return None;
}
if let Ok(dim_str) = std::env::var("EMBEDDING_DIMENSION")
&& let Ok(dim) = dim_str.parse::<usize>()
&& dim > 0
{
return Some(dim);
}
let model =
std::env::var("EMBEDDING_MODEL").unwrap_or_else(|_| "text-embedding-3-small".to_string());
Some(crate::config::embeddings::default_dimension_for_model(
&model,
))
}
impl LibSqlBackend {
/// Ensure the `libsql_vector_idx` on `memory_chunks.embedding` matches the
/// configured embedding dimension.
///
/// The V9 migration dropped the vector index (and changed `F32_BLOB(1536)`
/// to `BLOB`) to support flexible dimensions. This method restores a
/// properly-typed `F32_BLOB(N)` column and creates the vector index.
///
/// Tracks the active dimension in `_migrations` version `0` — a reserved
/// metadata row where `name` stores the dimension as a string. Version 0
/// is never used by incremental migrations (which start at 9), so there
/// is no collision. If the stored dimension matches, this is a no-op.
///
/// **Precondition:** `run_migrations()` must have been called first so that
/// the `_migrations` table exists. This is guaranteed when called from
/// `Database::run_migrations()`, but callers using this directly must
/// ensure migrations have run.
pub async fn ensure_vector_index(&self, dimension: usize) -> Result<(), DatabaseError> {
if dimension == 0 || dimension > 65536 {
return Err(DatabaseError::Migration(format!(
"ensure_vector_index: dimension {dimension} out of valid range (1..=65536)"
)));
}
let conn = self.connect().await?;
// Check current dimension from _migrations version=0 (reserved metadata row).
// The block scope ensures `rows` is dropped before `conn.transaction()` —
// holding a result set open would cause "database table is locked" errors.
let current_dim = {
let mut rows = conn
.query("SELECT name FROM _migrations WHERE version = 0", ())
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to check vector index metadata: {e}"))
})?;
rows.next().await.ok().flatten().and_then(|row| {
row.get::<String>(0)
.ok()
.and_then(|s| s.parse::<usize>().ok())
})
};
if current_dim == Some(dimension) {
tracing::debug!(
dimension,
"Vector index already matches configured dimension"
);
return Ok(());
}
tracing::info!(
old_dimension = ?current_dim,
new_dimension = dimension,
"Rebuilding memory_chunks table for vector index"
);
let tx = conn.transaction().await.map_err(|e| {
DatabaseError::Migration(format!(
"ensure_vector_index: failed to start transaction: {e}"
))
})?;
// 1. Drop FTS triggers that reference the old table
tx.execute_batch(
"DROP TRIGGER IF EXISTS memory_chunks_fts_insert;
DROP TRIGGER IF EXISTS memory_chunks_fts_delete;
DROP TRIGGER IF EXISTS memory_chunks_fts_update;",
)
.await
.map_err(|e| DatabaseError::Migration(format!("Failed to drop FTS triggers: {e}")))?;
// 2. Drop old vector index
tx.execute_batch("DROP INDEX IF EXISTS idx_memory_chunks_embedding;")
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to drop old vector index: {e}"))
})?;
// 3. Drop stale temp table (if a previous attempt crashed) and create fresh
tx.execute_batch("DROP TABLE IF EXISTS memory_chunks_new;")
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to drop stale memory_chunks_new: {e}"))
})?;
let create_sql = format!(
"CREATE TABLE memory_chunks_new (
_rowid INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
document_id TEXT NOT NULL REFERENCES memory_documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding F32_BLOB({dimension}),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (document_id, chunk_index)
)"
);
tx.execute_batch(&create_sql).await.map_err(|e| {
DatabaseError::Migration(format!(
"Failed to create memory_chunks_new with F32_BLOB({dimension}): {e}"
))
})?;
// 4. Copy data — embeddings with wrong byte length get NULLed
// (they will be re-embedded on next background pass).
// _rowid is explicitly preserved so the FTS5 content table
// (memory_chunks_fts, content_rowid='_rowid') stays in sync.
let expected_bytes = dimension * 4;
let copy_sql = format!(
"INSERT INTO memory_chunks_new
(_rowid, id, document_id, chunk_index, content, embedding, created_at)
SELECT _rowid, id, document_id, chunk_index, content,
CASE WHEN length(embedding) = {expected_bytes} THEN embedding ELSE NULL END,
created_at
FROM memory_chunks"
);
tx.execute_batch(&copy_sql).await.map_err(|e| {
DatabaseError::Migration(format!("Failed to copy data to memory_chunks_new: {e}"))
})?;
// 5. Swap tables
tx.execute_batch(
"DROP TABLE memory_chunks;
ALTER TABLE memory_chunks_new RENAME TO memory_chunks;",
)
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to swap memory_chunks tables: {e}"))
})?;
// 6. Recreate document index + vector index
tx.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
CREATE INDEX IF NOT EXISTS idx_memory_chunks_embedding ON memory_chunks(libsql_vector_idx(embedding));",
)
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to create indexes: {e}"))
})?;
// 7. Recreate FTS triggers
tx.execute_batch(
"CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_insert AFTER INSERT ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
END;
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_delete AFTER DELETE ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
VALUES ('delete', old._rowid, old.content);
END;
CREATE TRIGGER IF NOT EXISTS memory_chunks_fts_update AFTER UPDATE ON memory_chunks BEGIN
INSERT INTO memory_chunks_fts(memory_chunks_fts, rowid, content)
VALUES ('delete', old._rowid, old.content);
INSERT INTO memory_chunks_fts(rowid, content) VALUES (new._rowid, new.content);
END;",
)
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to recreate FTS triggers: {e}"))
})?;
// 8. Upsert dimension into _migrations(version=0)
tx.execute(
"INSERT INTO _migrations (version, name) VALUES (0, ?1)
ON CONFLICT(version) DO UPDATE SET name = ?1,
applied_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')",
params![dimension.to_string()],
)
.await
.map_err(|e| {
DatabaseError::Migration(format!("Failed to record vector index dimension: {e}"))
})?;
tx.commit().await.map_err(|e| {
DatabaseError::Migration(format!("ensure_vector_index: commit failed: {e}"))
})?;
tracing::info!(dimension, "Vector index created successfully");
Ok(())
}
}
#[async_trait]
impl WorkspaceStore for LibSqlBackend {
async fn get_document_by_path(
@@ -395,6 +616,9 @@ impl WorkspaceStore for LibSqlBackend {
reason: e.to_string(),
})?;
let id = Uuid::new_v4();
// Note: embedding dimension is not validated here — the F32_BLOB(N)
// column type created by ensure_vector_index() enforces byte length at
// the libSQL level and will reject mismatched dimensions.
let embedding_blob = embedding.map(|e| {
let bytes: Vec<u8> = e.iter().flat_map(|f| f.to_le_bytes()).collect();
bytes
@@ -561,9 +785,9 @@ impl WorkspaceStore for LibSqlBackend {
.join(",")
);
// vector_top_k requires a libsql_vector_idx index. After the V9
// migration the index is dropped (to support flexible embedding
// dimensions), so this query may fail. Fall back to FTS-only.
// vector_top_k requires a libsql_vector_idx index created by
// ensure_vector_index(). If the index is missing (embeddings not
// configured or dimension mismatch), fall back to FTS-only.
match conn
.query(
r#"
@@ -597,9 +821,9 @@ impl WorkspaceStore for LibSqlBackend {
results
}
Err(e) => {
tracing::debug!(
"Vector index query failed (expected after V9 migration), \
falling back to FTS-only: {e}"
tracing::warn!(
"Vector index query failed (ensure_vector_index may not have run \
or dimension mismatch), falling back to FTS-only: {e}"
);
Vec::new()
}
@@ -617,3 +841,246 @@ impl WorkspaceStore for LibSqlBackend {
Ok(fuse_results(fts_results, vector_results, config))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
/// Helper: create a file-backed backend with migrations applied.
async fn setup_backend() -> (LibSqlBackend, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("test_vector.db");
let backend = LibSqlBackend::new_local(&db_path).await.expect("new_local");
backend.run_migrations().await.expect("migrations");
(backend, dir)
}
/// Helper: insert a document and chunk with an optional embedding.
async fn insert_test_chunk(
backend: &LibSqlBackend,
user_id: &str,
path: &str,
content: &str,
embedding: Option<&[f32]>,
) -> (Uuid, Uuid) {
let conn = backend.connect().await.expect("connect");
let doc_id = Uuid::new_v4();
let now = super::fmt_ts(&Utc::now());
conn.execute(
"INSERT INTO memory_documents (id, user_id, path, content, created_at, updated_at, metadata)
VALUES (?1, ?2, ?3, '', ?4, ?4, '{}')",
params![doc_id.to_string(), user_id, path, now],
)
.await
.expect("insert doc");
let chunk_id = backend
.insert_chunk(doc_id, 0, content, embedding)
.await
.expect("insert chunk");
(doc_id, chunk_id)
}
#[tokio::test]
async fn test_ensure_vector_index_enables_vector_search() {
let (backend, _dir) = setup_backend().await;
// Create vector index with dim=4
backend.ensure_vector_index(4).await.expect("ensure dim=4");
// Insert a chunk with a 4-dim embedding
let embedding = [1.0_f32, 0.0, 0.0, 0.0];
let (_doc_id, _chunk_id) = insert_test_chunk(
&backend,
"test",
"notes.md",
"hello world",
Some(&embedding),
)
.await;
// Query using vector_top_k — should find the chunk
let conn = backend.connect().await.expect("connect");
let mut rows = conn
.query(
r#"SELECT c.id
FROM vector_top_k('idx_memory_chunks_embedding', vector('[1,0,0,0]'), 5) AS top_k
JOIN memory_chunks c ON c._rowid = top_k.id"#,
(),
)
.await
.expect("vector_top_k query");
let row = rows
.next()
.await
.expect("row fetch")
.expect("expected a result row");
let id: String = row.get(0).expect("get id");
assert!(!id.is_empty(), "vector search should return the chunk");
}
#[tokio::test]
async fn test_ensure_vector_index_dimension_change() {
let (backend, _dir) = setup_backend().await;
// Create with dim=4 and insert data
backend.ensure_vector_index(4).await.expect("ensure dim=4");
let embedding_4d = [1.0_f32, 2.0, 3.0, 4.0];
insert_test_chunk(&backend, "test", "a.md", "content a", Some(&embedding_4d)).await;
// Recreate with dim=8 — old 4-dim embeddings should be NULLed
backend.ensure_vector_index(8).await.expect("ensure dim=8");
// Verify metadata updated
let conn = backend.connect().await.expect("connect");
let mut rows = conn
.query("SELECT name FROM _migrations WHERE version = 0", ())
.await
.expect("query metadata");
let row = rows.next().await.expect("fetch").expect("metadata row");
let dim_str: String = row.get(0).expect("get name");
assert_eq!(dim_str, "8");
// Verify old embedding was NULLed (wrong byte length for dim=8)
let mut rows = conn
.query("SELECT embedding IS NULL FROM memory_chunks LIMIT 1", ())
.await
.expect("query embedding");
let row = rows.next().await.expect("fetch").expect("chunk row");
let is_null: i64 = row.get(0).expect("get is_null");
assert_eq!(
is_null, 1,
"old 4-dim embedding should be NULLed after dim change to 8"
);
}
#[tokio::test]
async fn test_ensure_vector_index_noop_when_unchanged() {
let (backend, _dir) = setup_backend().await;
// Create with dim=4 and insert data
backend.ensure_vector_index(4).await.expect("ensure dim=4");
let embedding = [1.0_f32, 0.0, 0.0, 0.0];
insert_test_chunk(&backend, "test", "b.md", "content b", Some(&embedding)).await;
// Run again with same dimension — should be a no-op
backend
.ensure_vector_index(4)
.await
.expect("ensure dim=4 again");
// Verify data is untouched (embedding not NULLed)
let conn = backend.connect().await.expect("connect");
let mut rows = conn
.query(
"SELECT embedding IS NOT NULL FROM memory_chunks LIMIT 1",
(),
)
.await
.expect("query embedding");
let row = rows.next().await.expect("fetch").expect("chunk row");
let has_embedding: i64 = row.get(0).expect("get");
assert_eq!(
has_embedding, 1,
"embedding should be preserved on no-op call"
);
}
#[tokio::test]
async fn test_hybrid_search_returns_vector_results() {
let (backend, _dir) = setup_backend().await;
// Create vector index with dim=4
backend.ensure_vector_index(4).await.expect("ensure dim=4");
// Insert chunk with embedding and searchable content
let embedding = [0.5_f32, 0.5, 0.0, 0.0];
insert_test_chunk(
&backend,
"user1",
"notes.md",
"quantum computing research",
Some(&embedding),
)
.await;
// Search via the WorkspaceStore trait with vector enabled
let query_emb = [0.5_f32, 0.5, 0.0, 0.0];
let config = SearchConfig::default().with_limit(5);
let results = backend
.hybrid_search("user1", None, "quantum", Some(&query_emb), &config)
.await
.expect("hybrid_search");
assert!(!results.is_empty(), "hybrid search should return results");
let first = &results[0];
assert!(
first.vector_rank.is_some(),
"result should have a vector_rank"
);
assert_eq!(first.content, "quantum computing research");
}
mod resolve_dimension {
use super::*;
use crate::config::helpers::ENV_MUTEX;
fn clear_embedding_env() {
// SAFETY: called under ENV_MUTEX
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_DIMENSION");
std::env::remove_var("EMBEDDING_MODEL");
}
}
#[test]
fn returns_none_when_disabled() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
clear_embedding_env();
assert!(resolve_embedding_dimension().is_none());
}
#[test]
fn returns_explicit_dimension() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
std::env::set_var("EMBEDDING_ENABLED", "true");
std::env::set_var("EMBEDDING_DIMENSION", "768");
}
assert_eq!(resolve_embedding_dimension(), Some(768));
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_DIMENSION");
}
}
#[test]
fn infers_from_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
std::env::set_var("EMBEDDING_ENABLED", "1");
std::env::set_var("EMBEDDING_MODEL", "all-minilm");
}
assert_eq!(resolve_embedding_dimension(), Some(384));
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_MODEL");
}
}
#[test]
fn defaults_to_1536_for_unknown_model() {
let _guard = ENV_MUTEX.lock().expect("env mutex");
clear_embedding_env();
// SAFETY: under ENV_MUTEX
unsafe {
std::env::set_var("EMBEDDING_ENABLED", "true");
std::env::set_var("EMBEDDING_MODEL", "some-unknown-model");
}
assert_eq!(resolve_embedding_dimension(), Some(1536));
unsafe {
std::env::remove_var("EMBEDDING_ENABLED");
std::env::remove_var("EMBEDDING_MODEL");
}
}
}
}
+6 -7
View File
@@ -240,9 +240,9 @@ CREATE TABLE IF NOT EXISTS memory_chunks (
CREATE INDEX IF NOT EXISTS idx_memory_chunks_document ON memory_chunks(document_id);
-- No vector index: BLOB column accepts any embedding dimension.
-- Vector search uses brute-force cosine distance (fast enough for
-- personal assistant workspaces). Matches PostgreSQL after V9 migration.
-- No vector index in base schema: BLOB column accepts any embedding dimension.
-- Vector index is created dynamically by ensure_vector_index() during
-- run_migrations() when embeddings are configured (EMBEDDING_ENABLED=true).
-- FTS5 virtual table for full-text search
CREATE VIRTUAL TABLE IF NOT EXISTS memory_chunks_fts USING fts5(
@@ -593,10 +593,9 @@ pub const INCREMENTAL_MIGRATIONS: &[(i64, &str, &str)] = &[
// constraint so any embedding dimension works. Existing embeddings
// are preserved; users only need to re-embed if they change models.
//
// The vector index (libsql_vector_idx) requires a fixed-dimension
// F32_BLOB(N), so we drop it entirely. Vector search falls back to
// brute-force cosine distance which is fast enough for personal
// assistant workspaces. This matches PostgreSQL after its V9 migration.
// The vector index is dropped here; ensure_vector_index() recreates
// it with the correct F32_BLOB(N) dimension during run_migrations()
// when embeddings are configured.
//
// SQLite cannot ALTER COLUMN types, so we recreate the table.
r#"
+1 -1
View File
@@ -89,7 +89,7 @@ Default k=60. Results from both methods are combined, with documents appearing i
**Backend differences:**
- **PostgreSQL:** `ts_rank_cd` for FTS, pgvector cosine distance for vectors, full RRF
- **libSQL:** FTS5 for keyword search only (vector search via `libsql_vector_idx` not yet wired)
- **libSQL:** FTS5 for keyword search + vector search via `libsql_vector_idx` (dimension set dynamically by `ensure_vector_index()` during startup)
## Heartbeat System