Skip to content

ENG-1734 Add Roam semantic search to Advanced Node Search - #1186

Merged
trangdoan982 merged 18 commits into
mainfrom
eng-1734-add-semantic-search-for-sync-enabled-graphs
Sep 27, 2026
Merged

trangdoan982 merged 18 commits into
mainfrom
eng-1734-add-semantic-search-for-sync-enabled-graphs

Conversation

@trangdoan982

@trangdoan982 trangdoan982 commented Jul 3, 2026 •

Copy link
Copy Markdown
Member

https://www.loom.com/share/acfb9a8fcae24dea892a655974210c52

Reviewer brief

Result: Advanced Node Search runs Roam semantic search when the graph has it enabled, filters hits to discourse nodes, and renders them in the order Roam returned. MiniSearch stays the path for graphs without semantic search, and backfills when semantic returns fewer than five discourse nodes.

Review focus: .semanticSearch returns no score. A raw hit is {type, uid, topUids} and nothing else, verified against discourse-graphs. score: item.score ?? 0 therefore gave every semantic result a score of 0, every score comparison tied, and the shared comparison || a.uid.localeCompare(b.uid) tie-break decided the final order. Semantic results were ranked alphabetically by uid.

The fix is that relevance sorting no longer derives order from score. Each provider already returns its own results in relevance order, so sortSearchResults preserves that order and only reverses it for ascending. Two consequences worth a careful look:

  • The ordering contract is now load-bearing. It holds because filteredResults preserves Roam's order, miniSearch.search returns score-descending, and combining concatenates semantic before MiniSearch. discourseNodeSearchTypes.test.ts pins it.
  • score and source are retained on ScoredSearchResult but no longer read for ordering. They are kept for diagnostics and possible rank fusion.

Risk and follow-up:

  • When semantic returns five or more discourse nodes, MiniSearch is skipped entirely, so exact and prefix matches do not appear. Confirmed as intended.
  • In the sparse branch, up to four weak semantic hits still outrank an exact-title MiniSearch match. The two score scales are incomparable and semantic has no score at all, so fixing it needs rank fusion rather than a threshold. Not in this PR.
  • getDiscourseNodes() and a resultsByUid map are rebuilt per debounced keystroke, and searchDiscourseNodesWithMiniSearch builds a second identical map. Memoizable, not addressed here.

Verification

Unit tests (50 passing)

pnpm --filter roam test, check-types, and lint all pass.

apps/roam/src/utils/__tests__/discourseNodeSearchTypes.test.ts pins the ordering contract:

Case Asserts
Provider order preserved Semantic entries scored 0.12 / 0.91 / 0.55 render in returned order, not score order
No uid tie-break Two entries tied at score 0 with uids zzz and aaa stay in returned order
Ascending reverses asc inverts the full list
Input not mutated sortSearchResults does not reorder its argument
Dedupe across sources A uid repeated within semantic, and again in MiniSearch, keeps only its first entry
Combined ordering Semantic entries scored 0/1/2 stay ahead of MiniSearch entries scored 100/99
Result cap Combining 200 semantic with 1 MiniSearch result slices to MAX_RESULTS
Live proof: ordering in the Advanced Node Search UI

Driven through Playwright against a real graph, with .semanticSearch stubbed to return a deliberately scrambled order so provider order, score order, and alphabetical order are all distinct. The UI matched provider order and neither of the others.

semanticStubCalls: 1
ordersAreDistinguishable: True
matchesProvider: True
matchesScoreDesc: False
matchesAlphabetical: False
assertion: PASS

Query transparency and legitimacy against ten purpose-built claims. The only two titles containing the query words are highlighted as keyword matches and rank last, below five semantically relevant claims containing neither word.

Real-graph ranking, before and after

Run on discourse-graphs, which has semantic search enabled. 24 of 50 page hits were discourse nodes.

Query transparency and legitimacy:

# After (Roam's order) Before (uid order)
1 Public trust declines when institutions hide... Publishing meeting minutes reduces disputes...
2 Open deliberation records make citizens... Legitimacy of a mathematical proof rests...
3 Communities comply with rules more readily... Public trust declines when institutions hide...
4 Publishing meeting minutes reduces disputes... personalized views of a shared canvas...
5 Transparent team budgeting Participatory budgeting raises civic engagement...

Across three queries the old sort displaced results by a mean of 5.9 to 6.8 positions. The node Roam ranks first moved from rank 3 to rank 1 on two of them.

Scope check

  • Ran $scope-check against the ENG ticket and final diff.
  • Scope beyond Done When: not assessed.

Standards check

  • Ran $dg-pr-adherence-check against the final diff and PR metadata.

