Skip to content

Add semantic search with child process embedding#98

Open
dakl wants to merge 11 commits intomainfrom
feat-semantic-search
Open

Add semantic search with child process embedding#98
dakl wants to merge 11 commits intomainfrom
feat-semantic-search

Conversation

@dakl
Copy link
Owner

@dakl dakl commented Feb 23, 2026

Summary

  • Move @huggingface/transformers embedding inference to a child process (child_process.fork()) to avoid SIGTRAP crashes from onnxruntime-node conflicting with Electron's V8
  • Add text chunking (chunker.ts), vector store with cosine similarity search, and hybrid search (FTS + semantic)
  • Add per-paper embedding status indicators in the library list (gray → queued → indexing → complete/failed)
  • Serial embedding queue to prevent OOM from concurrent ONNX inference
  • Auto-index papers on save with follow-up re-check for papers added during indexing
  • "Index N new" button for manually triggering indexing of unindexed papers
  • Pre-release workflow support: prerelease boolean input on the Release workflow, existing clients won't auto-update to beta versions
  • Test requirements added to CLAUDE.md to prevent UI state bugs

New files

  • src/main/services/embedding-worker.ts — standalone Node.js child process for ONNX inference
  • src/main/services/embedding-service.ts — child process manager with serial queue
  • src/main/services/indexing-service.ts — batch indexer with progress events
  • src/main/services/chunker.ts — paper text chunking (title/abstract + body)
  • src/main/db/vector-store.ts — embedding storage, status tracking, cosine similarity search
  • src/main/db/hybrid-search.ts — combined FTS + vector search
  • src/renderer/components/IndexNewButton.tsx — button to trigger indexing
  • src/renderer/__tests__/indexing-status-updates.test.ts — UI state machine tests
  • src/main/__tests__/indexing-service.test.ts — backend indexing flow tests
  • src/main/__tests__/vector-store.test.ts — vector store + status tests

Test plan

  • 282 tests pass (npm run test)
  • App starts without SIGTRAP (PAPERSHELF_DATA_DIR=/tmp/papershelf-dev npm run dev)
  • Saving papers triggers auto-indexing with visible status transitions
  • Semantic search returns results in library search
  • Verify pre-release workflow creates GitHub pre-release (manual trigger)

🤖 Generated with Claude Code

Move @huggingface/transformers embedding inference to a child process
via child_process.fork() to avoid SIGTRAP crashes from onnxruntime-node
conflicting with Electron's V8. Add chunking, vector store, hybrid
search, per-paper indexing status indicators, serial embedding queue,
auto-indexing on save, and pre-release workflow support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link

greptile-apps bot commented Feb 23, 2026

Greptile Summary

This PR implements semantic search using embeddings generated in a child process to avoid SIGTRAP crashes. The implementation is well-architected with proper separation of concerns (worker process, service layer, vector store, hybrid search). Tests comprehensively cover the event-driven UI state machine per CLAUDE.md requirements.

Major changes:

  • Child process embedding worker isolates ONNX inference from Electron's V8
  • Serial queue prevents OOM from concurrent inference
  • Hybrid search combines FTS5 + vector search using RRF scoring
  • Per-paper status tracking with UI badges (pending → queued → indexing → complete/failed)
  • Auto-indexing on save with follow-up re-check for papers added during indexing
  • Pre-release workflow support for beta versions

Issues found:

  • which command fails on Windows (embedding-service.ts:39)
  • Infinite loop possible if chunk break point is too small (chunker.ts:73)
  • Vector search performs full table scan computing cosine similarity in-memory — will be slow with thousands of chunks (acknowledged trade-off for beta)

Confidence Score: 4/5

  • Safe to merge with two bugs fixed (Windows compatibility and chunking edge case)
  • Comprehensive implementation with excellent test coverage. Two logic bugs need fixing before merge: Windows which command failure and potential infinite loop in chunker. Vector search performance will degrade with scale but acceptable for beta release.
  • Pay close attention to src/main/services/embedding-service.ts (Windows compatibility) and src/main/services/chunker.ts (infinite loop edge case)

Important Files Changed

