Skip to content

feat(ai): make the keyword search index resident and incremental - #825

Open
E2ern1ty wants to merge 3 commits into
apache:masterfrom
E2ern1ty:feature/resident-keyword-index
Open

feat(ai): make the keyword search index resident and incremental#825
E2ern1ty wants to merge 3 commits into
apache:masterfrom
E2ern1ty:feature/resident-keyword-index

Conversation

@E2ern1ty

@E2ern1ty E2ern1ty commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

The global keyword search in geaflow-ai rebuilt a throw-away Lucene index from a full graph scan on every query, then discarded it. Index construction cost sat entirely on the query path, verbalization was redone for the whole graph per query, and multi-round sessions ran a global search whose result was thrown away.

This makes the index resident per graph and maintains it incrementally, following the standard inverted index model rather than invalidate-and-rebuild.

Retrieval

  • Add ResidentSearchIndex, built at most once and kept alive across queries, held by GraphMemoryServer per keyword index store.
  • Give each document a non-analyzed primary key (ModelUtils.getGraphEntityKey), so writes map to Lucene update/delete by term. Cost is proportional to the change, not to graph size, and both are idempotent, so callers need not supply an exact delta.
  • Split the server hook into onEntitiesUpserted / onEntitiesRemoved / onSchemaChanged. Only a schema change needs wholesale invalidation.
  • Replace the close()-as-flush pattern with a near-real-time reader opened from the writer. No commit(): the directory is in memory, so a commit point buys no durability.
  • Memoize entity verbalization in a ConcurrentHashMap, each entry stamped with the source version it was computed from, so a write invalidates only the entries it affects.
  • Let EmbeddingOperator enumerate what the index store holds instead of scanning the graph, resolving each entity so deleted leftovers are filtered out.
  • Move the discarded global search out of the multi-round session path.

Not serving stale results

A resident structure over a mutable graph needs to know when it went stale, including when the graph is mutated outside the server (for example directly through MemoryMutableGraph). MemoryGraph therefore maintains a version, split into a general one and a vertex-only one so that edge writes do not invalidate a vertex-only index.

Reading the current version after applying a batch is not sufficient: it accepts any unreported change as already applied, and the affected documents then go missing permanently. So the writer states the range and the index verifies it — VertexVersionWindow is opened before a batch of writes and sealed after them, and a batch is applied in place only when it is provably complete (sealed, starting exactly at the version the index last accepted, ending at the version the graph still reports). Anything else falls back to a rebuild. Accessors that cannot report a version degrade to the previous per-query rebuild behaviour.

Recall is unchanged. The document set, query string, analyzer and topN all stay the same, and equivalence against the rebuild path is asserted by tests. The rebuild path is kept as SessionOperator.searchWithGlobalGraphByRebuild for that purpose.

Measured (macOS arm64, 10000 vertices, topN = 30, ranges over 3 runs):

before after
per query, 10000 vertices 101.6 ~ 105.7 ms 0.38 ~ 0.60 ms (steady state)
per round, writes interleaved with queries, 5000 vertices 46.3 ~ 46.6 ms 1.36 ~ 1.40 ms
full index builds over 40 write+query rounds 41 1
MutableGraphTest 0.887 s 0.129 s

Latency also stops tracking graph size: 5000 -> 20000 vertices moves the rebuild path 45 -> 177 ms per query but the resident path only 0.31 -> 0.55 ms.

geaflow-ai/docs/feature-resident-keyword-index.md carries the full design, the reasoning against comparable systems (Lucene/ES, Milvus, hugegraph-ai), the complete change list, measurements and limitations.

