Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/shell/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1802,7 +1802,11 @@ void app.whenReady().then(async () => {
if (rebuildToken !== ourToken) return; // superseded by a newer rebuild
if (!searchIndexer) return;
searchIndexer.rebuild(entities);
if (vectorIndexer) await vectorIndexer.rebuild(entities);
// 11.3 — the vector pass RECONCILES (embeds only changed entities)
// rather than re-embedding all N on every debounced write, which with
// the real model would peg a core. Lexical stays a full rebuild — FTS
// is cheap. First reconcile of a session embeds everything.
if (vectorIndexer) await vectorIndexer.reconcile(entities);
} catch (error) {
console.warn(`[brainstorm] search index rebuild failed: ${(error as Error).message}`);
}
Expand Down
8 changes: 8 additions & 0 deletions packages/shell/src/main/search/sqlite-vec-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ export class SqliteVecStore implements VectorStore {
return row.n;
}

snapshotIds(): Set<string> {
this.assertOpen();
const rows = this.db.prepare("SELECT entity_id FROM entity_vec_meta").all() as {
entity_id: string;
}[];
return new Set(rows.map((r) => r.entity_id));
}

clear(): void {
this.assertOpen();
const fn = this.db.transaction(() => {
Expand Down
73 changes: 72 additions & 1 deletion packages/shell/src/main/search/vector-indexer.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { EMBEDDING_DIM, StubEmbedder } from "./embedder";
import { EMBEDDING_DIM, StubEmbedder, type TextEmbedder } from "./embedder";
import type { IndexableEntity } from "./search-indexer";
import { VectorIndexer } from "./vector-indexer";
import { InMemoryVectorStore } from "./vector-store";
Expand Down Expand Up @@ -93,3 +93,74 @@ describe("VectorIndexer", () => {
await expect(ix.indexEntity(ent("a", "note", "x"))).rejects.toThrow(/disposed/);
});
});

class CountingEmbedder implements TextEmbedder {
readonly name = "counting";
readonly dim = EMBEDDING_DIM;
calls = 0;
readonly #inner = new StubEmbedder();
embed(text: string): Float32Array {
this.calls += 1;
return this.#inner.embed(text);
}
}

describe("VectorIndexer.reconcile (incremental, 11.3)", () => {
it("first reconcile embeds every entity", async () => {
const emb = new CountingEmbedder();
const ix = new VectorIndexer(new InMemoryVectorStore(EMBEDDING_DIM), emb);
await ix.reconcile([ent("a", "note", "alpha", "x"), ent("b", "note", "bravo", "y")]);
expect(ix.count()).toBe(2);
expect(emb.calls).toBe(2);
});

it("re-reconcile with UNCHANGED content embeds nothing (the per-write win)", async () => {
const emb = new CountingEmbedder();
const ix = new VectorIndexer(new InMemoryVectorStore(EMBEDDING_DIM), emb);
const entities = [ent("a", "note", "alpha", "x"), ent("b", "note", "bravo", "y")];
await ix.reconcile(entities);
emb.calls = 0;
await ix.reconcile(entities);
expect(emb.calls).toBe(0);
expect(ix.count()).toBe(2);
});

it("embeds ONLY the entity whose indexable content changed", async () => {
const emb = new CountingEmbedder();
const ix = new VectorIndexer(new InMemoryVectorStore(EMBEDDING_DIM), emb);
await ix.reconcile([ent("a", "note", "alpha", "x"), ent("b", "note", "bravo", "y")]);
emb.calls = 0;
await ix.reconcile([ent("a", "note", "alpha", "x"), ent("b", "note", "bravo", "CHANGED")]);
expect(emb.calls).toBe(1);
});

it("reaps a deleted entity", async () => {
const ix = make();
await ix.reconcile([ent("a", "note", "alpha", "x"), ent("b", "note", "bravo", "y")]);
await ix.reconcile([ent("a", "note", "alpha", "x")]);
expect(ix.count()).toBe(1);
});

it("reaps an entity that went blank (non-indexable)", async () => {
const ix = make();
await ix.reconcile([ent("a", "note", "alpha", "x")]);
expect(ix.count()).toBe(1);
await ix.reconcile([ent("a", "note", "", "")]);
expect(ix.count()).toBe(0);
});

it("first reconcile reaps a pre-existing store row not in the current set", async () => {
const store = new InMemoryVectorStore(EMBEDDING_DIM);
store.upsert({
entityId: "stale",
type: "note",
ownerAppId: "x",
updatedAt: 1,
embedding: new StubEmbedder().embed("stale from last session"),
});
const ix = new VectorIndexer(store, new StubEmbedder());
await ix.reconcile([ent("a", "note", "alpha", "x")]);
expect(store.snapshotIds().has("stale")).toBe(false);
expect(store.snapshotIds().has("a")).toBe(true);
});
});
63 changes: 62 additions & 1 deletion packages/shell/src/main/search/vector-indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
* swap needs no caller reshape.
*/

import { createHash } from "node:crypto";
import type { SqliteDatabase } from "../storage/sqlite";
import type { TextEmbedder } from "./embedder";
import { type IndexableEntity, isIndexable, pickIndexable } from "./search-indexer";
Expand All @@ -27,6 +28,12 @@ export class VectorIndexer {
private readonly store: VectorStore;
private readonly embedder: TextEmbedder;
private disposed = false;
/** entityId → sha256 of the embed text last embedded THIS session. Lets
* `reconcile` skip re-embedding an entity whose indexable content is
* unchanged. In-session only (a fresh indexer starts empty → one full
* re-embed on vault open, which also re-homes the cache under whatever
* embedder this instance holds, so a model swap re-embeds everything). */
private readonly embeddedHashes = new Map<string, string>();

constructor(store: VectorStore, embedder: TextEmbedder) {
if (embedder.dim !== store.dim) {
Expand Down Expand Up @@ -62,9 +69,13 @@ export class VectorIndexer {
this.store.remove(entityId);
}

/** Atomically rebuild the whole vector index from sources. */
/** Atomically rebuild the whole vector index from sources — embeds EVERY
* entity. Prefer `reconcile` on the write path; this is the from-scratch
* primitive (explicit reindex / tests). Seeds the content-hash cache so a
* following `reconcile` skips the just-embedded entities. */
async rebuild(entities: readonly IndexableEntity[], now: number = Date.now()): Promise<void> {
this.assertOpen();
this.embeddedHashes.clear();
const rows = [];
for (const e of pickIndexable(entities)) {
rows.push({
Expand All @@ -74,10 +85,52 @@ export class VectorIndexer {
updatedAt: now,
embedding: await this.embedder.embed(embedText(e)),
});
this.embeddedHashes.set(e.entityId, contentHash(e));
}
this.store.rebuild(rows);
}

/**
* Bring the vector index in line with `entities`, embedding ONLY the entities
* whose indexable content changed since this indexer last saw them, and
* reaping vectors for entities that vanished or went blank. The write-path
* alternative to `rebuild`: the search reindex fires (debounced) on every
* entity write, and with a real embedder re-embedding all N entities per
* write would peg a core — here a single-entity edit re-embeds one entity.
*
* First call in a session (empty cache) embeds everything, matching
* `rebuild`, and reaps any store rows (a prior session's) not in the current
* set. `embed()` failures propagate to the caller, which isolates the vector
* pass from the lexical rebuild (a stale entity is retried next reconcile).
*/
async reconcile(entities: readonly IndexableEntity[], now: number = Date.now()): Promise<void> {
this.assertOpen();
const keep = new Set<string>();
for (const entity of pickIndexable(entities)) {
keep.add(entity.entityId);
const hash = contentHash(entity);
if (this.embeddedHashes.get(entity.entityId) === hash) continue;
const embedding = await this.embedder.embed(embedText(entity));
this.store.upsert({
entityId: entity.entityId,
type: entity.type,
ownerAppId: entity.ownerAppId,
updatedAt: now,
embedding,
});
this.embeddedHashes.set(entity.entityId, hash);
}
// Reap anything the store or cache holds that's no longer indexable
// (deleted, or went blank so it fell out of `pickIndexable`).
const stale = this.store.snapshotIds();
for (const id of this.embeddedHashes.keys()) stale.add(id);
for (const id of stale) {
if (keep.has(id)) continue;
this.store.remove(id);
this.embeddedHashes.delete(id);
}
}

/** Nearest-neighbour search for `text`. Empty / token-less queries embed
* to a zero vector (no meaningful direction) → no hits, matching the
* lexical indexer's empty-query short-circuit. */
Expand Down Expand Up @@ -109,6 +162,14 @@ function embedText(entity: IndexableEntity): string {
return `${entity.title}\n${entity.body}`.trim();
}

/** Change-detection key for `reconcile`: a sha256 of the exact text that would
* be embedded. Same text ⇒ same vector, so an entity write that didn't touch
* the indexable content (a property change the index doesn't surface) skips a
* costly re-embed. sha256 (µs) is negligible next to an embed (ms). */
function contentHash(entity: IndexableEntity): string {
return createHash("sha256").update(embedText(entity)).digest("hex");
}

function isZero(v: Float32Array): boolean {
for (const x of v) {
if (x !== 0) return false;
Expand Down
9 changes: 9 additions & 0 deletions packages/shell/src/main/search/vector-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export interface VectorStore {
clear(): void;
/** Atomically replace the whole store with `rows`. */
rebuild(rows: Iterable<VectorRow>): void;
/** Every entity id currently in the store. Used by `VectorIndexer.reconcile`
* to reap vectors for entities that no longer exist (or went blank) without
* re-reading their embeddings. */
snapshotIds(): Set<string>;
dispose(): void;
}

Expand Down Expand Up @@ -161,6 +165,11 @@ export class InMemoryVectorStore implements VectorStore {
this.rows = next;
}

snapshotIds(): Set<string> {
this.assertOpen();
return new Set(this.rows.keys());
}

dispose(): void {
this.disposed = true;
this.rows.clear();
Expand Down
Loading