Filename Overview
src/main/services/embedding-worker.ts Child process worker for ONNX inference - stubbing sharp module to avoid asar.unpacked path issues, handles embeddings with proper batching
src/main/services/embedding-service.ts Child process manager with serial queue - relies on which command (fails on Windows), but other logic is sound
src/main/services/indexing-service.ts Batch indexer with serial processing, proper progress events, and follow-up check for papers added during indexing (fire-and-forget pattern is intentional)
src/main/db/vector-store.ts Vector storage and cosine similarity search - in-memory full table scan will be slow with thousands of chunks (>100 papers with full text)
src/main/db/hybrid-search.ts RRF-based hybrid search combining FTS and vector results - well-implemented with proper deduplication
.github/workflows/release.yml Pre-release workflow support added - flag syntax needs verification with electron-builder docs

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as LibraryList
    participant Store as paperStore
    participant IPC as ipcHandlers
    participant IndexSvc as indexing-service
    participant EmbedSvc as embedding-service
    participant Worker as embedding-worker<br/>(child process)
    participant VectorDB as vector-store
    participant Search as hybrid-search

    Note over User,Search: Paper Save Flow
    User->>Store: savePaper()
    Store->>IPC: savePaper()
    IPC->>IPC: save-paper.ts
    IPC-->>Store: success
    IPC->>IndexSvc: indexAllPapers() (fire-and-forget)
    
    Note over IndexSvc,Worker: Indexing Flow
    IndexSvc->>VectorDB: getPapersNeedingEmbedding()
    VectorDB-->>IndexSvc: [paper1, paper2, ...]
    
    loop For each paper
        IndexSvc->>UI: emit INDEXING_PROGRESS (status: indexing)
        UI->>UI: setActivelyIndexingPaperId
        IndexSvc->>EmbedSvc: embedDocumentTexts()
        EmbedSvc->>Worker: send embedDocuments message
        Worker->>Worker: ONNX inference (batched)
        Worker-->>EmbedSvc: embeddings result
        EmbedSvc-->>IndexSvc: Float32Array[]
        IndexSvc->>VectorDB: insertChunkWithEmbedding()
        IndexSvc->>VectorDB: setEmbeddingStatus(complete)
        IndexSvc->>UI: emit INDEXING_PROGRESS (status: indexed)
        UI->>UI: loadPapers() (refresh DB status)
    end
    
    IndexSvc->>UI: emit INDEXING_PROGRESS (status: complete)
    UI->>UI: clear indexing state

    Note over User,Search: Semantic Search Flow
    User->>UI: search query
    UI->>IPC: searchLibrary(query)
    IPC->>EmbedSvc: embedQuery(query)
    EmbedSvc->>Worker: send embedQuery message
    Worker-->>EmbedSvc: query embedding
    IPC->>Search: hybridSearch(query, embedding)
    Search->>VectorDB: vectorSearch() (cosine similarity)
    Search->>Search: FTS5 keyword search
    Search->>Search: RRF score fusion
    Search-->>IPC: ranked results
    IPC-->>UI: papers with match type
    UI->>User: display results
Loading

Last reviewed commit: 5569932

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

33 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines +35 to +55
function getNodeBinaryPath(): string {
const candidates = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];

try {
const nodePath = execSync('which node', { encoding: 'utf-8' }).trim();
if (nodePath) return nodePath;
} catch {
// which failed, try known paths
}

for (const candidate of candidates) {
try {
execSync(`${candidate} --version`, { encoding: 'utf-8' });
return candidate;
} catch {
// not found, try next
}
}

throw new Error('Could not find system Node.js binary. Install Node.js to enable semantic search.');
}
Copy link

Choose a reason for hiding this comment

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

which command doesn't exist on Windows

Suggested change
function getNodeBinaryPath(): string {
const candidates = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];
try {
const nodePath = execSync('which node', { encoding: 'utf-8' }).trim();
if (nodePath) return nodePath;
} catch {
// which failed, try known paths
}
for (const candidate of candidates) {
try {
execSync(`${candidate} --version`, { encoding: 'utf-8' });
return candidate;
} catch {
// not found, try next
}
}
throw new Error('Could not find system Node.js binary. Install Node.js to enable semantic search.');
}
function getNodeBinaryPath(): string {
const candidates = process.platform === 'win32'
? ['C:\\Program Files\\nodejs\\node.exe', 'C:\\Program Files (x86)\\nodejs\\node.exe']
: ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];
try {
const whichCmd = process.platform === 'win32' ? 'where node' : 'which node';
const nodePath = execSync(whichCmd, { encoding: 'utf-8' }).trim().split('\n')[0];
if (nodePath) return nodePath;
} catch {
// which/where failed, try known paths
}
for (const candidate of candidates) {
try {
execSync(`"${candidate}" --version`, { encoding: 'utf-8' });
return candidate;
} catch {
// not found, try next
}
}
throw new Error('Could not find system Node.js binary. Install Node.js to enable semantic search.');
}