Deliberately out of scope, all recorded in the doc:

  • KeywordRelationFunction.eval() builds a fresh index store and server per insert, making import O(V^2). It is the only path still rebuilding per O(V). Fixing it changes the ConsolidateFunction.eval contract and deserves its own change.
  • Vector retrieval still has no ANN. That needs Lucene 8.11.2 -> 9.8.0 and the JDK 11 constraint that comes with it, which is a project-level decision.
  • Building and writing still hold a write lock, so a cold build blocks queries. Removing that safely needs reference counting (SearcherManager), not just dropping the lock.
  • No cross-batch refresh batching; and the verbalization cache bound is approximate with non-LRU eviction, the price of keeping the read path lock-free.

How was this PR tested?

  • Tests have Added for the changes
  • Production environment verified

mvn -B -pl geaflow-ai -am clean install: all Reactor modules SUCCESS, 20 tests pass, Checkstyle 0 violations, Apache RAT Unapproved 0.

New tests, ResidentSearchIndexTest (13) and EmbeddingCandidateSetTest (1):

  • Recall equivalence against the per-query rebuild path, 10000 vertices and 10 queries, and separately with the verbalization cache on versus off. Compared as sets, since the rebuild path feeds Lucene from HashMap iteration and its tie-break order was never deterministic; match counts are kept under topN to avoid truncation sensitivity.
  • Insert / update / delete take effect in place: new content searchable, superseded documents no longer searchable, document count moves correctly, and buildCount does not change.
  • Upsert is idempotent: replaying the same write three times leaves one document.
  • A mutation that bypasses the hook forces a rebuild, and is still not lost when a later reported write arrives — the case that motivated VertexVersionWindow.
  • Edge writes invalidate neither the vertex index nor the memoized vertex verbalizations, while rewriting the vertex itself evicts exactly one entry.
  • 40 rounds of write-then-query: recall matches the invalidate-and-rebuild reference every round, with one build for the whole run.
  • The lock-free verbalization cache stays consistent under 8 threads x 50 rounds x 200 entities: identical content everywhere, hit + miss exactly equal to the call count, one entry per entity.

Existing GraphMemoryTest (LDBC data, strict content assertions), MemoryServerTest (HTTP end to end, 532-chunk import) and MutableGraphTest all pass unchanged, which is additional evidence recall did not move.

Not verified in a production environment.

E2ern1ty and others added 3 commits July 28, 2026 17:12
…lace

The global keyword search rebuilt a throw-away Lucene index from a full graph
scan on every query, so index construction cost was paid on the query path and
discarded afterwards. Verbalization was also redone for the whole graph per
query, and multi-round sessions ran a global search whose result was dropped.

Follow the standard inverted index maintenance model instead of
invalidate-and-rebuild:

- Add ResidentSearchIndex, a graph scoped index built at most once and kept
  alive across queries, held by GraphMemoryServer per keyword index store.
- Give each document a non analyzed primary key (ModelUtils.getGraphEntityKey)
  so writes map to Lucene update/delete by term. Cost is proportional to the
  change, not to graph size, and upserts are idempotent, so callers do not have
  to supply an exact delta.
- Split the server hook into onEntitiesUpserted / onEntitiesRemoved /
  onSchemaChanged; only a schema change needs wholesale invalidation.
- Track validity against a graph vertex version so mutations made outside the
  server (for example directly through MemoryMutableGraph) are detected and
  force a rebuild instead of serving stale results. Accessors that cannot
  report a version degrade to the previous per-query rebuild behaviour.
- Memoize entity verbalization in a bounded, version aware LRU cache.
- Replace the close()-as-flush pattern with a near real-time refresh
  (commit + openIfChanged), and make ensure+search atomic under one lock.
- Move the discarded global search out of the multi-round session path.
- Let EmbeddingOperator enumerate what the index store holds instead of
  scanning the graph, resolving each entity so deleted leftovers are filtered.

Recall is unchanged: the document set, query string, analyzer and topN all stay
the same, and equivalence against the rebuild path is asserted by tests. The
rebuild path is kept as SessionOperator.searchWithGlobalGraphByRebuild for that
purpose.

