Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 64 additions & 35 deletions packages/app/src-tauri/src/vector/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use crate::storage;
use anyhow::Result;
use rusqlite::{Connection, params};
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, Manager};

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -25,6 +26,12 @@ pub struct VectorDB {
dimension: usize,
}

fn dimension_from_vec_schema(sql: &str) -> Option<usize> {
let start = sql.find("float[")? + "float[".len();
let end = sql[start..].find(']')? + start;
sql[start..end].parse().ok()
}

fn parse_embedding_blob(blob: &[u8]) -> Vec<f32> {
let chunk_size = std::mem::size_of::<f32>();
if blob.len() % chunk_size != 0 {
Expand Down Expand Up @@ -60,7 +67,7 @@ impl VectorDB {
)));
}

conn.execute("PRAGMA busy_timeout=5000", [])?;
conn.busy_timeout(Duration::from_millis(5000))?;
let _: String = conn.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?;

conn.execute(
Expand All @@ -72,54 +79,53 @@ impl VectorDB {
[],
)?;

// Check if vec_embeddings already exists with a different dimension.
// If so, drop and recreate to avoid dimension mismatch.
// A vec0 table's dimension is fixed at creation. On startup, opening a
// persisted table must never discard vectors merely because the fallback
// dimension changed (for example, a 1024d remote model vs. 384d builtin).
// The vectorization path can explicitly reinitialize an *empty* table.
let table_exists: bool = conn.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='vec_embeddings'",
[],
|row| row.get(0),
)?;

if table_exists {
// Probe actual dimension by checking the schema via sqlite_master
// sqlite-vec stores the dimension in the table's SQL definition
let existing_sql: Option<String> = conn.query_row(
let actual_dimension = if table_exists {
let existing_sql: String = conn.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='vec_embeddings'",
[],
|row| row.get(0),
).ok();

let needs_recreate = if let Some(sql) = existing_sql {
// sql looks like: CREATE VIRTUAL TABLE vec_embeddings USING vec0(embedding float[4096])
let dim_str = format!("float[{}]", dimension);
!sql.contains(&dim_str)
} else {
true
};

if needs_recreate {
println!("[VectorDB] Existing vec_embeddings has wrong dimension, recreating for {}", dimension);
conn.execute("DROP TABLE IF EXISTS vec_embeddings", [])?;
conn.execute("DELETE FROM id_mapping", [])?;
}
}
)?;
dimension_from_vec_schema(&existing_sql).ok_or_else(|| {
anyhow::anyhow!("Could not determine existing vec_embeddings dimension")
})?
} else {
conn.execute(
&format!(
"CREATE VIRTUAL TABLE vec_embeddings USING vec0(
embedding float[{}]
)",
dimension
),
[],
)?;
dimension
};

conn.execute(
&format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS vec_embeddings USING vec0(
embedding float[{}]
)",
dimension
),
"CREATE INDEX IF NOT EXISTS idx_id_mapping_book ON id_mapping(book_id)",
[],
)?;

conn.execute("CREATE INDEX IF NOT EXISTS idx_id_mapping_book ON id_mapping(book_id)", [])?;

let version: String = conn.query_row("SELECT vec_version()", [], |row| row.get(0))?;
println!("[VectorDB] sqlite-vec version: {}, dimension: {}", version, dimension);

Ok(Self { conn, dimension })
println!(
"[VectorDB] sqlite-vec version: {}, dimension: {}",
version, actual_dimension
);

Ok(Self {
conn,
dimension: actual_dimension,
})
}

pub fn insert(&self, records: &[VectorRecord]) -> Result<()> {
Expand Down Expand Up @@ -293,6 +299,29 @@ impl VectorDB {
}
}

#[cfg(test)]
mod tests {
use super::dimension_from_vec_schema;

#[test]
fn reads_dimension_from_existing_vec0_schema() {
assert_eq!(
dimension_from_vec_schema(
"CREATE VIRTUAL TABLE vec_embeddings USING vec0(embedding float[1024])"
),
Some(1024),
);
}

#[test]
fn rejects_a_schema_without_an_embedding_dimension() {
assert_eq!(
dimension_from_vec_schema("CREATE TABLE vec_embeddings (id TEXT)"),
None
);
}
}