dakl and others added 2 commits February 23, 2026 11:49
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link

greptile-apps bot commented Feb 23, 2026

Greptile Summary

This PR adds semantic search capabilities to PaperShelf by moving ONNX embedding inference to a child process to avoid SIGTRAP crashes in Electron. The implementation includes text chunking, vector storage with cosine similarity search, and hybrid search combining FTS5 with vector search using Reciprocal Rank Fusion.

Key Changes

  • Child process architecture (embedding-worker.ts) spawned via fork() with system Node.js to isolate ONNX runtime from Electron's V8
  • Serial embedding queue prevents OOM from concurrent inference
  • Per-paper embedding status tracking with UI badges (gray → queued → indexing → complete/failed)
  • Auto-indexing on paper save with follow-up re-check for papers added during indexing
  • Hybrid search (FTS + semantic) with proper paper-level deduplication
  • Pre-release workflow support prevents beta auto-updates
  • Comprehensive UI state machine tests as required by CLAUDE.md

Issues Found

  • Windows compatibility: which command doesn't exist on Windows (line 39 in embedding-service.ts)

Confidence Score: 4/5

  • Safe to merge after fixing the Windows compatibility issue with the which command
  • The implementation is well-architected with proper child process isolation, comprehensive tests (282 passing), and good error handling. The serial queue prevents OOM, the hybrid search is correctly implemented with RRF, and the UI state machine is thoroughly tested per CLAUDE.md requirements. One critical Windows compatibility bug needs fixing before merge.
  • src/main/services/embedding-service.ts requires Windows compatibility fix for Node.js binary detection

Important Files Changed

Filename Overview
src/main/services/embedding-service.ts Child process manager with serial queue for embedding requests; has Windows compatibility issue with which command
src/main/services/embedding-worker.ts Standalone child process for ONNX embedding inference to avoid SIGTRAP crashes in Electron
src/main/services/indexing-service.ts Batch indexing service with progress events and follow-up re-check
src/main/db/hybrid-search.ts Reciprocal Rank Fusion combining FTS5 and vector search with deduplication
src/renderer/components/LibraryList.tsx State management for actively indexing paper with event subscriptions
src/renderer/tests/indexing-status-updates.test.ts Comprehensive UI state machine tests covering all indexing progress events

Sequence Diagram

sequenceDiagram
    participant User
    participant Renderer
    participant Main
    participant IndexingService
    participant EmbeddingService
    participant Worker as embedding-worker.ts
    participant VectorStore
    participant HybridSearch

    User->>Renderer: Save paper
    Renderer->>Main: IPC: papers:save
    Main->>VectorStore: insertPaper()
    Main->>IndexingService: indexAllPapers()
    
    IndexingService->>VectorStore: getPapersNeedingEmbedding()
    VectorStore-->>IndexingService: [paper1, paper2, ...]
    
    loop For each paper
        IndexingService->>Main: emit INDEXING_PROGRESS (indexing)
        Main->>Renderer: onIndexingProgress
        Renderer->>Renderer: Update badge to blue pulsing
        
        IndexingService->>EmbeddingService: embedDocumentTexts([chunks])
        EmbeddingService->>EmbeddingService: enqueue (serial)
        EmbeddingService->>Worker: fork child process
        Worker->>Worker: Load ONNX model
        Worker-->>EmbeddingService: model loaded
        
        EmbeddingService->>Worker: embedDocuments
        Worker->>Worker: Generate embeddings
        Worker-->>EmbeddingService: Float32Array[]
        
        IndexingService->>VectorStore: insertChunkWithEmbedding()
        IndexingService->>VectorStore: setEmbeddingStatus(complete)
        IndexingService->>Main: emit INDEXING_PROGRESS (indexed)
        Main->>Renderer: onIndexingProgress
        Renderer->>Renderer: Refresh paper list
    end
    
    IndexingService->>Main: emit INDEXING_PROGRESS (complete)
    Main->>Renderer: onIndexingProgress
    Renderer->>Renderer: Clear active state
    
    User->>Renderer: Search library
    Renderer->>Main: IPC: search:semantic
    Main->>EmbeddingService: embedQuery(query)
    EmbeddingService->>Worker: embedQuery
    Worker-->>EmbeddingService: Float32Array
    Main->>HybridSearch: hybridSearch(query, embedding)
    HybridSearch->>VectorStore: vectorSearch(embedding)
    HybridSearch->>VectorStore: FTS5 search
    HybridSearch->>HybridSearch: Reciprocal Rank Fusion
    HybridSearch-->>Main: Ranked results
    Main-->>Renderer: SemanticSearchResult[]
    Renderer->>User: Display results
