Garnet attribute query optimization for inline filter#1727
Draft
Garnet attribute query optimization for inline filter#1727
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Garnet-Side: Attribute Storage Design for Inline Filtering (Current Change)
Existing Attribute Store
The existing Garnet attribute store was designed for general-purpose access — attributes are stored as raw JSON keyed by external (user-facing) ID. This is the natural choice for a key-value store: the user inserts a vector with key
"doc:42"and attributes{"year": 2021, "genre": "action"}, so the attributes are stored under that same key. This store serves RESP command operations (e.g.,VGETATTR) and remains unchanged.However, this store creates a mismatch with how DiskANN's graph traversal operates during inline filtering. DiskANN works entirely in internal ID space — every candidate is a
uint32internal ID. To evaluate a filter using only the existing store, the callback must:ExternalIdMap[internal_id]→ translate the internal ID to the external key (one Garnet store read)Attributes[external_key]→ fetch the raw JSON payload (second Garnet store read)ExtractFields()runs a JSON tokenizer to locate and parse the fields referenced by the filter expressionWith inline filtering, this callback runs on every candidate the graph traversal considers (potentially thousands per query). The two store reads and JSON parsing per candidate become the dominant cost on the hot path.
Solution: Add a second attribute store optimized for query-time filter evaluation
The current change adds a new attribute store alongside the existing one. The two stores serve different purposes:
VGETATTR,VSETATTR, etc.)The existing external ID keyed JSON store is untouched — it continues to serve all RESP command operations. The new internal ID keyed binary store is a write-time derived projection of the same data, optimized purely for the inline filter callback's access pattern.
Why key by internal ID
DiskANN hands the callback an internal ID; the existing attribute store expects an external key. Bridging this gap requires reading the
ExternalIdMap— a store read that exists purely because of the keying mismatch. By adding a store keyed by internal ID, the filter callback can look up attributes directly without any ID translation. This eliminates theExternalIdMapread entirely — one fewer store read per candidate.Why store in binary format
Raw JSON forces parsing on every candidate at query time. Extracting a numeric field like
.yearrequires scanning for the key, skipping whitespace, and parsing a number string into a double. This work is repeated identically for every candidate, every query. The JSON structure does not change between queries — this is wasted work.The binary store shifts the cost of JSON parsing from query time to ingestion time:
ConvertJsonToBinary(). The binary format is[0xFF marker][field count][per-field: name_len, name, type_tag, value_len, value_bytes], with numbers pre-converted to 8-byte LE f64. This is a one-time cost, written to the new store alongside the existing JSON store.ExtractFieldsBinary()performs a direct scan over length-prefixed fields. No JSON tokenizer. Field names compared as raw byte spans. Numbers read directly as f64 — no string parsing. ~10× faster than JSON extraction.Since each vector is inserted once but may be evaluated as a candidate across thousands of queries, this tradeoff — pay more at write, pay less at read — is the correct one for a read-heavy similarity search workload.
Per-candidate callback comparison
Summary of inline filter per-candidate cost
Further optimization: Co-locate attributes with vector data