Skip to content

[ZEPPELIN-6412] Shard embedding search index persistence by note - #5535

Open
uommou wants to merge 1 commit into
apache:masterfrom
uommou:fix/ZEPPELIN-6412
Open

uommou wants to merge 1 commit into
apache:masterfrom
uommou:fix/ZEPPELIN-6412

Conversation

@uommou

@uommou uommou commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

What is this PR for?

EmbeddingSearch currently keeps all entries in a single embedding_index.bin file. Although paragraph embeddings are updated incrementally in memory, every flush serializes and rewrites the entire index.

This PR shards the persisted index by note. Updating a paragraph now rewrites only the shard belonging to the affected note, while semantic search continues to operate across the complete in-memory index.

What type of PR is it?

Bug Fix

What changes were made?

Per-note persistence

  • Store embedding index entries in per-note shard files under notes/
  • Track dirty state and coordinate writes per note
  • Atomically replace individual shard files
  • Preserve notebook-wide semantic search behavior

Shard lifecycle and recovery

  • Delete a shard when its note is removed
  • Clean up orphan shards during startup
  • Rebuild only the affected note when a shard is missing or corrupt
  • Prevent concurrent flushes from recreating a deleted note shard

Legacy index migration

  • Migrate the legacy embedding_index.bin using an all-or-nothing staging directory
  • Keep the legacy index available for retry if migration fails

Tests and documentation

  • Add tests for sharding, migration, recovery, and concurrent updates
  • Update the embedding search documentation to describe per-note persistence

Todos

  • Implement per-note index persistence
  • Handle shard migration and recovery
  • Add unit tests
  • Update documentation

What is the Jira issue?

How should this be tested?

./mvnw test -pl zeppelin-server --am \
  -Dtest=EmbeddingSearchShardingTest \
  -Dsurefire.failIfNoSpecifiedTests=false

EmbeddingSearchShardingTest contains 20 tests covering:

  • Isolation between note shards
  • Missing and corrupt shard recovery
  • Orphan shard cleanup
  • Legacy index migration and migration failure recovery
  • Concurrent note deletion and flushing
  • Prevention of partially loaded entries from truncated shards

All 20 tests pass.

Screenshots (if appropriate)

Not applicable.

Questions:

  • Does the license files need to update? No.
  • Is there breaking changes for older versions? No API or configuration changes. Older versions cannot read the new shard format, but the embedding index is rebuildable.
  • Does this needs documentation? Yes. docs/embedding-search.md has been updated to describe per-note persistence.

Persist embedding search entries in per-note shard files so that updating a paragraph rewrites only the affected note instead of the entire index.

Add note-level dirty tracking and locking, atomic shard writes, targeted recovery for missing or corrupt shards, orphan cleanup, and migration from the legacy single-file index.

Add tests covering shard isolation, migration, recovery, deletion races, and partial-load failures.

@tbonelee tbonelee 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.

It would be good to keep comments only where they explain a "why" the code can't express. For example, the reason query() doesn't take a lock (EmbeddingSearch.java L577-581) is worth keeping. The following look like they could be removed or replaced with code:

  • Ticket numbers and change history: EmbeddingSearch.java L146, L962, L1169, and EmbeddingSearchShardingTest.java L173, L363-364, L701-702. The history already lives in the commit message and JIRA.
  • Javadoc that argues design decisions at length: EmbeddingSearch.java L785-791, L961-971, L1176-1185. The last one also doesn't match the current behavior, where loadAllShards rebuilds notes with missing shards.
  • shouldBootstrapIndex (L842-854): ten lines of javadoc for a one-line body, zConf.isIndexRebuild(), which could be inlined at the call site.

Comment on lines +976 to +978
List<Map.Entry<String, IndexEntry>> entries = index.entrySet().stream()
.filter(e -> e.getKey().equals(noteId) || e.getKey().startsWith(noteId + "/"))
.collect(Collectors.toList());

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.

saveNoteShard, commitNoteEntries (L1160) and writeShardToDir (L1262) each scan the whole index with startsWith(noteId + "/") for every note, so handling many notes at once costs (number of notes × total entries).

Measured locally without the model (skipModel), with 2,000 notes × 25 paragraphs (50K entries): a full flush went from 270ms to 5.1s, and loading shards on startup from 133ms to 1.3s. With 4,000 notes they were 29s and 5.2s. Load was measured with a BufferedInputStream to leave out file read cost. Full bootstrap, zeppelin.search.index.rebuild and migration all go through this path.

If the index were kept per note, e.g. Map<noteId, Map<docId, IndexEntry>>, all three could look only at that note's map, and the prefix scans in deleteNoteIndex and updateNoteIndex would go away as well. What do you think?

* left in place (so the next restart retries from scratch; shard writes are keyed by
* noteId and idempotent, so repeating this is safe) and the staging directory is cleared.
*/
private void migrateLegacyIndexIfPresent() {

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.

#5218 (ZEPPELIN-6411) isn't in any release yet, and semantic search is opt-in, requiring its own config and a model install. So only users who turned it on with a master snapshot have an embedding_index.bin. Since the index can be rebuilt from the notebooks, would it be enough to delete the legacy file and run a full bootstrap? That would let us drop loadLegacyIndex, writeShardToDir, the staging directory handling and the related tests.

Even if we keep the migration, I don't think the staging directory is needed. If migration fails partway and only some notes' shards are left, that is the same as a few shards having gone missing, which loadAllShards (L1084-1090) already handles by reindexing only the notes without a shard. I changed the migration to write directly into notes/ and made it fail on one note, and both the migrated note and the rebuilt note were searchable.

This branch has not been deployed

No deployments
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