Measured on 10000 vertices: 98.4~119.5 ms per query -> 0.41~0.46 ms steady
state. On 5000 vertices with writes interleaved with queries: 44.5~47.1 ms per
round -> 1.60~1.81 ms, and full builds drop from 41 to 1. MutableGraphTest goes
from 0.887 s to 0.186 s.

See geaflow-ai/docs/feature-resident-keyword-index.md for the design, the
change list, full measurements and known limitations. The consolidate write
path still rebuilds all retrieval state per insert and is tracked there.
Review of the previous commit found the version guard does not actually
guard. It read the current vertex version after applying a write batch and
adopted it as "everything is applied", so any mutation made outside the
reporting path was silently accepted as already indexed. One later reported
write was enough to swallow it, and the missing document never came back.
Reproduced: build the index, add a vertex directly through
MemoryMutableGraph, then upsert an unrelated vertex through the server; the
first vertex becomes permanently unsearchable.

Make the writer state the range instead of the reader guessing it. Add
VertexVersionWindow, opened before a batch of writes and sealed after them.
A batch is applied in place only when the window proves it is complete:
sealed, starting exactly at the version the index last accepted, and ending
at the version the graph still reports. Anything else rebuilds. The window
opened by the writer between its own writes remains a blind spot, which now
needs writers to serialize rather than being papered over.

Also in this area:
- Register an edge schema against the edge version only. It cannot change
  how an existing vertex is verbalized, and bumping the vertex version made
  consolidate invalidate the index on its first insert for nothing.
- Move schema registration into MemoryGraph so bumpVersion can be private;
  advancing the version was a public operation any caller could trigger.

Concurrency and cost:
- Search under a read lock instead of one monitor covering build, write and
  search, so concurrent queries no longer serialize.
- Memoize verbalization in a ConcurrentHashMap with no lock on either path.
  The memoized function is pure and its value immutable, so exclusion is not
  needed for correctness; racing threads at worst duplicate work. This drops
  a double check that only existed to protect a cache wide version field and
  a remove that the following put already did. Cost: the bound is now
  approximate and eviction is not LRU. No throughput difference was
  measurable at 1 to 16 threads, the critical section is a single map get.
- Stamp each cache entry with its source version, vertex entries against the
  vertex version. Sharing one version discarded every memoized entry on any
  write, worst exactly where writes are frequent: consolidate issues about
  thirty edge writes per inserted entity.
- Drop IndexWriter#commit from refresh and open the reader from the writer.
  A commit point buys no durability on an in memory directory. Measured
  2.14~2.42 -> 1.72~1.86 ms per round on writes interleaved with queries.
- Drop the graph sized Set the index kept alongside Lucene. Delete by term
  is idempotent so it guarded nothing, and the document count can be read
  from Lucene. Also removes GraphSearchStore.entityNum, which had no reader
  and miscounted both upserts and deletes.
- Snapshot getIndexedEntities instead of returning a live key set view,
  which threw ConcurrentModificationException under concurrent writes.
- Build a fresh IndexWriterConfig per writer; Lucene rejects reuse, so a
  store that advertises a long life could not be reopened after close.
- Cache the schema label sets per search, guard residentIndexes with
  synchronizedMap, read the cache bound from Constants at use time so it is
  configurable, and translate the remaining Chinese Javadoc.

Corrects the design doc: it blamed the 1.7 ms write round on segment growth
slowing search and proposed a merge policy. 800 rounds bucketed say
otherwise, search cost falls from 0.75 to 0.21 ms while write plus refresh
dominates, so Lucene's default TieredMergePolicy already handles merging.
The open item is refresh batching, not a hand written merge policy.

Steady state read is 0.38~0.60 ms per query on 10000 vertices, writes
interleaved with queries 1.36~1.40 ms per round with one full build.
20 tests pass, three of them new: the swallowed mutation regression, edge
writes keeping memoized vertex verbalizations, and consistency of the lock
free cache under 8 threads.
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