diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx
index 9a9830af57..35dbf6a155 100644
--- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx
+++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchDialog.tsx
@@ -171,7 +171,7 @@ const AdvancedNodeSearchDialog = ({
discourseNodes.map((node) => [node.type, node]),
);
- const results = useAdvancedNodeSearchResults({
+ const { results, isSearching } = useAdvancedNodeSearchResults({
debouncedSearchTerm,
selectedNodeTypeIds,
sort,
@@ -291,7 +291,7 @@ const AdvancedNodeSearchDialog = ({
const contentState = indexError
? "error"
- : isIndexLoading
+ : isIndexLoading || isSearching
? "indexing"
: !debouncedSearchTerm && selectedNodeTypeIds.length === 0
? "initial"
diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx
index 463e98e211..bd48b7f05b 100644
--- a/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx
+++ b/apps/roam/src/components/AdvancedNodeSearchDialog/AdvancedSearchSidebarPanel.tsx
@@ -226,7 +226,7 @@ export const AdvancedSearchSidebarPanel = ({
};
}, [discourseNodes]);
- const results = useAdvancedNodeSearchResults({
+ const { results, isSearching } = useAdvancedNodeSearchResults({
debouncedSearchTerm,
selectedNodeTypeIds,
sort,
@@ -306,11 +306,14 @@ export const AdvancedSearchSidebarPanel = ({
results={results}
/>
)}
- {debouncedSearchTerm && !results.length && !isIndexLoading && (
-
- No matches. Try another keyword.
-
- )}
+ {debouncedSearchTerm &&
+ !results.length &&
+ !isIndexLoading &&
+ !isSearching && (
+
+ No matches. Try another keyword.
+
+ )}
>
)}
>
diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts
index d3a6828646..9de2a6adee 100644
--- a/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts
+++ b/apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts
@@ -1,8 +1,11 @@
-import { useMemo } from "react";
+import { useEffect, useMemo, useState } from "react";
import MiniSearch from "minisearch";
+import getDiscourseNodes from "~/utils/getDiscourseNodes";
+import { searchDiscourseNodes } from "~/utils/searchDiscourseNodes";
import {
- searchIndexedNodes,
+ searchDiscourseNodesWithMiniSearch,
sortSearchResults,
+ type ScoredSearchResult,
type SearchResult,
type SortConfig,
} from "./utils";
@@ -32,37 +35,110 @@ export const useAdvancedNodeSearchResults = ({
searchIndex,
dockedQuery,
dockedResults,
-}: UseAdvancedNodeSearchResultsArgs): SearchResult[] =>
- useMemo(() => {
- if (!debouncedSearchTerm) return [];
-
- const isDockedQuery =
+}: UseAdvancedNodeSearchResultsArgs): {
+ results: SearchResult[];
+ isSearching: boolean;
+} => {
+ // An empty persisted result set is not a usable cache: the docked panel also
+ // persists the transient [] published while an async search is in flight.
+ const hasUsableDockedResults = useMemo(
+ () =>
dockedQuery !== undefined &&
- debouncedSearchTerm.trim() === dockedQuery.trim();
+ debouncedSearchTerm.trim() === dockedQuery.trim() &&
+ !!dockedResults &&
+ dockedResults.length > 0,
+ [debouncedSearchTerm, dockedQuery, dockedResults],
+ );
+
+ const [unsortedScoredResults, setUnsortedScoredResults] = useState<
+ ScoredSearchResult[]
+ >([]);
+ const [isSearching, setIsSearching] = useState(false);
- if (isDockedQuery && dockedResults) {
- return dockedResults;
+ useEffect(() => {
+ if (hasUsableDockedResults) {
+ setUnsortedScoredResults([]);
+ setIsSearching(false);
+ return;
+ }
+
+ if (!debouncedSearchTerm) {
+ setUnsortedScoredResults([]);
+ setIsSearching(false);
+ return;
}
if (isIndexLoading || indexError || !searchIndex) {
- return [];
+ setUnsortedScoredResults([]);
+ setIsSearching(false);
+ return;
}
- const scoredHits = searchIndexedNodes({
- miniSearch: searchIndex.miniSearch,
- allResults: searchIndex.allResults,
- searchTerm: debouncedSearchTerm,
- typeFilter: selectedNodeTypeIds.length ? selectedNodeTypeIds : undefined,
- });
+ setUnsortedScoredResults([]);
+ setIsSearching(true);
+ let cancelled = false;
+ const typeFilter = selectedNodeTypeIds.length
+ ? selectedNodeTypeIds
+ : undefined;
+ const discourseNodes = getDiscourseNodes().filter(
+ (node) =>
+ node.backedBy === "user" &&
+ (!typeFilter || typeFilter.includes(node.type)),
+ );
+ const resultsByUid = new Map(
+ searchIndex.allResults.map((result) => [result.uid, result]),
+ );
+
+ const runMiniSearch = (): ScoredSearchResult[] =>
+ searchDiscourseNodesWithMiniSearch({
+ miniSearch: searchIndex.miniSearch,
+ allResults: searchIndex.allResults,
+ searchTerm: debouncedSearchTerm,
+ typeFilter,
+ });
- return sortSearchResults({ hits: scoredHits, sort });
+ void searchDiscourseNodes({
+ nodeTypes: discourseNodes,
+ query: debouncedSearchTerm,
+ resultsByUid,
+ runMiniSearch,
+ })
+ .then((results) => {
+ if (cancelled) return;
+ setUnsortedScoredResults(results);
+ })
+ .catch(() => {
+ if (cancelled) return;
+ try {
+ setUnsortedScoredResults(runMiniSearch());
+ } catch {
+ setUnsortedScoredResults([]);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setIsSearching(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
}, [
debouncedSearchTerm,
- dockedQuery,
- dockedResults,
+ hasUsableDockedResults,
indexError,
isIndexLoading,
searchIndex,
selectedNodeTypeIds,
- sort,
]);
+
+ const results = useMemo(
+ () => sortSearchResults({ scoredResults: unsortedScoredResults, sort }),
+ [unsortedScoredResults, sort],
+ );
+
+ if (hasUsableDockedResults && dockedResults) {
+ return { results: dockedResults, isSearching: false };
+ }
+
+ return { results, isSearching };
+};
diff --git a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts
index c3040eadf9..82c7a4be13 100644
--- a/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts
+++ b/apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts
@@ -9,9 +9,19 @@ import {
getPulledDiscourseNodeUid,
queryDiscourseNodesByFormat,
} from "~/utils/discourseNodeSearch";
+import {
+ MAX_RESULTS,
+ type ScoredSearchResult,
+ type SearchResult,
+} from "~/utils/discourseNodeSearchTypes";
+
+export type {
+ ScoredSearchResult,
+ SearchResult,
+} from "~/utils/discourseNodeSearchTypes";
export const DEBOUNCE_MS = 250;
-export const MAX_RESULTS = 50;
+export { MAX_RESULTS };
export const getSearchKeywords = (searchTerm: string): string[] =>
searchTerm.split(/\s+/).filter(Boolean);
@@ -36,17 +46,6 @@ export const SORT_FIELD_LABELS: Record = {
author: "Author",
};
-export type SearchResult = {
- uid: string;
- title: string;
- type: string;
- nodeTypeLabel: string;
- excerpt: string;
- createdAt: string;
- lastModified: string;
- authorName: string;
-};
-
export type DockedSearchState = {
query: string;
results: SearchResult[];
@@ -56,11 +55,6 @@ export type DockedSearchState = {
dgSearchId?: string;
};
-export type ScoredSearchHit = {
- result: SearchResult;
- score: number;
-};
-
type MiniSearchDocument = SearchResult & {
id: string;
};
@@ -214,23 +208,28 @@ export const isNonDefaultSort = (sort: SortConfig): boolean =>
sort.direction !== DEFAULT_SORT_CONFIG.direction;
export const sortSearchResults = ({
- hits,
+ scoredResults,
sort,
}: {
- hits: ScoredSearchHit[];
+ scoredResults: ScoredSearchResult[];
sort: SortConfig;
}): SearchResult[] => {
- const sorted = [...hits];
+ // Semantic and MiniSearch results already arrive in their provider's relevance
+ // order, so relevance sorting only has to reverse it for ascending.
+ if (sort.field === "relevance") {
+ const ordered =
+ sort.direction === "asc" ? [...scoredResults].reverse() : scoredResults;
+ return ordered.map((entry) => entry.result);
+ }
- sorted.sort((aHit, bHit) => {
- const a = aHit.result;
- const b = bHit.result;
+ const sorted = [...scoredResults];
+
+ sorted.sort((aEntry, bEntry) => {
+ const a = aEntry.result;
+ const b = bEntry.result;
let comparison = 0;
switch (sort.field) {
- case "relevance":
- comparison = compareNumbers(aHit.score, bHit.score, sort.direction);
- break;
case "alphabetical":
comparison = compareStrings(
getSortableTitle(a),
@@ -259,10 +258,10 @@ export const sortSearchResults = ({
return comparison || a.uid.localeCompare(b.uid);
});
- return sorted.map((hit) => hit.result);
+ return sorted.map((entry) => entry.result);
};
-export const searchIndexedNodes = ({
+export const searchDiscourseNodesWithMiniSearch = ({
miniSearch,
allResults,
searchTerm,
@@ -272,7 +271,7 @@ export const searchIndexedNodes = ({
allResults: SearchResult[];
searchTerm: string;
typeFilter?: string[];
-}): ScoredSearchHit[] => {
+}): ScoredSearchResult[] => {
const resultsByUid = new Map(
allResults.map((result) => [result.uid, result]),
);
@@ -291,7 +290,11 @@ export const searchIndexedNodes = ({
.map((result) => {
const searchResult = resultsByUid.get(String(result.id));
if (!searchResult) return null;
- return { result: searchResult, score: result.score };
+ return {
+ result: searchResult,
+ score: result.score,
+ source: "miniSearch",
+ };
})
- .filter((hit): hit is ScoredSearchHit => !!hit);
+ .filter((entry): entry is ScoredSearchResult => !!entry);
};
diff --git a/apps/roam/src/utils/__tests__/discourseNodeSearchTypes.test.ts b/apps/roam/src/utils/__tests__/discourseNodeSearchTypes.test.ts
new file mode 100644
index 0000000000..3e4df750d0
--- /dev/null
+++ b/apps/roam/src/utils/__tests__/discourseNodeSearchTypes.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it } from "vitest";
+import { sortSearchResults } from "~/components/AdvancedNodeSearchDialog/utils";
+import {
+ combineSemanticAndMiniSearchResults,
+ MAX_RESULTS,
+ type DiscourseNodeSearchSource,
+ type ScoredSearchResult,
+} from "~/utils/discourseNodeSearchTypes";
+
+const makeEntry = ({
+ uid,
+ score,
+ source,
+}: {
+ uid: string;
+ score: number;
+ source: DiscourseNodeSearchSource;
+}): ScoredSearchResult => ({
+ result: {
+ uid,
+ title: `[[CLM]] - ${uid}`,
+ type: "clm",
+ nodeTypeLabel: "Claim",
+ excerpt: "",
+ createdAt: "",
+ lastModified: "",
+ authorName: "",
+ },
+ score,
+ source,
+});
+
+const relevanceOrder = (
+ scoredResults: ScoredSearchResult[],
+ direction: "asc" | "desc" = "desc",
+): string[] =>
+ sortSearchResults({
+ scoredResults,
+ sort: { field: "relevance", direction },
+ }).map((result) => result.uid);
+
+describe("combineSemanticAndMiniSearchResults", () => {
+ it("drops duplicate uids from both sources", () => {
+ const combined = combineSemanticAndMiniSearchResults({
+ semantic: [
+ makeEntry({ uid: "a", score: 0.9, source: "semantic" }),
+ makeEntry({ uid: "a", score: 0.7, source: "semantic" }),
+ makeEntry({ uid: "b", score: 0.5, source: "semantic" }),
+ ],
+ miniSearch: [
+ makeEntry({ uid: "b", score: 10, source: "miniSearch" }),
+ makeEntry({ uid: "c", score: 8, source: "miniSearch" }),
+ ],
+ });
+
+ expect(combined.map((entry) => entry.result.uid)).toEqual(["a", "b", "c"]);
+ expect(combined[0]?.score).toBe(0.9);
+ });
+});
+
+describe("sortSearchResults relevance", () => {
+ it("preserves the order the providers returned, ignoring score", () => {
+ const scoredResults = [
+ makeEntry({ uid: "roamFirst", score: 0.12, source: "semantic" }),
+ makeEntry({ uid: "roamSecond", score: 0.91, source: "semantic" }),
+ makeEntry({ uid: "miniFirst", score: 9, source: "miniSearch" }),
+ makeEntry({ uid: "miniSecond", score: 2, source: "miniSearch" }),
+ ];
+
+ expect(relevanceOrder(scoredResults)).toEqual([
+ "roamFirst",
+ "roamSecond",
+ "miniFirst",
+ "miniSecond",
+ ]);
+ });
+
+ it("does not reorder tied scores by uid", () => {
+ const scoredResults = [
+ makeEntry({ uid: "zzz", score: 0, source: "semantic" }),
+ makeEntry({ uid: "aaa", score: 0, source: "semantic" }),
+ ];
+
+ expect(relevanceOrder(scoredResults)).toEqual(["zzz", "aaa"]);
+ });
+
+ it("reverses the provider order when sorting ascending", () => {
+ const scoredResults = [
+ makeEntry({ uid: "roamFirst", score: 0.12, source: "semantic" }),
+ makeEntry({ uid: "miniFirst", score: 9, source: "miniSearch" }),
+ ];
+
+ expect(relevanceOrder(scoredResults, "asc")).toEqual([
+ "miniFirst",
+ "roamFirst",
+ ]);
+ });
+
+ it("does not mutate the input array", () => {
+ const scoredResults = [
+ makeEntry({ uid: "a", score: 1, source: "semantic" }),
+ makeEntry({ uid: "b", score: 2, source: "miniSearch" }),
+ ];
+
+ relevanceOrder(scoredResults, "asc");
+
+ expect(scoredResults.map((entry) => entry.result.uid)).toEqual(["a", "b"]);
+ });
+});
+
+describe("combined semantic + MiniSearch ordering", () => {
+ it("keeps every semantic result, in order, ahead of MiniSearch results in order", () => {
+ const semantic = ["s1", "s2", "s3"].map((uid, i) =>
+ makeEntry({ uid, score: i, source: "semantic" }),
+ );
+ const miniSearch = ["m1", "m2"].map((uid, i) =>
+ makeEntry({ uid, score: 100 - i, source: "miniSearch" }),
+ );
+
+ const combined = combineSemanticAndMiniSearchResults({
+ semantic,
+ miniSearch,
+ });
+
+ expect(relevanceOrder(combined)).toEqual(["s1", "s2", "s3", "m1", "m2"]);
+ });
+
+ it("does not cap, so callers stay responsible for MAX_RESULTS", () => {
+ const semantic = Array.from({ length: 200 }, (_, i) =>
+ makeEntry({ uid: `s${i}`, score: 0, source: "semantic" }),
+ );
+
+ const combined = combineSemanticAndMiniSearchResults({
+ semantic,
+ miniSearch: [makeEntry({ uid: "m1", score: 9, source: "miniSearch" })],
+ });
+
+ // searchDiscourseNodes applies the cap; see searchDiscourseNodes.test.ts.
+ expect(combined).toHaveLength(201);
+ expect(combined.length).toBeGreaterThan(MAX_RESULTS);
+ });
+});
diff --git a/apps/roam/src/utils/__tests__/searchDiscourseNodes.test.ts b/apps/roam/src/utils/__tests__/searchDiscourseNodes.test.ts
new file mode 100644
index 0000000000..9163b806e2
--- /dev/null
+++ b/apps/roam/src/utils/__tests__/searchDiscourseNodes.test.ts
@@ -0,0 +1,151 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+type SemanticProviderPayload = {
+ filteredResults: { uid: string; text: string }[];
+ filteredResultCount: number;
+};
+
+const runRoamSemanticSearch =
+ vi.fn<(args: unknown) => Promise>();
+vi.mock("~/utils/discourseNodeSearchProviders", () => ({
+ runRoamSemanticSearch: (args: unknown) => runRoamSemanticSearch(args),
+}));
+
+import { searchDiscourseNodes } from "~/utils/searchDiscourseNodes";
+import {
+ MAX_RESULTS,
+ type ScoredSearchResult,
+ type SearchResult,
+} from "~/utils/discourseNodeSearchTypes";
+
+const originalWindow = (globalThis as { window?: unknown }).window;
+
+const setSemanticSearchEnabled = (enabled: boolean): void => {
+ (globalThis as { window: unknown }).window = {
+ roamAlphaAPI: { data: { semanticSearchEnabled: () => enabled } },
+ };
+};
+
+// Roam returns hits without a score, so semantic entries carry score 0.
+const semanticHits = (count: number): { uid: string; text: string }[] =>
+ Array.from({ length: count }, (_, i) => ({
+ uid: `s${i}`,
+ text: `[[CLM]] - semantic ${i}`,
+ }));
+
+const miniSearchEntry = (uid: string): ScoredSearchResult => ({
+ result: {
+ uid,
+ title: `[[CLM]] - ${uid}`,
+ type: "clm",
+ nodeTypeLabel: "Claim",
+ excerpt: "",
+ createdAt: "",
+ lastModified: "",
+ authorName: "",
+ },
+ score: 9,
+ source: "miniSearch",
+});
+
+const search = (
+ runMiniSearch: () => ScoredSearchResult[],
+): Promise =>
+ searchDiscourseNodes({
+ nodeTypes: [],
+ query: "governance",
+ resultsByUid: new Map(),
+ runMiniSearch,
+ });
+
+beforeEach(() => {
+ runRoamSemanticSearch.mockReset();
+ setSemanticSearchEnabled(true);
+});
+
+afterEach(() => {
+ (globalThis as { window?: unknown }).window = originalWindow;
+});
+
+describe("searchDiscourseNodes", () => {
+ it("caps the semantic path at MAX_RESULTS", async () => {
+ const filteredResults = semanticHits(200);
+ runRoamSemanticSearch.mockResolvedValue({
+ filteredResults,
+ filteredResultCount: filteredResults.length,
+ });
+
+ const results = await search(() => []);
+
+ expect(results).toHaveLength(MAX_RESULTS);
+ expect(results[0]?.result.uid).toBe("s0");
+ });
+
+ it("skips MiniSearch once semantic returns enough discourse nodes", async () => {
+ const filteredResults = semanticHits(5);
+ runRoamSemanticSearch.mockResolvedValue({
+ filteredResults,
+ filteredResultCount: filteredResults.length,
+ });
+ const runMiniSearch = vi.fn(() => [miniSearchEntry("m1")]);
+
+ const results = await search(runMiniSearch);
+
+ expect(runMiniSearch).not.toHaveBeenCalled();
+ expect(results.map((r) => r.result.uid)).toEqual([
+ "s0",
+ "s1",
+ "s2",
+ "s3",
+ "s4",
+ ]);
+ });
+
+ it("appends MiniSearch results after sparse semantic results", async () => {
+ const filteredResults = semanticHits(2);
+ runRoamSemanticSearch.mockResolvedValue({
+ filteredResults,
+ filteredResultCount: filteredResults.length,
+ });
+
+ const results = await search(() => [
+ miniSearchEntry("m1"),
+ miniSearchEntry("m2"),
+ ]);
+
+ expect(results.map((r) => r.result.uid)).toEqual(["s0", "s1", "m1", "m2"]);
+ });
+
+ it("falls back to MiniSearch when the semantic provider throws", async () => {
+ runRoamSemanticSearch.mockRejectedValue(new Error("semantic unavailable"));
+
+ const results = await search(() => [miniSearchEntry("m1")]);
+
+ expect(results.map((r) => r.result.uid)).toEqual(["m1"]);
+ });
+
+ it("degrades to an empty list when MiniSearch itself throws", async () => {
+ setSemanticSearchEnabled(false);
+
+ await expect(
+ search(() => {
+ throw new Error("minisearch exploded");
+ }),
+ ).resolves.toEqual([]);
+ });
+
+ it("returns nothing for a blank query without calling either provider", async () => {
+ const runMiniSearch = vi.fn(() => [miniSearchEntry("m1")]);
+
+ const results = await searchDiscourseNodes({
+ nodeTypes: [],
+ query: " ",
+ resultsByUid: new Map(),
+ runMiniSearch,
+ });
+
+ expect(results).toEqual([]);
+ expect(runMiniSearch).not.toHaveBeenCalled();
+ expect(runRoamSemanticSearch).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/roam/src/utils/discourseNodeSearchProviders.ts b/apps/roam/src/utils/discourseNodeSearchProviders.ts
index a34d1be34f..24f7595412 100644
--- a/apps/roam/src/utils/discourseNodeSearchProviders.ts
+++ b/apps/roam/src/utils/discourseNodeSearchProviders.ts
@@ -249,7 +249,7 @@ export const searchSemanticNodeTitles = async ({
);
};
-const runRoamSemanticSearch = async ({
+export const runRoamSemanticSearch = async ({
nodeTypes,
query,
}: {
diff --git a/apps/roam/src/utils/discourseNodeSearchTypes.ts b/apps/roam/src/utils/discourseNodeSearchTypes.ts
new file mode 100644
index 0000000000..516eee4721
--- /dev/null
+++ b/apps/roam/src/utils/discourseNodeSearchTypes.ts
@@ -0,0 +1,78 @@
+export type DiscourseNodeSearchSource = "semantic" | "miniSearch";
+
+// Caps what any search path returns, semantic included.
+export const MAX_RESULTS = 50;
+
+export type SearchResult = {
+ uid: string;
+ title: string;
+ type: string;
+ nodeTypeLabel: string;
+ excerpt: string;
+ createdAt: string;
+ lastModified: string;
+ authorName: string;
+};
+
+// score and source are carried for diagnostics and future rank fusion; ordering
+// reuses each provider's returned order instead of re-deriving it from score.
+export type ScoredSearchResult = {
+ result: SearchResult;
+ score: number;
+ source: DiscourseNodeSearchSource;
+};
+
+export const combineSemanticAndMiniSearchResults = ({
+ semantic,
+ miniSearch,
+}: {
+ semantic: ScoredSearchResult[];
+ miniSearch: ScoredSearchResult[];
+}): ScoredSearchResult[] => {
+ const seenUids = new Set();
+ const combined: ScoredSearchResult[] = [];
+
+ [...semantic, ...miniSearch].forEach((entry) => {
+ if (seenUids.has(entry.result.uid)) return;
+ seenUids.add(entry.result.uid);
+ combined.push(entry);
+ });
+
+ return combined;
+};
+
+export const toScoredSearchResultFromSemantic = ({
+ uid,
+ title,
+ type,
+ nodeTypeLabel,
+ score,
+ resultsByUid,
+}: {
+ uid: string;
+ title: string;
+ type?: string;
+ nodeTypeLabel?: string;
+ score: number;
+ resultsByUid: Map;
+}): ScoredSearchResult => {
+ const indexedResult = resultsByUid.get(uid);
+ if (indexedResult) {
+ return { result: indexedResult, score, source: "semantic" };
+ }
+
+ return {
+ result: {
+ uid,
+ title,
+ type: type || "",
+ nodeTypeLabel: nodeTypeLabel || "",
+ excerpt: "",
+ createdAt: "",
+ lastModified: "",
+ authorName: "Unknown",
+ },
+ score,
+ source: "semantic",
+ };
+};
diff --git a/apps/roam/src/utils/searchDiscourseNodes.ts b/apps/roam/src/utils/searchDiscourseNodes.ts
new file mode 100644
index 0000000000..9be5bacd15
--- /dev/null
+++ b/apps/roam/src/utils/searchDiscourseNodes.ts
@@ -0,0 +1,81 @@
+import type { DiscourseNode } from "~/utils/getDiscourseNodes";
+import {
+ combineSemanticAndMiniSearchResults,
+ MAX_RESULTS,
+ toScoredSearchResultFromSemantic,
+ type ScoredSearchResult,
+ type SearchResult,
+} from "~/utils/discourseNodeSearchTypes";
+import { runRoamSemanticSearch } from "~/utils/discourseNodeSearchProviders";
+
+export const SEMANTIC_SEARCH_MIN_DISCOURSE_RESULTS = 5;
+
+export type {
+ DiscourseNodeSearchSource,
+ ScoredSearchResult,
+ SearchResult,
+} from "~/utils/discourseNodeSearchTypes";
+
+export const isRoamSemanticSearchEnabled = (): boolean =>
+ window.roamAlphaAPI.data.semanticSearchEnabled();
+
+const runMiniSearchSafely = (
+ runMiniSearch: () => ScoredSearchResult[],
+): ScoredSearchResult[] => {
+ try {
+ return runMiniSearch();
+ } catch {
+ return [];
+ }
+};
+
+export const searchDiscourseNodes = async ({
+ nodeTypes,
+ query,
+ resultsByUid,
+ runMiniSearch,
+}: {
+ nodeTypes: DiscourseNode[];
+ query: string;
+ resultsByUid: Map;
+ runMiniSearch: () => ScoredSearchResult[];
+}): Promise => {
+ const trimmedQuery = query.trim();
+ if (!trimmedQuery) return [];
+
+ let results: ScoredSearchResult[];
+
+ if (!isRoamSemanticSearchEnabled()) {
+ results = runMiniSearchSafely(runMiniSearch);
+ } else {
+ try {
+ const providerResult = await runRoamSemanticSearch({
+ nodeTypes,
+ query: trimmedQuery,
+ });
+ const semanticResults = providerResult.filteredResults.map((item) =>
+ toScoredSearchResultFromSemantic({
+ uid: item.uid,
+ title: item.text,
+ type: item.type,
+ nodeTypeLabel: item.nodeTypeLabel,
+ score: item.score ?? 0,
+ resultsByUid,
+ }),
+ );
+
+ results =
+ providerResult.filteredResultCount >=
+ SEMANTIC_SEARCH_MIN_DISCOURSE_RESULTS
+ ? semanticResults
+ : combineSemanticAndMiniSearchResults({
+ semantic: semanticResults,
+ miniSearch: runMiniSearchSafely(runMiniSearch),
+ });
+ } catch {
+ results = runMiniSearchSafely(runMiniSearch);
+ }
+ }
+
+ return results.slice(0, MAX_RESULTS);
+};