Loading

Last reviewed commit: a336734

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

33 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

const candidates = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];

try {
const nodePath = execSync('which node', { encoding: 'utf-8' }).trim();
Copy link

Choose a reason for hiding this comment

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

which doesn't exist on Windows - use where instead or check process.platform

Suggested change
const nodePath = execSync('which node', { encoding: 'utf-8' }).trim();
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
const nodePath = execSync(`${whichCmd} node`, { encoding: 'utf-8' }).trim();

- Stub sharp via Module._resolveFilename in embedding worker (sharp is
  unused for text embeddings but imported at top level by transformers)
- Use require() instead of import() for transformers to get CJS build
  where the stub can intercept
- Follow-up indexing only picks up pending papers, not failed ones
- Don't flash loading spinner during paper list refreshes
- Only reload paper list on meaningful status changes (indexed/error/complete)
- Add worker integration tests (spawn, sharp stub, IPC protocol)
- Add TDD instruction to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link

greptile-apps bot commented Feb 23, 2026

Greptile Summary

Implements semantic search for papers using ONNX embeddings in a child process to avoid Electron/V8 crashes. Adds hybrid search (FTS + vector with RRF ranking), auto-indexing on save, UI status badges, and pre-release workflow support.

Key Changes

  • Child process architecture isolates ONNX runtime from Electron main process to prevent SIGTRAP crashes
  • Serial embedding queue prevents OOM from concurrent inference
  • Hybrid search combines keyword (FTS5) and semantic (cosine similarity) results using Reciprocal Rank Fusion
  • UI tracks indexing progress with status badges (pending → queued → indexing → complete/failed)
  • Auto-indexing triggers on paper save with follow-up check for papers added during indexing
  • Sharp module stubbed via Module._resolveFilename interception to avoid missing dependency in packaged app
  • Pre-release workflow input allows beta releases without auto-update for stable clients

Issues Found

  • Chunking overlap calculation can go negative causing infinite loop (chunker.ts:73)
  • Recursive indexAllPapers() follow-up not awaited, could spawn chains (indexing-service.ts:134)
  • Vector search does full table scan with in-memory cosine similarity — will slow with scale
  • Minor: shutdown guard missing in embedding-service, test data inconsistency

Architecture

  • embedding-worker.ts: Standalone Node.js process for ONNX inference
  • embedding-service.ts: Child process manager with serial queue and IPC
  • indexing-service.ts: Batch indexer with progress events and re-check loop
  • vector-store.ts: SQLite storage for chunks/embeddings with status tracking
  • hybrid-search.ts: RRF-based merging of FTS and vector results

Confidence Score: 3/5

  • Safe to merge with fixes for chunking infinite loop and recursive indexing
  • Two critical logic bugs found: chunker infinite loop and unawaited recursive call. Otherwise, architecture is sound with good test coverage (282 tests passing). Child process isolation effectively solves the SIGTRAP crash. Performance concerns exist at scale but acceptable for beta.
  • src/main/services/chunker.ts (infinite loop risk), src/main/services/indexing-service.ts (recursive call chain)

Important Files Changed

Filename Overview
src/main/services/embedding-worker.ts Standalone Node.js worker for ONNX inference with sharp stub workaround
src/main/services/embedding-service.ts Child process manager with serial queue, missing guard in shutdown
src/main/services/indexing-service.ts Batch indexer with progress events, recursive follow-up not awaited
src/main/services/chunker.ts Text chunking with overlap, potential infinite loop if breakPoint < overlap
src/main/db/vector-store.ts Embedding storage with in-memory cosine similarity search, will slow down with scale
src/main/db/hybrid-search.ts RRF-based hybrid search combining FTS and vector results, well-structured

Sequence Diagram

sequenceDiagram
    participant UI as Renderer (UI)
    participant Main as Main Process
    participant Indexer as indexing-service
    participant EmbedSvc as embedding-service
    participant Worker as embedding-worker (child)
    participant DB as vector-store

    UI->>Main: savePaperFromArxiv()
    Main->>DB: insertPaper()
    Main->>Indexer: indexAllPapers()
    
    Indexer->>DB: getPapersNeedingEmbedding()
    DB-->>Indexer: [paper1, paper2, ...]
    
    loop For each paper
        Indexer->>Main: emit INDEXING_PROGRESS (indexing)
        Main->>UI: indexing-progress event
        
        Indexer->>DB: chunkPaper()
        Indexer->>EmbedSvc: embedDocumentTexts(chunks)
        
        EmbedSvc->>Worker: fork() if not running
        EmbedSvc->>Worker: init (load model)
        Worker-->>EmbedSvc: loaded
        
        EmbedSvc->>Worker: embedDocuments
        Worker->>Worker: ONNX inference (batched)
        Worker-->>EmbedSvc: embeddings[]
        
        EmbedSvc-->>Indexer: Float32Array[]
        
        Indexer->>DB: insertChunkWithEmbedding() (transaction)
        Indexer->>DB: setEmbeddingStatus('complete')
        
        Indexer->>Main: emit INDEXING_PROGRESS (indexed)
        Main->>UI: indexing-progress event
    end
    
    Indexer->>Main: emit INDEXING_PROGRESS (complete)
    Main->>UI: indexing-progress event
    UI->>UI: refresh badge states
    
    Note over UI,Worker: Search Flow
    UI->>Main: semanticSearch(query)
    Main->>EmbedSvc: embedQuery(query)
    EmbedSvc->>Worker: embedQuery
    Worker-->>EmbedSvc: embedding
    EmbedSvc-->>Main: Float32Array
    Main->>DB: hybridSearch(query, embedding)
    DB->>DB: FTS + vector search + RRF merge
    DB-->>Main: SemanticSearchResult[]
    Main-->>UI: results
Loading

Last reviewed commit: b65a1ec

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

35 files reviewed, 9 comments

Edit Code Review Agent Settings | Greptile

}

