Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
585b6cc
feat(search): try to improve performance
perfectra1n Mar 12, 2026
77733ce
feat(search): try to rice performance some more
perfectra1n Mar 12, 2026
6a06fc7
feat(search): get rid of candidate capping
perfectra1n Mar 12, 2026
9403efa
feat(search): add even some more robust tests
perfectra1n Mar 12, 2026
1c148f4
feat(search): don't toss the entire index after each note change
perfectra1n Mar 12, 2026
b533546
fix(search): fix flying bracket
perfectra1n Mar 12, 2026
da3d71d
Merge branch 'main' into feat/search-perf-take1
perfectra1n Mar 12, 2026
5718631
fix(search): resolve issue with autocomplete with search performance …
perfectra1n Mar 18, 2026
f23a7b4
feat(settings): also allow for fuzzy searching to just be disabled
perfectra1n Mar 18, 2026
ba529d2
feat(tests): implement search benchmark test...
perfectra1n Mar 20, 2026
ac13af7
feat(search): add FTS5 migration for content search index
perfectra1n Mar 20, 2026
dcaebee
feat(search): add FTS5 index service for content search
perfectra1n Mar 20, 2026
f358563
feat(search): wire FTS index updates to note content changes
perfectra1n Mar 20, 2026
bc09421
feat(search): use FTS5 index in NoteContentFulltextExp with sequentia…
perfectra1n Mar 20, 2026
06fb9c0
test(search): add FTS5 integration test
perfectra1n Mar 20, 2026
24a01ae
feat(search): add user option to enable/disable FTS5 content index
perfectra1n Mar 20, 2026
8fd2cb3
fix(search): fix busy connection error in FTS5 index build
perfectra1n Mar 20, 2026
87fc4e1
docs(search): add FTS5 benchmark results to performance comparison
perfectra1n Mar 20, 2026
ac23137
perf(search): optimize scoring, highlighting, and tree walk
perfectra1n Mar 20, 2026
48dd93b
revert: remove FTS5 content search (no measured end-to-end improvement)
perfectra1n Mar 20, 2026
5bc9840
fix(search): restore toLowerCase in fuzzyMatchWordWithResult
perfectra1n Mar 21, 2026
90ac727
docs(search): update benchmark comparison with final optimized numbers
perfectra1n Mar 21, 2026
9aec8be
docs(search): add full search + fuzzy benchmark sections
perfectra1n Mar 21, 2026
bd25ae7
docs(search): rewrite benchmark doc for clarity
perfectra1n Mar 21, 2026
5ba7803
Merge branch 'main' into feat/search-perf-take1
eliandoran Apr 13, 2026
9b2be57
docs: remove search analysis
eliandoran Apr 13, 2026
6e90a41
feat(autocomplete): toggle for fuzzy matching (closes #8360)
eliandoran Apr 13, 2026
47ce77e
refactor(search): simplify branching for autocomplete
eliandoran Apr 13, 2026
597c6eb
chore(options): improve descriptions for search
eliandoran Apr 13, 2026
ead70ad
fix(autocomplete): fuzzy search not working if the search one was not…
eliandoran Apr 13, 2026
6763f4f
chore(becca): add log for cache memory consumption
eliandoran Apr 13, 2026
885e94c
test(server): migrate database
eliandoran Apr 13, 2026
301f23c
test(server): clean up search scripts
eliandoran Apr 13, 2026
e40504b
chore(search): address requested changes
eliandoran Apr 13, 2026
f58dd12
chore(search): use loop to prevent nested strip tags injection
eliandoran Apr 13, 2026
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
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,9 @@ Trilium provides powerful user scripting capabilities:
- To add a new user preference:
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to the `ALLOWED_OPTIONS` array — **without this, the API will reject changes with "Option 'X' is not allowed to be changed"**
4. If the option should be user-editable in the UI, add a control in the appropriate settings component (e.g., `apps/client/src/widgets/type_widgets/options/other.tsx`) and a translation key in `apps/client/src/translations/en/translation.json`
5. Use `useTriliumOption("optionName")` hook in React components to read/write the option
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation

Expand Down
7 changes: 7 additions & 0 deletions apps/client/src/translations/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,13 @@
"erase_excess_revision_snapshots": "Erase excess revision snapshots now",
"erase_excess_revision_snapshots_prompt": "Excess revision snapshots have been erased."
},
"search": {
"title": "Search",
"fuzzy_matching_label": "Typo tolerance in search",
"fuzzy_matching_description": "Affects quick search and full search. Finds similar words when exact matches are insufficient.",
"autocomplete_fuzzy_label": "Typo tolerance in autocomplete",
"autocomplete_fuzzy_description": "Affects jump-to-note and note selectors. Slower but tolerates typos."
},
"search_engine": {
"title": "Search Engine",
"custom_search_engine_info": "Custom search engine requires both a name and a URL to be set. If either of these is not set, DuckDuckGo will be used as the default search engine.",
Expand Down
36 changes: 36 additions & 0 deletions apps/client/src/widgets/type_widgets/options/other.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import FormGroup from "../../react/FormGroup";
import FormSelect from "../../react/FormSelect";
import FormText from "../../react/FormText";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import FormToggle from "../../react/FormToggle";
import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks";
import OptionsRow from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";

export default function OtherSettings() {
return (
<>
<SearchSettings />
{isElectron() && <>
<SearchEngineSettings />
<TrayOptionsSettings />
Expand All @@ -36,6 +39,39 @@ export default function OtherSettings() {
);
}

function SearchSettings() {
const [ fuzzyEnabled, setFuzzyEnabled ] = useTriliumOptionBool("searchEnableFuzzyMatching");
const [ autocompleteFuzzy, setAutocompleteFuzzy ] = useTriliumOptionBool("searchAutocompleteFuzzy");

return (
<OptionsSection title={t("search.title")}>
<OptionsRow
name="search-fuzzy-matching"
label={t("search.fuzzy_matching_label")}
description={t("search.fuzzy_matching_description")}
>
<FormToggle
switchOnName="" switchOffName=""
currentValue={fuzzyEnabled}
onChange={setFuzzyEnabled}
/>
</OptionsRow>

<OptionsRow
name="search-autocomplete-fuzzy"
label={t("search.autocomplete_fuzzy_label")}
description={t("search.autocomplete_fuzzy_description")}
>
<FormToggle
switchOnName="" switchOffName=""
currentValue={autocompleteFuzzy}
onChange={setAutocompleteFuzzy}
/>
</OptionsRow>
</OptionsSection>
);
}

function SearchEngineSettings() {
const [ customSearchEngineName, setCustomSearchEngineName ] = useTriliumOption("customSearchEngineName");
const [ customSearchEngineUrl, setCustomSearchEngineUrl ] = useTriliumOption("customSearchEngineUrl");
Expand Down
Binary file modified apps/server/spec/db/document.db
Binary file not shown.
78 changes: 77 additions & 1 deletion apps/server/src/becca/becca-interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import sql from "../services/sql.js";
import log from "../services/log.js";
import { formatSize } from "../services/utils.js";
import NoteSet from "../services/search/note_set.js";
import NotFoundError from "../errors/not_found_error.js";
import type BOption from "./entities/boption.js";
Expand Down Expand Up @@ -31,9 +33,22 @@ export default class Becca {

allNoteSetCache: NoteSet | null;

/**
* Pre-built parallel arrays for fast flat text scanning in search.
* Avoids per-note property access overhead when iterating 50K+ notes.
* Supports incremental updates: when individual notes change, only their
* entries are rebuilt rather than the entire index.
*/
flatTextIndex: { notes: BNote[], flatTexts: string[], noteIdToIdx: Map<string, number> } | null;

/** NoteIds whose flat text needs to be recomputed in the index. */
dirtyFlatTextNoteIds: Set<string>;

constructor() {
this.reset();
this.dirtyFlatTextNoteIds = new Set();
this.allNoteSetCache = null;
this.flatTextIndex = null;
this.reset();
}

reset() {
Expand Down Expand Up @@ -242,6 +257,67 @@ export default class Becca {
/** Should be called when the set of all non-skeleton notes changes (added/removed) */
dirtyNoteSetCache() {
this.allNoteSetCache = null;
// Full rebuild needed since the note set itself changed
this.flatTextIndex = null;
this.dirtyFlatTextNoteIds.clear();
}

/** Mark a single note's flat text as needing recomputation in the index. */
dirtyNoteFlatText(noteId: string) {
if (this.flatTextIndex) {
// Index exists — schedule an incremental update
this.dirtyFlatTextNoteIds.add(noteId);
}
// If flatTextIndex is null, full rebuild will happen on next access anyway
}

/**
* Returns pre-built parallel arrays of notes and their flat texts for fast scanning.
* The flat texts are already normalized (lowercase, diacritics removed).
* Supports incremental updates: when individual notes are dirtied, only their
* entries are recomputed rather than rebuilding the entire index.
*/
getFlatTextIndex(): { notes: BNote[], flatTexts: string[], noteIdToIdx: Map<string, number> } {
if (!this.flatTextIndex) {
// Measure heap before building
const heapBefore = process.memoryUsage().heapUsed;

const allNoteSet = this.getAllNoteSet();
const notes: BNote[] = [];
const flatTexts: string[] = [];
const noteIdToIdx = new Map<string, number>();

for (const note of allNoteSet.notes) {
noteIdToIdx.set(note.noteId, notes.length);
notes.push(note);
flatTexts.push(note.getFlatText());
}

this.flatTextIndex = { notes, flatTexts, noteIdToIdx };
this.dirtyFlatTextNoteIds.clear();

// Measure heap after building and log
const heapAfter = process.memoryUsage().heapUsed;
const heapDelta = heapAfter - heapBefore;
log.info(`Flat text search index built: ${notes.length} notes, ${formatSize(heapDelta)}`);
} else if (this.dirtyFlatTextNoteIds.size > 0) {
// Incremental update: only recompute flat texts for dirtied notes
const { flatTexts, noteIdToIdx } = this.flatTextIndex;

for (const noteId of this.dirtyFlatTextNoteIds) {
const idx = noteIdToIdx.get(noteId);
if (idx !== undefined) {
const note = this.notes[noteId];
if (note) {
flatTexts[idx] = note.getFlatText();
}
}
}

this.dirtyFlatTextNoteIds.clear();
}

return this.flatTextIndex;
}

getAllNoteSet() {
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/becca/entities/battribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dateUtils from "../../services/date_utils.js";
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
import sanitizeAttributeName from "../../services/sanitize_attribute_name.js";
import type { AttributeRow, AttributeType } from "@triliumnext/commons";
import { normalize } from "../../services/utils.js";

interface SavingOpts {
skipValidation?: boolean;
Expand Down Expand Up @@ -34,6 +35,11 @@ class BAttribute extends AbstractBeccaEntity<BAttribute> {
value!: string;
isInheritable!: boolean;

/** Pre-normalized (lowercase, diacritics removed) name for search. */
normalizedName!: string;
/** Pre-normalized (lowercase, diacritics removed) value for search. */
normalizedValue!: string;

constructor(row?: AttributeRow) {
super();

Expand All @@ -59,6 +65,10 @@ class BAttribute extends AbstractBeccaEntity<BAttribute> {
this.isInheritable = !!isInheritable;
this.utcDateModified = utcDateModified;

// Pre-compute normalized forms for search (avoids repeated normalize() calls in hot loops)
this.normalizedName = normalize(this.name);
this.normalizedValue = normalize(this.value);

return this;
}

Comment on lines +68 to 74
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

normalizedName / normalizedValue are computed only in update(), but attributes are frequently modified by setting attr.value = ... and then calling attr.save() (e.g. BNote.setAttribute). In that flow, these precomputed fields become stale, causing search/token matching to miss updated attributes. Recompute normalizedName / normalizedValue in beforeSaving() (after sanitizeAttributeName() and value normalization) and/or ensure they are updated whenever name/value changes.

Suggested change
// Pre-compute normalized forms for search (avoids repeated normalize() calls in hot loops)
this.normalizedName = normalize(this.name);
this.normalizedValue = normalize(this.value);
return this;
}
this.recomputeNormalizedFields();
return this;
}
private recomputeNormalizedFields() {
// Pre-compute normalized forms for search (avoids repeated normalize() calls in hot loops)
this.normalizedName = normalize(this.name || "");
this.normalizedValue = normalize(this.value || "");
}
override beforeSaving(_opts?: SavingOpts) {
this.name = sanitizeAttributeName(this.name);
this.value = this.value || "";
this.recomputeNormalizedFields();
}

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -192,6 +202,11 @@ class BAttribute extends AbstractBeccaEntity<BAttribute> {

this.utcDateModified = dateUtils.utcNowDateTime();

// Recompute normalized fields in case name/value were modified directly
// (e.g., attr.value = "..." followed by attr.save())
this.normalizedName = normalize(this.name);
this.normalizedValue = normalize(this.value);

super.beforeSaving();

this.becca.attributes[this.attributeId] = this;
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/becca/entities/bnote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,9 @@ class BNote extends AbstractBeccaEntity<BNote> {
this.__attributeCache = null;
this.__inheritableAttributeCache = null;
this.__ancestorCache = null;

// Mark only this note's flat text as dirty for incremental index update
this.becca.dirtyNoteFlatText(this.noteId);
}

invalidateSubTree(path: string[] = []) {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/routes/api/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"layoutOrientation",
"backgroundEffects",
"allowedHtmlTags",
"searchEnableFuzzyMatching",
"searchAutocompleteFuzzy",
"redirectBareDomain",
"showLoginInShareTheme",
"splitEditorOrientation",
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/services/options_init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ const defaultOptions: DefaultOption[] = [
isSynced: true
},

// Search settings
{ name: "searchEnableFuzzyMatching", value: "true", isSynced: true },
{ name: "searchAutocompleteFuzzy", value: "false", isSynced: true },

// Share settings
{ name: "redirectBareDomain", value: "false", isSynced: true },
{ name: "showLoginInShareTheme", value: "false", isSynced: true },
Expand Down
Loading
Loading