|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| import { createRequire } from "module";
|
| import type { EmbeddingResolution } from "./embedding/types";
|
| import {
|
| getMemoryVecMeta,
|
| setMemoryVecMeta,
|
| markAllMemoriesNeedReindex,
|
| countMemoryReindexPending,
|
| } from "../localDb";
|
| import { getDbInstance } from "../db/core";
|
| import { logger } from "../../../open-sse/utils/logger.ts";
|
| import { sanitizeErrorMessage } from "../../../open-sse/utils/error.ts";
|
|
|
| const _require = createRequire(import.meta.url);
|
|
|
| const log = logger("VECTOR_STORE");
|
|
|
|
|
|
|
| export interface VectorSearchHit {
|
| memoryId: string;
|
| distance: number;
|
| score: number;
|
| }
|
|
|
| export interface HybridRrfHit {
|
| memoryId: string;
|
| vecRank: number | null;
|
| ftsRank: number | null;
|
| rrfScore: number;
|
| vecDistance: number | null;
|
| ftsScore: number | null;
|
| }
|
|
|
| export interface VectorStore {
|
|
|
| ensureReady(resolution: EmbeddingResolution): Promise<{ ready: boolean; reason: string }>;
|
|
|
| upsertVector(memoryId: string, vector: Float32Array): Promise<void>;
|
|
|
| deleteVector(memoryId: string): Promise<void>;
|
|
|
| searchVector(vector: Float32Array, topK: number, apiKeyId?: string): Promise<VectorSearchHit[]>;
|
|
|
| searchHybrid(
|
| vector: Float32Array,
|
| queryText: string,
|
| topK: number,
|
| apiKeyId?: string,
|
| ): Promise<HybridRrfHit[]>;
|
|
|
| stats(): Promise<{
|
| rowCount: number;
|
| needsReindex: number;
|
| activeDim: number | null;
|
| signature: string | null;
|
| }>;
|
|
|
| resetForSignature(signature: string, dim: number): Promise<void>;
|
| }
|
|
|
|
|
|
|
| const RRF_K = Number(process.env["MEMORY_RRF_K"] ?? 60);
|
| const TOP_K_DEFAULT = Number(process.env["MEMORY_VEC_TOP_K"] ?? 20);
|
|
|
|
|
|
|
| |
| |
| |
|
|
| function encodeVector(v: Float32Array): Buffer {
|
| return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
|
| }
|
|
|
|
|
|
|
| class VectorStoreImpl implements VectorStore {
|
| async ensureReady(resolution: EmbeddingResolution): Promise<{ ready: boolean; reason: string }> {
|
| const db = getDbInstance();
|
| const meta = getMemoryVecMeta();
|
|
|
|
|
| if (resolution.dimensions !== null && resolution.signature !== meta.embeddingSignature) {
|
| await this.resetForSignature(resolution.signature, resolution.dimensions);
|
| return { ready: true, reason: `vec_memories recreated with dim=${resolution.dimensions}` };
|
| }
|
|
|
|
|
| if (meta.vecLoaded) {
|
| return { ready: true, reason: "vec_memories already ready" };
|
| }
|
|
|
|
|
| if (resolution.dimensions !== null) {
|
| const dim = meta.activeDim ?? resolution.dimensions;
|
| try {
|
| db.exec(
|
| `CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(embedding FLOAT[${dim}])`,
|
| );
|
| setMemoryVecMeta({ vecLoaded: true, activeDim: dim });
|
| return { ready: true, reason: `vec_memories created with dim=${dim}` };
|
| } catch (err: unknown) {
|
| const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
| return { ready: false, reason: `failed to create vec_memories: ${msg}` };
|
| }
|
| }
|
|
|
| return { ready: false, reason: "no dimensions available yet (lazy probe pending)" };
|
| }
|
|
|
| async upsertVector(memoryId: string, vector: Float32Array): Promise<void> {
|
| const db = getDbInstance();
|
|
|
|
|
| const row = db.prepare("SELECT rowid FROM memories WHERE id = ?").get(memoryId) as
|
| | { rowid: number }
|
| | undefined;
|
|
|
| if (!row) {
|
| throw new Error(`memory not found: ${memoryId}`);
|
| }
|
|
|
|
|
|
|
| db.prepare("DELETE FROM vec_memories WHERE rowid = ?").run(BigInt(row.rowid));
|
| db.prepare("INSERT INTO vec_memories(rowid, embedding) VALUES (?, ?)").run(
|
| BigInt(row.rowid),
|
| encodeVector(vector),
|
| );
|
| }
|
|
|
| async deleteVector(memoryId: string): Promise<void> {
|
| const db = getDbInstance();
|
| db.prepare(
|
| "DELETE FROM vec_memories WHERE rowid = (SELECT rowid FROM memories WHERE id = ?)",
|
| ).run(memoryId);
|
| }
|
|
|
| async searchVector(
|
| vector: Float32Array,
|
| topK: number,
|
| apiKeyId?: string,
|
| ): Promise<VectorSearchHit[]> {
|
| const db = getDbInstance();
|
| const k = topK > 0 ? topK : TOP_K_DEFAULT;
|
|
|
| const rows = db
|
| .prepare(
|
| `SELECT m.id AS memory_id, v.distance
|
| FROM vec_memories v
|
| JOIN memories m ON m.rowid = v.rowid
|
| WHERE v.embedding MATCH ?
|
| AND ($apiKeyId IS NULL OR m.api_key_id = $apiKeyId)
|
| AND k = ?
|
| ORDER BY v.distance ASC`,
|
| )
|
| .all(encodeVector(vector), { apiKeyId: apiKeyId ?? null }, k) as Array<{
|
| memory_id: string;
|
| distance: number;
|
| }>;
|
|
|
| return rows.map((r) => ({
|
| memoryId: r.memory_id,
|
| distance: r.distance,
|
| score: 1 / (1 + r.distance),
|
| }));
|
| }
|
|
|
| async searchHybrid(
|
| vector: Float32Array,
|
| queryText: string,
|
| topK: number,
|
| apiKeyId?: string,
|
| ): Promise<HybridRrfHit[]> {
|
| const db = getDbInstance();
|
| const k = topK > 0 ? topK : TOP_K_DEFAULT;
|
| const rrfK = RRF_K;
|
|
|
|
|
|
|
| const rows = db
|
| .prepare(
|
| `WITH vec_results AS (
|
| SELECT m.id AS memory_id,
|
| ROW_NUMBER() OVER (ORDER BY v.distance ASC) AS vec_rank,
|
| v.distance AS vec_distance
|
| FROM vec_memories v
|
| JOIN memories m ON m.rowid = v.rowid
|
| WHERE v.embedding MATCH ?
|
| AND ($apiKeyId IS NULL OR m.api_key_id = $apiKeyId)
|
| AND k = ?
|
| ),
|
| fts_results AS (
|
| SELECT m.id AS memory_id,
|
| ROW_NUMBER() OVER (ORDER BY fts.rank ASC) AS fts_rank,
|
| fts.rank AS fts_score
|
| FROM memory_fts fts
|
| JOIN memories m ON m.memory_id = fts.rowid
|
| WHERE fts.memory_fts MATCH ?
|
| AND ($apiKeyId IS NULL OR m.api_key_id = $apiKeyId)
|
| LIMIT ?
|
| ),
|
| fused AS (
|
| SELECT
|
| memory_id,
|
| MAX(vec_rank) AS vec_rank,
|
| MAX(fts_rank) AS fts_rank,
|
| MAX(vec_distance) AS vec_distance,
|
| MAX(fts_score) AS fts_score,
|
| SUM(rrf_contrib) AS rrf_score
|
| FROM (
|
| SELECT memory_id, vec_rank, NULL AS fts_rank, vec_distance,
|
| NULL AS fts_score, 1.0 / (${rrfK} + vec_rank) AS rrf_contrib
|
| FROM vec_results
|
| UNION ALL
|
| SELECT memory_id, NULL, fts_rank, NULL, fts_score, 1.0 / (${rrfK} + fts_rank)
|
| FROM fts_results
|
| )
|
| GROUP BY memory_id
|
| )
|
| SELECT memory_id, vec_rank, fts_rank, vec_distance, fts_score, rrf_score
|
| FROM fused
|
| ORDER BY rrf_score DESC
|
| LIMIT ?`,
|
| )
|
| .all(
|
| encodeVector(vector),
|
| { apiKeyId: apiKeyId ?? null },
|
| k,
|
| queryText,
|
| k,
|
| k,
|
| ) as Array<{
|
| memory_id: string;
|
| vec_rank: number | null;
|
| fts_rank: number | null;
|
| vec_distance: number | null;
|
| fts_score: number | null;
|
| rrf_score: number;
|
| }>;
|
|
|
| return rows.map((r) => ({
|
| memoryId: r.memory_id,
|
| vecRank: r.vec_rank,
|
| ftsRank: r.fts_rank,
|
| rrfScore: r.rrf_score,
|
| vecDistance: r.vec_distance,
|
| ftsScore: r.fts_score,
|
| }));
|
| }
|
|
|
| async stats(): Promise<{
|
| rowCount: number;
|
| needsReindex: number;
|
| activeDim: number | null;
|
| signature: string | null;
|
| }> {
|
| let rowCount = 0;
|
| try {
|
| const db = getDbInstance();
|
| const row = db.prepare("SELECT COUNT(*) AS cnt FROM vec_memories").get() as
|
| | { cnt: number }
|
| | undefined;
|
| rowCount = row?.cnt ?? 0;
|
| } catch {
|
|
|
| rowCount = 0;
|
| }
|
|
|
| const needsReindex = countMemoryReindexPending();
|
| const meta = getMemoryVecMeta();
|
|
|
| return {
|
| rowCount,
|
| needsReindex,
|
| activeDim: meta.activeDim,
|
| signature: meta.embeddingSignature,
|
| };
|
| }
|
|
|
| async resetForSignature(signature: string, dim: number): Promise<void> {
|
| const db = getDbInstance();
|
|
|
|
|
| db.exec("DROP TABLE IF EXISTS vec_memories");
|
| db.exec(`CREATE VIRTUAL TABLE vec_memories USING vec0(embedding FLOAT[${dim}])`);
|
|
|
| markAllMemoriesNeedReindex();
|
| setMemoryVecMeta({
|
| activeDim: dim,
|
| embeddingSignature: signature,
|
| lastResetAt: new Date().toISOString(),
|
| vecLoaded: true,
|
| });
|
| }
|
| }
|
|
|
|
|
|
|
| let _instance: VectorStore | null | undefined = undefined;
|
|
|
| |
| |
| |
| |
|
|
| export function getVectorStore(): VectorStore | null {
|
| if (_instance !== undefined) {
|
| return _instance;
|
| }
|
|
|
|
|
| if (process.env["VECTOR_STORE_DISABLE_VEC"] === "true") {
|
| log.warn(
|
| "VECTOR_STORE_DISABLE_VEC is set β sqlite-vec disabled. Degrading to FTS5 keyword search.",
|
| );
|
| _instance = null;
|
| return null;
|
| }
|
|
|
| const db = getDbInstance();
|
| const raw = db.raw as { loadExtension?: (path: string) => void } | null;
|
|
|
|
|
|
|
| if (!raw || typeof raw.loadExtension !== "function") {
|
| log.warn(
|
| "sqlite-vec not loaded: db driver does not support loadExtension (cloud/WASM backend). " +
|
| "Degrading to FTS5 keyword search.",
|
| );
|
| _instance = null;
|
| return null;
|
| }
|
|
|
| try {
|
| const sqliteVec = _require("sqlite-vec") as { load: (db: unknown) => void };
|
| sqliteVec.load(raw);
|
| log.info("sqlite-vec loaded successfully");
|
| _instance = new VectorStoreImpl();
|
| } catch (err: unknown) {
|
| const safeMsg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
| log.warn(`sqlite-vec failed to load: ${safeMsg}. Degrading to FTS5 keyword search.`);
|
| _instance = null;
|
| }
|
|
|
| return _instance;
|
| }
|
|
|
| |
| |
| |
|
|
| export function _resetVectorStoreSingleton(): void {
|
| _instance = undefined;
|
| }
|
|
|