pub struct VectorDBState {
pub db: Mutex<Option<VectorDB>>,
}
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/lib/rag/vectorize-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export async function triggerVectorizeBook(
url: selected.url,
apiKey: selected.apiKey,
modelId: selected.modelId,
dimension: selected.dimension,
};
})(),
};
Expand Down
82 changes: 55 additions & 27 deletions packages/app/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/globals.css";
import { setEmbeddingWorkerFactory, setStreamingFetch } from "@readany/core/ai";
import { BUILTIN_EMBEDDING_MODELS } from "@readany/core/ai/builtin-embedding-models";
import { onLibraryChanged } from "@readany/core/events/library-events";
import { installFeedbackLogCapture, setFeedbackWorkerUrl } from "@readany/core/feedback";
import { setVectorDB } from "@readany/core/rag";
import {
createBuiltinEmbeddingService,
EmbeddingService,
normalizeEmbeddingEndpoint,
clearSearchConfiguration,
configureSearch,
setVectorDB,
} from "@readany/core/rag";
import { setPlatformService } from "@readany/core/services";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { TauriPlatformService } from "./lib/platform/tauri-platform-service";
Expand Down Expand Up @@ -46,38 +52,60 @@ setEmbeddingWorkerFactory(
new Worker(new URL("@readany/core/ai/embedding-worker", import.meta.url), { type: "module" }),
);

/**
* The vectorization pipeline configures its embedding source independently from
* search. Keep the query-side service aligned with the active vector-model setting
* so Reader Agent ragSearch can generate query embeddings as well.
*/
function configureRagSearchFromVectorModelStore(): void {
const state = useVectorModelStore.getState();
if (!state.vectorModelEnabled) {
clearSearchConfiguration();
return;
}

if (state.vectorModelMode === "builtin" && state.selectedBuiltinModelId) {
configureSearch(createBuiltinEmbeddingService(state.selectedBuiltinModelId));
return;
}

const remoteModel = state.getSelectedVectorModel();
if (state.vectorModelMode === "remote" && remoteModel) {
configureSearch(
new EmbeddingService({
model: {
id: remoteModel.modelId,
name: remoteModel.name || remoteModel.modelId,
dimensions: remoteModel.dimension ?? 0,
maxTokens: 8192,
provider: "openai",
},
apiKey: remoteModel.apiKey || "local",
baseUrl: remoteModel.url,
}),
{
kind: "remote",
modelId: remoteModel.modelId,
endpoint: normalizeEmbeddingEndpoint(remoteModel.url),
dimensions: remoteModel.dimension ?? 0,
},
);
return;
}

clearSearchConfiguration();
}

configureRagSearchFromVectorModelStore();
useVectorModelStore.subscribe(configureRagSearchFromVectorModelStore);

// Set vector database reference (initialized in Rust setup)
const tauriVectorDB = new TauriVectorDB();
setVectorDB(tauriVectorDB);
console.log("[VectorDB] TauriVectorDB reference set");

const desktopDataRootReady = syncLegacyDesktopLibraryRootConfig().catch(console.error);

// Align vector DB dimension with the currently selected model
(async () => {
try {
await desktopDataRootReady;
const { vectorModelMode, selectedBuiltinModelId, getSelectedVectorModel } =
useVectorModelStore.getState();
let dimension: number | undefined;

if (vectorModelMode === "builtin" && selectedBuiltinModelId) {
const model = BUILTIN_EMBEDDING_MODELS.find((m) => m.id === selectedBuiltinModelId);
dimension = model?.dimension;
} else if (vectorModelMode === "remote") {
const remoteModel = getSelectedVectorModel();
dimension = remoteModel?.dimension;
}

if (dimension && dimension !== 384) {
await tauriVectorDB.reinit(dimension);
console.log(`[VectorDB] Aligned dimension to ${dimension}`);
}
} catch (err) {
console.warn("[VectorDB] Failed to align dimension on startup:", err);
}
})();