// Advance with overlap
offset += breakPoint - OVERLAP_CHARS;
Copy link

Choose a reason for hiding this comment

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

overlap can go negative if breakPoint < OVERLAP_CHARS, causing infinite loop

Suggested change
offset += breakPoint - OVERLAP_CHARS;
offset += Math.max(breakPoint - OVERLAP_CHARS, 1);

Comment on lines 126 to 137
const remaining = db
.prepare(
`SELECT p.id FROM papers p
LEFT JOIN embedding_status es ON p.id = es.paper_id
WHERE es.paper_id IS NULL OR es.status = 'pending'`,
)
.all();
if (remaining.length > 0) {
indexAllPapers().catch((err) => {
console.warn('Follow-up indexing failed:', err);
});
}
Copy link

Choose a reason for hiding this comment

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

recursive call not awaited — can spawn infinite chain if papers keep arriving

Suggested change
const remaining = db
.prepare(
`SELECT p.id FROM papers p
LEFT JOIN embedding_status es ON p.id = es.paper_id
WHERE es.paper_id IS NULL OR es.status = 'pending'`,
)
.all();
if (remaining.length > 0) {
indexAllPapers().catch((err) => {
console.warn('Follow-up indexing failed:', err);
});
}
const remaining = db
.prepare(
`SELECT p.id FROM papers p
LEFT JOIN embedding_status es ON p.id = es.paper_id
WHERE es.paper_id IS NULL OR es.status = 'pending'`,
)
.all();
if (remaining.length > 0) {
await indexAllPapers();
}


