mirror of
https://github.com/zadam/trilium.git
synced 2025-11-06 13:26:01 +01:00
Revert "feat(search): try to deal with huge dbs, might need to squash later"
This reverts commit 37d0136c50.
This commit is contained in:
@@ -219,29 +219,52 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- FTS5 Full-Text Search Support
|
-- FTS5 Full-Text Search Support
|
||||||
-- Optimized FTS5 virtual table with advanced configuration for millions of notes
|
-- Create FTS5 virtual table with porter stemming for word-based searches
|
||||||
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
CREATE VIRTUAL TABLE notes_fts USING fts5(
|
||||||
noteId UNINDEXED,
|
noteId UNINDEXED,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
tokenize = 'porter unicode61',
|
tokenize = 'porter unicode61'
|
||||||
prefix = '2 3 4', -- Index prefixes of 2, 3, and 4 characters for faster prefix searches
|
|
||||||
columnsize = 0, -- Reduce index size by not storing column sizes (saves ~25% space)
|
|
||||||
detail = full -- Keep full detail for snippet generation
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Optimized triggers to keep FTS table synchronized with notes
|
-- Create FTS5 virtual table with trigram tokenizer for substring searches
|
||||||
-- Consolidated from 7 triggers to 4 for better performance and maintainability
|
CREATE VIRTUAL TABLE notes_fts_trigram USING fts5(
|
||||||
|
noteId UNINDEXED,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
tokenize = 'trigram',
|
||||||
|
detail = 'none'
|
||||||
|
);
|
||||||
|
|
||||||
-- Smart trigger for INSERT operations on notes
|
-- Triggers to keep FTS table synchronized with notes
|
||||||
-- Handles: INSERT, INSERT OR REPLACE, INSERT OR IGNORE, and upsert scenarios
|
-- IMPORTANT: These triggers must handle all SQL operations including:
|
||||||
|
-- - Regular INSERT/UPDATE/DELETE
|
||||||
|
-- - INSERT OR REPLACE
|
||||||
|
-- - INSERT ... ON CONFLICT ... DO UPDATE (upsert)
|
||||||
|
-- - Cases where notes are created before blobs (import scenarios)
|
||||||
|
|
||||||
|
-- Trigger for INSERT operations on notes
|
||||||
|
-- Handles: INSERT, INSERT OR REPLACE, INSERT OR IGNORE, and the INSERT part of upsert
|
||||||
CREATE TRIGGER notes_fts_insert
|
CREATE TRIGGER notes_fts_insert
|
||||||
AFTER INSERT ON notes
|
AFTER INSERT ON notes
|
||||||
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||||
AND NEW.isDeleted = 0
|
AND NEW.isDeleted = 0
|
||||||
AND NEW.isProtected = 0
|
AND NEW.isProtected = 0
|
||||||
BEGIN
|
BEGIN
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
-- First delete any existing FTS entries (in case of INSERT OR REPLACE)
|
||||||
|
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
||||||
|
DELETE FROM notes_fts_trigram WHERE noteId = NEW.noteId;
|
||||||
|
|
||||||
|
-- Then insert the new entry into both FTS tables
|
||||||
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
|
SELECT
|
||||||
|
NEW.noteId,
|
||||||
|
NEW.title,
|
||||||
|
COALESCE(b.content, '') -- Use empty string if blob doesn't exist yet
|
||||||
|
FROM (SELECT NEW.noteId) AS note_select
|
||||||
|
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
||||||
|
|
||||||
|
INSERT INTO notes_fts_trigram (noteId, title, content)
|
||||||
SELECT
|
SELECT
|
||||||
NEW.noteId,
|
NEW.noteId,
|
||||||
NEW.title,
|
NEW.title,
|
||||||
@@ -250,35 +273,47 @@ BEGIN
|
|||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
||||||
END;
|
END;
|
||||||
|
|
||||||
-- Smart trigger for UPDATE operations on notes table
|
-- Trigger for UPDATE operations on notes table
|
||||||
-- Only fires when relevant fields actually change to reduce unnecessary work
|
-- Handles: Regular UPDATE and the UPDATE part of upsert (ON CONFLICT DO UPDATE)
|
||||||
|
-- Fires for ANY update to searchable notes to ensure FTS stays in sync
|
||||||
CREATE TRIGGER notes_fts_update
|
CREATE TRIGGER notes_fts_update
|
||||||
AFTER UPDATE ON notes
|
AFTER UPDATE ON notes
|
||||||
WHEN (OLD.title != NEW.title OR OLD.type != NEW.type OR OLD.blobId != NEW.blobId OR
|
WHEN NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||||
OLD.isDeleted != NEW.isDeleted OR OLD.isProtected != NEW.isProtected)
|
-- Fire on any change, not just specific columns, to handle all upsert scenarios
|
||||||
AND NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Remove old entry
|
-- Always delete the old entries from both FTS tables
|
||||||
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
||||||
|
DELETE FROM notes_fts_trigram WHERE noteId = NEW.noteId;
|
||||||
|
|
||||||
-- Add new entry if eligible
|
-- Insert new entries into both FTS tables if note is not deleted and not protected
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
|
SELECT
|
||||||
|
NEW.noteId,
|
||||||
|
NEW.title,
|
||||||
|
COALESCE(b.content, '') -- Use empty string if blob doesn't exist yet
|
||||||
|
FROM (SELECT NEW.noteId) AS note_select
|
||||||
|
LEFT JOIN blobs b ON b.blobId = NEW.blobId
|
||||||
|
WHERE NEW.isDeleted = 0
|
||||||
|
AND NEW.isProtected = 0;
|
||||||
|
|
||||||
|
INSERT INTO notes_fts_trigram (noteId, title, content)
|
||||||
SELECT
|
SELECT
|
||||||
NEW.noteId,
|
NEW.noteId,
|
||||||
NEW.title,
|
NEW.title,
|
||||||
COALESCE(b.content, '')
|
COALESCE(b.content, '')
|
||||||
FROM (SELECT NEW.noteId) AS note_select
|
FROM (SELECT NEW.noteId) AS note_select
|
||||||
LEFT JOIN blobs b ON b.blobId = NEW.blobId
|
LEFT JOIN blobs b ON b.blobId = NEW.blobId
|
||||||
WHERE NEW.isDeleted = 0 AND NEW.isProtected = 0;
|
WHERE NEW.isDeleted = 0
|
||||||
|
AND NEW.isProtected = 0;
|
||||||
END;
|
END;
|
||||||
|
|
||||||
-- Smart trigger for UPDATE operations on blobs
|
-- Trigger for UPDATE operations on blobs
|
||||||
-- Only fires when content actually changes
|
-- Handles: Regular UPDATE and the UPDATE part of upsert (ON CONFLICT DO UPDATE)
|
||||||
|
-- IMPORTANT: Uses INSERT OR REPLACE for efficiency with deduplicated blobs
|
||||||
CREATE TRIGGER notes_fts_blob_update
|
CREATE TRIGGER notes_fts_blob_update
|
||||||
AFTER UPDATE ON blobs
|
AFTER UPDATE ON blobs
|
||||||
WHEN OLD.content != NEW.content
|
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Update FTS table for all notes sharing this blob
|
-- Update both FTS tables for all notes sharing this blob
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
||||||
SELECT
|
SELECT
|
||||||
n.noteId,
|
n.noteId,
|
||||||
@@ -289,11 +324,100 @@ BEGIN
|
|||||||
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||||
AND n.isDeleted = 0
|
AND n.isDeleted = 0
|
||||||
AND n.isProtected = 0;
|
AND n.isProtected = 0;
|
||||||
|
|
||||||
|
INSERT OR REPLACE INTO notes_fts_trigram (noteId, title, content)
|
||||||
|
SELECT
|
||||||
|
n.noteId,
|
||||||
|
n.title,
|
||||||
|
NEW.content
|
||||||
|
FROM notes n
|
||||||
|
WHERE n.blobId = NEW.blobId
|
||||||
|
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||||
|
AND n.isDeleted = 0
|
||||||
|
AND n.isProtected = 0;
|
||||||
END;
|
END;
|
||||||
|
|
||||||
-- Trigger for DELETE operations (handles both hard delete and cleanup)
|
-- Trigger for DELETE operations
|
||||||
CREATE TRIGGER notes_fts_delete
|
CREATE TRIGGER notes_fts_delete
|
||||||
AFTER DELETE ON notes
|
AFTER DELETE ON notes
|
||||||
BEGIN
|
BEGIN
|
||||||
DELETE FROM notes_fts WHERE noteId = OLD.noteId;
|
DELETE FROM notes_fts WHERE noteId = OLD.noteId;
|
||||||
|
DELETE FROM notes_fts_trigram WHERE noteId = OLD.noteId;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Trigger for soft delete (isDeleted = 1)
|
||||||
|
CREATE TRIGGER notes_fts_soft_delete
|
||||||
|
AFTER UPDATE ON notes
|
||||||
|
WHEN OLD.isDeleted = 0 AND NEW.isDeleted = 1
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
||||||
|
DELETE FROM notes_fts_trigram WHERE noteId = NEW.noteId;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Trigger for notes becoming protected
|
||||||
|
-- Remove from FTS when a note becomes protected
|
||||||
|
CREATE TRIGGER notes_fts_protect
|
||||||
|
AFTER UPDATE ON notes
|
||||||
|
WHEN OLD.isProtected = 0 AND NEW.isProtected = 1
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
||||||
|
DELETE FROM notes_fts_trigram WHERE noteId = NEW.noteId;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Trigger for notes becoming unprotected
|
||||||
|
-- Add to FTS when a note becomes unprotected (if eligible)
|
||||||
|
CREATE TRIGGER notes_fts_unprotect
|
||||||
|
AFTER UPDATE ON notes
|
||||||
|
WHEN OLD.isProtected = 1 AND NEW.isProtected = 0
|
||||||
|
AND NEW.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||||
|
AND NEW.isDeleted = 0
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM notes_fts WHERE noteId = NEW.noteId;
|
||||||
|
DELETE FROM notes_fts_trigram WHERE noteId = NEW.noteId;
|
||||||
|
|
||||||
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
|
SELECT
|
||||||
|
NEW.noteId,
|
||||||
|
NEW.title,
|
||||||
|
COALESCE(b.content, '')
|
||||||
|
FROM (SELECT NEW.noteId) AS note_select
|
||||||
|
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
||||||
|
|
||||||
|
INSERT INTO notes_fts_trigram (noteId, title, content)
|
||||||
|
SELECT
|
||||||
|
NEW.noteId,
|
||||||
|
NEW.title,
|
||||||
|
COALESCE(b.content, '')
|
||||||
|
FROM (SELECT NEW.noteId) AS note_select
|
||||||
|
LEFT JOIN blobs b ON b.blobId = NEW.blobId;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Trigger for INSERT operations on blobs
|
||||||
|
-- Handles: INSERT, INSERT OR REPLACE, and the INSERT part of upsert
|
||||||
|
-- Updates all notes that reference this blob (common during import and deduplication)
|
||||||
|
CREATE TRIGGER notes_fts_blob_insert
|
||||||
|
AFTER INSERT ON blobs
|
||||||
|
BEGIN
|
||||||
|
-- Update both FTS tables for all notes that reference this blob
|
||||||
|
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
||||||
|
SELECT
|
||||||
|
n.noteId,
|
||||||
|
n.title,
|
||||||
|
NEW.content
|
||||||
|
FROM notes n
|
||||||
|
WHERE n.blobId = NEW.blobId
|
||||||
|
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||||
|
AND n.isDeleted = 0
|
||||||
|
AND n.isProtected = 0;
|
||||||
|
|
||||||
|
INSERT OR REPLACE INTO notes_fts_trigram (noteId, title, content)
|
||||||
|
SELECT
|
||||||
|
n.noteId,
|
||||||
|
n.title,
|
||||||
|
NEW.content
|
||||||
|
FROM notes n
|
||||||
|
WHERE n.blobId = NEW.blobId
|
||||||
|
AND n.type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||||
|
AND n.isDeleted = 0
|
||||||
|
AND n.isProtected = 0;
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -17,18 +17,7 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
// Create FTS5 virtual table with porter tokenizer
|
// Create FTS5 virtual table with porter tokenizer
|
||||||
log.info("Creating FTS5 virtual table...");
|
log.info("Creating FTS5 virtual table...");
|
||||||
|
|
||||||
// Set optimal SQLite pragmas for FTS5 operations with millions of notes
|
|
||||||
sql.executeScript(`
|
sql.executeScript(`
|
||||||
-- Memory and performance pragmas for large-scale FTS operations
|
|
||||||
PRAGMA cache_size = -262144; -- 256MB cache for better performance
|
|
||||||
PRAGMA temp_store = MEMORY; -- Use RAM for temporary storage
|
|
||||||
PRAGMA mmap_size = 536870912; -- 512MB memory-mapped I/O
|
|
||||||
PRAGMA synchronous = NORMAL; -- Faster writes with good safety
|
|
||||||
PRAGMA journal_mode = WAL; -- Write-ahead logging for better concurrency
|
|
||||||
PRAGMA wal_autocheckpoint = 1000; -- Auto-checkpoint every 1000 pages
|
|
||||||
PRAGMA automatic_index = ON; -- Allow automatic indexes
|
|
||||||
PRAGMA threads = 4; -- Use multiple threads for sorting
|
|
||||||
|
|
||||||
-- Drop existing FTS tables if they exist
|
-- Drop existing FTS tables if they exist
|
||||||
DROP TABLE IF EXISTS notes_fts;
|
DROP TABLE IF EXISTS notes_fts;
|
||||||
DROP TABLE IF EXISTS notes_fts_trigram;
|
DROP TABLE IF EXISTS notes_fts_trigram;
|
||||||
@@ -36,26 +25,25 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
DROP TABLE IF EXISTS notes_fts_stats;
|
DROP TABLE IF EXISTS notes_fts_stats;
|
||||||
DROP TABLE IF EXISTS notes_fts_aux;
|
DROP TABLE IF EXISTS notes_fts_aux;
|
||||||
|
|
||||||
-- Create optimized FTS5 virtual table for millions of notes
|
-- Create FTS5 virtual table with porter tokenizer for stemming
|
||||||
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
||||||
noteId UNINDEXED,
|
noteId UNINDEXED,
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
tokenize = 'porter unicode61',
|
tokenize = 'porter unicode61',
|
||||||
prefix = '2 3 4', -- Index prefixes of 2, 3, and 4 characters for faster prefix searches
|
prefix = '2 3' -- Index prefixes of 2 and 3 characters for faster prefix searches
|
||||||
columnsize = 0, -- Reduce index size by not storing column sizes (saves ~25% space)
|
|
||||||
detail = full -- Keep full detail for snippet generation
|
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
log.info("Populating FTS5 table with existing note content...");
|
log.info("Populating FTS5 table with existing note content...");
|
||||||
|
|
||||||
// Optimized population with batch inserts and better memory management
|
// Populate the FTS table with existing notes
|
||||||
const batchSize = 5000; // Larger batch size for better performance
|
const batchSize = 1000;
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Count eligible notes first
|
sql.transactional(() => {
|
||||||
|
// Count eligible notes
|
||||||
const totalNotes = sql.getValue<number>(`
|
const totalNotes = sql.getValue<number>(`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM notes n
|
FROM notes n
|
||||||
@@ -68,18 +56,11 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
|
|
||||||
log.info(`Found ${totalNotes} notes to index`);
|
log.info(`Found ${totalNotes} notes to index`);
|
||||||
|
|
||||||
// Process in optimized batches using a prepared statement
|
// Insert notes in batches
|
||||||
sql.transactional(() => {
|
|
||||||
// Prepare statement for batch inserts
|
|
||||||
const insertStmt = sql.prepare(`
|
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (offset < totalNotes) {
|
while (offset < totalNotes) {
|
||||||
// Fetch batch of notes
|
sql.execute(`
|
||||||
const notesBatch = sql.getRows<{noteId: string, title: string, content: string}>(`
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
SELECT
|
SELECT
|
||||||
n.noteId,
|
n.noteId,
|
||||||
n.title,
|
n.title,
|
||||||
@@ -94,31 +75,13 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`, [batchSize, offset]);
|
`, [batchSize, offset]);
|
||||||
|
|
||||||
if (!notesBatch || notesBatch.length === 0) {
|
offset += batchSize;
|
||||||
break;
|
processedCount = Math.min(offset, totalNotes);
|
||||||
}
|
|
||||||
|
|
||||||
// Batch insert using prepared statement
|
if (processedCount % 10000 === 0) {
|
||||||
for (const note of notesBatch) {
|
log.info(`Indexed ${processedCount} of ${totalNotes} notes...`);
|
||||||
insertStmt.run(note.noteId, note.title, note.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += notesBatch.length;
|
|
||||||
processedCount += notesBatch.length;
|
|
||||||
|
|
||||||
// Progress reporting every 10k notes
|
|
||||||
if (processedCount % 10000 === 0 || processedCount === totalNotes) {
|
|
||||||
log.info(`Indexed ${processedCount} of ${totalNotes} notes (${Math.round((processedCount / totalNotes) * 100)}%)...`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Early exit if we processed fewer notes than batch size
|
|
||||||
if (notesBatch.length < batchSize) {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize prepared statement
|
|
||||||
insertStmt.finalize();
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(`Failed to populate FTS index: ${error}`);
|
log.error(`Failed to populate FTS index: ${error}`);
|
||||||
@@ -143,7 +106,7 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
sql.execute(`DROP TRIGGER IF EXISTS ${trigger}`);
|
sql.execute(`DROP TRIGGER IF EXISTS ${trigger}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create optimized triggers for notes table operations
|
// Create triggers for notes table operations
|
||||||
sql.execute(`
|
sql.execute(`
|
||||||
CREATE TRIGGER notes_fts_insert
|
CREATE TRIGGER notes_fts_insert
|
||||||
AFTER INSERT ON notes
|
AFTER INSERT ON notes
|
||||||
@@ -151,8 +114,7 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
AND NEW.isDeleted = 0
|
AND NEW.isDeleted = 0
|
||||||
AND NEW.isProtected = 0
|
AND NEW.isProtected = 0
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Use INSERT OR REPLACE for better handling of duplicate entries
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
SELECT
|
||||||
NEW.noteId,
|
NEW.noteId,
|
||||||
NEW.title,
|
NEW.title,
|
||||||
@@ -165,20 +127,12 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
sql.execute(`
|
sql.execute(`
|
||||||
CREATE TRIGGER notes_fts_update
|
CREATE TRIGGER notes_fts_update
|
||||||
AFTER UPDATE ON notes
|
AFTER UPDATE ON notes
|
||||||
WHEN (
|
|
||||||
-- Only fire when relevant fields change or status changes
|
|
||||||
OLD.title != NEW.title OR
|
|
||||||
OLD.type != NEW.type OR
|
|
||||||
OLD.blobId != NEW.blobId OR
|
|
||||||
OLD.isDeleted != NEW.isDeleted OR
|
|
||||||
OLD.isProtected != NEW.isProtected
|
|
||||||
)
|
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Always remove old entry first
|
-- Delete old entry
|
||||||
DELETE FROM notes_fts WHERE noteId = OLD.noteId;
|
DELETE FROM notes_fts WHERE noteId = OLD.noteId;
|
||||||
|
|
||||||
-- Insert new entry if eligible (avoid redundant work)
|
-- Insert new entry if eligible
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
SELECT
|
SELECT
|
||||||
NEW.noteId,
|
NEW.noteId,
|
||||||
NEW.title,
|
NEW.title,
|
||||||
@@ -199,14 +153,19 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
END;
|
END;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Create optimized triggers for blob updates
|
// Create triggers for blob updates
|
||||||
sql.execute(`
|
sql.execute(`
|
||||||
CREATE TRIGGER blobs_fts_update
|
CREATE TRIGGER blobs_fts_update
|
||||||
AFTER UPDATE ON blobs
|
AFTER UPDATE ON blobs
|
||||||
WHEN OLD.content != NEW.content -- Only fire when content actually changes
|
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Use efficient INSERT OR REPLACE to update all notes referencing this blob
|
-- Update all notes that reference this blob
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
DELETE FROM notes_fts
|
||||||
|
WHERE noteId IN (
|
||||||
|
SELECT noteId FROM notes
|
||||||
|
WHERE blobId = NEW.blobId
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
SELECT
|
SELECT
|
||||||
n.noteId,
|
n.noteId,
|
||||||
n.title,
|
n.title,
|
||||||
@@ -223,8 +182,7 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
CREATE TRIGGER blobs_fts_insert
|
CREATE TRIGGER blobs_fts_insert
|
||||||
AFTER INSERT ON blobs
|
AFTER INSERT ON blobs
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Use INSERT OR REPLACE to handle potential race conditions
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
|
||||||
SELECT
|
SELECT
|
||||||
n.noteId,
|
n.noteId,
|
||||||
n.title,
|
n.title,
|
||||||
@@ -243,31 +201,16 @@ export default function addFTS5SearchAndPerformanceIndexes() {
|
|||||||
log.info("Optimizing FTS5 index...");
|
log.info("Optimizing FTS5 index...");
|
||||||
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
sql.execute(`INSERT INTO notes_fts(notes_fts) VALUES('optimize')`);
|
||||||
|
|
||||||
// Set comprehensive SQLite pragmas optimized for millions of notes
|
// Set essential SQLite pragmas for better performance
|
||||||
log.info("Configuring SQLite pragmas for large-scale FTS performance...");
|
|
||||||
|
|
||||||
sql.executeScript(`
|
sql.executeScript(`
|
||||||
-- Memory Management (Critical for large databases)
|
-- Increase cache size (50MB)
|
||||||
PRAGMA cache_size = -262144; -- 256MB cache (was 50MB) - critical for FTS performance
|
PRAGMA cache_size = -50000;
|
||||||
PRAGMA temp_store = MEMORY; -- Use memory for temporary tables and indices
|
|
||||||
PRAGMA mmap_size = 536870912; -- 512MB memory-mapped I/O for better read performance
|
|
||||||
|
|
||||||
-- Write Optimization (Important for batch operations)
|
-- Use memory for temp storage
|
||||||
PRAGMA synchronous = NORMAL; -- Balance between safety and performance (was FULL)
|
PRAGMA temp_store = 2;
|
||||||
PRAGMA journal_mode = WAL; -- Write-Ahead Logging for better concurrency
|
|
||||||
PRAGMA wal_autocheckpoint = 1000; -- Checkpoint every 1000 pages for memory management
|
|
||||||
|
|
||||||
-- Query Optimization (Essential for FTS queries)
|
-- Run ANALYZE on FTS tables
|
||||||
PRAGMA automatic_index = ON; -- Allow SQLite to create automatic indexes
|
|
||||||
PRAGMA optimize; -- Update query planner statistics
|
|
||||||
|
|
||||||
-- FTS-Specific Optimizations
|
|
||||||
PRAGMA threads = 4; -- Use multiple threads for FTS operations (if available)
|
|
||||||
|
|
||||||
-- Run comprehensive ANALYZE on all FTS-related tables
|
|
||||||
ANALYZE notes_fts;
|
ANALYZE notes_fts;
|
||||||
ANALYZE notes;
|
|
||||||
ANALYZE blobs;
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
log.info("FTS5 migration completed successfully");
|
log.info("FTS5 migration completed successfully");
|
||||||
|
|||||||
@@ -81,7 +81,18 @@ class NoteContentFulltextExp extends Expression {
|
|||||||
// Try to use FTS5 if available for better performance
|
// Try to use FTS5 if available for better performance
|
||||||
if (ftsSearchService.checkFTS5Availability() && this.canUseFTS5()) {
|
if (ftsSearchService.checkFTS5Availability() && this.canUseFTS5()) {
|
||||||
try {
|
try {
|
||||||
// Use FTS5 for optimized search
|
// Performance comparison logging for FTS5 vs traditional search
|
||||||
|
const searchQuery = this.tokens.join(" ");
|
||||||
|
const isQuickSearch = searchContext.fastSearch === false; // quick-search sets fastSearch to false
|
||||||
|
if (isQuickSearch) {
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] Starting comparison for query: "${searchQuery}" with operator: ${this.operator}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we need to search protected notes
|
||||||
|
const searchProtected = protectedSessionService.isProtectedSessionAvailable();
|
||||||
|
|
||||||
|
// Time FTS5 search
|
||||||
|
const ftsStartTime = Date.now();
|
||||||
const noteIdSet = inputNoteSet.getNoteIds();
|
const noteIdSet = inputNoteSet.getNoteIds();
|
||||||
const ftsResults = ftsSearchService.searchSync(
|
const ftsResults = ftsSearchService.searchSync(
|
||||||
this.tokens,
|
this.tokens,
|
||||||
@@ -92,6 +103,8 @@ class NoteContentFulltextExp extends Expression {
|
|||||||
searchProtected: false // FTS5 doesn't index protected notes
|
searchProtected: false // FTS5 doesn't index protected notes
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
const ftsEndTime = Date.now();
|
||||||
|
const ftsTime = ftsEndTime - ftsStartTime;
|
||||||
|
|
||||||
// Add FTS results to note set
|
// Add FTS results to note set
|
||||||
for (const result of ftsResults) {
|
for (const result of ftsResults) {
|
||||||
@@ -100,8 +113,53 @@ class NoteContentFulltextExp extends Expression {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For quick-search, also run traditional search for comparison
|
||||||
|
if (isQuickSearch) {
|
||||||
|
const traditionalStartTime = Date.now();
|
||||||
|
|
||||||
|
// Log the input set size for debugging
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] Input set size: ${inputNoteSet.notes.length} notes`);
|
||||||
|
|
||||||
|
// Run traditional search for comparison
|
||||||
|
// Use the dedicated comparison method that always runs the full search
|
||||||
|
const traditionalResults = this.executeTraditionalSearch(inputNoteSet, searchContext);
|
||||||
|
|
||||||
|
const traditionalEndTime = Date.now();
|
||||||
|
const traditionalTime = traditionalEndTime - traditionalStartTime;
|
||||||
|
|
||||||
|
// Log performance comparison
|
||||||
|
const speedup = traditionalTime > 0 ? (traditionalTime / ftsTime).toFixed(2) : "N/A";
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] ===== Results for query: "${searchQuery}" =====`);
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] FTS5 search: ${ftsTime}ms, found ${ftsResults.length} results`);
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] Traditional search: ${traditionalTime}ms, found ${traditionalResults.notes.length} results`);
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] FTS5 is ${speedup}x faster (saved ${traditionalTime - ftsTime}ms)`);
|
||||||
|
|
||||||
|
// Check if results match
|
||||||
|
const ftsNoteIds = new Set(ftsResults.map(r => r.noteId));
|
||||||
|
const traditionalNoteIds = new Set(traditionalResults.notes.map(n => n.noteId));
|
||||||
|
const matchingResults = ftsNoteIds.size === traditionalNoteIds.size &&
|
||||||
|
Array.from(ftsNoteIds).every(id => traditionalNoteIds.has(id));
|
||||||
|
|
||||||
|
if (!matchingResults) {
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] Results differ! FTS5: ${ftsNoteIds.size} notes, Traditional: ${traditionalNoteIds.size} notes`);
|
||||||
|
|
||||||
|
// Find differences
|
||||||
|
const onlyInFTS = Array.from(ftsNoteIds).filter(id => !traditionalNoteIds.has(id));
|
||||||
|
const onlyInTraditional = Array.from(traditionalNoteIds).filter(id => !ftsNoteIds.has(id));
|
||||||
|
|
||||||
|
if (onlyInFTS.length > 0) {
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] Only in FTS5: ${onlyInFTS.slice(0, 5).join(", ")}${onlyInFTS.length > 5 ? "..." : ""}`);
|
||||||
|
}
|
||||||
|
if (onlyInTraditional.length > 0) {
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] Only in Traditional: ${onlyInTraditional.slice(0, 5).join(", ")}${onlyInTraditional.length > 5 ? "..." : ""}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] Results match perfectly! ✓`);
|
||||||
|
}
|
||||||
|
log.info(`[QUICK-SEARCH-COMPARISON] ========================================`);
|
||||||
|
}
|
||||||
|
|
||||||
// If we need to search protected notes, use the separate method
|
// If we need to search protected notes, use the separate method
|
||||||
const searchProtected = protectedSessionService.isProtectedSessionAvailable();
|
|
||||||
if (searchProtected) {
|
if (searchProtected) {
|
||||||
const protectedResults = ftsSearchService.searchProtectedNotesSync(
|
const protectedResults = ftsSearchService.searchProtectedNotesSync(
|
||||||
this.tokens,
|
this.tokens,
|
||||||
@@ -200,6 +258,24 @@ class NoteContentFulltextExp extends Expression {
|
|||||||
return resultNoteSet;
|
return resultNoteSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes traditional search for comparison purposes
|
||||||
|
* This always runs the full traditional search regardless of operator
|
||||||
|
*/
|
||||||
|
private executeTraditionalSearch(inputNoteSet: NoteSet, searchContext: SearchContext): NoteSet {
|
||||||
|
const resultNoteSet = new NoteSet();
|
||||||
|
|
||||||
|
for (const row of sql.iterateRows<SearchRow>(`
|
||||||
|
SELECT noteId, type, mime, content, isProtected
|
||||||
|
FROM notes JOIN blobs USING (blobId)
|
||||||
|
WHERE type IN ('text', 'code', 'mermaid', 'canvas', 'mindMap')
|
||||||
|
AND isDeleted = 0
|
||||||
|
AND LENGTH(content) < ${MAX_SEARCH_CONTENT_SIZE}`)) {
|
||||||
|
this.findInText(row, inputNoteSet, resultNoteSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultNoteSet;
|
||||||
|
}
|
||||||
|
|
||||||
findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) {
|
findInText({ noteId, isProtected, content, type, mime }: SearchRow, inputNoteSet: NoteSet, resultNoteSet: NoteSet) {
|
||||||
if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
|
if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ describe('FTS5 Search Service', () => {
|
|||||||
getRows: vi.fn(),
|
getRows: vi.fn(),
|
||||||
getColumn: vi.fn(),
|
getColumn: vi.fn(),
|
||||||
execute: vi.fn(),
|
execute: vi.fn(),
|
||||||
prepare: vi.fn(),
|
|
||||||
iterateRows: vi.fn(),
|
iterateRows: vi.fn(),
|
||||||
transactional: vi.fn((fn: Function) => fn())
|
transactional: vi.fn((fn: Function) => fn())
|
||||||
};
|
};
|
||||||
@@ -254,19 +253,10 @@ describe('FTS5 Search Service', () => {
|
|||||||
];
|
];
|
||||||
mockSql.getRows.mockReturnValue(missingNotes);
|
mockSql.getRows.mockReturnValue(missingNotes);
|
||||||
|
|
||||||
// Mock prepared statement
|
|
||||||
const mockPreparedStatement = {
|
|
||||||
run: vi.fn(),
|
|
||||||
finalize: vi.fn()
|
|
||||||
};
|
|
||||||
mockSql.prepare.mockReturnValue(mockPreparedStatement);
|
|
||||||
|
|
||||||
const count = ftsSearchService.syncMissingNotes();
|
const count = ftsSearchService.syncMissingNotes();
|
||||||
|
|
||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
expect(mockSql.prepare).toHaveBeenCalledTimes(1);
|
expect(mockSql.execute).toHaveBeenCalledTimes(2);
|
||||||
expect(mockPreparedStatement.run).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mockPreparedStatement.finalize).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should optimize index', () => {
|
it('should optimize index', () => {
|
||||||
|
|||||||
@@ -70,30 +70,15 @@ const FTS_CONFIG = {
|
|||||||
*/
|
*/
|
||||||
class FTSSearchService {
|
class FTSSearchService {
|
||||||
private isFTS5Available: boolean | null = null;
|
private isFTS5Available: boolean | null = null;
|
||||||
private checkingAvailability = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if FTS5 is available and properly configured
|
* Check if FTS5 is available and properly configured
|
||||||
* Thread-safe implementation to prevent race conditions
|
|
||||||
*/
|
*/
|
||||||
checkFTS5Availability(): boolean {
|
checkFTS5Availability(): boolean {
|
||||||
// Return cached result if available
|
|
||||||
if (this.isFTS5Available !== null) {
|
if (this.isFTS5Available !== null) {
|
||||||
return this.isFTS5Available;
|
return this.isFTS5Available;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent concurrent checks
|
|
||||||
if (this.checkingAvailability) {
|
|
||||||
// Wait for ongoing check to complete by checking again after a short delay
|
|
||||||
while (this.checkingAvailability && this.isFTS5Available === null) {
|
|
||||||
// This is a simple spin-wait; in a real async context, you'd use proper synchronization
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return this.isFTS5Available ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.checkingAvailability = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if FTS5 extension is available
|
// Check if FTS5 extension is available
|
||||||
const result = sql.getRow(`
|
const result = sql.getRow(`
|
||||||
@@ -116,8 +101,6 @@ class FTSSearchService {
|
|||||||
|
|
||||||
if (!this.isFTS5Available) {
|
if (!this.isFTS5Available) {
|
||||||
log.info("FTS5 table not found, full-text search not available");
|
log.info("FTS5 table not found, full-text search not available");
|
||||||
} else {
|
|
||||||
log.info("FTS5 full-text search is available and configured");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.isFTS5Available;
|
return this.isFTS5Available;
|
||||||
@@ -125,8 +108,6 @@ class FTSSearchService {
|
|||||||
log.error(`Error checking FTS5 availability: ${error}`);
|
log.error(`Error checking FTS5 availability: ${error}`);
|
||||||
this.isFTS5Available = false;
|
this.isFTS5Available = false;
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
|
||||||
this.checkingAvailability = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,19 +268,14 @@ class FTSSearchService {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert missing notes using efficient batch processing
|
// Insert missing notes in batches
|
||||||
sql.transactional(() => {
|
sql.transactional(() => {
|
||||||
// Use prepared statement for better performance
|
|
||||||
const insertStmt = sql.prepare(`
|
|
||||||
INSERT OR REPLACE INTO notes_fts (noteId, title, content)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
for (const note of missingNotes) {
|
for (const note of missingNotes) {
|
||||||
insertStmt.run(note.noteId, note.title, note.content);
|
sql.execute(`
|
||||||
|
INSERT INTO notes_fts (noteId, title, content)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`, [note.noteId, note.title, note.content]);
|
||||||
}
|
}
|
||||||
|
|
||||||
insertStmt.finalize();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info(`Synced ${missingNotes.length} missing notes to FTS index`);
|
log.info(`Synced ${missingNotes.length} missing notes to FTS index`);
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ async function initDbConnection() {
|
|||||||
|
|
||||||
await migrationService.migrateIfNecessary();
|
await migrationService.migrateIfNecessary();
|
||||||
|
|
||||||
// Initialize optimized SQLite pragmas for FTS and large database performance
|
|
||||||
initializeFTSPragmas();
|
|
||||||
|
|
||||||
sql.execute('CREATE TEMP TABLE "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
|
sql.execute('CREATE TEMP TABLE "param_list" (`paramId` TEXT NOT NULL PRIMARY KEY)');
|
||||||
|
|
||||||
sql.execute(`
|
sql.execute(`
|
||||||
@@ -188,42 +185,6 @@ function setDbAsInitialized() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize SQLite pragmas optimized for FTS5 and large databases
|
|
||||||
*/
|
|
||||||
function initializeFTSPragmas() {
|
|
||||||
if (config.General.readOnly) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
log.info("Setting SQLite pragmas for FTS5 and large database optimization...");
|
|
||||||
|
|
||||||
sql.executeScript(`
|
|
||||||
-- Memory Management (Critical for FTS performance with millions of notes)
|
|
||||||
PRAGMA cache_size = -262144; -- 256MB cache for better query performance
|
|
||||||
PRAGMA temp_store = MEMORY; -- Use memory for temporary tables and indices
|
|
||||||
PRAGMA mmap_size = 536870912; -- 512MB memory-mapped I/O for better read performance
|
|
||||||
|
|
||||||
-- Write Optimization (Better for concurrent operations)
|
|
||||||
PRAGMA synchronous = NORMAL; -- Balance safety and performance (FULL is too slow for large operations)
|
|
||||||
PRAGMA journal_mode = WAL; -- Write-Ahead Logging for better concurrency
|
|
||||||
PRAGMA wal_autocheckpoint = 1000; -- Checkpoint every 1000 pages for memory management
|
|
||||||
|
|
||||||
-- Query Optimization (Essential for complex FTS queries)
|
|
||||||
PRAGMA automatic_index = ON; -- Allow SQLite to create automatic indexes when beneficial
|
|
||||||
|
|
||||||
-- FTS-Specific Optimizations
|
|
||||||
PRAGMA threads = 4; -- Use multiple threads for FTS operations if available
|
|
||||||
`);
|
|
||||||
|
|
||||||
log.info("FTS pragmas initialized successfully");
|
|
||||||
} catch (error) {
|
|
||||||
log.error(`Failed to initialize FTS pragmas: ${error}`);
|
|
||||||
// Don't throw - continue with default settings
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimize() {
|
function optimize() {
|
||||||
if (config.General.readOnly) {
|
if (config.General.readOnly) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user