// Ensure i18n is fully initialized before rendering
i18nReady.then(() => {
desktopDataRootReady.catch(console.error);
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/rag-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,19 @@ export async function configureRagSearchForCli(
const key = `${model.url}\n${model.modelId}\n${model.apiKey}`;
if (configuredEmbeddingKey === key) return { embeddingConfigured: true };

const { EmbeddingService, configureSearch } = await import("@readany/core/rag");
const { EmbeddingService, configureSearch, normalizeEmbeddingEndpoint } = await import("@readany/core/rag");
configureSearch(
new EmbeddingService({
model: toEmbeddingModel(model),
apiKey: model.apiKey || "local",
baseUrl: model.url,
}),
{
kind: "remote",
modelId: model.modelId,
endpoint: normalizeEmbeddingEndpoint(model.url),
dimensions: model.dimension ?? 0,
},
);
configuredEmbeddingKey = key;
return { embeddingConfigured: true };
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/ai/tools/rag-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ export function createRagSearchTool(bookId: string): ToolDefinition {
returnedResults: truncatedResults.length,
totalTokens,
tokenBudget: MAX_TOTAL_TOKENS,
...(results[0]?.vectorStatus
? {
vectorStatus: results[0].vectorStatus,
vectorError: results[0].vectorError,
}
: {}),
};
},
};
Expand Down
50 changes: 48 additions & 2 deletions packages/core/src/db/chunk-queries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Chunk } from "../types";
import type { Chunk, VectorIndexProvenance } from "../types";
import { getDB, getLocalDB, serializeEmbedding, deserializeEmbedding } from "./db-core";

export async function getChunks(bookId: string): Promise<Chunk[]> {
Expand Down Expand Up @@ -57,11 +57,57 @@ export async function deleteChunks(bookId: string): Promise<void> {
await database.execute("DELETE FROM chunks WHERE book_id = ?", [bookId]);
}

export async function getVectorIndexProvenance(bookId: string): Promise<VectorIndexProvenance | null> {
const database = await getLocalDB();
const rows = await database.select<{
book_id: string;
model_kind: "builtin" | "remote";
model_id: string;
endpoint: string | null;
dimensions: number;
created_at: number;
}>("SELECT * FROM vector_index_provenance WHERE book_id = ?", [bookId]);
const row = rows[0];
if (!row) return null;
return {
bookId: row.book_id,
kind: row.model_kind,
modelId: row.model_id,
endpoint: row.endpoint || undefined,
dimensions: row.dimensions,
createdAt: row.created_at,
};
}

export async function setVectorIndexProvenance(provenance: VectorIndexProvenance): Promise<void> {
const database = await getLocalDB();
await database.execute(
`INSERT OR REPLACE INTO vector_index_provenance
(book_id, model_kind, model_id, endpoint, dimensions, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[
provenance.bookId,
provenance.kind,
provenance.modelId,
provenance.endpoint || null,
provenance.dimensions,
provenance.createdAt,
],
);
}

export async function deleteVectorIndexProvenance(bookId: string): Promise<void> {
const database = await getLocalDB();
await database.execute("DELETE FROM vector_index_provenance WHERE book_id = ?", [bookId]);
}

export async function clearVectorizationFlagsWithoutLocalChunks(): Promise<void> {
const database = await getDB();
const localDatabase = await getLocalDB();
const rows = await localDatabase.select<{ book_id: string }>(
"SELECT DISTINCT book_id FROM chunks",
`SELECT DISTINCT chunks.book_id
FROM chunks
INNER JOIN vector_index_provenance ON vector_index_provenance.book_id = chunks.book_id`,
);
const bookIds = rows.map((row) => row.book_id).filter((bookId) => !!bookId);

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ export {
getChunks,
insertChunks,
deleteChunks,
getVectorIndexProvenance,
setVectorIndexProvenance,
deleteVectorIndexProvenance,
clearVectorizationFlagsWithoutLocalChunks,
} from "./chunk-queries";

Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/db/db-core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { deserializeEmbedding, serializeEmbedding } from "./db-core";

describe("embedding serialization", () => {
it("decodes the JSON byte-array TEXT form persisted by Tauri SQL", () => {
const original = [0.125, -0.5, 1.25];
const bytes = serializeEmbedding(original)!;

expect(deserializeEmbedding(JSON.stringify(Array.from(bytes)))).toEqual(original);
});

it("rejects malformed byte lengths instead of constructing a partial float", () => {
expect(deserializeEmbedding("[1,2,3]")).toBeUndefined();
});
});
Loading