export function shutdownEmbeddingService(): void {
if (child?.connected) {
child.send({ type: 'shutdown' });
Copy link

Choose a reason for hiding this comment

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

child may not exist if first send fails — guard before sending shutdown

Comment on lines +96 to +123
export function vectorSearch(db: Database.Database, queryEmbedding: Float32Array, limit: number): VectorSearchResult[] {
const rows = db
.prepare('SELECT id, paper_id, chunk_text, chunk_type, embedding FROM paper_chunks WHERE embedding IS NOT NULL')
.all() as {
id: string;
paper_id: string;
chunk_text: string;
chunk_type: string;
embedding: Buffer;
}[];

const scored = rows.map((row) => {
const embeddingArray = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, EMBEDDING_DIMS);
const similarity = cosineSimilarity(queryEmbedding, embeddingArray);
// Convert similarity to distance (lower = more similar) for compatibility
const distance = 1 - similarity;
return {
chunkId: row.id,
paperId: row.paper_id,
distance,
chunkText: row.chunk_text,
chunkType: row.chunk_type,
};
});

scored.sort((a, b) => a.distance - b.distance);
return scored.slice(0, limit);
}
Copy link

Choose a reason for hiding this comment

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

full table scan computing cosine similarity in-memory — will be slow with thousands of chunks (>100 papers with full text)

});