Local delegated full review

  • Ran a comprehensive review of the entire final diff in a subagent with a fresh context.

Findings addressed in this PR: results are now capped at MAX_RESULTS on every path, the docked panel no longer caches the empty result list published while a search is in flight, the async search renders the indexing state instead of "No matches", and the recovery path routes through the same MiniSearch guard as the other call sites. Remaining findings are listed under Risk and follow-up.

Wire Advanced Node Search to Roam semantic search when enabled, with discourse-note filtering and MiniSearch fallback when fewer than five semantic results remain.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
discourse-graph Skipped Skipped Sep 22, 2026 11:12pm UTC

Request Review

@linear-code

linear-code Bot commented Jul 3, 2026

Copy link
Copy Markdown

ENG-1734

@supabase

supabase Bot commented Jul 3, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

Co-authored-by: Cursor <cursoragent@cursor.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts Outdated
Comment thread apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts Outdated
Return frozen docked results synchronously and avoid persisting empty results that clobber the sidebar registry on mount.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use main-branch inline docked query check, remove dockedSearchSnapshot helper and Roam tests, and apply sort in useMemo so sort changes do not re-run async search.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Extract shared search hit types and remove DiscourseSearchHit adapter conversions in the Advanced Node Search hook.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use searchDiscourseNodes for the semantic-plus-miniSearch orchestrator, searchDiscourseNodesWithMiniSearch for MiniSearch, and ScoredSearchResult instead of hit-based names.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sort runs in useMemo only; the search effect does not depend on sort config.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment thread apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts Outdated
Co-authored-by: Cursor <cursoragent@cursor.com>
trangdoan982 and others added 2 commits July 3, 2026 22:36
Drop temporary search logging, clear stale results when docking or starting a new search, and keep semantic hits first when sorting by relevance.

Co-authored-by: Cursor <cursoragent@cursor.com>
@trangdoan982

Copy link
Copy Markdown
Member Author

Addressed open review feedback in 7acc534:

  • Docked query stale results: clear unsortedScoredResults when isDockedQuery and before starting a new async search.
  • Relevance sort: semantic hits stay first regardless of sort direction; score direction still applies within each source group.
  • Debug logs: removed temporary logDiscourseNodeSearchResults / DEBUG_DISCOURSE_NODE_SEARCH logging added for manual testing.

Also merged latest main. CI is green.

@trangdoan982
trangdoan982 requested a review from mdroidian July 4, 2026 03:27
@mdroidian

Copy link
Copy Markdown
Member

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Refactors discourse node search into a shared type system (SearchResult, ScoredSearchResult, DiscourseNodeSearchSource) with merge/conversion helpers, adds a new searchDiscourseNodes function combining semantic and miniSearch results, exports runRoamSemanticSearch, and converts the advanced search hook to an async, effect-driven pipeline.

Changes

Semantic + MiniSearch Discourse Node Search

Layer / File(s) Summary
Shared search result types and merge helpers
apps/roam/src/utils/discourseNodeSearchTypes.ts
Adds SearchResult, ScoredSearchResult, DiscourseNodeSearchSource types, plus combineSemanticAndMiniSearchResults (dedupe by uid) and toScoredSearchResultFromSemantic (builds result from semantic input or resultsByUid lookup).
searchDiscourseNodes orchestration and semantic provider export
apps/roam/src/utils/searchDiscourseNodes.ts, apps/roam/src/utils/discourseNodeSearchProviders.ts
Adds isRoamSemanticSearchEnabled and searchDiscourseNodes, which falls back to miniSearch when semantic search is disabled/errored/insufficient, otherwise returns or merges semantic results; exports runRoamSemanticSearch from providers module.
AdvancedNodeSearchDialog utils type and sort updates
apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts
Replaces local SearchResult/ScoredSearchHit types with shared imports, updates sortSearchResults to accept scoredResults with source-aware relevance ordering, and updates searchIndexedNodes to return ScoredSearchResult tagged with source: "miniSearch".
Async search hook rewrite
apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts
Converts the synchronous useMemo search to a stateful async effect calling searchDiscourseNodes with a runMiniSearch fallback, guarded by a cancellation flag, then sorts results via sortSearchResults before returning docked or sorted results.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Hook as useAdvancedNodeSearchResults
  participant Search as searchDiscourseNodes
  participant Semantic as runRoamSemanticSearch
  participant Mini as runMiniSearch

  Hook->>Search: query, nodeTypes, resultsByUid, runMiniSearch
  Search->>Search: check semantic search enabled
  alt semantic disabled or errors
    Search->>Mini: runMiniSearch()
    Mini-->>Search: ScoredSearchResult[]
  else semantic enabled
    Search->>Semantic: query
    Semantic-->>Search: filteredResults
    alt sufficient semantic results
      Search-->>Hook: semantic ScoredSearchResult[]
    else insufficient results
      Search->>Mini: runMiniSearch()
      Mini-->>Search: ScoredSearchResult[]
      Search->>Search: combineSemanticAndMiniSearchResults
    end
  end
  Search-->>Hook: ScoredSearchResult[]
  Hook->>Hook: sortSearchResults(scoredResults, sort)
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: adding Roam semantic search to Advanced Node Search.
Description check ✅ Passed The description gives a detailed reviewer brief, verification results, a Loom link, and completed standards and review checks. The scope check says “not assessed” instead of stating whether the diff s…

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/roam/src/utils/discourseNodeSearchTypes.ts (1)

