diff --git a/.fluree-memory/repo.ttl b/.fluree-memory/repo.ttl index 5077405a0f..60c3e7b070 100644 --- a/.fluree-memory/repo.ttl +++ b/.fluree-memory/repo.ttl @@ -906,7 +906,7 @@ mem:fact-01kn800ps56as4xe2kycr10yq1 a mem:Fact ; mem:createdAt "2026-04-02T21:01:33.861145+00:00"^^xsd:dateTime . mem:fact-01knjakv2d3t3y7av973g38tm7 a mem:Fact ; - mem:content "Incremental indexing has two notable gotchas in `fluree-db-indexer`: 1) when incremental fulltext refresh cannot fetch/decode the prior fulltext arena, `build/incremental.rs` starts from `FulltextArena::new()` and then writes a replacement arena built from novelty only, which can drop historical `fulltext()` scoring data for that `(g_id,p_id)`; 2) incremental schema refresh carries forward the base schema if base-store load fails, which leaves `snapshot.schema` stale for reasoning until a later successful refresh/rebuild. Also, `IndexerConfig.incremental_max_commit_bytes` defaults to `None`, and only the embedded orchestrator populates it from `reindex_max_bytes`." ; + mem:content "Incremental indexing gotchas in `fluree-db-indexer`: 1) fulltext prior-arena fetch/decode failure now HARD-FAILS the incremental build (incremental.rs ~1482; recovery contract = build_index_for_ledger falls back to full rebuild) — the old silent start-from-empty fallback that dropped historical fulltext scoring is FIXED; 2) incremental schema refresh carries forward the base schema if base-store load fails, which leaves `snapshot.schema` stale for reasoning until a later successful refresh/rebuild. Also, `IndexerConfig.incremental_max_commit_bytes` defaults to `None`, and only the embedded orchestrator populates it from `reindex_max_bytes`." ; mem:tag "fulltext" ; mem:tag "incremental-indexing" ; mem:tag "indexer" ; @@ -1662,6 +1662,84 @@ mem:fact-01ktyzzbs4ewxqpgvssvj72tt5 a mem:Fact ; mem:createdAt "2026-06-12T22:41:50.884413+00:00"^^xsd:dateTime ; mem:rationale "Continuation point for the decimal fix series; the remaining priority-4 cluster (canonical identity) is the prerequisite for issues #1324/#1325." . +mem:decision-01kwphem9r3gvcvj2j5842ddt6 a mem:Decision ; + mem:content "Fulltext positioning: f:fullTextDefaults (#config graph) is the RECOMMENDED path — values keep standard xsd:string/rdf:langString datatypes (external RDF consumers see ordinary literals) and language-tagged values get per-language analyzers. The @fulltext datatype REPLACES the stored datatype with Fluree-specific f:fullText, so docs/guidance must not push it as the default; it's a quick-start for siloed databases or properties orthogonal to the core data model. Docs repositioned 2026-07 (fulltext.md, indexing-and-search README, cookbook-search, concepts/datatypes)." ; + mem:tag "datatypes" ; + mem:tag "docs" ; + mem:tag "fulltext" ; + mem:tag "positioning" ; + mem:tag "rdf" ; + mem:scope mem:repo ; + mem:artifactRef "docs/concepts/datatypes.md" ; + mem:artifactRef "docs/guides/cookbook-search.md" ; + mem:artifactRef "docs/indexing-and-search/fulltext.md" ; + mem:branch "fix/fulltext-persisted-arena-lookup" ; + mem:createdAt "2026-07-04T12:25:30.680597+00:00"^^xsd:dateTime ; + mem:rationale "User-stated product intent (bplatz): don't steer standards-focused users to a custom datatype; @fulltext is only for silos/orthogonal properties. Future docs/examples/tutorials should default to the config path." . + +mem:fact-01kwn1qw4yhk6fy47xhmc9d7dm a mem:Fact ; + mem:content "fulltext() must handle BOTH binding shapes: BinaryScanOperator disables late materialization whenever overlay epoch != 0 (binary_scan.rs), so with ANY novelty every scanned value reaches eval_fulltext as Binding::Lit (p_id + lang in dtc), not EncodedLit. The Lit arm originally scored only @fulltext-datatype values → configured (f:fullTextDefaults) predicates returned unbound for EVERY row the moment one unindexed commit existed (Solo/S3 2026-07-03 bug; memory-mode tests passed because they queried exactly at index_t with empty novelty). Fixed on fix/fulltext-persisted-arena-lookup: Lit arm mirrors EncodedLit arena lookup. Persisted (file-backed, fresh-process) reproducers now in it_query_fulltext.rs." ; + mem:tag "binding" ; + mem:tag "bm25" ; + mem:tag "fulltext" ; + mem:tag "materialization" ; + mem:tag "novelty" ; + mem:tag "query" ; + mem:scope mem:repo ; + mem:artifactRef "fluree-db-api/tests/it_query_fulltext.rs" ; + mem:artifactRef "fluree-db-query/src/binary_scan.rs" ; + mem:artifactRef "fluree-db-query/src/eval/fulltext.rs" ; + mem:branch "fix/fulltext-persisted-arena-lookup" ; + mem:createdAt "2026-07-03T22:31:41.982333+00:00"^^xsd:dateTime ; + mem:rationale "Any future change to materialization gates or fulltext eval must keep both arms (EncodedLit and Lit) scoring configured predicates, or the field regression recurs invisibly (memory tests won't catch it)." . + +mem:fact-01kwn4gk9gz7vp31r6g9awth8g a mem:Fact ; + mem:content "fulltext() per-row eval is hot: PreparedFulltextQuery (eval/fulltext.rs) caches bucket analyzer choice, query stems, arena term_ids, IDFs, and effective stats per (store_id, epoch, to_t, g_id, p_id, lang_id, query) — moka + thread-local one-slot memo; per-row = doc_bow probe + one u32 binary search per term (50k rows: 1.45s→61ms, 24x). store_id in the key pins term_ids to the arena instance. Analyzer::shared(lang) is the per-language static (for_language CLONES the whole stopword set — never call per row). Materialized Lit rows resolve string_id→BoW instead of re-analyzing text. Next lever if still slow: posting-driven retrieval/WAND pushdown (graph-source BM25 already has WAND)." ; + mem:tag "bm25" ; + mem:tag "cache" ; + mem:tag "fulltext" ; + mem:tag "performance" ; + mem:tag "query" ; + mem:scope mem:repo ; + mem:artifactRef "fluree-db-api/tests/it_query_fulltext.rs" ; + mem:artifactRef "fluree-db-binary-index/src/analyzer.rs" ; + mem:artifactRef "fluree-db-query/src/eval/fulltext.rs" ; + mem:branch "fix/fulltext-persisted-arena-lookup" ; + mem:createdAt "2026-07-03T23:20:09.264477+00:00"^^xsd:dateTime ; + mem:rationale "Any future work on fulltext eval or analyzers must preserve the once-per-query invariant; the ignored fulltext_perf_50k_labels test is the regression harness." . + +mem:fact-01kwncjr1vh93xbsx3q4213w54 a mem:Fact ; + mem:content "Flat vector scoring: eval resolves args to borrowed slices — f64 from Lit/const, and indexed EncodedLit VECTOR_ID via BinaryIndexStore::vector_slice (zero-copy f32 shard slice, widened into thread-local scratch; decode fallback for ephemeral novelty handles) — and routes through vector_math SIMD kernels. FlakeValue::Vector is Arc<[f64]> (O(1) clones). KEY MEASUREMENT (50k x 256): indexed pipeline = 21ms vs novelty-resident = 137ms for the SAME query (6.5x) — keep vector corpora indexed; novelty-path floor is per-row operator machinery, not scoring/copies. Zero-copy shard read saved ~22% on indexed warm (27→21ms). Next step-change = top-k fast path over packed f32 shards (~4-8x more, exact, consistent) or HNSW routing. Harness: vector_flatrank_perf_50k (ignored, scalar-verified, novelty + indexed phases)." ; + mem:tag "flatrank" ; + mem:tag "performance" ; + mem:tag "query" ; + mem:tag "simd" ; + mem:tag "vector" ; + mem:scope mem:repo ; + mem:artifactRef "fluree-db-api/tests/it_vector_flatrank.rs" ; + mem:artifactRef "fluree-db-query/src/eval/vector.rs" ; + mem:artifactRef "fluree-db-query/src/eval/vector_math.rs" ; + mem:branch "fix/fulltext-persisted-arena-lookup" ; + mem:createdAt "2026-07-04T01:41:08.283260+00:00"^^xsd:dateTime ; + mem:rationale "Prevents re-optimizing eval when the flat-rank bottleneck is scan-side vector copying; documents that ComparableValue conversion of vectors is a full copy." . + +mem:fact-01kwnn592htcq4hcpjxtj1p69a a mem:Fact ; + mem:content "fast_vector_topk.rs: flat-rank top-k fast path — bind ?score dotProduct(?vec, const/1-row-VALUES) [+threshold/orderBy desc/limit, project {?s,?score}] runs as a direct shard scan: VectorArenaSnapshot pins shards once (per-row lookup_vector cache probe was the bottleneck capping gains at 2x), overlay-merging cursor rows, leaf/LEAFLET-refined subject partitions on rayon, ephemeral novelty vectors pre-decoded before the parallel section (ctx not Sync). Scores bit-identical (widen f32→f64, same dot kernel). 50k×256 indexed: 21ms→3.3ms (6.4x). GOTCHA: query VALUES vectors are NOT f32-quantized (only storage is) — ground truth = dot(quantized_row, raw_target)." ; + mem:tag "fast-path" ; + mem:tag "flatrank" ; + mem:tag "performance" ; + mem:tag "query" ; + mem:tag "topk" ; + mem:tag "vector" ; + mem:scope mem:repo ; + mem:artifactRef "fluree-db-api/tests/it_vector_flatrank.rs" ; + mem:artifactRef "fluree-db-binary-index/src/arena/vector.rs" ; + mem:artifactRef "fluree-db-query/src/execute/operator_tree.rs" ; + mem:artifactRef "fluree-db-query/src/fast_vector_topk.rs" ; + mem:branch "fix/fulltext-persisted-arena-lookup" ; + mem:createdAt "2026-07-04T04:11:04.145477+00:00"^^xsd:dateTime ; + mem:rationale "Extending the fast path (cosine/euclidean, hydration join, larger projections) starts here; snapshot + partition + overlay idioms are the reusable pieces. Parity harness: vector_topk_fast_path_parity." . + mem:fact-01kta2t986nsjneban0hgbwzq7 a mem:Fact ; mem:content "RDF term/fact identity in Fluree is (s, p, o, dt, m) where m=FlakeMeta{lang, i} — matching Flake::eq/hash. Any dedup/stale-removal key that omits m collapses distinct facts: langString variants sharing a value (\"animal\"@en vs @fr), and @list members repeating a value at different indices. Issue #1273 was range::remove_stale_flakes (overlay-only/genesis read path) keying on (s,p,o,dt) only; the binary-scan stale-removal (binary_scan.rs FactKeyRef) already included m. Including m also correctly scopes retraction cancellation per variant." ; mem:tag "dedup" ; diff --git a/docs/concepts/datatypes.md b/docs/concepts/datatypes.md index 1dccd9c253..2848394cf5 100644 --- a/docs/concepts/datatypes.md +++ b/docs/concepts/datatypes.md @@ -264,6 +264,14 @@ See [Vector Search](../indexing-and-search/vector-search.md) for complete docume **@fulltext** (full IRI: `https://ns.flur.ee/db#fullText`, prefix form: `f:fullText`) marks a string value for full-text search indexing. Values annotated with `@fulltext` are automatically analyzed (tokenized, stemmed, stopword-filtered) and indexed into per-predicate fulltext arenas during background index builds. This enables BM25-ranked relevance scoring via the `fulltext()` query function. +Because the annotation *is* the stored datatype, tagged values are +`f:fullText` literals rather than `xsd:string` — a custom datatype that +external RDF consumers won't recognize. For standards-conformant data, +prefer declaring searchable properties via `f:fullTextDefaults` in the +ledger's `#config` graph, which full-text indexes plain `xsd:string` / +`rdf:langString` values (with per-language analysis) without changing +their datatypes. + Without this type annotation, strings are stored as plain `xsd:string` values and support only exact matching and prefix queries -- not relevance-ranked full-text search. See [Inline Fulltext Search](../indexing-and-search/fulltext.md) for complete documentation. diff --git a/docs/guides/cookbook-search.md b/docs/guides/cookbook-search.md index a39d7aee15..ad5402d5f9 100644 --- a/docs/guides/cookbook-search.md +++ b/docs/guides/cookbook-search.md @@ -8,6 +8,15 @@ This guide covers practical patterns for both approaches. ### 1. Insert searchable data +There are two ways to make string values searchable. The examples below use +the `@fulltext` datatype annotation — the zero-config path, handy for a +self-contained cookbook. Note that it stores values under the Fluree-specific +`f:fullText` datatype instead of `xsd:string`; if you're building +standards-conformant RDF (or want to search values already in your ledger, +or need non-English analysis), declare the properties once with +[`f:fullTextDefaults` in the ledger's `#config` graph](../indexing-and-search/fulltext.md#configured-full-text-properties-ffulltextdefaults) +instead — the queries in this guide work identically with either setup. + Annotate string values with `@fulltext` to make them searchable: ```bash diff --git a/docs/indexing-and-search/README.md b/docs/indexing-and-search/README.md index 166a2ad52b..ce2af900c1 100644 --- a/docs/indexing-and-search/README.md +++ b/docs/indexing-and-search/README.md @@ -25,8 +25,8 @@ Manual index rebuilding for recovery and maintenance: ### [Inline Fulltext Search](fulltext.md) Inline BM25-ranked text scoring. Two entry points, same query surface: -- **`@fulltext` datatype** — per-value annotation (analogous to `@vector`), always English, zero config -- **`f:fullTextDefaults` config** — declare properties + language once at the ledger level; supports 18 languages with Snowball stemming and per-graph overrides for multilingual setups +- **`f:fullTextDefaults` config** (recommended) — declare properties + language once in the ledger's `#config` graph; values keep their standard `xsd:string` / `rdf:langString` datatypes, with 18 languages of Snowball stemming and per-graph overrides for multilingual setups +- **`@fulltext` datatype** — per-value annotation (analogous to `@vector`), always English, zero config; stores values under the custom `f:fullText` datatype, so best suited to siloed databases or properties orthogonal to your core RDF model - `fulltext(?var, "query")` scoring function in `bind` expressions (same for both paths) - Automatic per-(graph, property, language) fulltext arena construction during background indexing - Unified scoring across indexed and novelty documents @@ -277,8 +277,8 @@ let result = fluree.drop_full_text_index("products-search:main").await?; ### Vector Search -- **Flat scan (inline functions)**: O(n) brute-force, viable up to ~100K vectors with binary indexing; binary index provides ~6x speedup over novelty-only scans and ~25x for filtered queries -- **HNSW index**: O(log n) approximate nearest neighbor, recommended for 100K+ vectors or strict latency requirements +- **Flat scan (inline functions)**: O(n) brute-force, exact and strongly consistent (novelty included). Queries matching the canonical top-k signature (`dotProduct` + threshold/order/limit over one vector predicate) run a parallel shard-scan fast path at ~6M vectors/sec (768-dim) — interactive to ~1M vectors; general-shape queries pay pipeline costs per row and stay interactive to ~100K +- **HNSW index**: O(log n) approximate nearest neighbor, eventually consistent; recommended for multi-million-vector corpora, strict latency targets at any size, or query shapes the fast path doesn't serve - **Space**: ~1.5x embedding size - **Updates**: Incremental, O(1) per vector - See [Vector Search -- Performance and Scaling](vector-search.md#performance-and-scaling) for benchmark data and guidance on when to adopt HNSW @@ -449,7 +449,7 @@ The `Bm25MaintenanceWorker` watches for source ledger commits and syncs indexes ### 1. Choose Appropriate Index Type - **Structured queries**: Use core graph indexes -- **Keyword search (< 500K docs)**: Use inline `@fulltext` for zero-config BM25 scoring +- **Keyword search (< 500K docs)**: Use inline fulltext (`f:fullTextDefaults` config; or the `@fulltext` datatype for siloed data) for BM25 scoring - **Keyword search (1M+ docs)**: Use the BM25 graph source for WAND-optimized top-k retrieval - **Semantic similarity**: Use vector search - **Hybrid**: Combine multiple indexes @@ -512,7 +512,7 @@ Limit results for performance: ## Related Documentation - [Background Indexing](background-indexing.md) - Core index details -- [Inline Fulltext Search](fulltext.md) - `@fulltext` datatype and `fulltext()` scoring +- [Inline Fulltext Search](fulltext.md) - `f:fullTextDefaults` config, `@fulltext` datatype, and `fulltext()` scoring - [BM25](bm25.md) - Dedicated full-text search graph source - [Vector Search](vector-search.md) - Similarity search - [Graph Sources](../graph-sources/README.md) - Graph source concepts diff --git a/docs/indexing-and-search/fulltext.md b/docs/indexing-and-search/fulltext.md index 1fb4479485..0245aa343e 100644 --- a/docs/indexing-and-search/fulltext.md +++ b/docs/indexing-and-search/fulltext.md @@ -4,8 +4,8 @@ Inline fulltext search enables BM25-ranked text scoring directly in queries, usi Two ways to enable fulltext scoring on a property: -- **Per-value annotation** (`@fulltext` datatype) — zero-config, always English. Tag individual literal values at insert time. Good for a handful of obviously-fulltext fields where English is fine. -- **Property-level configuration** (`f:fullTextDefaults`) — declare once in the ledger's config graph which properties should be full-text indexed, and optionally which language to analyze them in. Plain-string values on those properties get indexed automatically — no `@type` annotation needed at insert time. Required when you want non-English stemming/stopwords, or when you want every value of a property indexed by default. +- **Property-level configuration** (`f:fullTextDefaults`) — **the recommended approach.** Declare once in the ledger's `#config` graph which properties should be full-text indexed, and optionally which language to analyze them in. Your data keeps its standard datatypes — `xsd:string` and `rdf:langString` values are indexed as-is, with language-tagged values routed to per-language analyzers automatically. Nothing about the stored RDF changes. +- **Per-value annotation** (`@fulltext` datatype) — a zero-config shortcut, always English. Tagging a value `@fulltext` **replaces its datatype** with the Fluree-specific `f:fullText` — consumers of your data see a custom datatype instead of `xsd:string`. Good for a quick start, a siloed database where RDF interoperability isn't a concern, or a property that's orthogonal to your core data model (e.g. an internal search blob). If you're building standards-conformant RDF, use `f:fullTextDefaults` instead. Both paths produce the same on-disk BM25 arenas and are queried with the same `fulltext(?var, "query")` function. @@ -24,6 +24,15 @@ Plain strings in Fluree are stored as `xsd:string` values. They are indexed for `@fulltext` is a JSON-LD shorthand that resolves to the full IRI `https://ns.flur.ee/db#fullText`, which can also be written as `f:fullText` when the Fluree namespace prefix is declared in your `@context`. +**The trade-off:** the value is *stored* with that datatype. Where a plain +literal would be `"Rust programming"^^xsd:string`, a tagged one is +`"Rust programming"^^f:fullText` — in query results, exports, and any +downstream RDF consumer. If your data needs to stay standard RDF (shared +vocabularies, external consumers, SPARQL federation), prefer +[`f:fullTextDefaults`](#configured-full-text-properties-ffulltextdefaults), +which indexes plain `xsd:string` / `rdf:langString` values without touching +their datatypes. + ### Inserting fulltext values (JSON-LD) Use `"@type": "@fulltext"` to annotate a string as fulltext-searchable: @@ -127,6 +136,12 @@ Each property produces an independent fulltext index (arena). When you query wit `@fulltext` annotations are fully portable across Fluree's data distribution pipeline. Import, export, push, and pull all preserve `@fulltext` type annotations, and indexes are rebuilt transparently on the receiving side. +Portability *outside* Fluree is where the custom datatype shows: an export +hands non-Fluree consumers literals typed `f:fullText` rather than +`xsd:string`. Tools that dispatch on standard datatypes (validation, mapping, +federation) will treat those values as opaque typed literals. `f:fullTextDefaults` +avoids this entirely — the searchable values stay ordinary strings. + ## Configured Full-Text Properties (`f:fullTextDefaults`) The `@fulltext` datatype is a per-value shortcut — you decide at insert time, one triple at a time, whether a string gets full-text indexed, and English is the only supported language. For many real-world workloads that's not what you want. You want to say once, at the ledger level, "index every value of `ex:title`", or "index `ex:productName` in the product catalog graph in Spanish." That's what `f:fullTextDefaults` gives you. @@ -137,13 +152,21 @@ The `@fulltext` datatype continues to work exactly as before: any value tagged ` ### When to use which +Default to `f:fullTextDefaults`: it searches your data as it already is — +standard `xsd:string` / `rdf:langString` datatypes, existing values, no +insert-time annotation — and it's the only path with language-aware analysis. +Reach for the `@fulltext` datatype when the custom datatype doesn't matter: +a siloed database, a prototype, or a property orthogonal to your core model. + | Need | Use | |------|-----| -| English-only, a few obviously-fulltext fields, want the choice per-value | `@fulltext` datatype | +| Standards-conformant RDF — values keep `xsd:string` / `rdf:langString` | `f:fullTextDefaults` | +| Search over data that's already in the ledger (no value rewriting) | `f:fullTextDefaults` | | Non-English (or mixed languages) | `f:fullTextDefaults` with `f:defaultLanguage` | | Every value of a property should be searchable, no per-value opt-in | `f:fullTextDefaults` | | Different languages per graph (e.g. multilingual catalog) | `f:fullTextDefaults` with per-graph overrides | -| Zero config, just works | `@fulltext` datatype | +| Siloed / single-app database, quick start, per-value opt-in is a feature | `@fulltext` datatype | +| Property is orthogonal to the core data model (search-only text blob) | `@fulltext` datatype | ### Setting it up @@ -555,6 +578,15 @@ The indexed path is 7-7.5x faster because it reads precomputed BoW entries via b Scaling is near-linear. Extrapolating, the indexed path handles approximately 625K documents within a 1-second query budget. +**The numbers hold with unindexed commits present.** Scoring uses prepared +per-query state (analyzer choice, query stems, term ids, IDF weights, and +corpus stats are computed once per query, not per row), and materialized rows +— the norm whenever a ledger carries any novelty — resolve their `string_id` +and score from the precomputed arena BoW rather than re-analyzing document +text. A ledger that is a few commits ahead of its index scores at the same +~1-2 µs/row as one that is exactly caught up; only genuinely unindexed +documents pay per-row text analysis, proportional to the novelty footprint. + ### When to consider the BM25 graph source pipeline Inline `@fulltext` works well for **tens to hundreds of thousands of documents** per predicate. For larger corpora (1M+ documents), consider the dedicated [BM25 graph source pipeline](bm25.md), which provides: @@ -570,8 +602,15 @@ Inline `@fulltext` works well for **tens to hundreds of thousands of documents** |-------------|----------------| | < 100K docs | Inline `@fulltext` works well, especially with binary indexing | | 100K - 500K | Inline `@fulltext` remains viable; query times scale linearly | -| 500K - 1M | Evaluate based on latency requirements; WAND pruning may help | -| 1M+ | Use the [BM25 graph source](bm25.md) for production workloads | +| 500K - 1M | Evaluate against your latency budget: inline scores every document (~1-2 µs/doc — no top-k pruning), so ~1M docs ≈ 1-2 s per query | +| 1M+ | Use the [BM25 graph source](bm25.md) for production workloads — WAND top-k pruning makes query cost sub-linear in corpus size | + +Note the consistency trade: inline `@fulltext` is strongly consistent — a +document is searchable the moment its transaction commits, including before +the next index build. The BM25 graph source is eventually consistent (it lags +the source ledger until its pipeline refreshes). Workloads that need +read-your-writes search should weigh that against the graph source's speed at +large corpus sizes. ## Comparison with `@vector` diff --git a/docs/indexing-and-search/vector-search.md b/docs/indexing-and-search/vector-search.md index 88953651c1..3e874736bc 100644 --- a/docs/indexing-and-search/vector-search.md +++ b/docs/indexing-and-search/vector-search.md @@ -642,38 +642,92 @@ Key takeaways: - **Filtered queries** (where graph patterns reduce the candidate set before scoring) are ~25x faster -- the index enables efficient predicate-first access that avoids loading irrelevant vectors entirely - At 5,000 vectors, a filtered indexed query completes in **2.4 ms** -- well within interactive latency budgets +### The flat-rank top-k fast path + +Queries matching the **canonical similarity signature** skip the generic +operator pipeline entirely and execute as a direct parallel scan of the packed +f32 vector shards, with a bounded top-k heap: + +```json +{ + "select": ["?s", "?score"], + "values": [["?target"], [{"@value": [/* query vector */], "@type": "https://ns.flur.ee/db#embeddingVector"}]], + "where": [ + {"@id": "?s", "ex:embedding": "?vec"}, + ["bind", "?score", ["dotProduct", "?vec", "?target"]], + ["filter", "(> ?score 0.5)"] + ], + "orderBy": [["desc", "?score"]], + "limit": 10 +} +``` + +The signature: a single triple pattern over one vector predicate, a `bind` of +`dotProduct(?vec, )`, an optional score +threshold filter, optional `ORDER BY DESC(?score)`, optional `LIMIT`/`OFFSET`, +projecting only `?s` and/or `?score`. Scores are bit-identical to the general +pipeline, results are exact (every vector is scored — no approximation), and +novelty is folded correctly (unindexed asserts appear, retractions cancel). +Anything outside the signature — additional join patterns, `cosineSimilarity` +/ `euclideanDistance`, projecting `?vec`, policy-enforced views, multi-ledger +datasets, time travel — runs the general pipeline instead, correct but slower. + +Measured at 50,000 × 768-dim vectors (release, Apple M-series), threshold + +`ORDER BY DESC` query: + +| Lane | Query time | Throughput | +|------|-----------|------------| +| Novelty-only (no binary index) | 324 ms | ~155K vec/s | +| Binary index, general pipeline | 30 ms | ~1.6M vec/s | +| Binary index, **top-k fast path** | **8.1 ms** | ~6.2M vec/s | + +The scan is memory-bandwidth-bound and partitions across cores, so it scales +near-linearly with corpus bytes: roughly **6M vectors/sec at 768 dims** on +laptop-class hardware. + ### Inline similarity functions (flat scan) - **Best for**: Small to medium datasets, ad-hoc similarity queries, prototyping - **Complexity**: O(n) linear scan -- computes similarity against every matching vector -- **Advantage**: No index setup required, works immediately after insert -- **SIMD acceleration**: Fluree uses runtime-detected SIMD kernels (SSE2/AVX on x86_64, NEON on ARM) for vectorized dot/cosine/L2 computation -- **Normalized embedding optimization**: For unit-normalized vectors (most transformer embeddings), cosine similarity reduces to a dot product, avoiding magnitude computation entirely +- **Advantage**: No index setup required, works immediately after insert; results are exact and strongly consistent (novelty included) +- **SIMD acceleration**: Fluree uses runtime-detected SIMD kernels (SSE2/AVX on x86_64, NEON on ARM) for vectorized dot/cosine/L2 computation, reading indexed vectors zero-copy from the packed f32 shards +- **Prefer `dotProduct` for normalized embeddings**: for unit-normalized vectors (most transformer embeddings), dot product ranks identically to cosine similarity, computes fewer terms, and is the only scoring function served by the top-k fast path today ### When to consider HNSW Inline similarity functions perform a brute-force scan over all candidate vectors. This scales linearly and remains fast for moderate datasets, but at larger scales an HNSW index provides O(log n) approximate nearest-neighbor search. -**Rule of thumb:** - -| Vector count (per property) | Recommendation | -|----------------------------|----------------| -| < 100K | Flat scan works well, especially with binary indexing. Sub-100ms queries typical. | -| 100K -- 1M | **Start evaluating HNSW.** Flat scan may still be acceptable depending on latency target and hardware, but HNSW will provide more consistent low-latency results. | -| 1M -- 10M | HNSW strongly recommended for interactive latency. Flat scan can work if vectors are memory-resident and you can tolerate ~1-2 second queries. | -| > 10M | HNSW (or other ANN index) is the default recommendation. Flat scan becomes I/O- and cache-bound for low-latency use cases. | +**Rule of thumb** (768-dim; scale thresholds down for higher dimensions). +The two columns matter: queries matching the top-k fast-path signature scale +much further than general-shape queries (extra joins, cosine/euclidean, +projected vectors, policy-enforced views), which pay pipeline costs per row. + +| Vector count (per property) | Top-k signature (`dotProduct` + order/limit) | General-shape queries | +|----------------------------|----------------------------------------------|----------------------| +| < 100K | Flat scan is interactive (< ~20 ms indexed). | Flat scan works well (< ~60 ms indexed). | +| 100K -- 1M | Flat scan stays interactive (~20-170 ms, memory-resident). HNSW optional. | **Start evaluating HNSW**; flat scan runs ~60-600 ms. | +| 1M -- 10M | Flat scan viable for sub-second budgets if shards are memory-resident (~0.2-1.7 s). HNSW for strict latency. | HNSW strongly recommended for interactive latency. | +| > 10M | HNSW (or other ANN index) is the default recommendation. | HNSW default; flat scan becomes I/O- and cache-bound. | + +Remember the trade: the HNSW graph source is **eventually consistent** (it +lags the ledger until its index refreshes), while the flat scan is exact and +strongly consistent, including uncommitted-to-index novelty. If your workload +needs read-your-writes similarity search, that pushes the flat-scan ceiling up; +if it needs guaranteed low latency at any corpus size, that pulls HNSW in +earlier. Factors that shift the crossover: -- **Hardware**: Fast NVMe / large RAM pushes the threshold higher; object storage (S3) pulls it lower +- **Query shape**: only the canonical top-k signature gets the fast path — check both columns above +- **Hardware**: Fast NVMe / large RAM pushes the threshold higher; object storage (S3) pulls it lower (first-touch shard loads dominate cold queries) - **Latency target**: A 50 ms budget favors HNSW earlier than a 2-second budget - **Filter selectivity**: If graph patterns reduce candidates to a small fraction before scoring, flat scan remains viable at higher counts -- **Normalized embeddings**: Cosine-as-dot-product is faster, pushing the threshold higher -- **Binary indexing**: An indexed dataset scans ~6x faster than novelty-only, effectively raising the flat-scan ceiling +- **Dimensions**: the scan is memory-bandwidth-bound; 1536-dim costs ~2x the 768-dim numbers +- **Binary indexing**: an indexed dataset scans ~10-40x faster than novelty-only — ensure background indexing runs for vector-heavy ledgers ### HNSW vector indexes -- **Best for**: Large datasets (100K+ vectors), production similarity search with strict latency requirements +- **Best for**: Multi-million-vector datasets, strict latency requirements at any size, and query shapes the flat-scan fast path doesn't serve; note HNSW results are eventually consistent (the index lags the ledger until it refreshes) - **Complexity**: O(log n) approximate nearest neighbor - **Space**: ~1.5x embedding size + IRI mapping overhead - **Updates**: Incremental via affected-subject tracking diff --git a/fluree-db-api/src/format/agent_json.rs b/fluree-db-api/src/format/agent_json.rs index 7d42311120..0857390c5d 100644 --- a/fluree-db-api/src/format/agent_json.rs +++ b/fluree-db-api/src/format/agent_json.rs @@ -758,7 +758,10 @@ mod tests { FlakeValue::Json(r#"{"k":[1,2]}"#.to_string()), Sid::new(3, "JSON"), ), - Binding::lit(FlakeValue::Vector(vec![1.0, -2.5]), Sid::new(2, "double")), + Binding::lit( + FlakeValue::Vector(vec![1.0, -2.5].into()), + Sid::new(2, "double"), + ), ]], ); assert_parity(&r, &super::super::config::FormatterConfig::agent_json()); diff --git a/fluree-db-api/src/format/jsonld.rs b/fluree-db-api/src/format/jsonld.rs index bab5b1a1ba..f3a73e187d 100644 --- a/fluree-db-api/src/format/jsonld.rs +++ b/fluree-db-api/src/format/jsonld.rs @@ -920,7 +920,7 @@ mod tests { Sid::new(3, "JSON"), ), Binding::lit( - FlakeValue::Vector(vec![1.0, 2.5, -3.0]), + FlakeValue::Vector(vec![1.0, 2.5, -3.0].into()), Sid::new(2, "double"), ), ]], diff --git a/fluree-db-api/src/format/sparql.rs b/fluree-db-api/src/format/sparql.rs index c4bfec2c7c..fd5360b483 100644 --- a/fluree-db-api/src/format/sparql.rs +++ b/fluree-db-api/src/format/sparql.rs @@ -1084,7 +1084,7 @@ mod tests { Sid::new(3, "JSON"), ), Binding::lit( - FlakeValue::Vector(vec![1.0, 2.5, -3.0]), + FlakeValue::Vector(vec![1.0, 2.5, -3.0].into()), Sid::new(2, "double"), ), ]], diff --git a/fluree-db-api/src/format/typed.rs b/fluree-db-api/src/format/typed.rs index a5f7a43e42..ce4ed184c9 100644 --- a/fluree-db-api/src/format/typed.rs +++ b/fluree-db-api/src/format/typed.rs @@ -923,7 +923,7 @@ mod tests { Sid::new(3, "JSON"), ), Binding::lit( - FlakeValue::Vector(vec![1.0, 2.5, -3.0]), + FlakeValue::Vector(vec![1.0, 2.5, -3.0].into()), Sid::new(2, "double"), ), ]], diff --git a/fluree-db-api/tests/it_query_fulltext.rs b/fluree-db-api/tests/it_query_fulltext.rs index 634a028cc9..3c0577aaa2 100644 --- a/fluree-db-api/tests/it_query_fulltext.rs +++ b/fluree-db-api/tests/it_query_fulltext.rs @@ -1378,3 +1378,597 @@ async fn fulltext_configured_two_commits_two_values_full_rebuild() { } } } + +/// Persisted-backend reproducer for the Solo/S3 bug report (2026-07-03): +/// arena builds correctly at index time, but `fulltext(?v, "…")` returns +/// unbound for every value when the query runs in a *separate process* that +/// loads the index from persisted storage. +/// +/// All prior reproducers in this file use `FlureeBuilder::memory()` where the +/// indexer and query side share one process. This test mirrors the field +/// deployment shape: instance A (the "indexing Lambda") writes config + data +/// and publishes a full index to file storage, then is dropped; instance B +/// (the "query Lambda") is a fresh `Fluree` over the same directory that must +/// load the FTA1 arena via the FIR6 root and score both an untagged +/// `xsd:string` and an `@en`-tagged value on the configured predicate. +#[tokio::test] +async fn fulltext_configured_persisted_reload_scores_plain_and_langtagged() { + use fluree_db_api::ReindexOptions; + use fluree_db_transact::{CommitOpts, TxnOpts}; + + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().to_string_lossy().to_string(); + let ledger_id = "it/fulltext-persisted:main"; + let no_auto = fluree_db_api::IndexConfig { + reindex_min_bytes: 1_000_000_000, + reindex_max_bytes: 1_000_000_000, + }; + + // ── Instance A: writer + indexer (the "indexing Lambda") ────────────── + { + let fluree = FlureeBuilder::file(&path).build().expect("file fluree"); + let ledger = fluree.create_ledger(ledger_id).await.expect("create"); + + // Config: rdfs:label is a fulltext-configured predicate (ncit shape). + let config_iri = format!("urn:fluree:{ledger_id}#config"); + let config_trig = format!( + r" + @prefix f: . + @prefix rdf: . + @prefix rdfs: . + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:fullTextDefaults . + rdf:type f:FullTextDefaults . + f:property . + rdf:type f:FullTextProperty . + f:target rdfs:label . + }} + " + ); + let ledger = fluree + .stage_owned(ledger) + .upsert_turtle(&config_trig) + .execute() + .await + .expect("write config") + .ledger; + + // Data: both probe shapes from the bug report — untagged xsd:string + // and @en-tagged — plus filler so the arena has corpus stats. + let ledger = fluree + .insert_with_opts( + ledger, + &json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "@graph": [ + {"@id": "ex:ZZTESTPLAIN", "rdfs:label": "Qjklm Marker Syndrome"}, + {"@id": "ex:ZZTESTEN", + "rdfs:label": {"@value": "Zqxwv Marker Syndrome", "@language": "en"}}, + {"@id": "ex:OTHER1", "rdfs:label": "Unrelated cardiac disorder"}, + {"@id": "ex:OTHER2", "rdfs:label": "Another unrelated entry"} + ] + }), + TxnOpts::default(), + CommitOpts::default(), + &no_auto, + ) + .await + .expect("insert probes") + .ledger; + let _ = ledger; + + // Full reindex — same admin-reindex path the field report used. + fluree + .reindex(ledger_id, ReindexOptions::default()) + .await + .expect("reindex"); + } + + // ── Instance B: fresh process (the "query Lambda") ───────────────────── + let fluree = FlureeBuilder::file(&path).build().expect("re-open fluree"); + let loaded = fluree.ledger(ledger_id).await.expect("load ledger"); + + for (probe, expect_id) in [("qjklm", "ex:ZZTESTPLAIN"), ("zqxwv", "ex:ZZTESTEN")] { + let bind = format!("(fulltext ?label \"{probe}\")"); + let query = json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "select": ["?s", "?label", "?score"], + "where": [ + {"@id": "?s", "rdfs:label": "?label"}, + ["bind", "?score", bind], + ["filter", "(> ?score 0)"] + ] + }); + let result = support::query_jsonld(&fluree, &loaded, &query) + .await + .expect("query"); + let json_rows = result.to_jsonld(&loaded.snapshot).expect("jsonld"); + let rows = json_rows.as_array().cloned().unwrap_or_default(); + let hit = rows.iter().any(|row| { + row.as_array() + .and_then(|r| r.first()) + .and_then(|v| v.as_str()) + .is_some_and(|id| id == expect_id) + }); + assert!( + hit, + "persisted reload: fulltext(\"{probe}\") must score {expect_id} > 0 \ + when the arena is loaded from disk in a fresh process, got rows: {rows:?}" + ); + } +} + +/// Shared setup for the persisted-backend tests below: a file-backed Fluree +/// over `path` with `rdfs:label` fulltext-configured and the given docs +/// committed, followed by a full reindex. The instance is dropped before +/// return so a fresh instance sees only persisted state. +async fn persisted_setup_with_labels(path: &str, ledger_id: &str, docs: &JsonValue) { + use fluree_db_api::ReindexOptions; + use fluree_db_transact::{CommitOpts, TxnOpts}; + + let no_auto = fluree_db_api::IndexConfig { + reindex_min_bytes: 1_000_000_000, + reindex_max_bytes: 1_000_000_000, + }; + let fluree = FlureeBuilder::file(path).build().expect("file fluree"); + let ledger = fluree.create_ledger(ledger_id).await.expect("create"); + + let config_iri = format!("urn:fluree:{ledger_id}#config"); + let config_trig = format!( + r" + @prefix f: . + @prefix rdf: . + @prefix rdfs: . + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:fullTextDefaults . + rdf:type f:FullTextDefaults . + f:property . + rdf:type f:FullTextProperty . + f:target rdfs:label . + }} + " + ); + let ledger = fluree + .stage_owned(ledger) + .upsert_turtle(&config_trig) + .execute() + .await + .expect("write config") + .ledger; + + let ledger = fluree + .insert_with_opts( + ledger, + docs, + TxnOpts::default(), + CommitOpts::default(), + &no_auto, + ) + .await + .expect("insert docs") + .ledger; + let _ = ledger; + + fluree + .reindex(ledger_id, ReindexOptions::default()) + .await + .expect("reindex"); +} + +/// Run a `fulltext(?label, probe)` query on `rdfs:label` and return the set +/// of matching subject ids (compacted against the `ex:` prefix). +async fn persisted_query_label_hits( + fluree: &support::MemoryFluree, + loaded: &support::MemoryLedger, + probe: &str, +) -> Vec { + let bind = format!("(fulltext ?label \"{probe}\")"); + let query = json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "select": ["?s", "?score"], + "where": [ + {"@id": "?s", "rdfs:label": "?label"}, + ["bind", "?score", bind], + ["filter", "(> ?score 0)"] + ] + }); + let result = support::query_jsonld(fluree, loaded, &query) + .await + .expect("query"); + let json_rows = result.to_jsonld(&loaded.snapshot).expect("jsonld"); + json_rows + .as_array() + .map(|rows| { + rows.iter() + .filter_map(|row| { + row.as_array()? + .first()? + .as_str() + .map(std::string::ToString::to_string) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Persisted incremental indexing: after a full reindex + process restart, +/// a new commit on the configured predicate is picked up by the incremental +/// indexer (prior FTA1 arena fetched from persisted storage, extended, and +/// re-published), and a further fresh process scores both old and new values. +#[tokio::test] +async fn fulltext_configured_persisted_incremental_extends_arena() { + use fluree_db_transact::{CommitOpts, TxnOpts}; + + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().to_string_lossy().to_string(); + let ledger_id = "it/fulltext-persisted-incr:main"; + let no_auto = fluree_db_api::IndexConfig { + reindex_min_bytes: 1_000_000_000, + reindex_max_bytes: 1_000_000_000, + }; + + persisted_setup_with_labels( + &path, + ledger_id, + &json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "@graph": [ + {"@id": "ex:OLD", "rdfs:label": "Qjklm Marker Syndrome"}, + {"@id": "ex:FILLER", "rdfs:label": "Unrelated cardiac disorder"} + ] + }), + ) + .await; + + // Fresh process: commit a new value, then incremental index (prior index + // exists, so build_index_for_ledger takes the incremental path and must + // fetch + extend the persisted arena). + { + let fluree = FlureeBuilder::file(&path).build().expect("re-open"); + let ledger = fluree.ledger(ledger_id).await.expect("load"); + let ledger = fluree + .insert_with_opts( + ledger, + &json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "@id": "ex:NEW", + "rdfs:label": "Wvbnm Novel Finding" + }), + TxnOpts::default(), + CommitOpts::default(), + &no_auto, + ) + .await + .expect("insert new label") + .ledger; + let _ = ledger; + + let idx_config = fluree_db_indexer::IndexerConfig::default() + .with_fulltext_config_provider(fluree.fulltext_config_provider()); + let result = fluree_db_indexer::build_index_for_ledger( + fluree.content_store(ledger_id), + fluree.nameservice(), + ledger_id, + idx_config, + ) + .await + .expect("incremental build"); + fluree + .nameservice_mode() + .publisher() + .expect("publisher") + .publish_index_allow_equal(ledger_id, result.index_t, &result.root_id) + .await + .expect("publish incremental"); + } + + // Another fresh process: both the pre-existing and the incrementally + // added value must score from the persisted arena. + let fluree = FlureeBuilder::file(&path).build().expect("re-open 2"); + let loaded = fluree.ledger(ledger_id).await.expect("load final"); + let old_hits = persisted_query_label_hits(&fluree, &loaded, "qjklm").await; + assert!( + old_hits.iter().any(|id| id == "ex:OLD"), + "incremental index must preserve prior persisted arena docs, got {old_hits:?}" + ); + let new_hits = persisted_query_label_hits(&fluree, &loaded, "wvbnm").await; + assert!( + new_hits.iter().any(|id| id == "ex:NEW"), + "incremental index must extend the persisted arena with new docs, got {new_hits:?}" + ); +} + +/// Persisted novelty/overlay: a commit made AFTER the last index build (and +/// never indexed) must still score via the novelty-delta path when queried +/// from a fresh process — both for the unindexed value itself and without +/// breaking scores for indexed values. +#[tokio::test] +async fn fulltext_configured_persisted_novelty_scores_unindexed_commit() { + use fluree_db_transact::{CommitOpts, TxnOpts}; + + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().to_string_lossy().to_string(); + let ledger_id = "it/fulltext-persisted-novelty:main"; + let no_auto = fluree_db_api::IndexConfig { + reindex_min_bytes: 1_000_000_000, + reindex_max_bytes: 1_000_000_000, + }; + + persisted_setup_with_labels( + &path, + ledger_id, + &json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "@graph": [ + {"@id": "ex:INDEXED", "rdfs:label": "Qjklm Marker Syndrome"}, + {"@id": "ex:FILLER", "rdfs:label": "Unrelated cardiac disorder"} + ] + }), + ) + .await; + + // Fresh process: commit a new value but do NOT index it. + { + let fluree = FlureeBuilder::file(&path).build().expect("re-open"); + let ledger = fluree.ledger(ledger_id).await.expect("load"); + let ledger = fluree + .insert_with_opts( + ledger, + &json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "@id": "ex:NOVELTY", + "rdfs:label": "Xplqr Overlay Finding" + }), + TxnOpts::default(), + CommitOpts::default(), + &no_auto, + ) + .await + .expect("insert novelty label") + .ledger; + let _ = ledger; + } + + // Fresh process: the unindexed (novelty) value must score, and the + // indexed value must keep scoring. + let fluree = FlureeBuilder::file(&path).build().expect("re-open 2"); + let loaded = fluree.ledger(ledger_id).await.expect("load with novelty"); + let novelty_hits = persisted_query_label_hits(&fluree, &loaded, "xplqr").await; + assert!( + novelty_hits.iter().any(|id| id == "ex:NOVELTY"), + "unindexed novelty value on a configured predicate must score, got {novelty_hits:?}" + ); + let indexed_hits = persisted_query_label_hits(&fluree, &loaded, "qjklm").await; + assert!( + indexed_hits.iter().any(|id| id == "ex:INDEXED"), + "indexed value must keep scoring with novelty present, got {indexed_hits:?}" + ); +} + +/// Persisted language buckets: language-tagged values on a configured +/// predicate build language-specific arenas — a French value is analyzed +/// with the French stemmer in its own `(g_id, p_id, lang_id)` bucket, and +/// English/untagged values live in the English bucket. Verified across a +/// process restart so the buckets round-trip through the FIR6 root. +#[tokio::test] +async fn fulltext_configured_persisted_language_buckets() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().to_string_lossy().to_string(); + let ledger_id = "it/fulltext-persisted-lang:main"; + + persisted_setup_with_labels( + &path, + ledger_id, + &json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "@graph": [ + {"@id": "ex:FR", + "rdfs:label": {"@value": "Maladies cardiaques chroniques", "@language": "fr"}}, + {"@id": "ex:EN", + "rdfs:label": {"@value": "Chronic heart diseases", "@language": "en"}}, + {"@id": "ex:PLAIN", "rdfs:label": "Chronic kidney disease"} + ] + }), + ) + .await; + + let fluree = FlureeBuilder::file(&path).build().expect("re-open"); + let loaded = fluree.ledger(ledger_id).await.expect("load"); + + // French bucket: "maladie" (singular) must match "Maladies" via the + // French stemmer — this only works if the @fr value was analyzed with + // the French analyzer in its own arena bucket. + let fr_hits = persisted_query_label_hits(&fluree, &loaded, "maladie").await; + assert!( + fr_hits.iter().any(|id| id == "ex:FR"), + "@fr value must score via its language-specific arena (French stemming), got {fr_hits:?}" + ); + + // English bucket: "diseases" matches the @en value; the untagged value + // shares the English bucket, so "disease" matches it too. + let en_hits = persisted_query_label_hits(&fluree, &loaded, "diseases").await; + assert!( + en_hits.iter().any(|id| id == "ex:EN"), + "@en value must score via the English bucket, got {en_hits:?}" + ); + assert!( + en_hits.iter().any(|id| id == "ex:PLAIN"), + "untagged value must share the English bucket, got {en_hits:?}" + ); + + // Cross-bucket isolation: a French-only term must not surface the + // English-bucket docs. + assert!( + !fr_hits.iter().any(|id| id == "ex:EN" || id == "ex:PLAIN"), + "French query terms must not match English-bucket docs, got {fr_hits:?}" + ); +} + +/// Perf smoke harness for per-row `fulltext()` cost at scale (run manually): +/// +/// ```sh +/// cargo test -p fluree-db-api --test grp_query --release \ +/// fulltext_perf_50k_labels -- --ignored --nocapture +/// ``` +/// +/// Seeds 50k configured labels, full-reindexes, then commits ONE extra label +/// WITHOUT indexing so the overlay epoch is non-zero — forcing the eager +/// materialization / `Binding::Lit` path for every row, which is the +/// production norm (the Solo/S3 27s report shape). Prints wall time for +/// cold and warm runs; asserts only correctness (hit counts), not timing. +#[tokio::test] +#[ignore = "perf smoke — run manually with --ignored --nocapture"] +async fn fulltext_perf_50k_labels() { + use fluree_db_api::ReindexOptions; + use fluree_db_transact::{CommitOpts, TxnOpts}; + + const N: usize = 50_000; + let words = [ + "disease", + "syndrome", + "carcinoma", + "gene", + "protein", + "receptor", + "chronic", + "acute", + "cardiac", + "renal", + "hepatic", + "neural", + "benign", + "malignant", + "primary", + "secondary", + "infection", + "deficiency", + "disorder", + "lesion", + ]; + + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().to_string_lossy().to_string(); + let ledger_id = "it/fulltext-perf:main"; + let no_auto = fluree_db_api::IndexConfig { + reindex_min_bytes: 1_000_000_000, + reindex_max_bytes: 1_000_000_000, + }; + + { + let fluree = FlureeBuilder::file(&path).build().expect("file fluree"); + let ledger = fluree.create_ledger(ledger_id).await.expect("create"); + + let config_iri = format!("urn:fluree:{ledger_id}#config"); + let config_trig = format!( + r" + @prefix f: . + @prefix rdf: . + @prefix rdfs: . + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:fullTextDefaults . + rdf:type f:FullTextDefaults . + f:property . + rdf:type f:FullTextProperty . + f:target rdfs:label . + }} + " + ); + let mut ledger = fluree + .stage_owned(ledger) + .upsert_turtle(&config_trig) + .execute() + .await + .expect("write config") + .ledger; + + // Bulk-seed N labels via turtle (3 pseudo-random pool words each). + let mut turtle = String::from("@prefix rdfs: .\n"); + for i in 0..N { + let a = words[i % words.len()]; + let b = words[(i / words.len() + 3) % words.len()]; + let c = words[(i * 7 + 11) % words.len()]; + turtle.push_str(&format!( + " rdfs:label \"{a} {b} {c} entry {i}\" .\n" + )); + } + ledger = fluree + .stage_owned(ledger) + .upsert_turtle(&turtle) + .execute() + .await + .expect("seed labels") + .ledger; + let _ = ledger; + + fluree + .reindex(ledger_id, ReindexOptions::default()) + .await + .expect("reindex"); + + // One unindexed commit → overlay epoch != 0 → eager materialization. + let ledger = fluree.ledger(ledger_id).await.expect("reload"); + let _ = fluree + .insert_with_opts( + ledger, + &json!({ + "@context": { + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "ex": "http://ex.test/" + }, + "@id": "ex:NOVELTY", + "rdfs:label": "xplqr overlay entry" + }), + TxnOpts::default(), + CommitOpts::default(), + &no_auto, + ) + .await + .expect("novelty commit"); + } + + let fluree = FlureeBuilder::file(&path).build().expect("re-open"); + let loaded = fluree.ledger(ledger_id).await.expect("load"); + + // "disease" appears in a large fraction of the pool-generated labels. + for run in ["cold", "warm"] { + let start = std::time::Instant::now(); + let hits = persisted_query_label_hits(&fluree, &loaded, "disease").await; + let elapsed = start.elapsed(); + println!( + "fulltext_perf_50k: {run} run: {} hits in {elapsed:?}", + hits.len() + ); + assert!( + hits.len() > 1000, + "{run}: expected thousands of 'disease' hits, got {}", + hits.len() + ); + } +} diff --git a/fluree-db-api/tests/it_vector_flatrank.rs b/fluree-db-api/tests/it_vector_flatrank.rs index d7efa47955..b8df5ff484 100644 --- a/fluree-db-api/tests/it_vector_flatrank.rs +++ b/fluree-db-api/tests/it_vector_flatrank.rs @@ -1194,3 +1194,591 @@ async fn sparql_vector_with_score_filter() { assert_eq!(arr.len(), 1); assert_eq!(arr[0][0], "Homer"); } + +// ============================================================================= +// Perf smoke harness +// ============================================================================= + +/// Deterministic pseudo-random f64 in [-1, 1) (LCG; no process randomness so +/// the test can regenerate the exact vectors for scalar verification). +struct Lcg(u64); + +impl Lcg { + fn next_f64(&mut self) -> f64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + ((self.0 >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0 + } +} + +/// Perf smoke harness for per-row flat vector scoring at scale (run manually): +/// +/// ```sh +/// cargo test -p fluree-db-api --features vector --test it_vector_flatrank \ +/// --release vector_flatrank_perf_50k -- --ignored --nocapture +/// ``` +/// +/// Seeds 50k entities with 256-dim vectors (novelty-only, so every scanned +/// row arrives as a materialized `Binding::Lit` — the production shape once +/// any unindexed commit exists), then times `bind ?score (dotProduct ...)` +/// + threshold filter. Results are verified against scalar recomputation of +/// the same LCG-generated vectors: exact hit count, and top-5 ids/scores +/// within 1e-9 — so before/after runs must agree on output, not just speed. +#[tokio::test] +#[ignore = "perf smoke — run manually with --ignored --nocapture"] +async fn vector_flatrank_perf_50k() { + const N: usize = 50_000; + const DIMS: usize = 256; + const CHUNK: usize = 10_000; + const THRESHOLD: f64 = 11.0; + + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "test/vector-perf:main"; + let mut ledger = fluree.create_ledger(ledger_id).await.unwrap(); + + // Generate all vectors once (kept for scalar verification below). + let mut rng = Lcg(42); + let vectors: Vec> = (0..N) + .map(|_| (0..DIMS).map(|_| rng.next_f64()).collect()) + .collect(); + let target: Vec = (0..DIMS).map(|_| rng.next_f64()).collect(); + + let seed_start = std::time::Instant::now(); + for chunk_start in (0..N).step_by(CHUNK) { + let entities: Vec = (chunk_start..(chunk_start + CHUNK).min(N)) + .map(|i| { + json!({ + "@id": format!("ex:v{i}"), + "ex:vec": { + "@value": vectors[i], + "@type": "https://ns.flur.ee/db#embeddingVector" + } + }) + }) + .collect(); + let txn = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@graph": entities + }); + ledger = fluree + .insert(ledger, &txn) + .await + .expect("seed chunk") + .ledger; + } + println!( + "vector_perf_50k: seeded {N} x {DIMS}-dim in {:?}", + seed_start.elapsed() + ); + + // Scalar ground truth: expected hits and top-5 (id index, score). + let scalar_dot = |a: &[f64], b: &[f64]| a.iter().zip(b).map(|(x, y)| x * y).sum::(); + let mut expected: Vec<(usize, f64)> = vectors + .iter() + .enumerate() + .map(|(i, v)| (i, scalar_dot(v, &target))) + .filter(|(_, s)| *s > THRESHOLD) + .collect(); + expected.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); + assert!( + expected.len() > 100, + "threshold should pass a meaningful subset, got {}", + expected.len() + ); + + let query_dot = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "select": ["?x", "?score"], + "values": [["?targetVec"], + [{"@value": target, "@type": "https://ns.flur.ee/db#embeddingVector"}]], + "where": [ + {"@id": "?x", "ex:vec": "?vec"}, + ["bind", "?score", ["dotProduct", "?vec", "?targetVec"]], + ["filter", format!("(> ?score {THRESHOLD})")] + ], + "orderBy": [["desc", "?score"]] + }); + + for run in ["cold", "warm"] { + let start = std::time::Instant::now(); + let result = support::query_jsonld(&fluree, &ledger, &query_dot) + .await + .expect("dotProduct query"); + let rows = result.to_jsonld(&ledger.snapshot).unwrap(); + let arr = rows.as_array().unwrap().clone(); + println!( + "vector_perf_50k: dotProduct {run} run: {} hits in {:?}", + arr.len(), + start.elapsed() + ); + + assert_eq!( + arr.len(), + expected.len(), + "{run}: hit count must match scalar recomputation" + ); + for (rank, (exp_idx, exp_score)) in expected.iter().take(5).enumerate() { + let row = arr[rank].as_array().unwrap(); + let id = row[0].as_str().unwrap(); + let score = row[1].as_f64().unwrap(); + assert_eq!(id, format!("ex:v{exp_idx}"), "{run}: rank {rank} id"); + assert!( + (score - exp_score).abs() <= 1e-6 * exp_score.abs().max(1.0), + "{run}: rank {rank} score {score} != scalar {exp_score}" + ); + } + } + + // Cosine + euclidean: one timed pass each, count-verified against scalar. + for (func, name) in [ + ("cosineSimilarity", "cosine"), + ("euclideanDistance", "euclidean"), + ] { + let (filter, expected_count) = match name { + "cosine" => { + let cnt = vectors + .iter() + .filter(|v| { + let dot = scalar_dot(v, &target); + let ma = scalar_dot(v, v).sqrt(); + let mb = scalar_dot(&target, &target).sqrt(); + dot / (ma * mb) > 0.2 + }) + .count(); + ("(> ?score 0.2)".to_string(), cnt) + } + _ => { + let cnt = vectors + .iter() + .filter(|v| { + v.iter() + .zip(&target) + .map(|(x, y)| (x - y) * (x - y)) + .sum::() + .sqrt() + < 12.0 + }) + .count(); + ("(< ?score 12.0)".to_string(), cnt) + } + }; + let query = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "select": ["?x", "?score"], + "values": [["?targetVec"], + [{"@value": target, "@type": "https://ns.flur.ee/db#embeddingVector"}]], + "where": [ + {"@id": "?x", "ex:vec": "?vec"}, + ["bind", "?score", [func, "?vec", "?targetVec"]], + ["filter", filter] + ] + }); + let start = std::time::Instant::now(); + let result = support::query_jsonld(&fluree, &ledger, &query) + .await + .expect("scored query"); + let rows = result.to_jsonld(&ledger.snapshot).unwrap(); + let got = rows.as_array().unwrap().len(); + println!( + "vector_perf_50k: {name} run: {got} hits in {:?}", + start.elapsed() + ); + assert_eq!( + got, expected_count, + "{name}: hit count must match scalar recomputation" + ); + } + + // ── Indexed phase ────────────────────────────────────────────────────── + // Full reindex, then reload: novelty is empty (overlay epoch 0), so the + // scan late-materializes and `?vec` reaches eval as EncodedLit VECTOR_ID + // — the packed-f32-shard path, exercised with the same verification. + let reindex_start = std::time::Instant::now(); + fluree + .reindex(ledger_id, fluree_db_api::ReindexOptions::default()) + .await + .expect("reindex"); + let ledger = fluree.ledger(ledger_id).await.expect("reload indexed"); + println!( + "vector_perf_50k: reindexed in {:?} (t={})", + reindex_start.elapsed(), + ledger.t() + ); + + let query_dot_pipeline = { + let mut q = query_dot.clone(); + // Duplicate threshold filter: same semantics, but defeats the top-k + // fast-path detection (≤1 filter) so the generic pipeline runs. + q["where"] + .as_array_mut() + .unwrap() + .push(json!(["filter", format!("(> ?score {THRESHOLD})")])); + q + }; + for run in ["indexed pipeline cold", "indexed pipeline warm"] { + let start = std::time::Instant::now(); + let result = support::query_jsonld(&fluree, &ledger, &query_dot_pipeline) + .await + .expect("indexed pipeline query"); + let rows = result.to_jsonld(&ledger.snapshot).unwrap(); + println!( + "vector_perf_50k: dotProduct {run} run: {} hits in {:?}", + rows.as_array().unwrap().len(), + start.elapsed() + ); + } + for run in ["indexed cold", "indexed warm"] { + let start = std::time::Instant::now(); + let result = support::query_jsonld(&fluree, &ledger, &query_dot) + .await + .expect("indexed dotProduct query"); + let rows = result.to_jsonld(&ledger.snapshot).unwrap(); + let arr = rows.as_array().unwrap().clone(); + println!( + "vector_perf_50k: dotProduct {run} run: {} hits in {:?}", + arr.len(), + start.elapsed() + ); + + assert_eq!( + arr.len(), + expected.len(), + "{run}: hit count must match scalar recomputation" + ); + for (rank, (exp_idx, exp_score)) in expected.iter().take(5).enumerate() { + let row = arr[rank].as_array().unwrap(); + let id = row[0].as_str().unwrap(); + let score = row[1].as_f64().unwrap(); + assert_eq!(id, format!("ex:v{exp_idx}"), "{run}: rank {rank} id"); + assert!( + (score - exp_score).abs() <= 1e-6 * exp_score.abs().max(1.0), + "{run}: rank {rank} score {score} != scalar {exp_score}" + ); + } + } +} + +// ============================================================================= +// Flat-rank top-k fast-path parity +// ============================================================================= + +/// End-to-end parity for the flat-rank vector fast path against scalar +/// ground truth, across its lanes: +/// +/// - indexed base (epoch 0): threshold + ORDER BY DESC + LIMIT top-k +/// - overlay lane: novelty assert (new top vector) + retraction of an +/// indexed vector via upsert — both must be reflected without reindexing +/// - multi-cardinality: a subject with two vectors yields two scored rows +/// - fallback shapes: an extra hydration triple and a projected `?vec` +/// must not detect (results still correct via the pipeline) +#[tokio::test] +async fn vector_topk_fast_path_parity() { + use fluree_db_api::ReindexOptions; + + const DIMS: usize = 8; + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "test/vector-topk-parity:main"; + let ledger0 = fluree.create_ledger(ledger_id).await.unwrap(); + + // Deterministic vectors; values chosen to be exactly f32-representable + // is NOT required — ground truth below quantizes the same way ingest does. + let mut rng = Lcg(7); + let vectors: Vec> = (0..40) + .map(|_| (0..DIMS).map(|_| rng.next_f64()).collect()) + .collect(); + let target: Vec = (0..DIMS).map(|_| rng.next_f64()).collect(); + // Give one subject (v39) a second vector (multi-cardinality). + let second_vec: Vec = (0..DIMS).map(|_| rng.next_f64()).collect(); + + let mut entities: Vec = (0..40) + .map(|i| { + json!({ + "@id": format!("ex:v{i}"), + "ex:vec": {"@value": vectors[i], "@type": "https://ns.flur.ee/db#embeddingVector"} + }) + }) + .collect(); + entities.push(json!({ + "@id": "ex:v39", + "ex:vec": {"@value": second_vec, "@type": "https://ns.flur.ee/db#embeddingVector"} + })); + let ledger = fluree + .insert( + ledger0, + &json!({"@context": {"ex": "http://example.org/ns/"}, "@graph": entities}), + ) + .await + .expect("seed") + .ledger; + let _ = ledger; + + fluree + .reindex(ledger_id, ReindexOptions::default()) + .await + .expect("reindex"); + let ledger = fluree.ledger(ledger_id).await.expect("reload indexed"); + + // Ground truth: stored vectors are f32-quantized at ingest; the query + // VALUES vector is used at full precision (only *storage* quantizes). + let q = |v: &[f64]| -> Vec { v.iter().map(|&x| (x as f32) as f64).collect() }; + let dot = |a: &[f64], b: &[f64]| a.iter().zip(b).map(|(x, y)| x * y).sum::(); + let qt = target.clone(); + // (subject index, score); index 40 = v39's second vector. + let mut facts: Vec<(usize, f64)> = vectors + .iter() + .enumerate() + .map(|(i, v)| (i, dot(&q(v), &qt))) + .collect(); + facts.push((39, dot(&q(&second_vec), &qt))); + + let run_topk = |ledger: support::MemoryLedger, k: usize| { + let fluree = &fluree; + let target = target.clone(); + async move { + let query = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "select": ["?x", "?score"], + "values": [["?t"], [{"@value": target, "@type": "https://ns.flur.ee/db#embeddingVector"}]], + "where": [ + {"@id": "?x", "ex:vec": "?vec"}, + ["bind", "?score", ["dotProduct", "?vec", "?t"]], + ["filter", "(> ?score 0)"] + ], + "orderBy": [["desc", "?score"]], + "limit": k + }); + let result = support::query_jsonld(fluree, &ledger, &query) + .await + .expect("topk query"); + let rows = result.to_jsonld(&ledger.snapshot).unwrap(); + let out: Vec<(String, f64)> = rows + .as_array() + .unwrap() + .iter() + .map(|r| { + let r = r.as_array().unwrap(); + (r[0].as_str().unwrap().to_string(), r[1].as_f64().unwrap()) + }) + .collect(); + (ledger, out) + } + }; + + let expect_topk = |facts: &[(usize, f64)], k: usize| -> Vec<(String, f64)> { + let mut passing: Vec<(usize, f64)> = + facts.iter().copied().filter(|(_, s)| *s > 0.0).collect(); + passing.sort_by(|a, b| b.1.total_cmp(&a.1)); + passing + .into_iter() + .take(k) + .map(|(i, s)| (format!("ex:v{i}"), s)) + .collect() + }; + + let assert_rows = |got: &[(String, f64)], want: &[(String, f64)], label: &str| { + assert_eq!(got.len(), want.len(), "{label}: row count"); + for (idx, ((gid, gs), (wid, ws))) in got.iter().zip(want).enumerate() { + assert_eq!( + gid, wid, + "{label}: rank {idx} id (got {got:?} want {want:?})" + ); + assert!( + (gs - ws).abs() <= 1e-9 * ws.abs().max(1.0), + "{label}: rank {idx} score {gs} != {ws}" + ); + } + }; + + // ── Phase A: indexed base, top-5 ─────────────────────────────────────── + let (ledger, got) = run_topk(ledger, 5).await; + assert_rows(&got, &expect_topk(&facts, 5), "indexed base"); + + // ── Phase B: novelty — new top vector + retraction via upsert ───────── + // New subject whose vector is target*2 → dominant top score. + let big: Vec = target.iter().map(|x| x * 2.0).collect(); + let ledger = fluree + .insert( + ledger, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:vNEW", + "ex:vec": {"@value": big, "@type": "https://ns.flur.ee/db#embeddingVector"} + }), + ) + .await + .expect("novelty insert") + .ledger; + // Retract the current best base vector by replacing it (upsert) with a + // strongly negative one. + let best_idx = facts + .iter() + .filter(|(i, _)| *i != 39) // v39 is multi-cardinality; upsert replaces BOTH its vectors + .max_by(|a, b| a.1.total_cmp(&b.1)) + .unwrap() + .0; + let neg: Vec = target.iter().map(|x| -x).collect(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": format!("ex:v{best_idx}"), + "ex:vec": {"@value": neg, "@type": "https://ns.flur.ee/db#embeddingVector"} + }), + ) + .await + .expect("retract via upsert") + .ledger; + + // Update ground truth: drop best_idx's old fact, add its replacement and vNEW. + facts.retain(|(i, _)| *i != best_idx); + facts.push((best_idx, dot(&q(&neg), &qt))); + let mut facts_with_new: Vec<(String, f64)> = facts + .iter() + .map(|(i, s)| (format!("ex:v{i}"), *s)) + .collect(); + facts_with_new.push(("ex:vNEW".to_string(), dot(&q(&big), &qt))); + let mut want: Vec<(String, f64)> = facts_with_new + .into_iter() + .filter(|(_, s)| *s > 0.0) + .collect(); + want.sort_by(|a, b| b.1.total_cmp(&a.1)); + want.truncate(5); + assert_eq!(want[0].0, "ex:vNEW", "sanity: novelty vector must lead"); + + let (ledger, got) = run_topk(ledger, 5).await; + assert_rows(&got, &want, "overlay lane"); + + // ── Phase C: fallback shapes stay correct ────────────────────────────── + // Projecting ?vec must not detect (vector materialization is pipeline + // work); the result set must be the same subjects/scores. + let query = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "select": ["?x", "?score", "?vec"], + "values": [["?t"], [{"@value": target, "@type": "https://ns.flur.ee/db#embeddingVector"}]], + "where": [ + {"@id": "?x", "ex:vec": "?vec"}, + ["bind", "?score", ["dotProduct", "?vec", "?t"]], + ["filter", "(> ?score 0)"] + ], + "orderBy": [["desc", "?score"]], + "limit": 5 + }); + let result = support::query_jsonld(&fluree, &ledger, &query) + .await + .expect("fallback query"); + let rows = result.to_jsonld(&ledger.snapshot).unwrap(); + let got_fb: Vec<(String, f64)> = rows + .as_array() + .unwrap() + .iter() + .map(|r| { + let r = r.as_array().unwrap(); + (r[0].as_str().unwrap().to_string(), r[1].as_f64().unwrap()) + }) + .collect(); + assert_rows(&got_fb, &want, "fallback (?vec projected)"); +} + +/// A non-vector literal physically stored on the vector predicate passes +/// fast-path *detection* (the canonical `{?x, ?score}` shape), then trips the +/// runtime `Scorer::score → Err(Bail)` mid-scan. The whole fast path must defer +/// to the generic pipeline and return the pipeline's exact rows — never a +/// partial/garbage result. +#[tokio::test] +async fn vector_topk_fast_path_runtime_bail_defers_to_pipeline() { + use fluree_db_api::ReindexOptions; + + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "test/vector-topk-runtime-bail:main"; + let ledger0 = fluree.create_ledger(ledger_id).await.unwrap(); + + let ctx = json!({"ex": "http://example.org/ns/"}); + // Real vectors plus one non-vector string on the SAME predicate. + let real: [(&str, [f64; 2]); 4] = [ + ("ex:a", [0.9, 0.1]), + ("ex:b", [0.5, 0.5]), + ("ex:c", [0.1, 0.9]), + ("ex:d", [0.8, 0.2]), + ]; + let mut graph: Vec = real + .iter() + .map(|(id, v)| { + json!({ + "@id": id, + "ex:vec": {"@value": [v[0], v[1]], "@type": "https://ns.flur.ee/db#embeddingVector"} + }) + }) + .collect(); + graph.push(json!({"@id": "ex:bad", "ex:vec": "Not a Vector"})); + + let ledger = fluree + .insert(ledger0, &json!({"@context": ctx, "@graph": graph})) + .await + .expect("seed") + .ledger; + let _ = ledger; + fluree + .reindex(ledger_id, ReindexOptions::default()) + .await + .expect("reindex"); + let ledger = fluree.ledger(ledger_id).await.expect("reload indexed"); + + let target = [0.7_f64, 0.3_f64]; + let query = json!({ + "@context": ctx, + "select": ["?x", "?score"], + "values": [["?t"], [{"@value": [target[0], target[1]], "@type": "https://ns.flur.ee/db#embeddingVector"}]], + "where": [ + {"@id": "?x", "ex:vec": "?vec"}, + ["bind", "?score", ["dotProduct", "?vec", "?t"]], + ["filter", "(> ?score 0)"] + ], + "orderBy": [["desc", "?score"]], + "limit": 3 + }); + let result = support::query_jsonld(&fluree, &ledger, &query) + .await + .expect("runtime-bail query"); + let rows = result.to_jsonld(&ledger.snapshot).unwrap(); + let got: Vec<(String, f64)> = rows + .as_array() + .unwrap() + .iter() + .map(|r| { + let r = r.as_array().unwrap(); + (r[0].as_str().unwrap().to_string(), r[1].as_f64().unwrap()) + }) + .collect(); + + // Scalar ground truth over the real vectors only (f32-quantized at ingest; + // the target stays full precision). The non-vector subject contributes + // nothing, so it must be absent from the deferred pipeline's output. + let q = |x: f64| (x as f32) as f64; + let mut want: Vec<(String, f64)> = real + .iter() + .map(|(id, v)| (id.to_string(), q(v[0]) * target[0] + q(v[1]) * target[1])) + .filter(|(_, s)| *s > 0.0) + .collect(); + want.sort_by(|a, b| b.1.total_cmp(&a.1)); + want.truncate(3); + + assert_eq!( + got.len(), + want.len(), + "runtime bail must defer, not emit partial: got {got:?}" + ); + for (i, ((gid, gs), (wid, ws))) in got.iter().zip(&want).enumerate() { + assert_eq!(gid, wid, "rank {i} id: got {got:?} want {want:?}"); + assert!( + (gs - ws).abs() <= 1e-9 * ws.abs().max(1.0), + "rank {i} score {gs} != {ws}" + ); + } + assert!( + !got.iter().any(|(id, _)| id == "ex:bad"), + "non-vector subject must not appear: {got:?}" + ); +} diff --git a/fluree-db-binary-index/src/analyzer.rs b/fluree-db-binary-index/src/analyzer.rs index 9d7c846056..a056cb35f0 100644 --- a/fluree-db-binary-index/src/analyzer.rs +++ b/fluree-db-binary-index/src/analyzer.rs @@ -339,6 +339,27 @@ impl Analyzer { Self { tokenizer, filters } } + /// Process-wide shared analyzer for the given language. + /// + /// [`Self::for_language`] clones the language's whole stopword set on + /// every call, which is far too expensive for per-row use (query + /// evaluation scores one row at a time). Each language's analyzer is + /// built once on first use and leaked (bounded: one per `Language` + /// variant); all subsequent calls are a read-locked map probe. + pub fn shared(lang: Language) -> &'static Analyzer { + use std::collections::HashMap; + use std::sync::{LazyLock, RwLock}; + static SHARED: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + + if let Some(a) = SHARED.read().expect("analyzer cache poisoned").get(&lang) { + return a; + } + let mut map = SHARED.write().expect("analyzer cache poisoned"); + map.entry(lang) + .or_insert_with(|| Box::leak(Box::new(Analyzer::for_language(lang)))) + } + /// Analyze text into tokens. pub fn analyze(&self, text: &str) -> Vec { let mut tokens = self.tokenizer.tokenize(text); diff --git a/fluree-db-binary-index/src/arena/vector.rs b/fluree-db-binary-index/src/arena/vector.rs index 41e8893d12..0d7294dffe 100644 --- a/fluree-db-binary-index/src/arena/vector.rs +++ b/fluree-db-binary-index/src/arena/vector.rs @@ -626,6 +626,25 @@ impl VectorSlice { } } +/// All of a [`LazyVectorArena`]'s shards, pinned for direct zero-probe access. +pub struct VectorArenaSnapshot { + shards: Vec>, + shard_capacity: u32, + total_count: u32, +} + +impl VectorArenaSnapshot { + /// Zero-copy vector read by handle. `None` for out-of-range handles. + #[inline] + pub fn get_f32(&self, handle: u32) -> Option<&[f32]> { + if handle >= self.total_count { + return None; + } + let shard = self.shards.get((handle / self.shard_capacity) as usize)?; + shard.get_f32(handle % self.shard_capacity) + } +} + impl LazyVectorArena { /// Create a new lazy arena from a parsed manifest, shard source metadata, /// a shared cache handle, and an optional content store for remote fetching. @@ -825,6 +844,22 @@ impl LazyVectorArena { Ok(Some(VectorSlice { shard, offset })) } + /// Pin every shard once and return a direct-access snapshot. + /// + /// [`Self::lookup_vector`] pays a shard-cache probe (and its `Arc` clone) + /// per call; scan loops that touch most handles should resolve the shards + /// once up front and index into them directly. + pub fn snapshot(&self) -> io::Result { + let shards = (0..self.shard_count()) + .map(|i| self.load_shard_cached(i)) + .collect::>>()?; + Ok(VectorArenaSnapshot { + shards, + shard_capacity: self.manifest.shard_capacity, + total_count: self.manifest.total_count, + }) + } + /// Batch point-lookup. Sorts handles for sequential shard access, /// loading each shard at most once through the cache. pub fn lookup_many(&self, handles: &mut [u32], mut f: F) -> io::Result<()> diff --git a/fluree-db-binary-index/src/read/binary_index_store.rs b/fluree-db-binary-index/src/read/binary_index_store.rs index 6b0b4ebe77..c9d118b85b 100644 --- a/fluree-db-binary-index/src/read/binary_index_store.rs +++ b/fluree-db-binary-index/src/read/binary_index_store.rs @@ -978,7 +978,7 @@ impl BinaryIndexStore { ) })?; let f64_vec: Vec = vs.as_f32().iter().map(|&x| x as f64).collect(); - Ok(FlakeValue::Vector(f64_vec)) + Ok(FlakeValue::Vector(f64_vec.into())) } DecodeKind::SpatialArena => Err(io::Error::other( "spatial arena decode not yet implemented in V6", @@ -986,6 +986,52 @@ impl BinaryIndexStore { } } + /// Zero-copy lookup of an indexed vector by arena handle. + /// + /// Returns the shard-backed [`VectorSlice`](crate::arena::vector::VectorSlice) + /// for `(g_id, p_id, handle)` — `as_f32()` borrows the packed shard data + /// directly, with no per-call allocation or f64 widening (unlike the + /// `decode_value` path, which collects a fresh `Vec` per call). + /// `None` when the predicate has no arena or the handle is out of range + /// (e.g. an ephemeral novelty handle) — callers fall back to the + /// decoding path in that case. + pub fn vector_slice( + &self, + g_id: GraphId, + p_id: u32, + handle: u32, + ) -> io::Result> { + let arena = match self + .graph_indexes + .get(&g_id) + .and_then(|gi| gi.vectors.get(&p_id)) + { + Some(a) => a, + None => return Ok(None), + }; + arena.lookup_vector(handle) + } + + /// Pin all of `(g_id, p_id)`'s vector shards for direct scan access. + /// + /// See [`crate::arena::vector::LazyVectorArena::snapshot`] — scan loops + /// use this to avoid the per-row shard-cache probe that + /// [`Self::vector_slice`] pays. `None` when the predicate has no arena. + pub fn vector_arena_snapshot( + &self, + g_id: GraphId, + p_id: u32, + ) -> io::Result> { + match self + .graph_indexes + .get(&g_id) + .and_then(|gi| gi.vectors.get(&p_id)) + { + Some(arena) => Ok(Some(arena.snapshot()?)), + None => Ok(None), + } + } + /// Find the existing vector arena handle for the given quantized f32 /// `value` under `(g_id, p_id)`. Returns `None` if no arena exists for /// the predicate, or if the value isn't stored. diff --git a/fluree-db-core/src/coerce.rs b/fluree-db-core/src/coerce.rs index bdfe2d30d7..316ac2c4e3 100644 --- a/fluree-db-core/src/coerce.rs +++ b/fluree-db-core/src/coerce.rs @@ -758,7 +758,7 @@ fn coerce_array_to_vector(arr: &[serde_json::Value]) -> CoercionResult { buf.push(OTag::Vector as u8); encode_varint(v.len() as u64, buf); - for &element in v { + for &element in v.iter() { buf.extend_from_slice(&element.to_le_bytes()); } } @@ -656,7 +656,7 @@ mod tests { let flake = Flake::new( Sid::new(101, "x"), Sid::new(101, "embedding"), - FlakeValue::Vector(vec![1.0, 2.5, -3.7]), + FlakeValue::Vector(vec![1.0, 2.5, -3.7].into()), Sid::new(2, "vector"), 1, true, @@ -687,7 +687,7 @@ mod tests { let flake = Flake::new( Sid::new(101, "x"), Sid::new(101, "embedding"), - FlakeValue::Vector(vec![]), + FlakeValue::Vector(vec![].into()), Sid::new(2, "vector"), 1, true, @@ -702,6 +702,6 @@ mod tests { let mut pos = 0; let decoded = decode_op(&buf, &mut pos, &read_dicts, 1).unwrap(); assert_eq!(pos, buf.len()); - assert_eq!(decoded.o, FlakeValue::Vector(vec![])); + assert_eq!(decoded.o, FlakeValue::Vector(vec![].into())); } } diff --git a/fluree-db-core/src/commit/codec/raw_reader.rs b/fluree-db-core/src/commit/codec/raw_reader.rs index 0ad6c31769..df54c63850 100644 --- a/fluree-db-core/src/commit/codec/raw_reader.rs +++ b/fluree-db-core/src/commit/codec/raw_reader.rs @@ -274,7 +274,7 @@ impl<'a> TryFrom> for crate::FlakeValue { RawObject::GeoPoint { lat, lng } => GeoPointBits::new(lat, lng) .map(FlakeValue::GeoPoint) .ok_or_else(|| parse_err!("geo point", format!("({lat}, {lng})"))), - RawObject::Vector(vec) => Ok(FlakeValue::Vector(vec)), + RawObject::Vector(vec) => Ok(FlakeValue::Vector(vec.into())), } } } diff --git a/fluree-db-core/src/serde/flakes_transport.rs b/fluree-db-core/src/serde/flakes_transport.rs index b908f63ab1..3ef358a7a3 100644 --- a/fluree-db-core/src/serde/flakes_transport.rs +++ b/fluree-db-core/src/serde/flakes_transport.rs @@ -151,7 +151,7 @@ impl From<&FlakeValue> for TransportValue { FlakeValue::YearMonthDuration(v) => TransportValue::String(v.to_string()), FlakeValue::DayTimeDuration(v) => TransportValue::String(v.to_string()), FlakeValue::Duration(v) => TransportValue::String(v.to_string()), - FlakeValue::Vector(v) => TransportValue::Vector(v.clone()), + FlakeValue::Vector(v) => TransportValue::Vector(v.to_vec()), FlakeValue::Json(s) => TransportValue::Json(s.clone()), FlakeValue::GeoPoint(bits) => TransportValue::String(bits.to_string()), FlakeValue::Null => TransportValue::Null, @@ -200,7 +200,7 @@ impl TransportValue { .map_err(|e| FlakesTransportError::ParseError(format!("Time: {e}")))?; Ok(FlakeValue::Time(Box::new(t))) } - TransportValue::Vector(v) => Ok(FlakeValue::Vector(v.clone())), + TransportValue::Vector(v) => Ok(FlakeValue::Vector(v.as_slice().into())), TransportValue::Json(s) => Ok(FlakeValue::Json(s.clone())), TransportValue::Null => Ok(FlakeValue::Null), } @@ -409,7 +409,7 @@ mod tests { (FlakeValue::Double(3.13), "double"), (FlakeValue::Boolean(true), "bool"), (FlakeValue::Ref(make_sid(1, "ref")), "ref"), - (FlakeValue::Vector(vec![1.0, 2.0, 3.0]), "vector"), + (FlakeValue::Vector(vec![1.0, 2.0, 3.0].into()), "vector"), (FlakeValue::Json(r#"{"key":"value"}"#.to_string()), "json"), (FlakeValue::Null, "null"), ]; diff --git a/fluree-db-core/src/value.rs b/fluree-db-core/src/value.rs index 5e4a78b213..98cf4f41f8 100644 --- a/fluree-db-core/src/value.rs +++ b/fluree-db-core/src/value.rs @@ -35,6 +35,7 @@ use num_traits::ToPrimitive; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::fmt; +use std::sync::Arc; // ============================================================================ // GeoPointBits — Packed lat/lng for geographic point storage @@ -152,8 +153,14 @@ pub enum FlakeValue { Duration(Box), /// String value (xsd:string and other string-like types) String(String), - /// Dense vector/embedding (fluree:vector) - Vector(Vec), + /// Dense vector/embedding (fluree:vector). + /// + /// Reference-counted: values flow through scan → binding → batch → + /// output pipelines that clone per row, and embeddings are large + /// (a 1536-dim vector is 12 KB). `Arc` makes every clone a refcount + /// bump instead of a buffer copy. Serialized as a plain number array + /// (same wire form as the previous `Vec`). + Vector(#[serde(with = "vector_arc_serde")] Arc<[f64]>), /// JSON value (@json datatype) - stored as serialized JSON string /// Deserialized on output for queries Json(String), @@ -178,7 +185,8 @@ impl FlakeValue { pub fn max() -> Self { // IMPORTANT: we treat the empty vector as a special-case sentinel // that compares greater-than any non-empty vector (see Ord impl). - FlakeValue::Vector(Vec::new()) + static EMPTY: std::sync::LazyLock> = std::sync::LazyLock::new(|| Arc::from([])); + FlakeValue::Vector(Arc::clone(&EMPTY)) } /// Get the type discriminant for ordering @@ -719,7 +727,7 @@ impl FlakeValue { let mut hasher = Xxh64::new(0); hasher.update(&[0x0C]); // type tag hasher.update(&(v.len() as u64).to_le_bytes()); - for f in v { + for f in v.iter() { let canonical_bits = if f.is_nan() { CANONICAL_NAN_BITS } else if *f == 0.0 { @@ -977,7 +985,7 @@ impl std::hash::Hash for FlakeValue { FlakeValue::Vector(v) => { self.type_discriminant().hash(state); v.len().hash(state); - for f in v { + for f in v.iter() { f.to_bits().hash(state); } } @@ -1076,7 +1084,23 @@ impl From<&str> for FlakeValue { impl From> for FlakeValue { fn from(v: Vec) -> Self { - FlakeValue::Vector(v) + FlakeValue::Vector(v.into()) + } +} + +/// Serde adapter for `Arc<[f64]>` — serializes as a plain `f64` sequence +/// (identical wire form to `Vec`) without opting the whole crate into +/// serde's `rc` feature. +mod vector_arc_serde { + use std::sync::Arc; + + pub fn serialize(v: &Arc<[f64]>, s: S) -> Result { + serde::Serialize::serialize(&v[..], s) + } + + pub fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v: Vec = serde::Deserialize::deserialize(d)?; + Ok(v.into()) } } @@ -1240,6 +1264,21 @@ fn count_significant_digits(s: &str) -> usize { mod tests { use super::*; + /// `FlakeValue::Vector` is `Arc<[f64]>` behind a `serde(with)` adapter + /// inside a `serde(untagged)` enum — verify it still round-trips as a + /// plain number array (the wire form of the previous `Vec`). + #[test] + fn test_vector_serde_roundtrip_as_number_array() { + let val = FlakeValue::Vector(vec![0.5, -1.25, 3.0].into()); + let json = serde_json::to_string(&val).expect("serialize"); + assert_eq!(json, "[0.5,-1.25,3.0]"); + let back: FlakeValue = serde_json::from_str(&json).expect("deserialize"); + match back { + FlakeValue::Vector(v) => assert_eq!(&v[..], &[0.5, -1.25, 3.0]), + other => panic!("expected Vector, got {other:?}"), + } + } + #[test] fn test_type_ordering() { // Type ordering: Null < Ref < Boolean < Numeric < Temporal < String < Json < Vector @@ -1249,7 +1288,7 @@ mod tests { let long_val = FlakeValue::Long(42); let double_val = FlakeValue::Double(3.13); let string_val = FlakeValue::String("hello".to_string()); - let vector_val = FlakeValue::Vector(vec![1.0, 2.0]); + let vector_val = FlakeValue::Vector(vec![1.0, 2.0].into()); assert!(null < ref_val); assert!(ref_val < bool_val); @@ -1409,9 +1448,9 @@ mod tests { assert!(regular < max); // max sentinel should be greater than any "real" vector values - let v1 = FlakeValue::Vector(vec![0.0]); - let v2 = FlakeValue::Vector(vec![f64::MAX]); - let v3 = FlakeValue::Vector(vec![f64::MAX, 0.0]); + let v1 = FlakeValue::Vector(vec![0.0].into()); + let v2 = FlakeValue::Vector(vec![f64::MAX].into()); + let v3 = FlakeValue::Vector(vec![f64::MAX, 0.0].into()); assert!(v1 < max); assert!(v2 < max); assert!(v3 < max); diff --git a/fluree-db-query/src/dict_overlay.rs b/fluree-db-query/src/dict_overlay.rs index e07f23c052..3fdb13b540 100644 --- a/fluree-db-query/src/dict_overlay.rs +++ b/fluree-db-query/src/dict_overlay.rs @@ -87,7 +87,7 @@ pub struct DictOverlay { const EPHEMERAL_NUMBIG_BASE: u32 = 0x8000_0000; /// Handles above this value are ephemeral Vector entries from DictOverlay. -const EPHEMERAL_VECTOR_BASE: u32 = 0x8000_0000; +pub(crate) const EPHEMERAL_VECTOR_BASE: u32 = 0x8000_0000; /// Base local ID for ephemeral subjects within a namespace. /// @@ -577,7 +577,7 @@ impl DictOverlay { FlakeValue::Decimal(_) => Ok(self.assign_numbig_handle(val)), - FlakeValue::Vector(v) => Ok(self.assign_vector_handle(v.as_slice())), + FlakeValue::Vector(v) => Ok(self.assign_vector_handle(v)), FlakeValue::GeoPoint(bits) => Ok((ObjKind::GEO_POINT, ObjKey::from_u64(bits.as_u64()))), } @@ -645,7 +645,7 @@ impl DictOverlay { if handle >= EPHEMERAL_VECTOR_BASE { let idx = (handle - EPHEMERAL_VECTOR_BASE) as usize; if let Some(v) = self.ext_vectors.get(idx) { - return Ok(FlakeValue::Vector(v.clone())); + return Ok(FlakeValue::Vector(v.as_slice().into())); } } } diff --git a/fluree-db-query/src/eval/fulltext.rs b/fluree-db-query/src/eval/fulltext.rs index 670278d4bd..260ee0ac9e 100644 --- a/fluree-db-query/src/eval/fulltext.rs +++ b/fluree-db-query/src/eval/fulltext.rs @@ -40,11 +40,6 @@ use fluree_db_core::value_id::ObjKind; use fluree_db_core::{FlakeValue, OverlayProvider, Sid}; use fluree_vocab::namespaces::FLUREE_DB; -/// Lazily-initialized English analyzer (reused for the `@fulltext` datatype -/// path which is always English — kept for backward compatibility of -/// non-config callers; new code should prefer `Analyzer::for_language(...)`). -static ENGLISH_ANALYZER: Lazy = Lazy::new(Analyzer::english_default); - /// BM25 parameters const K1: f64 = 1.2; const B: f64 = 0.75; @@ -419,83 +414,202 @@ fn effective_df( df.min(n_prime) } -/// Score an indexed doc (from arena DocBoW) with unified BM25. -/// -/// Iterates query terms directly against DocBoW via binary search — -/// no HashMap allocation per row. -fn score_bm25_indexed( - arena: &FulltextArena, - doc_bow: &DocBoW, - query_terms: &[String], - delta: Option<&NoveltyFulltextDelta>, -) -> f64 { - let stats = compute_effective_stats(arena, delta); - let dl = doc_bow.doc_len as f64; - let mut score = 0.0; - - for term in query_terms { - let df = effective_df(term, arena, delta, stats.n); - let idf = ((stats.n - df + 0.5) / (df + 0.5) + 1.0).ln(); - - // Look up TF via arena term_id → binary search in DocBoW.terms - let tf = arena - .term_id(term) - .and_then(|tid| doc_bow.terms.binary_search_by_key(&tid, |(id, _)| *id).ok()) - .map(|idx| doc_bow.terms[idx].1 as f64) - .unwrap_or(0.0); +/// Analyze query text with the caller-provided analyzer and deduplicate stems. +fn analyze_and_dedup_query_with(analyzer: &Analyzer, query_text: &str) -> Vec { + let mut terms = analyzer.analyze_to_strings(query_text); + terms.sort(); + terms.dedup(); + terms +} - if tf > 0.0 { - let tf_norm = (tf * (K1 + 1.0)) / (tf + K1 * (1.0 - B + B * dl / stats.avgdl)); - score += idf * tf_norm; - } - } +// ============================================================================= +// Prepared per-query scoring +// ============================================================================= - score +/// One analyzed query stem with its precomputed scoring inputs. +struct PreparedTerm { + /// The analyzed stem — used to probe novelty-doc term freqs. + stem: String, + /// Arena term id, if the stem is indexed — used to probe DocBoW. + arena_term_id: Option, + /// Effective-IDF weight: `ln((N' - df' + 0.5) / (df' + 0.5) + 1)`, + /// where `N'`/`df'` are arena stats merged with the novelty delta. + idf: f64, } -/// Score a novelty/decoded doc with unified BM25 using explicit term_freqs. -fn score_bm25_novelty( - doc_term_freqs: &HashMap, - doc_len: u32, - query_terms: &[String], - arena: &FulltextArena, - delta: Option<&NoveltyFulltextDelta>, -) -> f64 { - let stats = compute_effective_stats(arena, delta); - let dl = doc_len as f64; - let mut score = 0.0; +/// Query-invariant scoring state for one `(arena bucket, query string)` pair. +/// +/// `eval_fulltext` runs once per bound row; everything here — bucket +/// analyzer choice, query tokenize+stem, per-term arena term ids, IDF +/// weights, effective corpus stats — depends only on the bucket and the +/// query string. Computed once and cached (`PREPARED_CACHE`), so scoring a +/// row reduces to a `doc_bow` probe plus one binary search per query term. +struct PreparedFulltextQuery { + /// Bucket language — needed again only on the fallback path where an + /// unindexed document's text must be analyzed per row. + language: Language, + /// One entry per distinct query stem, in sorted stem order (the order + /// the analyze+dedup produces, so score accumulation matches the + /// unprepared reference formula term-for-term). + terms: Vec, + /// Effective average document length (arena + novelty delta). + avgdl: f64, +} - for term in query_terms { - let df = effective_df(term, arena, delta, stats.n); - let idf = ((stats.n - df + 0.5) / (df + 0.5) + 1.0).ln(); +impl PreparedFulltextQuery { + /// Compute prepared state for `stems` against one arena bucket. + /// + /// Pure function of its inputs — the caching / context plumbing lives in + /// [`get_or_prepare`]. + fn compute( + arena: &FulltextArena, + delta: Option<&NoveltyFulltextDelta>, + language: Language, + stems: Vec, + ) -> Self { + let stats = compute_effective_stats(arena, delta); + let terms = stems + .into_iter() + .map(|stem| { + let df = effective_df(&stem, arena, delta, stats.n); + let idf = ((stats.n - df + 0.5) / (df + 0.5) + 1.0).ln(); + PreparedTerm { + arena_term_id: arena.term_id(&stem), + idf, + stem, + } + }) + .collect(); + Self { + language, + terms, + avgdl: stats.avgdl, + } + } - let tf = doc_term_freqs.get(term.as_str()).copied().unwrap_or(0) as f64; + /// BM25 TF-saturation component for one term occurrence count. + #[inline] + fn tf_norm(&self, tf: f64, dl: f64) -> f64 { + (tf * (K1 + 1.0)) / (tf + K1 * (1.0 - B + B * dl / self.avgdl)) + } - if tf > 0.0 { - let tf_norm = (tf * (K1 + 1.0)) / (tf + K1 * (1.0 - B + B * dl / stats.avgdl)); - score += idf * tf_norm; + /// Score an indexed doc from its arena BoW — no text analysis, no + /// string hashing: one `u32` binary search per query term. + fn score_indexed(&self, doc_bow: &DocBoW) -> f64 { + let dl = doc_bow.doc_len as f64; + let mut score = 0.0; + for term in &self.terms { + let tf = term + .arena_term_id + .and_then(|tid| doc_bow.terms.binary_search_by_key(&tid, |(id, _)| *id).ok()) + .map(|idx| doc_bow.terms[idx].1 as f64) + .unwrap_or(0.0); + if tf > 0.0 { + score += term.idf * self.tf_norm(tf, dl); + } } + score } - score + /// Score an unindexed (novelty/decoded) doc from analyzed term freqs. + fn score_novelty_doc(&self, doc_term_freqs: &HashMap, doc_len: u32) -> f64 { + let dl = doc_len as f64; + let mut score = 0.0; + for term in &self.terms { + let tf = doc_term_freqs.get(term.stem.as_str()).copied().unwrap_or(0) as f64; + if tf > 0.0 { + score += term.idf * self.tf_norm(tf, dl); + } + } + score + } } -/// Analyze query text with the default English analyzer and deduplicate stems. +/// Cache key for prepared per-query scoring state. /// -/// Used on the `@fulltext`-datatype path where the bucket is always English. -/// For language-aware lookup the caller should use -/// [`analyze_and_dedup_query_with`] with the bucket's analyzer so the query -/// stems match the arena's indexed stems. -fn analyze_and_dedup_query(query_text: &str) -> Vec { - analyze_and_dedup_query_with(&ENGLISH_ANALYZER, query_text) +/// `store_id` is the process-unique `BinaryIndexStore` instance id — it +/// changes on every store reload/rebuild, so prepared term ids can never +/// outlive the arena whose term dictionary they index into. `epoch`/`to_t` +/// invalidate on novelty change (they parameterize the delta baked into the +/// IDF weights). +#[derive(Clone, PartialEq, Eq, Hash)] +struct PreparedKey { + store_id: u64, + epoch: u64, + to_t: i64, + g_id: fluree_db_core::GraphId, + p_id: u32, + lang_id: u16, + analyzer_version: u8, + query: Arc, } -/// Analyze query text with the caller-provided analyzer and deduplicate stems. -fn analyze_and_dedup_query_with(analyzer: &Analyzer, query_text: &str) -> Vec { - let mut terms = analyzer.analyze_to_strings(query_text); - terms.sort(); - terms.dedup(); - terms +static PREPARED_CACHE: Lazy>> = + Lazy::new(|| moka::sync::Cache::builder().max_capacity(64).build()); + +thread_local! { + /// One-slot memo in front of `PREPARED_CACHE`: rows arrive in long runs + /// of the same `(bucket, query)`, so consecutive hits skip the moka + /// probe (and its key hash of the query string) entirely. + static LAST_PREPARED: std::cell::RefCell)>> = + const { std::cell::RefCell::new(None) }; +} + +/// Get (or compute and cache) the prepared scoring state for one arena +/// bucket and query string. +fn get_or_prepare( + ctx: &ExecutionContext<'_>, + arena: &FulltextArena, + g_id: fluree_db_core::GraphId, + p_id: u32, + lang_id: u16, + query_str: &Arc, +) -> Arc { + let key = PreparedKey { + store_id: ctx.binary_store.as_ref().map(|s| s.store_id()).unwrap_or(0), + epoch: ctx + .overlay + .map(fluree_db_core::OverlayProvider::epoch) + .unwrap_or(0), + to_t: ctx.to_t, + g_id, + p_id, + lang_id, + analyzer_version: ANALYZER_VERSION, + query: Arc::clone(query_str), + }; + + if let Some(hit) = LAST_PREPARED.with(|slot| { + slot.borrow() + .as_ref() + .filter(|(k, _)| *k == key) + .map(|(_, v)| Arc::clone(v)) + }) { + return hit; + } + + let prepared = PREPARED_CACHE.get_with(key.clone(), || { + // Bucket's BCP-47 tag → analyzer; same choice that built the arena. + let bucket_tag: String = ctx + .binary_store + .as_ref() + .and_then(|s| s.resolve_language_tag(lang_id)) + .unwrap_or_else(|| "en".to_string()); + let language = Language::from_bcp47(&bucket_tag); + let stems = analyze_and_dedup_query_with(Analyzer::shared(language), query_str); + let delta = get_or_build_delta(ctx, g_id, p_id, lang_id); + Arc::new(PreparedFulltextQuery::compute( + arena, + delta.as_deref(), + language, + stems, + )) + }); + + LAST_PREPARED.with(|slot| { + *slot.borrow_mut() = Some((key, Arc::clone(&prepared))); + }); + prepared } // ============================================================================= @@ -577,27 +691,15 @@ pub fn eval_fulltext( .fulltext_providers .and_then(|providers| providers.get(&(g_id, *p_id, lookup_lang_id))) { - // Bucket's BCP-47 tag → analyzer; same choice that built the arena. - let bucket_tag: String = ctx - .binary_store - .as_ref() - .and_then(|s| s.resolve_language_tag(lookup_lang_id)) - .unwrap_or_else(|| "en".to_string()); - let bucket_language = Language::from_bcp47(&bucket_tag); - let bucket_analyzer = Analyzer::for_language(bucket_language); - let query_terms = - analyze_and_dedup_query_with(&bucket_analyzer, &query_str); - if query_terms.is_empty() { + let prepared = + get_or_prepare(ctx, arena, g_id, *p_id, lookup_lang_id, &query_str); + if prepared.terms.is_empty() { return Ok(Some(ComparableValue::Double(0.0))); } - let delta = get_or_build_delta(ctx, g_id, *p_id, lookup_lang_id); - if let Some(bow) = arena.doc_bow(*o_key as u32) { // Indexed doc: score directly from arena BoW - let score = - score_bm25_indexed(arena, bow, &query_terms, delta.as_deref()); - return Ok(Some(ComparableValue::Double(score))); + return Ok(Some(ComparableValue::Double(prepared.score_indexed(bow)))); } // Doc not in arena (novelty doc appearing as EncodedLit — rare). @@ -606,15 +708,10 @@ pub fn eval_fulltext( if let Ok(FlakeValue::String(text)) = gv.decode_value_from_kind(*o_kind, *o_key, *p_id, *dt_id, *lang_id) { - let doc_term_freqs = bucket_analyzer.analyze_to_term_freqs(&text); + let doc_term_freqs = Analyzer::shared(prepared.language) + .analyze_to_term_freqs(&text); let doc_len = doc_term_freqs.values().sum::(); - let score = score_bm25_novelty( - &doc_term_freqs, - doc_len, - &query_terms, - arena, - delta.as_deref(), - ); + let score = prepared.score_novelty_doc(&doc_term_freqs, doc_len); return Ok(Some(ComparableValue::Double(score))); } } @@ -657,47 +754,79 @@ pub fn eval_fulltext( p_id: lit_p_id, .. } => { - // Check if the datatype matches @fulltext (Sid fields: namespace_code, name) - let dt = dtc.datatype(); - if !(dt.namespace_code == FLUREE_DB && dt.name.as_ref() == "fullText") { - return Ok(None); - } - let text = match val { FlakeValue::String(s) => s.as_str(), _ => return Ok(None), }; - // If p_id is available (from early materialization), use unified BM25 + // Check if the datatype matches @fulltext (Sid fields: namespace_code, name) + let dt = dtc.datatype(); + let is_fulltext_dt = dt.namespace_code == FLUREE_DB && dt.name.as_ref() == "fullText"; + + // Arena-based unified BM25 scoring — mirrors the EncodedLit arm. + // + // Materialized rows are the NORM whenever the ledger carries + // novelty: BinaryScanOperator disables late materialization for + // overlay epochs != 0, so every scanned value arrives here as + // `Binding::Lit` with `p_id` attached. Configured (non-`@fulltext`) + // values must score via their arena on this path too, otherwise + // `fulltext()` goes unbound for EVERY row the moment one + // unindexed commit exists. if let (Some(p_id), Some(ctx)) = (lit_p_id, ctx) { let g_id = ctx.binary_g_id; - // `@fulltext`-datatype values carry no lang tag — always route - // through the English bucket, keyed by the dict-assigned `"en"` lang_id. - let lookup_lang_id = ctx.english_lang_id.unwrap_or(0); + // Resolve arena lang_id: + // 1. `@fulltext` datatype (never lang-tagged) → English bucket. + // 2. lang-tagged (rdf:langString) → the tag's dict-assigned id. + // 3. untagged strings → English bucket. + let lookup_lang_id = match dtc.lang_tag() { + Some(tag) => ctx + .binary_store + .as_ref() + .and_then(|s| s.resolve_lang_id(tag)) + .unwrap_or(0), + None => ctx.english_lang_id.unwrap_or(0), + }; if let Some(arena) = ctx .fulltext_providers .and_then(|providers| providers.get(&(g_id, *p_id, lookup_lang_id))) { - let query_terms = analyze_and_dedup_query(&query_str); - if query_terms.is_empty() { + let prepared = + get_or_prepare(ctx, arena, g_id, *p_id, lookup_lang_id, &query_str); + if prepared.terms.is_empty() { return Ok(Some(ComparableValue::Double(0.0))); } - let delta = get_or_build_delta(ctx, g_id, *p_id, lookup_lang_id); - let doc_term_freqs = ENGLISH_ANALYZER.analyze_to_term_freqs(text); + // Indexed fast path: a materialized row usually carries a + // value that IS in the arena — resolve its string_id and + // score from the precomputed BoW instead of re-analyzing + // the document text per row. + if let Some(bow) = ctx + .binary_store + .as_ref() + .and_then(|store| { + resolve_string_id(store, ctx.dict_novelty.as_deref(), text) + }) + .and_then(|string_id| arena.doc_bow(string_id)) + { + return Ok(Some(ComparableValue::Double(prepared.score_indexed(bow)))); + } + + // Genuinely unindexed value (novelty doc): analyze and + // score with unified BM25. + let doc_term_freqs = + Analyzer::shared(prepared.language).analyze_to_term_freqs(text); let doc_len = doc_term_freqs.values().sum::(); - let score = score_bm25_novelty( - &doc_term_freqs, - doc_len, - &query_terms, - arena, - delta.as_deref(), - ); + let score = prepared.score_novelty_doc(&doc_term_freqs, doc_len); return Ok(Some(ComparableValue::Double(score))); } } - // Fallback: TF-saturation (no arena or no p_id) + // No arena (or no p_id): preserve pre-config semantics — + // non-`@fulltext` string values stay unbound; `@fulltext` values + // fall back to TF-saturation. + if !is_fulltext_dt { + return Ok(None); + } let score = score_tf_saturation(text, &query_str); Ok(Some(ComparableValue::Double(score))) } @@ -710,7 +839,7 @@ pub fn eval_fulltext( /// Fallback path used when no FulltextArena is available. Uses only per-document /// term-frequency saturation without corpus-wide IDF or avgdl normalization. fn score_tf_saturation(doc_text: &str, query_text: &str) -> f64 { - let analyzer = &*ENGLISH_ANALYZER; + let analyzer = Analyzer::shared(Language::English); let query_terms = analyzer.analyze_to_strings(query_text); if query_terms.is_empty() { @@ -803,13 +932,23 @@ mod tests { assert_eq!(score, 0.0); } + /// Prepared English-bucket scoring state for `query` against `arena`. + fn prepare( + arena: &FulltextArena, + delta: Option<&NoveltyFulltextDelta>, + query: &str, + ) -> PreparedFulltextQuery { + let stems = analyze_and_dedup_query_with(Analyzer::shared(Language::English), query); + PreparedFulltextQuery::compute(arena, delta, Language::English, stems) + } + /// Helper to build a FulltextArena from text strings (analyze + insert). /// /// Uses two-pass approach to avoid term_id shifting: /// 1. Collect all unique terms from all documents /// 2. Build BoWs with stable term_ids fn build_test_arena(docs: &[(u32, &str)]) -> FulltextArena { - let analyzer = &*ENGLISH_ANALYZER; + let analyzer = Analyzer::shared(Language::English); let mut arena = FulltextArena::new(); // Pass 1: collect all unique terms across all documents @@ -854,16 +993,16 @@ mod tests { (30, "database query engine performance"), ]); - let query_terms = analyze_and_dedup_query("cargo"); + let prepared = prepare(&arena, None, "cargo"); - // score_bm25_indexed with no delta + // Indexed scoring with no delta let bow_10 = arena.doc_bow(10).unwrap(); let bow_20 = arena.doc_bow(20).unwrap(); let bow_30 = arena.doc_bow(30).unwrap(); - let score_10 = score_bm25_indexed(&arena, bow_10, &query_terms, None); - let score_20 = score_bm25_indexed(&arena, bow_20, &query_terms, None); - let score_30 = score_bm25_indexed(&arena, bow_30, &query_terms, None); + let score_10 = prepared.score_indexed(bow_10); + let score_20 = prepared.score_indexed(bow_20); + let score_30 = prepared.score_indexed(bow_30); assert!(score_10 > 0.0, "Doc with 'cargo' should score > 0"); assert!(score_20 > 0.0, "Doc with 'cargo' should score > 0"); @@ -883,16 +1022,17 @@ mod tests { (20, "database performance tuning"), ]); - let query_terms = analyze_and_dedup_query("cargo runner"); + let prepared = prepare(&arena, None, "cargo runner"); // Score with unified (no delta) should produce same result as arena.score_bm25 let bow = arena.doc_bow(10).unwrap(); - let unified_score = score_bm25_indexed(&arena, bow, &query_terms, None); + let unified_score = prepared.score_indexed(bow); // Compare with arena's own scoring - let arena_term_ids: Vec = query_terms + let arena_term_ids: Vec = prepared + .terms .iter() - .filter_map(|t| arena.term_id(t)) + .filter_map(|t| t.arena_term_id) .collect(); let arena_score = arena.score_bm25(10, &arena_term_ids); @@ -909,8 +1049,6 @@ mod tests { (20, "database performance tuning"), ]); - let query_terms = analyze_and_dedup_query("cargo"); - // Create a delta simulating one novelty assertion with "cargo" in it let delta = NoveltyFulltextDelta { delta_df: HashMap::from([("cargo".to_string(), 1)]), @@ -919,8 +1057,8 @@ mod tests { }; let bow = arena.doc_bow(10).unwrap(); - let score_no_delta = score_bm25_indexed(&arena, bow, &query_terms, None); - let score_with_delta = score_bm25_indexed(&arena, bow, &query_terms, Some(&delta)); + let score_no_delta = prepare(&arena, None, "cargo").score_indexed(bow); + let score_with_delta = prepare(&arena, Some(&delta), "cargo").score_indexed(bow); // With an extra doc containing "cargo", the IDF of "cargo" should decrease // (it's now present in more documents), so the score should be lower @@ -937,16 +1075,15 @@ mod tests { (20, "database performance tuning"), ]); - let query_terms = analyze_and_dedup_query("cargo"); + let prepared = prepare(&arena, None, "cargo"); // A novelty doc with the same content as doc 10 should score identically - let doc_term_freqs = ENGLISH_ANALYZER.analyze_to_term_freqs("cargo nextest runner"); + let doc_term_freqs = + Analyzer::shared(Language::English).analyze_to_term_freqs("cargo nextest runner"); let doc_len = doc_term_freqs.values().sum::(); - let indexed_score = - score_bm25_indexed(&arena, arena.doc_bow(10).unwrap(), &query_terms, None); - let novelty_score = - score_bm25_novelty(&doc_term_freqs, doc_len, &query_terms, &arena, None); + let indexed_score = prepared.score_indexed(arena.doc_bow(10).unwrap()); + let novelty_score = prepared.score_novelty_doc(&doc_term_freqs, doc_len); assert!( (indexed_score - novelty_score).abs() < 1e-10, @@ -981,7 +1118,8 @@ mod tests { #[test] fn test_analyze_and_dedup_query() { // Duplicate terms should be deduplicated - let terms = analyze_and_dedup_query("cargo cargo cargo"); + let terms = + analyze_and_dedup_query_with(Analyzer::shared(Language::English), "cargo cargo cargo"); // After stemming, "cargo" stays "cargo", should appear once let cargo_count = terms.iter().filter(|t| t.as_str() == "cargo").count(); assert_eq!( diff --git a/fluree-db-query/src/eval/helpers.rs b/fluree-db-query/src/eval/helpers.rs index 51d475d194..c88b45be49 100644 --- a/fluree-db-query/src/eval/helpers.rs +++ b/fluree-db-query/src/eval/helpers.rs @@ -541,7 +541,7 @@ fn hash_flake_value(value: &FlakeValue, state: &mut impl Hasher) { FlakeValue::Duration(v) => v.original().hash(state), FlakeValue::String(v) => v.hash(state), FlakeValue::Vector(v) => { - for item in v { + for item in v.iter() { item.to_bits().hash(state); } } diff --git a/fluree-db-query/src/eval/value.rs b/fluree-db-query/src/eval/value.rs index 4dc8d142ed..2b91d1ba95 100644 --- a/fluree-db-query/src/eval/value.rs +++ b/fluree-db-query/src/eval/value.rs @@ -483,7 +483,7 @@ impl ComparableValue { )), ComparableValue::Sid(sid) => Ok(Binding::sid(sid)), ComparableValue::Vector(v) => Ok(Binding::lit( - FlakeValue::Vector(v.to_vec()), + FlakeValue::Vector(v), datatypes.fluree_vector.clone(), )), ComparableValue::BigInt(n) => Ok(Binding::lit( @@ -602,7 +602,7 @@ impl TryFrom<&FlakeValue> for ComparableValue { FlakeValue::Boolean(b) => Ok(ComparableValue::Bool(*b)), FlakeValue::Ref(sid) => Ok(ComparableValue::Sid(sid.clone())), FlakeValue::Null => Err(NullValueError), - FlakeValue::Vector(v) => Ok(ComparableValue::Vector(Arc::from(v.as_slice()))), + FlakeValue::Vector(v) => Ok(ComparableValue::Vector(Arc::clone(v))), FlakeValue::BigInt(n) => Ok(ComparableValue::BigInt(n.clone())), FlakeValue::Decimal(d) => Ok(ComparableValue::Decimal(d.clone())), FlakeValue::DateTime(dt) => Ok(ComparableValue::DateTime(dt.as_ref().clone())), @@ -636,7 +636,7 @@ impl TryFrom for ComparableValue { FlakeValue::Boolean(b) => Ok(ComparableValue::Bool(b)), FlakeValue::Ref(sid) => Ok(ComparableValue::Sid(sid)), FlakeValue::Null => Err(NullValueError), - FlakeValue::Vector(v) => Ok(ComparableValue::Vector(Arc::from(v))), + FlakeValue::Vector(v) => Ok(ComparableValue::Vector(v)), FlakeValue::BigInt(n) => Ok(ComparableValue::BigInt(n)), FlakeValue::Decimal(d) => Ok(ComparableValue::Decimal(d)), FlakeValue::DateTime(dt) => Ok(ComparableValue::DateTime(*dt)), @@ -667,7 +667,7 @@ impl From for FlakeValue { ComparableValue::String(s) => FlakeValue::String(s.to_string()), ComparableValue::Bool(b) => FlakeValue::Boolean(b), ComparableValue::Sid(sid) => FlakeValue::Ref(sid), - ComparableValue::Vector(v) => FlakeValue::Vector(v.to_vec()), + ComparableValue::Vector(v) => FlakeValue::Vector(v), ComparableValue::BigInt(n) => FlakeValue::BigInt(n), ComparableValue::Decimal(d) => FlakeValue::Decimal(d), ComparableValue::DateTime(dt) => FlakeValue::DateTime(Box::new(dt)), @@ -688,7 +688,7 @@ impl From<&ComparableValue> for FlakeValue { ComparableValue::String(s) => FlakeValue::String(s.to_string()), ComparableValue::Bool(b) => FlakeValue::Boolean(*b), ComparableValue::Sid(sid) => FlakeValue::Ref(sid.clone()), - ComparableValue::Vector(v) => FlakeValue::Vector(v.to_vec()), + ComparableValue::Vector(v) => FlakeValue::Vector(Arc::clone(v)), ComparableValue::BigInt(n) => FlakeValue::BigInt(n.clone()), ComparableValue::Decimal(d) => FlakeValue::Decimal(d.clone()), ComparableValue::DateTime(dt) => FlakeValue::DateTime(Box::new(dt.clone())), diff --git a/fluree-db-query/src/eval/vector.rs b/fluree-db-query/src/eval/vector.rs index 983d5efd11..39ebcb0d07 100644 --- a/fluree-db-query/src/eval/vector.rs +++ b/fluree-db-query/src/eval/vector.rs @@ -1,14 +1,27 @@ //! Vector function implementations //! -//! Implements vector/embedding functions: dotProduct, cosineSimilarity, euclideanDistance +//! Implements vector/embedding functions: dotProduct, cosineSimilarity, euclideanDistance. +//! +//! These run once per bound row (flat ranking over every value of a vector +//! property), so the per-row path is kept allocation-free: arguments are +//! resolved to borrowed `&[f64]` slices straight from the row's bindings +//! (or the expression's constant) instead of round-tripping through +//! `ComparableValue`, which would alloc + copy the full vector — including +//! the query vector, which never changes across rows. The math itself goes +//! through the runtime-dispatched SIMD kernels in [`super::vector_math`]. + +use std::sync::Arc; -use crate::binding::RowAccess; +use crate::binding::{Binding, RowAccess}; use crate::context::ExecutionContext; use crate::error::{QueryError, Result}; use crate::ir::Expression; +use fluree_db_core::{FlakeValue, ObjKind}; + use super::helpers::check_arity; use super::value::ComparableValue; +use super::vector_math; pub fn eval_dot_product( args: &[Expression], @@ -16,7 +29,7 @@ pub fn eval_dot_product( ctx: Option<&ExecutionContext<'_>>, ) -> Result> { eval_binary_vector_fn(args, row, ctx, "dotProduct", |a, b| { - Some(a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()) + Some(vector_math::dot_f64(a, b)) }) } @@ -25,16 +38,9 @@ pub fn eval_cosine_similarity( row: &R, ctx: Option<&ExecutionContext<'_>>, ) -> Result> { - eval_binary_vector_fn(args, row, ctx, "cosineSimilarity", |a, b| { - let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); - let mag_a: f64 = a.iter().map(|x| x * x).sum::().sqrt(); - let mag_b: f64 = b.iter().map(|x| x * x).sum::().sqrt(); - if mag_a == 0.0 || mag_b == 0.0 { - None // mathematically undefined, not a type error - } else { - Some(dot / (mag_a * mag_b)) - } - }) + // Zero-magnitude input is mathematically undefined, not a type error → + // the kernel returns None and the row's score is unbound. + eval_binary_vector_fn(args, row, ctx, "cosineSimilarity", vector_math::cosine_f64) } pub fn eval_euclidean_distance( @@ -43,19 +49,111 @@ pub fn eval_euclidean_distance( ctx: Option<&ExecutionContext<'_>>, ) -> Result> { eval_binary_vector_fn(args, row, ctx, "euclideanDistance", |a, b| { - let sum_sq: f64 = a - .iter() - .zip(b.iter()) - .map(|(x, y)| { - let diff = x - y; - diff * diff - }) - .sum(); - Some(sum_sq.sqrt()) + Some(vector_math::l2_f64(a, b)) }) } -/// Evaluate a binary vector function +/// A resolved vector argument: borrowed from the row/expression wherever +/// possible, a shared handle when the value came from a nested expression, +/// or a zero-copy f32 shard slice for indexed (arena) vectors. +enum VecArg<'a> { + Slice(&'a [f64]), + Shared(Arc<[f64]>), + /// Indexed vector borrowed straight from a packed f32 shard. Widened + /// into a reusable scratch buffer at compute time — same values and + /// same f64 kernels as the decode path, but without its per-row + /// `Vec` + `Arc` allocations. + F32(fluree_db_binary_index::arena::vector::VectorSlice), +} + +impl VecArg<'_> { + /// View as `&[f64]`, widening f32 shard data into `scratch` if needed. + fn as_f64<'s>(&'s self, scratch: &'s mut Vec) -> &'s [f64] { + match self { + VecArg::Slice(s) => s, + VecArg::Shared(a) => a, + VecArg::F32(slice) => { + let f32s = slice.as_f32(); + scratch.clear(); + scratch.extend(f32s.iter().map(|&x| f64::from(x))); + &scratch[..] + } + } + } +} + +thread_local! { + /// Reused widening buffers for `VecArg::F32` arguments (one per side) + /// — avoids a per-row allocation on the indexed-vector path. + static WIDEN_SCRATCH: std::cell::RefCell<(Vec, Vec)> = + const { std::cell::RefCell::new((Vec::new(), Vec::new())) }; +} + +/// Resolve one argument to a vector without copying when avoidable. +/// +/// Returns `Ok(None)` for unbound variables and non-vector values (the +/// caller maps that to an unbound result, mirroring the other eval fns). +fn resolve_vector_arg<'a, R: RowAccess>( + expr: &'a Expression, + row: &'a R, + ctx: Option<&ExecutionContext<'_>>, +) -> Result>> { + match expr { + // Row/VALUES-bound variable: borrow the vector in place. + Expression::Var(var) => match row.get(*var) { + Some(Binding::Lit { + val: FlakeValue::Vector(v), + .. + }) => Ok(Some(VecArg::Slice(v))), + Some(Binding::EncodedLit { + o_kind, + o_key, + p_id, + dt_id, + lang_id, + .. + }) => { + // Indexed vector fast path: borrow the packed f32 shard data + // zero-copy. Misses (no arena for the predicate, ephemeral + // novelty handle) fall through to the generic decode below. + if ObjKind::from_u8(*o_kind) == ObjKind::VECTOR_ID { + if let Some(ctx) = ctx { + if let Some(slice) = ctx.binary_store.as_ref().and_then(|store| { + store + .vector_slice(ctx.binary_g_id, *p_id, *o_key as u32) + .ok() + .flatten() + }) { + return Ok(Some(VecArg::F32(slice))); + } + } + } + let Some(decoded) = ctx + .and_then(|c| c.decode_encoded_value(*o_kind, *o_key, *p_id, *dt_id, *lang_id)) + else { + return Ok(None); + }; + match decoded { + Ok(FlakeValue::Vector(v)) => Ok(Some(VecArg::Shared(v))), + Ok(_) => Ok(None), + Err(e) => Err(QueryError::execution(format!( + "decode encoded vector (o_kind={o_kind}, o_key={o_key}, p_id={p_id}): {e}" + ))), + } + } + _ => Ok(None), + }, + // Inline constant vector: borrow from the expression itself. + Expression::Const(FlakeValue::Vector(v)) => Ok(Some(VecArg::Slice(v))), + // Anything else (nested expression): evaluate and keep the Arc. + _ => match expr.eval_to_comparable(row, ctx)? { + Some(ComparableValue::Vector(v)) => Ok(Some(VecArg::Shared(v))), + _ => Ok(None), + }, + } +} + +/// Evaluate a binary vector function over borrowed slices. fn eval_binary_vector_fn( args: &[Expression], row: &R, @@ -67,10 +165,13 @@ where F: Fn(&[f64], &[f64]) -> Option, { check_arity(args, 2, fn_name)?; - let v1 = args[0].eval_to_comparable(row, ctx)?; - let v2 = args[1].eval_to_comparable(row, ctx)?; + let v1 = resolve_vector_arg(&args[0], row, ctx)?; + let v2 = resolve_vector_arg(&args[1], row, ctx)?; match (v1, v2) { - (Some(ComparableValue::Vector(a)), Some(ComparableValue::Vector(b))) => { + (Some(a), Some(b)) => WIDEN_SCRATCH.with(|cell| { + let scratch = &mut *cell.borrow_mut(); + let a = a.as_f64(&mut scratch.0); + let b = b.as_f64(&mut scratch.1); if a.len() != b.len() { Err(QueryError::InvalidFilter(format!( "{} requires vectors of equal length (got {} and {})", @@ -79,9 +180,9 @@ where b.len() ))) } else { - Ok(compute(&a, &b).map(ComparableValue::Double)) + Ok(compute(a, b).map(ComparableValue::Double)) } - } + }), // Type mismatch or unbound -> return None (SPARQL-style graceful handling) _ => Ok(None), } diff --git a/fluree-db-query/src/eval/vector_math.rs b/fluree-db-query/src/eval/vector_math.rs index 311a83b4ae..afb6a23e9d 100644 --- a/fluree-db-query/src/eval/vector_math.rs +++ b/fluree-db-query/src/eval/vector_math.rs @@ -94,8 +94,13 @@ pub fn l2_f64(a: &[f64], b: &[f64]) -> f64 { pub fn cosine_f64(a: &[f64], b: &[f64]) -> Option { debug_assert_eq!(a.len(), b.len()); - // We compute dot + squared magnitudes in a single pass for cache efficiency. - let (dot, mag_a2, mag_b2) = dot_mag2_f64(a, b); + // Above the SIMD threshold, three dispatched dot-kernel passes beat one + // scalar fused pass; below it, the single-pass scalar loop wins. + let (dot, mag_a2, mag_b2) = if a.len() < SIMD_LEN_THRESHOLD { + dot_mag2_f64(a, b) + } else { + (dot_f64(a, b), dot_f64(a, a), dot_f64(b, b)) + }; if mag_a2 == 0.0 || mag_b2 == 0.0 { None } else { @@ -394,7 +399,12 @@ pub fn l2_f32(a: &[f32], b: &[f32]) -> f32 { pub fn cosine_f32(a: &[f32], b: &[f32]) -> Option { debug_assert_eq!(a.len(), b.len()); - let (dot, mag_a2, mag_b2) = dot_mag2_f32(a, b); + // Same pass-count trade-off as `cosine_f64`. + let (dot, mag_a2, mag_b2) = if a.len() < SIMD_LEN_THRESHOLD { + dot_mag2_f32(a, b) + } else { + (dot_f32(a, b), dot_f32(a, a), dot_f32(b, b)) + }; if mag_a2 == 0.0 || mag_b2 == 0.0 { None } else { diff --git a/fluree-db-query/src/execute/operator_tree.rs b/fluree-db-query/src/execute/operator_tree.rs index 88431e6a1a..b460459144 100644 --- a/fluree-db-query/src/execute/operator_tree.rs +++ b/fluree-db-query/src/execute/operator_tree.rs @@ -2662,6 +2662,20 @@ fn build_operator_tree_inner( } } + // Fast-path: flat-rank vector scoring (`bind ?score dotProduct(?vec, C)` + // over a single vector predicate, optional threshold/order/limit), served + // by a direct scan of the packed f32 vector arena instead of driving + // every row through scan → bind → filter → sort. + if enable_fused_fast_paths { + if let Some(spec) = crate::fast_vector_topk::detect_vector_topk(query) { + let fallback = build_operator_tree_inner(query, stats.clone(), false, planning)?; + return Ok(crate::fast_vector_topk::vector_topk_operator( + spec, + Some(fallback), + )); + } + } + // Fast-path: label scan + regex filter + rdf:type membership check. if enable_fused_fast_paths { if let Some(spec) = detect_label_regex_type(query) { diff --git a/fluree-db-query/src/execute/runner.rs b/fluree-db-query/src/execute/runner.rs index a398dc6ff1..64bc0afd67 100644 --- a/fluree-db-query/src/execute/runner.rs +++ b/fluree-db-query/src/execute/runner.rs @@ -90,16 +90,32 @@ impl ExecutableQuery { Self { query, reasoning } } - /// True if any pattern in this query calls `fulltext(...)`. + /// True if any expression in this query calls `fulltext(...)`. /// /// The query-context setup code checks this before allocating the /// per-graph fulltext arena map and resolving the English `lang_id`, /// skipping that work for queries that don't use full-text scoring. + /// + /// Covers WHERE patterns plus the expression positions outside them: + /// ORDER BY expression binds, post-aggregation binds, and HAVING. + /// (Aggregate inputs are variables only — expression arguments lower + /// to synthetic binds inside `patterns` first.) pub fn uses_fulltext(&self) -> bool { + let target = crate::ir::Function::Fulltext; self.query .patterns .iter() - .any(|p| p.contains_function(&crate::ir::Function::Fulltext)) + .any(|p| p.contains_function(&target)) + || self + .query + .order_binds + .iter() + .any(|(_, expr)| expr.contains_function(&target)) + || self.query.grouping.as_ref().is_some_and(|g| { + g.aggregation() + .is_some_and(|a| a.binds.iter().any(|(_, e)| e.contains_function(&target))) + || g.having().is_some_and(|h| h.contains_function(&target)) + }) } } diff --git a/fluree-db-query/src/fast_vector_topk.rs b/fluree-db-query/src/fast_vector_topk.rs new file mode 100644 index 0000000000..e242671483 --- /dev/null +++ b/fluree-db-query/src/fast_vector_topk.rs @@ -0,0 +1,793 @@ +//! Flat-rank vector scoring fast path. +//! +//! Serves the canonical vector-similarity shape — +//! +//! ```sparql +//! SELECT ?s ?score WHERE { +//! VALUES ?target { } # or an inline constant +//! ?s ?vec . +//! BIND(dotProduct(?vec, ?target) AS ?score) +//! FILTER(?score > K) # optional threshold +//! } ORDER BY DESC(?score) LIMIT k # optional order/limit +//! ``` +//! +//! — as a tight scan over the predicate's rows scoring the packed f32 vector +//! arena directly, instead of driving every row through the scan → bind → +//! filter → sort operator pipeline. Scores are computed exactly as the eval +//! path computes them (f32 shard values widened to f64, same SIMD dot +//! kernel), so results are identical; only the per-row machinery is removed. +//! +//! Overlay correctness: rows come from the shared overlay-merging cursor +//! (novelty asserts appear, retracted base rows are cancelled — the same +//! `merge_overlay_into_batch` lane every count fast path uses). Novelty +//! vector values (ephemeral handles) are pre-decoded once into a map before +//! the scan. Large base scans parallelize across subject-range partitions on +//! the global rayon pool with per-partition sliced overlay ops. +//! +//! Bails to the generic pipeline (`Ok(None)` from the compute closure) on +//! any uncertainty: non-vector rows on the predicate, missing arena with +//! novelty present, dimension mismatch, unresolvable overlay ops, policy / +//! multi-ledger / time-travel contexts. + +use std::cmp::Reverse; +use std::collections::BinaryHeap; +use std::sync::Arc; + +use fluree_db_binary_index::format::run_record::RunSortOrder; +use fluree_db_binary_index::BinaryIndexStore; +use fluree_db_core::o_type::OType; +use fluree_db_core::value_id::ObjKind; +use fluree_db_core::{FlakeValue, GraphId, Sid}; +use rustc_hash::FxHashMap; + +use crate::binding::{Batch, Binding}; +use crate::context::ExecutionContext; +use crate::error::{QueryError, Result}; +use crate::eval::vector_math; +use crate::fast_path_common::{ + allow_cursor_fast_path, build_overlay_cursor_for_predicate, + build_overlay_cursor_for_subject_range, cached_overlay_ops, count_rows_for_predicate_psot, + cursor_projection_sid_otype_okey, empty_batch, leaf_entries_for_predicate, normalize_pred_sid, + parallel_map_pooled, slice_overlay_ops_by_subject, FastPathOperator, +}; +use crate::ir::{Expression, Function, Pattern, Query, Ref, Term}; +use crate::operator::BoxedOperator; +use crate::sort::SortDirection; +use crate::var_registry::VarId; + +// ============================================================================ +// Detection +// ============================================================================ + +/// Detected flat-rank scoring query, ready for the shard-scan executor. +pub struct VectorTopKSpec { + /// SELECT vars in projection order (subset of `{subject_var, score_var}`). + pub projected: Vec, + pub subject_var: VarId, + pub score_var: VarId, + /// The vector predicate. + pub pred: Ref, + /// Query/target vector (f32-quantized at ingest, widened — same values + /// the pipeline sees). + pub target: Vec, + /// Score threshold from `FILTER(?score > K)` / `>= K`; `bool` = strict. + pub threshold: Option<(f64, bool)>, + /// `ORDER BY DESC(?score)` present. + pub order_desc: bool, + pub limit: Option, + pub offset: usize, +} + +/// Extract an f64 threshold constant from a filter comparison operand. +fn const_to_f64(e: &Expression) -> Option { + match e { + Expression::Const(FlakeValue::Long(n)) => Some(*n as f64), + Expression::Const(FlakeValue::Double(d)) => Some(*d), + _ => None, + } +} + +/// Detect the flat-rank vector scoring shape. Shape-only; runtime gates +/// (store/arena presence, policy, overlay resolution) live in the operator. +pub fn detect_vector_topk(query: &Query) -> Option { + if query.grouping.is_some() || query.post_values.is_some() || !query.order_binds.is_empty() { + return None; + } + if query.output.is_distinct() { + return None; + } + + // Ordering: none, or exactly `DESC(?score)` (score var checked below). + let order_var = match query.ordering.as_slice() { + [] => None, + [ob] if ob.direction == SortDirection::Descending => Some(ob.var), + _ => return None, + }; + + // A bare LIMIT with no ORDER BY means "any k rows"; the generic pipeline + // returns them in scan order, so imposing top-k-by-score here would change + // which rows come back. Only serve LIMIT alongside DESC(?score) — otherwise + // decline so the shape routes to the pipeline unchanged. + if query.limit.is_some() && order_var.is_none() { + return None; + } + + // Walk WHERE: exactly one triple, one dotProduct bind, ≤1 threshold + // filter, ≤1 single-row VALUES supplying the target var. + let mut triple: Option<(VarId, Ref, VarId)> = None; + let mut bind: Option<(VarId, &Expression)> = None; + let mut filter: Option<&Expression> = None; + let mut values: Option<(VarId, &[Binding])> = None; + + for p in &query.patterns { + match p { + Pattern::Triple(tp) => { + if triple.is_some() || tp.dtc.is_some() || !tp.p_bound() { + return None; + } + let Ref::Var(sv) = &tp.s else { return None }; + let Term::Var(ov) = &tp.o else { return None }; + if matches!(&tp.p, Ref::Var(_)) { + return None; + } + triple = Some((*sv, tp.p.clone(), *ov)); + } + Pattern::Bind { var, expr } => { + if bind.is_some() { + return None; + } + bind = Some((*var, expr)); + } + Pattern::Filter(f) => { + if filter.is_some() { + return None; + } + filter = Some(f); + } + Pattern::Values { vars, rows } => { + if values.is_some() || vars.len() != 1 || rows.len() != 1 { + return None; + } + values = Some((vars[0], rows[0].as_slice())); + } + _ => return None, + } + } + + let (subject_var, pred, vec_var) = triple?; + let (score_var, bind_expr) = bind?; + if subject_var == vec_var || score_var == subject_var || score_var == vec_var { + return None; + } + if let Some(ov) = order_var { + if ov != score_var { + return None; + } + } + + // Bind must be `dotProduct` over {?vec, target} in either order (dot is + // commutative), target = inline const vector or the VALUES-bound var. + let Expression::Call { func, args } = bind_expr else { + return None; + }; + if *func != Function::DotProduct || args.len() != 2 { + return None; + } + let target_of = |e: &Expression| -> Option> { + match e { + Expression::Const(FlakeValue::Vector(v)) => Some(v.to_vec()), + Expression::Var(v) => { + let (tv, row) = values.as_ref()?; + if *v != *tv || *v == vec_var || *v == subject_var || *v == score_var { + return None; + } + match row.first()? { + Binding::Lit { + val: FlakeValue::Vector(t), + .. + } => Some(t.to_vec()), + _ => None, + } + } + _ => None, + } + }; + let is_vec_var = |e: &Expression| matches!(e, Expression::Var(v) if *v == vec_var); + let target = if is_vec_var(&args[0]) { + target_of(&args[1])? + } else if is_vec_var(&args[1]) { + target_of(&args[0])? + } else { + return None; + }; + if target.is_empty() { + return None; + } + // If a VALUES exists, it must be the one supplying the target (a stray + // unrelated VALUES changes semantics). + if let Some((tv, _)) = &values { + let used = args + .iter() + .any(|a| matches!(a, Expression::Var(v) if v == tv)); + if !used { + return None; + } + } + + // Optional threshold filter: `?score > K` / `>= K` (or mirrored `K < ?score`). + let threshold = match filter { + None => None, + Some(Expression::Call { func, args }) if args.len() == 2 => { + let is_score = |e: &Expression| matches!(e, Expression::Var(v) if *v == score_var); + let t = match func { + Function::Gt if is_score(&args[0]) => (const_to_f64(&args[1])?, true), + Function::Ge if is_score(&args[0]) => (const_to_f64(&args[1])?, false), + Function::Lt if is_score(&args[1]) => (const_to_f64(&args[0])?, true), + Function::Le if is_score(&args[1]) => (const_to_f64(&args[0])?, false), + _ => return None, + }; + Some(t) + } + Some(_) => return None, + }; + + // Projection: non-empty subset of {subject_var, score_var}. The vector + // var itself must not escape (materializing vectors is the pipeline's job). + let projected = query.output.projected_vars()?; + if projected.is_empty() + || projected + .iter() + .any(|v| *v != subject_var && *v != score_var) + { + return None; + } + + Some(VectorTopKSpec { + projected, + subject_var, + score_var, + pred, + target, + threshold, + order_desc: order_var.is_some(), + limit: query.limit, + offset: query.offset.unwrap_or(0), + }) +} + +// ============================================================================ +// Executor +// ============================================================================ + +/// Heap entry ordered ascending by (score, then descending s_id), so the +/// min-heap root is the weakest kept row and equal-score evictions drop the +/// larger s_id first (final order: score desc, s_id asc — deterministic ties). +struct HeapEntry { + score: f64, + s_id: u64, +} + +impl PartialEq for HeapEntry { + fn eq(&self, other: &Self) -> bool { + self.score.total_cmp(&other.score) == std::cmp::Ordering::Equal && self.s_id == other.s_id + } +} +impl Eq for HeapEntry {} +impl PartialOrd for HeapEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for HeapEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.score + .total_cmp(&other.score) + .then_with(|| other.s_id.cmp(&self.s_id)) + } +} + +/// Per-partition fold state: bounded top-k heap or unbounded passing list. +enum Fold { + TopK { + need: usize, + heap: BinaryHeap>, + }, + All(Vec<(u64, f64)>), +} + +impl Fold { + fn new(need: Option) -> Self { + match need { + Some(n) => Fold::TopK { + need: n, + // Cap the eager reservation: `n` is a user literal (limit + + // offset), so a large-but-legal LIMIT would otherwise reserve an + // oversized heap per partition. The heap grows if genuinely + // needed, and `push` caps it at `need`. `saturating_add` also + // avoids the `n + 1` overflow debug-panic at pathological limits. + heap: BinaryHeap::with_capacity(n.saturating_add(1).min(4096)), + }, + None => Fold::All(Vec::new()), + } + } + + #[inline] + fn push(&mut self, s_id: u64, score: f64) { + match self { + Fold::TopK { need, heap } => { + let entry = HeapEntry { score, s_id }; + if heap.len() < *need { + heap.push(Reverse(entry)); + } else if let Some(weakest) = heap.peek() { + if entry > weakest.0 { + heap.pop(); + heap.push(Reverse(entry)); + } + } + } + Fold::All(v) => v.push((s_id, score)), + } + } + + fn into_rows(self) -> Vec<(u64, f64)> { + match self { + Fold::TopK { heap, .. } => heap + .into_iter() + .map(|Reverse(e)| (e.s_id, e.score)) + .collect(), + Fold::All(v) => v, + } + } +} + +/// Shared per-scan scoring state. +struct Scorer<'a> { + /// Pinned arena shards for direct zero-probe base-row reads. `None` + /// when the predicate has no arena (novelty-only rows). + snapshot: Option<&'a fluree_db_binary_index::arena::vector::VectorArenaSnapshot>, + target: &'a [f64], + /// Pre-decoded, pre-widened novelty vector values by ephemeral handle. + ephemeral: &'a FxHashMap>, + threshold: Option<(f64, bool)>, + vector_o_type: u16, +} + +impl Scorer<'_> { + /// Score one row. `Ok(None)` = row filtered out; `Err(Bail)` = shape + /// assumption violated (non-vector row, dims mismatch, missing handle) — + /// the whole fast path must defer to the pipeline. + #[inline] + fn score(&self, o_type: u16, o_key: u64, widen_scratch: &mut Vec) -> BailOr> { + if o_type != self.vector_o_type { + return Err(Bail); + } + let score = if (o_key as u32) >= crate::dict_overlay::EPHEMERAL_VECTOR_BASE { + let Some(v) = self.ephemeral.get(&o_key) else { + return Err(Bail); + }; + if v.len() != self.target.len() { + return Err(Bail); + } + vector_math::dot_f64(v, self.target) + } else { + let Some(f32s) = self.snapshot.and_then(|s| s.get_f32(o_key as u32)) else { + return Err(Bail); + }; + if f32s.len() != self.target.len() { + return Err(Bail); + } + // Widen to f64 and use the same kernel the eval path uses — + // scores are bit-identical to the pipeline. + widen_scratch.clear(); + widen_scratch.extend(f32s.iter().map(|&x| f64::from(x))); + vector_math::dot_f64(widen_scratch, self.target) + }; + if let Some((bound, strict)) = self.threshold { + let pass = if strict { + score > bound + } else { + score >= bound + }; + if !pass { + return Ok(None); + } + } + Ok(Some(score)) + } +} + +/// Sentinel error for "defer to the generic pipeline". +struct Bail; +type BailOr = std::result::Result; + +/// Scored `(s_id, score)` rows from one scan lane / partition. +type ScoredRows = Vec<(u64, f64)>; + +/// Minimum base rows before the partitioned parallel scan is worth its setup. +const PARALLEL_MIN_ROWS: u64 = 8_192; +/// Cap on scan partitions (matches the count fast paths' scale). +const MAX_PARTITIONS: usize = 16; + +/// Build the flat-rank vector scoring fast-path operator. +pub fn vector_topk_operator( + spec: VectorTopKSpec, + fallback: Option, +) -> BoxedOperator { + let VectorTopKSpec { + projected, + subject_var, + score_var, + pred, + target, + threshold, + order_desc, + limit, + offset, + } = spec; + let schema: Arc<[VarId]> = Arc::from(projected.into_boxed_slice()); + + Box::new(FastPathOperator::with_schema( + Arc::clone(&schema), + move |ctx| { + let Some(store) = ctx.binary_store.as_ref() else { + return Ok(None); + }; + if !allow_cursor_fast_path(ctx) { + return Ok(None); + } + // Base + overlay reconstructs any `to_t >= index_t`; earlier + // needs the history sidecar — defer. + if ctx.to_t < store.max_t() { + return Ok(None); + } + let overlay_present = ctx + .overlay + .map(fluree_db_core::OverlayProvider::epoch) + .unwrap_or(0) + != 0; + let g_id: GraphId = ctx.binary_g_id; + + let _span = tracing::debug_span!( + "fast_vector_topk", + pred = ?pred, + limit, + offset, + order_desc, + overlay = overlay_present, + ) + .entered(); + + // Resolve the predicate. Absent p_id with overlay may mean + // novelty-only rows → defer; without overlay the result is empty. + let pred_sid = normalize_pred_sid(store.as_ref(), &pred)?; + let p_id = match store.sid_to_p_id(&pred_sid) { + Some(p) => p, + None => { + if overlay_present { + return Ok(None); + } + return Ok(Some(empty_batch(Arc::clone(&schema))?)); + } + }; + + // Overlay ops for the predicate (cached per execution). `None` + // means an op failed to resolve — defer. + let ops = match cached_overlay_ops(ctx, store, g_id, RunSortOrder::Psot, &pred_sid)? { + Some(o) => o, + None => return Ok(None), + }; + + // Pre-decode novelty vector values (ephemeral handles) once, so + // partition workers never need the ExecutionContext. + let mut ephemeral: FxHashMap> = FxHashMap::default(); + for op in ops.iter() { + if op.o_type != OType::VECTOR.as_u16() { + // Non-vector novelty row on the predicate — the per-row + // o_type gate would bail mid-scan anyway; defer up front. + return Ok(None); + } + let key = op.o_key; + if (key as u32) >= crate::dict_overlay::EPHEMERAL_VECTOR_BASE + && !ephemeral.contains_key(&key) + { + let Some(gv) = ctx.graph_view() else { + return Ok(None); + }; + match gv.decode_value_from_kind(ObjKind::VECTOR_ID.as_u8(), key, p_id, 0, 0) { + Ok(FlakeValue::Vector(v)) => { + ephemeral.insert(key, v.iter().copied().collect()); + } + // Non-vector novelty value on the predicate, or a + // decode failure — defer. + _ => return Ok(None), + } + } + } + + let need = limit.map(|k| k.saturating_add(offset)); + if need == Some(0) { + return Ok(Some(empty_batch(Arc::clone(&schema))?)); + } + + // Pin the arena shards once — the scan reads them directly with + // no per-row cache probes. A shard load failure defers. + let snapshot = match store.vector_arena_snapshot(g_id, p_id) { + Ok(s) => s, + Err(_) => return Ok(None), + }; + let scorer = Scorer { + snapshot: snapshot.as_ref(), + target: &target, + ephemeral: &ephemeral, + threshold, + vector_o_type: OType::VECTOR.as_u16(), + }; + + let total_rows = count_rows_for_predicate_psot(store, g_id, p_id)?; + let scan = VectorScan { + ctx, + store, + g_id, + p_id, + scorer: &scorer, + need, + }; + + // ── Scan lanes ───────────────────────────────────────────── + // Partitioned lane first (large predicates); its outcome decides + // whether we fall back to the serial lane or defer straight to the + // pipeline. A genuine bail must NOT re-scan serially. + let partitioned: Option> = if total_rows >= PARALLEL_MIN_ROWS { + match scan_partitioned(&scan, &ops)? { + PartitionScan::Rows(r) => Some(r), + PartitionScan::Bailed => return Ok(None), + PartitionScan::TooFewPartitions => None, + } + } else { + None + }; + let rows = match partitioned { + Some(r) => r, + None => match scan_serial(&scan, &pred_sid)? { + Some(r) => r, + None => return Ok(None), + }, + }; + + // ── Order / offset / limit ───────────────────────────────── + let mut rows = rows; + if order_desc || limit.is_some() { + rows.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + } + let start = offset.min(rows.len()); + let end = match limit { + Some(k) => (start + k).min(rows.len()), + None => rows.len(), + }; + let out_rows = &rows[start..end]; + + tracing::debug!(returned = out_rows.len(), "fast-path: vector-topk emitted"); + + // ── Build the output batch ───────────────────────────────── + // Overlay-lane subjects may be novelty-only (absent from the + // persisted dict) — materialize via the novelty-aware view. + let view = if overlay_present { + Some(ctx.graph_view().ok_or_else(|| { + QueryError::Internal("graph view unavailable for overlay subjects".into()) + })?) + } else { + None + }; + let mut cols: Vec> = Vec::with_capacity(schema.len()); + for var in schema.iter() { + let mut col: Vec = Vec::with_capacity(out_rows.len()); + if *var == subject_var { + for (s_id, _) in out_rows { + let b = match &view { + Some(gv) => Binding::sid( + gv.resolve_subject_sid(*s_id) + .map_err(|e| QueryError::from_io("resolve_subject_sid", e))?, + ), + None => Binding::encoded_sid(*s_id), + }; + col.push(b); + } + } else if *var == score_var { + for (_, score) in out_rows { + col.push(Binding::lit(FlakeValue::Double(*score), Sid::xsd_double())); + } + } else { + return Ok(None); + } + cols.push(col); + } + Ok(Some(Batch::new(Arc::clone(&schema), cols)?)) + }, + fallback, + "vector-topk", + )) +} + +/// Serial lane: whole-predicate overlay-merging cursor. +fn scan_serial(scan: &VectorScan, pred_sid: &Sid) -> Result>> { + let &VectorScan { + ctx, + store, + g_id, + p_id, + scorer, + need, + } = scan; + let Some(mut cursor) = build_overlay_cursor_for_predicate( + ctx, + store, + g_id, + RunSortOrder::Psot, + pred_sid.clone(), + p_id, + cursor_projection_sid_otype_okey(), + )? + else { + return Ok(None); + }; + let mut fold = Fold::new(need); + let mut scratch: Vec = Vec::new(); + while let Some(batch) = cursor + .next_batch() + .map_err(|e| QueryError::Internal(format!("cursor batch: {e}")))? + { + for r in 0..batch.row_count { + match scorer.score(batch.o_type.get(r), batch.o_key.get(r), &mut scratch) { + Ok(Some(score)) => fold.push(batch.s_id.get(r), score), + Ok(None) => {} + Err(Bail) => return Ok(None), + } + } + } + Ok(Some(fold.into_rows())) +} + +/// Outcome of the partitioned scan lane. Distinguishes "not enough partitions, +/// fall back to the serial lane" from "a partition genuinely bailed, defer +/// straight to the pipeline" — the caller must not re-scan serially on a bail +/// (it would hit the same bail row and waste a full pass). +enum PartitionScan { + /// Too few CPUs / partitions to parallelize — try the serial lane. + TooFewPartitions, + /// A partition hit a `Bail` condition — defer to the generic pipeline. + Bailed, + /// Scored rows. + Rows(Vec<(u64, f64)>), +} + +/// Shared context for one vector top-k scan of predicate `p_id` in graph +/// `g_id`: where to read (`store`/`ctx`), how to score (`scorer`), and the +/// top-k bound (`need`). Both the partitioned and serial lanes take this so the +/// lane functions only carry their lane-specific extras. +struct VectorScan<'a> { + ctx: &'a ExecutionContext<'a>, + store: &'a Arc, + g_id: GraphId, + p_id: u32, + scorer: &'a Scorer<'a>, + need: Option, +} + +/// Parallel lane: subject-range partitions at leaf boundaries, each with a +/// bounded overlay cursor and its subject-sliced overlay ops, folded on the +/// global rayon pool. Returns [`PartitionScan::TooFewPartitions`] to fall back +/// to the serial lane, or [`PartitionScan::Bailed`] to defer to the pipeline. +fn scan_partitioned( + scan: &VectorScan, + ops: &crate::fast_path_common::SharedOverlayOps, +) -> Result { + let &VectorScan { + ctx, + store, + g_id, + p_id, + scorer, + need, + } = scan; + let to_t = ctx.to_t; + let epoch = ctx + .overlay + .map(fluree_db_core::OverlayProvider::epoch) + .unwrap_or(0); + let ncpu = std::thread::available_parallelism() + .map(std::num::NonZeroUsize::get) + .unwrap_or(1); + let k = ncpu.min(MAX_PARTITIONS); + if k < 2 { + return Ok(PartitionScan::TooFewPartitions); + } + // Candidate subject boundaries: leaf first-subjects when there are + // enough leaves; else refine to leaflet first-subjects (opens the few + // driver leaves, which the partitions scan anyway). + let leaves = leaf_entries_for_predicate(store, g_id, RunSortOrder::Psot, p_id); + let candidates: Vec = if leaves.len() >= k { + leaves.iter().map(|e| e.first_key.s_id.as_u64()).collect() + } else { + use fluree_db_binary_index::format::run_record_v2::read_ordered_key_v2; + let mut bounds: Vec = Vec::new(); + for leaf in leaves { + let handle = store + .open_leaf_handle(&leaf.leaf_cid, leaf.sidecar_cid.as_ref(), false) + .map_err(|e| QueryError::Internal(format!("leaf open: {e}")))?; + for entry in &handle.dir().entries { + if entry.row_count == 0 || entry.p_const != Some(p_id) { + continue; + } + let first = read_ordered_key_v2(RunSortOrder::Psot, &entry.first_key); + bounds.push(first.s_id.as_u64()); + } + } + bounds + }; + if candidates.len() < 2 { + return Ok(PartitionScan::TooFewPartitions); + } + let mut bounds: Vec = vec![0]; + for j in 1..k { + let b = candidates[j * candidates.len() / k]; + if b > *bounds.last().expect("nonempty") { + bounds.push(b); + } + } + bounds.push(u64::MAX); + if bounds.len() < 3 { + return Ok(PartitionScan::TooFewPartitions); + } + let ranges: Vec<(u64, u64)> = bounds.windows(2).map(|w| (w[0], w[1])).collect(); + + let cancellation = &ctx.cancellation; + let partials: Vec>> = parallel_map_pooled(ranges, |(lo, hi)| { + crate::fast_path_common::bail_if_cancelled(cancellation)?; + let sliced = slice_overlay_ops_by_subject(ops, lo, hi); + let Some(mut cursor) = build_overlay_cursor_for_subject_range( + store, + g_id, + p_id, + cursor_projection_sid_otype_okey(), + lo, + hi, + sliced, + to_t, + epoch, + ) else { + return Ok(Some(Vec::new())); + }; + let mut fold = Fold::new(need); + let mut scratch: Vec = Vec::new(); + while let Some(batch) = cursor + .next_batch() + .map_err(|e| QueryError::Internal(format!("cursor batch: {e}")))? + { + for r in 0..batch.row_count { + let s = batch.s_id.get(r); + if s < lo || s >= hi { + continue; // boundary leaf shared with adjacent partition + } + match scorer.score(batch.o_type.get(r), batch.o_key.get(r), &mut scratch) { + Ok(Some(score)) => fold.push(s, score), + Ok(None) => {} + Err(Bail) => return Ok(None), + } + } + } + Ok(Some(fold.into_rows())) + }); + + let mut merged = Fold::new(need); + for partial in partials { + match partial? { + Some(rows) => { + for (s, score) in rows { + merged.push(s, score); + } + } + None => return Ok(PartitionScan::Bailed), // a partition hit a bail condition + } + } + Ok(PartitionScan::Rows(merged.into_rows())) +} diff --git a/fluree-db-query/src/lib.rs b/fluree-db-query/src/lib.rs index 66e701b0ef..a77976b43e 100644 --- a/fluree-db-query/src/lib.rs +++ b/fluree-db-query/src/lib.rs @@ -52,6 +52,7 @@ pub(crate) mod fast_string_fold; pub(crate) mod fast_string_prefix_count_all; pub(crate) mod fast_sum_strlen_group_concat; pub(crate) mod fast_union_star_count_all; +pub(crate) mod fast_vector_topk; pub mod filter; pub(crate) mod filter_fold; pub mod geo_rewrite; diff --git a/fluree-db-query/src/materializer.rs b/fluree-db-query/src/materializer.rs index 9c1dd2edef..76dde4f0ff 100644 --- a/fluree-db-query/src/materializer.rs +++ b/fluree-db-query/src/materializer.rs @@ -92,7 +92,7 @@ impl Hash for FlakeValueKey { FlakeValue::Time(t) => t.hash(state), FlakeValue::Json(s) => s.hash(state), FlakeValue::Vector(v) => { - for f in v { + for f in v.iter() { f.to_bits().hash(state); } } diff --git a/fluree-db-query/src/parse/lower.rs b/fluree-db-query/src/parse/lower.rs index 9ed05ebf30..824595a01a 100644 --- a/fluree-db-query/src/parse/lower.rs +++ b/fluree-db-query/src/parse/lower.rs @@ -502,7 +502,7 @@ fn lower_values_cell(cell: &UnresolvedValue, encoder: &E) -> Resu LiteralValue::Decimal(d) => FlakeValue::Decimal(d.clone()), LiteralValue::BigInt(n) => FlakeValue::BigInt(n.clone()), LiteralValue::Boolean(b) => FlakeValue::Boolean(*b), - LiteralValue::Vector(v) => FlakeValue::Vector(v.clone()), + LiteralValue::Vector(v) => FlakeValue::Vector(v.as_slice().into()), }; match dtc { @@ -1848,7 +1848,7 @@ fn lower_literal(lit: &LiteralValue) -> FlakeValue { LiteralValue::Decimal(d) => FlakeValue::Decimal(d.clone()), LiteralValue::BigInt(n) => FlakeValue::BigInt(n.clone()), LiteralValue::Boolean(b) => FlakeValue::Boolean(*b), - LiteralValue::Vector(v) => FlakeValue::Vector(v.clone()), + LiteralValue::Vector(v) => FlakeValue::Vector(v.as_slice().into()), } } diff --git a/fluree-db-reasoner/src/execute/derived.rs b/fluree-db-reasoner/src/execute/derived.rs index 9287e180e5..428fd26c42 100644 --- a/fluree-db-reasoner/src/execute/derived.rs +++ b/fluree-db-reasoner/src/execute/derived.rs @@ -69,7 +69,7 @@ impl DerivedSet { } FlakeValue::Vector(v) => { 6u8.hash(&mut hasher); - for f in v { + for f in v.iter() { f.to_bits().hash(&mut hasher); } } diff --git a/fluree-db-sparql/src/lower/term.rs b/fluree-db-sparql/src/lower/term.rs index 47cdcdcdbf..08f0886261 100644 --- a/fluree-db-sparql/src/lower/term.rs +++ b/fluree-db-sparql/src/lower/term.rs @@ -361,7 +361,7 @@ impl LoweringContext<'_, E> { datatype.span, ) })?; - Ok(FlakeValue::Vector(arr)) + Ok(FlakeValue::Vector(arr.into())) } _ => { // Default to string for unknown datatypes diff --git a/fluree-db-transact/src/generate/flakes.rs b/fluree-db-transact/src/generate/flakes.rs index 0767bcc094..e90642f284 100644 --- a/fluree-db-transact/src/generate/flakes.rs +++ b/fluree-db-transact/src/generate/flakes.rs @@ -443,7 +443,7 @@ pub(crate) fn validate_value_dt_pair(val: &FlakeValue, dt: &Sid) -> Result<()> { // is reserved as the `FlakeValue::max()` upper-bound sentinel, and the shared // vector arena hard-rejects them. Catch any path that bypassed // `coerce_array_to_vector`'s upstream check (e.g. a future direct - // construction of `FlakeValue::Vector(vec![])`). + // construction of `FlakeValue::Vector(vec![].into())`). if is_vector_dt { if let FlakeValue::Vector(v) = val { if v.is_empty() { @@ -673,7 +673,7 @@ mod tests { // vector arena rejects it; the write-path guard must catch it even if // it bypassed coerce_array_to_vector. let err = validate_value_dt_pair( - &FlakeValue::Vector(Vec::new()), + &FlakeValue::Vector(Vec::new().into()), &Sid::new(FLUREE_DB, "embeddingVector"), ) .expect_err("empty vector must be rejected"); @@ -683,7 +683,7 @@ mod tests { #[test] fn test_validate_pair_accepts_non_empty_vector() { validate_value_dt_pair( - &FlakeValue::Vector(vec![0.1, 0.2]), + &FlakeValue::Vector(vec![0.1, 0.2].into()), &Sid::new(FLUREE_DB, "embeddingVector"), ) .expect("non-empty vector with embeddingVector dt is valid"); diff --git a/fluree-db-transact/src/lower_sparql_update.rs b/fluree-db-transact/src/lower_sparql_update.rs index feabac1577..97e2c7872b 100644 --- a/fluree-db-transact/src/lower_sparql_update.rs +++ b/fluree-db-transact/src/lower_sparql_update.rs @@ -1473,7 +1473,7 @@ fn coerce_typed_value(lexical: &str, datatype_iri: &str) -> UnresolvedTerm { if let Ok(FlakeValue::Vector(v)) = fluree_db_core::coerce::coerce_string_value(lexical, datatype_iri) { - return UnresolvedTerm::Literal(LiteralValue::Vector(v)); + return UnresolvedTerm::Literal(LiteralValue::Vector(v.to_vec())); } } _ => {}