ipcMain.handle('indexing:reindexAll', () => {
indexAllPapers().catch((err) => {
Copy link

Choose a reason for hiding this comment

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

fire-and-forget pattern intentionally used here for background indexing — errors caught in .catch()

Comment on lines +24 to +35
require.cache['sharp-stub'] = {
id: 'sharp-stub',
filename: 'sharp-stub',
loaded: true,
exports: {},
children: [],
paths: [],
path: '',
parent: null,
isPreloading: false,
require: require,
} as unknown as NodeModule;
Copy link

Choose a reason for hiding this comment

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

stubbed module object missing some NodeModule properties — may break in future Node versions


const handleClick = useCallback(async () => {
setIsIndexing(true);
await window.electronAPI.reindexAllPapers();
Copy link

Choose a reason for hiding this comment

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

fire-and-forget pattern used here — indexing progress tracked via onIndexingProgress events

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

insertPaper(makePaper({ arxivId: '31', title: 'Needs Index', abstract: 'About something.' }));
const db = getDb();

setEmbeddingStatus(db, paper1.id, 'complete', undefined, 1);
Copy link

Choose a reason for hiding this comment

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

test sets status to complete with chunk_count: 1 but doesn't insert actual chunks — inconsistent with real behavior

Comment on lines +46 to +49
// Trigger batch indexer (handles progress events and sequential processing)
indexAllPapers().catch((err) => {
console.warn('Background indexing failed:', err);
});
Copy link

Choose a reason for hiding this comment

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

triggers on every save — indexAllPapers() guards against concurrent execution with indexingInProgress flag

dakl and others added 2 commits February 23, 2026 21:52
CI runs vitest without building first, and lacks native onnxruntime
binaries. Use describe.skipIf to gracefully skip these tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link

greptile-apps bot commented Feb 23, 2026

Greptile Summary

Implements semantic search by moving @huggingface/transformers embedding inference to a child process to avoid SIGTRAP crashes from onnxruntime-node conflicting with Electron's V8.

Key changes:

  • Child process isolation: embedding-worker.ts runs ONNX inference in a forked Node process with sharp module stubbing to prevent import crashes in packaged apps
  • Serial queue: embedding-service.ts manages a single worker with sequential embedding requests to prevent OOM from concurrent ONNX inference
  • Hybrid search: Combines FTS5 keyword search with vector similarity using Reciprocal Rank Fusion (RRF), deduplicating by paper (best chunk per paper)
  • Auto-indexing: Papers trigger batch indexing on save; follow-up check re-indexes papers added during the initial run
  • UI feedback: Per-paper embedding status badges (pending → indexing → complete/failed) with progress events
  • Pre-release support: Workflow now supports beta releases that won't trigger auto-updates for stable clients

Performance consideration: Vector search currently does full table scan with in-memory cosine similarity (line 96 in vector-store.ts). This will scale poorly beyond ~100 papers with full text. Consider adding a vector indexing strategy (e.g., HNSW, product quantization) or an external vector DB if the library grows large.

Test coverage: Added comprehensive tests for indexing state machine, event-driven UI updates, and worker lifecycle per CLAUDE.md requirements.

Confidence Score: 4/5

  • Safe to merge with one minor syntax fix and one configuration verification needed
  • The implementation is well-architected with proper error handling, comprehensive tests (282 passing), and follows the project's TDD requirements. The child process isolation correctly solves the SIGTRAP crash issue. One syntax bug needs fixing (shutdown guard), and the prerelease workflow flag should be verified. The vector search performance limitation is documented and acceptable for the beta release scope.
  • Check embedding-service.ts:262 for the shutdown guard syntax issue and verify the electron-builder prerelease flag syntax in release.yml:68

Important Files Changed

Filename Overview
src/main/services/embedding-worker.ts Standalone child process for ONNX embedding inference with sharp stubbing to avoid packaged app crashes
src/main/services/embedding-service.ts Child process manager with serial queue for embeddings, graceful error handling and reconnection logic
src/main/services/indexing-service.ts Batch indexer with proper sequential processing, error recovery, and follow-up check for papers added during indexing
src/main/services/chunker.ts Text chunking with overlap for embeddings, proper break detection at paragraph/sentence boundaries
src/main/db/vector-store.ts Vector storage and in-memory cosine similarity search - will scale poorly beyond ~100 papers with full text
src/main/db/hybrid-search.ts Reciprocal Rank Fusion combining FTS and vector search with proper deduplication by paper

Sequence Diagram

sequenceDiagram
    participant UI as Renderer
    participant Main as Main Process
    participant IndexSvc as IndexingService
    participant EmbedSvc as EmbeddingService
    participant Worker as Child Process
    participant DB as SQLite

    UI->>Main: savePaper()
    Main->>DB: insertPaper()
    Main->>IndexSvc: indexAllPapers() [fire-and-forget]
    
    IndexSvc->>DB: getPapersNeedingEmbedding()
    DB-->>IndexSvc: [paper1, paper2, ...]
    
    loop For each paper
        IndexSvc->>Main: emit INDEXING_PROGRESS (indexing)
        Main->>UI: send indexing-progress event
        
        IndexSvc->>EmbedSvc: embedDocumentTexts([chunks])
        EmbedSvc->>Worker: send embedDocuments
        
        alt First time
            Worker->>Worker: Load ONNX model
            Worker->>Main: progress events
            Main->>UI: send embedding-progress
        end
        
        Worker->>Worker: Run inference (batched)
        Worker-->>EmbedSvc: return embeddings
        EmbedSvc-->>IndexSvc: Float32Array[]
        
        IndexSvc->>DB: insertChunkWithEmbedding() [transaction]
        IndexSvc->>DB: setEmbeddingStatus('complete')
        
        IndexSvc->>Main: emit INDEXING_PROGRESS (indexed)
        Main->>UI: send indexing-progress event
    end
    
    IndexSvc->>Main: emit INDEXING_PROGRESS (complete)
    Main->>UI: send indexing-progress event
    
    UI->>Main: semanticSearch(query)
    Main->>EmbedSvc: embedQuery(query)
    EmbedSvc->>Worker: send embedQuery
    Worker-->>EmbedSvc: embedding
    Main->>DB: hybridSearch(query, embedding)
    DB->>DB: FTS + vectorSearch + RRF
    DB-->>Main: ranked results
    Main-->>UI: papers with scores
Loading

Last reviewed commit: 107ddbc

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

35 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +262 to +264
export function shutdownEmbeddingService(): void {
if (child?.connected) {
child.send({ type: 'shutdown' });
Copy link

Choose a reason for hiding this comment

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

check child?.connected before sending shutdown message

Suggested change
export function shutdownEmbeddingService(): void {
if (child?.connected) {
child.send({ type: 'shutdown' });
export function shutdownEmbeddingService(): void {
if (child?.connected) {
child.send({ type: 'shutdown' });

Comment on lines +68 to +73
run: |
if [ "${{ github.event.inputs.prerelease }}" = "true" ]; then
npx electron-builder --mac --arm64 --publish always -c.publish.releaseType=prerelease
else
npx electron-builder --mac --arm64 --publish always
fi
Copy link

Choose a reason for hiding this comment

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

verify -c.publish.releaseType=prerelease syntax with electron-builder docs - might need --config.publish.releaseType=prerelease instead

dakl and others added 2 commits February 24, 2026 09:04
Papers stuck in 'indexing' state (from worker crash mid-indexing) are
now reset to 'pending' on app startup so they get retried. Added
logging throughout the indexing and embedding pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

35 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

const candidates = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];

try {
const nodePath = execSync('which node', { encoding: 'utf-8' }).trim();
Copy link

Choose a reason for hiding this comment

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

which doesn't exist on Windows — use where on Windows or check process.platform first

Suggested change
const nodePath = execSync('which node', { encoding: 'utf-8' }).trim();
const command = process.platform === 'win32' ? 'where node' : 'which node';
const nodePath = execSync(command, { encoding: 'utf-8' }).trim();

Comment on lines +170 to +172
indexAllPapers().catch((err) => {
console.warn('[indexing-service] Follow-up indexing failed:', err);
});
Copy link

Choose a reason for hiding this comment

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

recursive call not awaited — can spawn multiple concurrent indexing chains if papers keep arriving, bypassing the indexingInProgress guard

Suggested change
indexAllPapers().catch((err) => {
console.warn('[indexing-service] Follow-up indexing failed:', err);
});
await indexAllPapers();

Comment on lines +96 to +122
export function vectorSearch(db: Database.Database, queryEmbedding: Float32Array, limit: number): VectorSearchResult[] {
const rows = db
.prepare('SELECT id, paper_id, chunk_text, chunk_type, embedding FROM paper_chunks WHERE embedding IS NOT NULL')
.all() as {
id: string;
paper_id: string;
chunk_text: string;
chunk_type: string;
embedding: Buffer;
}[];

const scored = rows.map((row) => {
const embeddingArray = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, EMBEDDING_DIMS);
const similarity = cosineSimilarity(queryEmbedding, embeddingArray);
// Convert similarity to distance (lower = more similar) for compatibility
const distance = 1 - similarity;
return {
chunkId: row.id,
paperId: row.paper_id,
distance,
chunkText: row.chunk_text,
chunkType: row.chunk_type,
};
});

scored.sort((a, b) => a.distance - b.distance);
return scored.slice(0, limit);
Copy link

Choose a reason for hiding this comment

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

loads all chunks into memory and computes cosine similarity in JS — will be slow with >100 papers with full text (thousands of chunks)

run: npx electron-builder --mac --arm64 --publish always
run: |
if [ "${{ github.event.inputs.prerelease }}" = "true" ]; then
npx electron-builder --mac --arm64 --publish always -c.publish.releaseType=prerelease
Copy link

Choose a reason for hiding this comment

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

verify this flag syntax works — electron-builder docs should confirm if -c.publish.releaseType=prerelease or --config.publish.releaseType=prerelease is correct

dakl and others added 3 commits February 25, 2026 21:28
* origin/main:
  Bump version to 1.2.1
  Replace modal dialog with notification-based MCP tool approval
  Bump version to 1.2.0
  Add drag-and-drop import, redesign toolbar, SF Symbol icons (#96)
  Bump version to 1.1.5
  Dynamic MCP tool settings without server restart (#95)
  Bump version to 1.1.4
  Use RELEASE_PAT in release workflow (#94)
  Fix broken sticky note icons in PDF viewer (#93)
  Bump version to 1.1.3
  Fix ReleaseNoteInfo import from correct package (#92)

# Conflicts:
#	package-lock.json
#	package.json
#	src/main/mcp/tools/search-tools.ts
…o feat-semantic-search

* 'feat-semantic-search' of github.com:dakl/papershelf:
  Bump version to 2.0.0-beta.3
Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

35 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

}

// Advance with overlap
offset += breakPoint - OVERLAP_CHARS;
Copy link

Choose a reason for hiding this comment

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

negative offset if breakPoint < OVERLAP_CHARS causes infinite loop

Suggested change
offset += breakPoint - OVERLAP_CHARS;
offset += Math.max(breakPoint - OVERLAP_CHARS, 1);

const candidates = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node'];

try {
const nodePath = execSync('which node', { encoding: 'utf-8' }).trim();
Copy link

Choose a reason for hiding this comment

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

which doesn't exist on Windows

Suggested change
const nodePath = execSync('which node', { encoding: 'utf-8' }).trim();
const nodePath = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8' }).trim();

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.

1 participant