39-73: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Undocumented fallback limitation and inconsistent placeholder defaults.

When uid isn't found in resultsByUid, the placeholder SearchResult silently drops excerpt, createdAt, and lastModified (empty strings) but sets authorName to "Unknown" instead of "". This inconsistency could surface oddly in the UI (e.g., "Unknown" author next to blank dates), and the fact that this fallback path exists at all (and why real fields are unavailable) isn't documented anywhere.

As per coding guidelines, **/*.{ts,tsx,js,jsx,md,mdx} files should "Document limitations, known bugs, or edge cases where behavior may not align with expectations."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/roam/src/utils/discourseNodeSearchTypes.ts` around lines 39 - 73, The
semantic fallback in toScoredSearchResultFromSemantic creates a partial
SearchResult when resultsByUid has no match, but its placeholder defaults are
inconsistent and the limitation is undocumented. Update the fallback in
toScoredSearchResultFromSemantic so the placeholder fields use a consistent
convention, and add a brief comment/doc note near this path explaining that the
data is incomplete because no indexed result was found and which fields are
intentionally unavailable.

Source: Coding guidelines

apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts (1)

71-75: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse excludeDefaultNodes instead of duplicating the backedBy filter.

getDiscourseNodes.ts already exports excludeDefaultNodes for exactly this "drop default-backed nodes" check. Inlining node.backedBy === "user" here duplicates that logic and risks drifting if the definition of "default" changes.

♻️ Reuse existing utility
-import getDiscourseNodes from "~/utils/getDiscourseNodes";
+import getDiscourseNodes, { excludeDefaultNodes } from "~/utils/getDiscourseNodes";
...
     const discourseNodes = getDiscourseNodes().filter(
-      (node) =>
-        node.backedBy === "user" &&
-        (!typeFilter || typeFilter.includes(node.type)),
+      (node) =>
+        excludeDefaultNodes(node) &&
+        (!typeFilter || typeFilter.includes(node.type)),
     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts`
around lines 71 - 75, The discourse node filtering in
useAdvancedNodeSearchResults duplicates the default-node check by inlining
node.backedBy === "user". Update the discourseNodes computation to reuse the
existing excludeDefaultNodes helper exported from getDiscourseNodes.ts, then
keep the typeFilter condition on top of that shared utility so the filtering
logic stays consistent and centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/roam/src/utils/discourseNodeSearchTypes.ts`:
- Around line 20-37: The combineSemanticAndMiniSearchResults helper currently
seeds seenUids from semantic but does not deduplicate semantic entries
themselves, so repeated uid values can still appear in the returned list. Update
combineSemanticAndMiniSearchResults to filter the semantic array with the same
seenUids logic used for miniSearch before building combined, keeping only the
first ScoredSearchResult per uid.

In `@apps/roam/src/utils/searchDiscourseNodes.ts`:
- Around line 21-69: The MiniSearch fallback in searchDiscourseNodes is not
protected, so a thrown error from runMiniSearch() can still reject the search
update. Update searchDiscourseNodes to invoke runMiniSearch through a safe
wrapper in both the isRoamSemanticSearchEnabled() disabled branch and the
catch/combine fallback path, and return an empty array if MiniSearch fails so
the function always degrades cleanly.

---

Nitpick comments:
In
`@apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts`:
- Around line 71-75: The discourse node filtering in
useAdvancedNodeSearchResults duplicates the default-node check by inlining
node.backedBy === "user". Update the discourseNodes computation to reuse the
existing excludeDefaultNodes helper exported from getDiscourseNodes.ts, then
keep the typeFilter condition on top of that shared utility so the filtering
logic stays consistent and centralized.

In `@apps/roam/src/utils/discourseNodeSearchTypes.ts`:
- Around line 39-73: The semantic fallback in toScoredSearchResultFromSemantic
creates a partial SearchResult when resultsByUid has no match, but its
placeholder defaults are inconsistent and the limitation is undocumented. Update
the fallback in toScoredSearchResultFromSemantic so the placeholder fields use a
consistent convention, and add a brief comment/doc note near this path
explaining that the data is incomplete because no indexed result was found and
which fields are intentionally unavailable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0b498032-11de-469b-b587-52f3e638f6c8

📥 Commits

Reviewing files that changed from the base of the PR and between 4d4ddff and 7acc534.

📒 Files selected for processing (5)
  • apps/roam/src/components/AdvancedNodeSearchDialog/useAdvancedNodeSearchResults.ts
  • apps/roam/src/components/AdvancedNodeSearchDialog/utils.ts
  • apps/roam/src/utils/discourseNodeSearchProviders.ts
  • apps/roam/src/utils/discourseNodeSearchTypes.ts
  • apps/roam/src/utils/searchDiscourseNodes.ts

Comment thread apps/roam/src/utils/discourseNodeSearchTypes.ts
Comment thread apps/roam/src/utils/searchDiscourseNodes.ts

@mdroidian mdroidian left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think the semantic result ordering needs another pass.

Right now we take Roam semantic search results, filter them to discourse-node pages, then sort semantic results by score in sortSearchResults.

I think we should preserve Roam’s returned order for semantic results. A clean fix would be to store a semanticRank/providerRank when mapping providerResult.filteredResults, then for relevance sorting:

  • semantic results stay before MiniSearch results
  • semantic-vs-semantic uses Roam return order
  • MiniSearch-vs-MiniSearch uses MiniSearch score
  • combined sparse fallback still dedupes by uid

That would better match the user expectation that Roam semantic search’s own ordering is the relevance ordering.

comparison = compareNumbers(aEntry.score, bEntry.score, sort.direction);
break;
case "alphabetical":
comparison = compareStrings(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks like this is doing some odd scoring even when roam's semantic search returns properly scored results:

Ours:
Image

Roam's:

Image

Roam's semantic scores are not comparable across hits, so sorting by them
reordered results away from what Roam returned. Record each provider's
returned position as `rank` and sort semantic entries by it; MiniSearch
entries still sort by score and stay below semantic ones.

Also dedupe semantic uids when combining the two sources, and degrade to
an empty list when the MiniSearch fallback itself throws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both providers already return results in relevance order, and combining
them appends MiniSearch after semantic, so relevance sorting only has to
reverse for ascending. Sorting by score re-derived that order from Roam
scores that are not comparable across hits, and the uid tie-break broke
ties in the process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Semantic results were never capped: MAX_RESULTS only applied inside the
MiniSearch helper, so the semantic and combined paths could return far
more. Move the constant down to the search types and slice every path.

The docked panel persists results on every change, including the empty
list published while an async search is in flight. If it unmounted in
that window the cached empty list was treated as a hit on restore, so a
query with matches stayed at zero results. Only reuse a non-empty cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread apps/roam/src/utils/__tests__/discourseNodeSearchTypes.test.ts Outdated
Search became async with semantic search, but contentState only knew
about isIndexLoading, so "No matches" rendered for the whole semanticSearch
round trip. Track an in-flight flag and use it in the dialog and the
docked panel.

Also route the recovery path's runMiniSearch() through the same guard the
other call sites use, so a throw there cannot become an unhandled rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The cap lives in searchDiscourseNodes, not in the combine helper, so the
combine test could not prove it. Add searchDiscourseNodes coverage for the
cap, the semantic-only threshold, the sparse combine, both fallbacks, and
the blank query, and correct the combine test to state what it checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@trangdoan982

Copy link
Copy Markdown
Member Author

@mdroidian you're so right! bug fixed. The sorting function was the problem cuz Roam semantic search doesn't return a score value -> all the semantic results are actually ranked lower because the value defaults to 0 when there's no score.

I fixed this bug: now Relevance is the default order, which will simply return the original order that was given by Roam semantic. If the search has to use both semantic + miniSearch, the ranking would prioritize the order from semantic search first.

here's the screenshot of the same test case on graph discourse-graph:

before fix:
01-review-ours-before-fix

roam semantic search:
02-review-roam-semantic-ranking

after fix:
03-advanced-search-after-fix

The wrapper existed only to give the cap a single place to apply. One
function with a single exit does the same thing without the indirection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entire-Checkpoint: 01M35P984XJ2TWP81W573WTT9H
@trangdoan982
trangdoan982 merged commit 7c973af into main Sep 27, 2026
10 checks passed
@trangdoan982
trangdoan982 deleted the eng-1734-add-semantic-search-for-sync-enabled-graphs branch September 27, 2026 23:10

This branch was previously deployed

1 inactive deployment
Preview — 75910495 Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants