mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-24 02:38:06 +02:00
* perf(engine): route Expand node hydration through the id BTREE via structured filter
hydrate_nodes built an `id IN (...)` SQL string applied via Scanner::filter,
which DataFusion evaluates with InListEval (O(N×M)) rather than using the id
BTREE scalar index — measured at 72× the indexed cost on a 100k-node hop
(MR-376). Build the id IN-list as a structured DataFusion Expr, AND it with
the pushable destination filters, and apply via Scanner::filter_expr (the same
path execute_node_scan already uses); Lance then compiles it to
scalar-index-search -> take.
Destination-filter pushability is now decided by ir_filter_to_expr (structured)
instead of ir_filter_to_sql, so list-contains (array_has) pushes down too.
Removes the now-dead string-filter helpers build_lance_filter, ir_filter_to_sql,
and ir_expr_to_sql; literal_to_sql stays (still used by the mutation delete path).
* feat(engine): add TableStore::scan_edges_by_endpoint for indexed neighbor lookup
Static helper returning edge rows that match a set of endpoint keys on src/dst,
projected to [key_col, opposite_col], via a structured `key_col IN (keys)`
filter_expr. Lance routes it through the persisted BTREE on the endpoint column
(index-search -> take), so cost scales with the frontier size rather than |E|.
Unused until execute_expand's indexed mode lands; isolated in its own commit so
the storage-layer primitive is reviewable on its own.
* feat(engine): add BTREE-indexed Expand traversal path
Split execute_expand into a dispatcher over execute_expand_csr (the existing
in-memory CSR BFS, unchanged) and a new execute_expand_indexed that serves each
hop by batching the frontier into one scan_edges_by_endpoint call against the
persisted src/dst BTREE (index-search -> take), then fans out per source row.
Both share expand_hydrate_and_align — the destination hydration + alignment +
hconcat + in-memory non-pushable filters — which now aligns by string id (a
HashMap) instead of a dense row-id vec, so one tail serves both modes.
Mode selection is OMNIGRAPH_TRAVERSAL_MODE for now (default csr); the
frontier-size auto policy and lazy CSR build follow. AntiJoin stays on CSR.
tests/traversal_indexed.rs (its own #[serial] binary, so env writes never race a
reader) asserts the indexed path matches CSR for one-hop, multi-hop, cross-type,
and no-match cases, and that a freshly-appended unindexed edge is still found
(partial index coverage — fast_search=false unindexed-fragment scan).
* feat(engine): frontier-size Expand dispatcher + lazy CSR build
Replace the env-only mode switch with an auto policy: Expand uses the
BTREE-indexed path when the source frontier is small and the hop count bounded
(OMNIGRAPH_EXPAND_INDEXED_MAX_FRONTIER=1024, OMNIGRAPH_EXPAND_INDEXED_MAX_HOPS=6),
else the in-memory CSR. OMNIGRAPH_TRAVERSAL_MODE=indexed|csr still forces a mode.
Make the CSR index lazy: thread a GraphIndexHandle (memoizing OnceCell over a
Cached/Direct/None builder) through execute_query/execute_pipeline/
execute_rrf_query/execute_anti_join instead of a pre-built Option<&GraphIndex>.
A query served entirely by the indexed path with no AntiJoin never pays the
O(|E|) CSR build — the perf win of Tier 3. AntiJoin still realizes the index
(its negation uses CSR has_neighbors).
Net effect: selective traversals (the common case) skip the whole-graph CSR
build and resolve neighbors from the persisted, incrementally-maintained
src/dst BTREE. Existing traversal/aggregation/end_to_end/search suites now run
the indexed path by default and stay green.
Docs: constants.md (new env knobs), query-language.md (Expand dual path),
indexes.md (graph index is lazy + the indexed alternative).
* test(engine): bench indexed vs CSR selective traversal
Add a selective single-source knows{1,2} comparison to bench_expand: per growing
|E|, time the cold query in csr vs indexed mode (fresh db each, so CSR pays its
O(|E|) build) and assert both modes return identical rows — a guard against the
scalar-index physical_rows silent fallback dropping unindexed-fragment rows. The
existing dense hop1/2/3 latency bench is unchanged.
* feat(engine): surface silent scalar-index fallback in indexed traversal (C6)
Add TableStore::key_column_index_coverage — a metadata-only check (no IO) of
whether a `key_col IN (...)` scan will be served by the persisted BTREE or
silently fall back to a full filtered scan, mirroring Lance's own decision:
no BTREE on the column, or any fragment missing physical_rows (which disables
scalar indices for the whole scan, lance dataset/scanner.rs create_filter_plan).
execute_expand_indexed calls it once per traversal and tracing::warn!s on
Degraded, so the perf cliff is observable instead of hidden behind a bench oracle.
Detection-only: results are correct either way (the scan returns all rows). Closes
the "no silent failures" gap the traversal best-practice audit flagged as the top
deviation, and adds an IndexCoverage value a future cost-based planner can consume.
* perf(engine): dense-id BFS on the indexed traversal path (C3)
execute_expand_indexed ran its per-source BFS in string space
(Vec<HashSet<String>>, HashMap<String,Vec<String>>, ~4 String clones per neighbor
occurrence). Intern node ids to u32 once via a per-traversal TypeIndex (no
GraphIndex/CSR build — laziness preserved) and run visited/seen/frontier/
neighbor-map in dense u32 space, mirroring the CSR path; de-intern only for the
per-hop IN-list and the emitted dst ids handed to the hydrate+align tail.
Behavior-preserving — the traversal_indexed CSR-vs-indexed equivalence tests are
the guard (results are identical, the key type just changes String -> u32).
* refactor(engine): thread the opened edge dataset into indexed Expand
Hoist the edge-dataset open and the C6 index-coverage warning out of
execute_expand_indexed into execute_expand, threading the opened dataset in
as a parameter so it is opened exactly once. Extract the endpoint-column
mapping (endpoint_columns) and the coverage warning (warn_on_degraded_coverage)
as helpers.
Behavior-preserving: same dataset, same warning, same dispatch decision. This
only relocates the open so the upcoming cost-based chooser can consult index
coverage before dispatch without opening the dataset twice.
* feat(engine): cost-based Expand dispatch chooser (C5)
Replace the fixed frontier<=1024 && hops<=6 dispatch threshold with a pure,
IO-free cost model. choose_expand_mode compares the indexed path's
frontier-relative work (hops * frontier * fanout, or hops * |E| when BTREE
coverage is degraded) against the cost of building the whole-graph CSR
(BUILD_FACTOR * |E|), from cheap manifest row counts. Under good coverage this
reduces to a selectivity ratio independent of |E|, preserving the flat-in-|E|
indexed win for selective traversals while routing dense / deep / high-fanout
or degraded-and-expensive traversals to CSR.
execute_expand decides cardinality-first and only opens the edge dataset to
confirm coverage when it leans indexed (no open on a clearly-CSR traversal).
The two env knobs become hard ceilings layered on the model; the
OMNIGRAPH_TRAVERSAL_MODE override still forces a path; the chosen mode is
traced. Results are unchanged across modes — only the path differs.
Adds inline crossover unit tests and extends the traversal_indexed both_modes
harness with an auto pass asserting the chooser is result-preserving across
every traversal shape. Documents the new flag semantics in
docs/user/{constants,query-language}.md.
* test(engine): pin Lance scalar-index coverage + system-column/deletion-metadata surface
Add three Lance surface guards de-risking a future persisted-adjacency cache:
- a compile-only guard pinning the fragment physical_rows + index-detail
surface that key_column_index_coverage mirrors (the C6 fallback);
- a runtime probe confirming a scalar BTREE on the system column
_row_last_updated_at_version is not buildable via the normal create-index
path (the column is not in the user schema), so a version-column range delta
is not viable as drafted;
- a runtime probe confirming per-fragment deletion metadata
(deletion_file.num_deleted_rows) is available as cheap O(fragments) metadata,
the primitive a fragment-coverage delete model would rely on.
The probes turn the two largest substrate assumptions into green/red CI facts
before any cache work begins.
* test(engine): regression for cross-type id-collision in indexed traversal
A node id is unique only within a type, so a Person and a Company can share an
id string. A variable-length traversal over a cross-type edge (WorksAt) must
structurally stop after one hop. This test builds a graph where 'shared' is both
a Person and a Company id and asserts worksAt{1,2} returns only the one-hop
company. It fails today: the indexed path's single string interner de-interns
the hop-1 Company id back to the colliding Person id and runs a hop-2 scan that
matches that Person's edges, emitting a spurious second-hop company (indexed
["other","shared"] vs csr ["shared"]).
* fix(engine): structurally cap cross-type Expand at one hop
A cross-type edge cannot chain (e.g. a Company is not a WorksAt source), so a
variable-length traversal over one is structurally single-hop. Both traversal
paths now enforce this by capping max hops at 1 when from_type != to_type,
instead of relying on the hop-2 scan returning empty.
That reliance was a correctness hole on the indexed path: it interns every
endpoint string into one dense id space, so a cross-type id-string collision (a
Person and a Company sharing an id) let hop 2 de-intern a destination id back to
the colliding source-type id and match its edges, emitting rows the CSR path
never produces. With the cap the cross-type second-hop scan never runs, so the
shared interner can no longer alias across types. Turns the regression test
green (indexed == csr == ["shared"]).
* perf(engine): set-oriented filtered anti-join, remove per-row dispatch
execute_anti_join's filtered slow path sliced the outer batch to one row at a
time and re-ran the inner pipeline per row, so each 1-row inner Expand dispatched
to the indexed path — one Lance scan per outer row, while the CSR realized up
front sat unused.
Replace it with a set-oriented anti-semi-join: tag each outer row with a
synthetic index column, run the inner pipeline once over the whole frontier (the
tag survives Expand's hconcat and Filter's row-drop), then exclude outer rows
whose tag survived. The inner Expand now runs as a single set-at-a-time traversal
over the full frontier; config is read once per operator, not per row (the env
nit is mooted). A produced-but-untagged inner batch fails loudly rather than
silently keeping every row. Results are unchanged (the predicated-negation tests
exercise the path over a multi-row outer with dst-filters).
* test(engine): drop flaky wall-clock budget from the merge truth table
The 30s wall-clock assertion in merge_pair_truth_table flakes under parallel
test load: it tripped at ~31s in the full --test-threads=4 gate while passing at
~20s in isolation. A fixed time budget in a correctness test depends on machine
and parallelism, not correctness; elapsed is still logged for visibility, and a
real merge-perf regression belongs in a bench. The cell-count correctness
assertions (81 / 36 / 45) are unchanged.
* fix(engine): total deterministic ORDER via entity-key tie-break + NULL contract
apply_ordering used an unstable lexsort with no tie-break, so rows with equal
user-sort keys came out in a run-dependent order (the input order depends on
scan parallelism / upstream hashing) — making ORDER ... LIMIT non-deterministic,
a latent deny-list violation (no nondeterministic result ordering).
Append the bound entities' key columns (<var>.id, unique per row) in canonical
name-sorted order as ascending tie-breaks, giving a total, reproducible order
(and a deterministic top-N when ties straddle the LIMIT cutoff). NULL placement
(nulls_first = !descending) is unchanged and now documented as the contract.
New tests/ordering.rs locks descending, multi-key precedence, the deterministic
key tie-break (data loaded in a different order than the expected output, so it
proves the tie sorts by key not by load order), and NULL placement under ASC/DESC.
docs/user/query-language.md documents the total-order + NULL contract.
* test(engine): property-based query-correctness invariants over generated graphs
Adds a proptest harness (new dev-dep) that generates small graphs whose Person
and Company keys are drawn from a shared 5-key alphabet, so cross-type id
collisions, cycles, and self-loops arise by search rather than from one
hand-built fixture. Three invariants:
- prop_expand_indexed_eq_csr: csr == indexed == auto over knows{1,3} (same-type,
cycles) and worksAt{1,2} (cross-type, collision-prone) from every start.
- prop_results_subset_of_existing_nodes: no phantom rows (catches over-emission
even if both modes are wrong identically).
- prop_antijoin_partitions_persons: not{worksAt} and its complement are disjoint
and cover all persons.
Verified the guard bites: neutering the cross-type hop cap makes
prop_expand_indexed_eq_csr fail and proptest shrinks it to persons["c","e"] /
companies["b","c"] — the cross-type collision class the hand-built fixture
only sampled once. Tests are sync + #[serial] (per-case runtime; the mode test
writes OMNIGRAPH_TRAVERSAL_MODE).
* test(engine): cover cycle/self-loop termination + nested anti-join (C5 edge cases)
- variable_hops_terminate_and_dedup_on_cycle: a 3-cycle a->b->c->a traversed with
knows{1,5} (ceiling above the cycle length) terminates and emits each node once
(the c->a back-edge hits the seeded source); both_modes confirms indexed == csr.
Uses a bounded range deliberately — unbounded {1,} is a typecheck error, not a
runtime path.
- variable_hops_handle_self_loop: a->a self-loop does not loop forever and does
not re-emit the seeded source.
- nested_anti_join_double_negation: not { worksAt; not { name = Acme } } recurses
through execute_pipeline, yielding [Alice,Charlie,Diana] (people with no non-Acme
employer) — distinct from plain unemployed [Charlie,Diana].
* test(engine): execution goldens for typed-literal filters (C4 gap #4)
New literal_filters.rs covers filtering by F64/F32/Bool/Date/DateTime LITERALS
across both arms: standalone comparisons ($m.score > 1.5, $m.ratio <= 0.25,
$m.active = true, $m.born >= date(...), $m.seen < datetime(...)) exercise the
in-memory comparison path, and inline bindings (Metric { active: true },
Metric { score: 3.0 }) exercise Lance filter_expr pushdown. Seeds partition each
predicate so a dropped/miscast filter returns all rows. (Param-bound scalars and
list-column contains are covered elsewhere.)
* test(engine): full rank-order goldens for nearest + bm25 (gap #2)
Existing search tests stopped at top-1 (nearest) or non-empty (bm25), so a
regression corrupting ranks 2..k or reversing the sort direction passed CI
silently. Pin the FULL ordered slug list: nearest([0.1,0.2,0.3,0.4]) ->
[ml-intro, nlp-guide, rl-intro] (ml-intro exact at dist 0, rest by ascending
L2); bm25(Learning) -> [rl-intro, ml-intro, dl-basics] (descending score).
nearest/bm25 skip apply_ordering (is_search_ordered) and return Lance native
order, so result_slugs row order == rank order; values resolved by running and
confirmed stable across runs.
* test(engine): search fuzzy/match_text characterization + RRF non-default pairings
- match_text_matches_exact_set_excludes_unrelated: match_text(body,'neural') ==
[dl-basics] exactly (not just contains).
- fuzzy_does_not_match_under_default_tokenizer: characterizes that fuzzy() is
inert with the default tokenizer here (search/match_text work, fuzzy returns
nothing); turns red — to be promoted to a real golden — if fuzzy starts matching.
- rrf_fuses_two_fts_fields / rrf_fuses_two_vector_queries: RRF fuses arms other
than the default nearest+bm25 (bm25 title+body; two vector queries), proving
primary_var resolves and fusion runs. New fixtures/search.gq queries +
two_vector_params helper. Orders resolved by running, confirmed stable.
* test(engine): anti-join fast-vs-slow path equivalence harness
anti_join_fast_and_slow_paths_agree: the CSR has_neighbors fast path
(not { $p worksAt $_ }) and the set-oriented inner-pipeline replay (same
negation forced slow by an always-true $c.name != "" dst filter) must produce
the same result ([Charlie, Diana]). Closes the second real engine fork explicitly.
* test(engine): regression for nested slow-path anti-join tag collision
A nested not { ... not { ... } } where both levels hit the set-oriented slow
path collides on the fixed __antijoin_outer_row correlation column: the inner
call appends a duplicate, and column_by_name reads the OUTER tag. Fan-out (p1
works at two companies) makes inner row indices diverge from outer tags, so the
bug returns the wrong person set. Fails on current code (left ["p2","p4"] vs
right ["p3","p4"]).
* fix(engine): collision-free anti-join correlation tag for nested negation
The set-oriented anti-join tagged the outer batch with a fixed column name and
read it back by name. Under a nested slow-path anti-join the enclosing tag rides
through the inner pipeline, so the inner call produced a duplicate field; Arrow
permits duplicate names and column_by_name returns the first, so the inner
negation mis-correlated against the outer row indices.
Choose a tag name not already present in the batch (suffix-incremented), so each
nesting level reads its own correlation column. Turns the fan-out regression
green; the existing nested/fast-vs-slow/proptest anti-join invariants still pass.
* fix(engine): cap cross-type hops in the Expand cost model
gather_cost_inputs fed the requested max_hops into choose_expand_mode even though
execute_expand_indexed runs at most one hop for a cross-type edge. So a cross-type
variable-length expand (e.g. worksAt{1,5}) had its indexed cost scaled by 5 while
only one hop runs, skewing the chooser toward CSR (an unnecessary whole-graph
build) near the crossover. Results were unaffected (modes are equivalent); this
is a plan-accuracy fix.
Add cost_effective_hops(requested, same_type) — caps to 1 for cross-type — and
apply it in gather_cost_inputs so the estimate matches what executes. Unit test
covers the cap and the crossover consequence (capped 1 hop stays indexed where
the requested 5 would have flipped to CSR).
* perf(engine): realize anti-join CSR lazily + reuse a warm CSR in the chooser
Two CSR build/reuse fixes flagged on the set-oriented anti-join work (results
unchanged — plan/perf accuracy):
- execute_anti_join called graph_index.get() (the O(|E|) whole-graph CSR build)
unconditionally, but only the bulk fast path consumes it; a filtered/nested
slow-path anti-join's inner Expand picks its own access path. Gate the build on
a pure shape predicate (bulk_anti_join_applies) so a selective anti-join over a
large graph no longer pays a build it won't use.
- gather_cost_inputs hardcoded csr_cached=false, so once an earlier op realized
the CSR, later Expands still cost it as a cold build and could pick per-hop
indexed scans over reusing the warm in-memory CSR. Add GraphIndexHandle::
is_built() and thread it through so the chooser reuses a materialized CSR.
Anti-join, cross-type, proptest-equivalence, and chooser unit tests stay green.
* test(engine): RAII traversal-mode guard in proptest equivalence
prop_expand_indexed_eq_csr set/cleared OMNIGRAPH_TRAVERSAL_MODE manually; a panic
between set and clear (e.g. a query unwrap on a generated case) would leak the
forced mode into proptest's shrink/subsequent cases and mask the divergence under
test. Replace with a ModeGuard that clears on drop (including on unwind), scoping
the forced mode to a single query.
* test(engine): regression for multi-hop anti-join hop bounds
The bulk anti-join fast path answers via has_neighbors (one-hop existence), so
not { $p knows{2,2} $x } wrongly drops a node with a 1-hop neighbor but no
2-hop path. On a->b (sink) and c->d->e, only c has a 2-hop path; the query should
keep [a,b,d,e]. Fails on current code (left ["b","e"] — only the sinks).
* fix(engine): restrict anti-join bulk fast path to one-hop expands
bulk_anti_join_applies accepted any single Expand, but try_bulk_anti_join_mask
decides via the CSR has_neighbors one-hop existence check — wrong for multi-hop
negations. Require min_hops==1 && max_hops==1 in the predicate; anything else
falls to the slow path, whose inner Expand runs the real bounded traversal.
Turns the multi-hop regression green; one-hop anti-joins unchanged.
* fix(engine): IndexCoverage reports Degraded for uncovered fragments
key_column_index_coverage checked BTREE-exists + physical_rows but not that the
index actually covers the current fragments. Since edge-index creation is skipped
once a BTREE exists, fragments appended later stay unindexed while coverage still
reported Indexed — so the cost chooser priced a partly-full scan as fully indexed.
Compare the BTREE's fragment_bitmap (public on lance_table IndexMetadata) against
the dataset's current fragment ids; report Degraded when any are uncovered. A None
bitmap means Lance can't report coverage — don't over-degrade. Results are
unaffected (the scan returns unindexed-fragment rows either way); this corrects
the cost signal.
Test: a freshly-loaded edge BTREE is Indexed; after appending an edge the new
fragment is uncovered → Degraded. Surface guard pins IndexMetadata.fragment_bitmap.
* docs: clarify the Expand frontier ceiling bounds the initial dispatch frontier
The cap is applied at dispatch on the initial frontier; per-hop fan-out
(union_dense) is not hard-capped. Correct the constants.md and query-language.md
claims: the ceilings bound the initial-dispatch frontier/hops, the cost model
estimates total indexed work as ~hops*frontier*fanout (pricing dense fan-out
toward CSR), and per-hop work is not a hard bound. Drops the overstated 'hard
caps bound indexed work' / 'cost ∝ frontier' wording.
783 lines
22 KiB
Rust
783 lines
22 KiB
Rust
mod helpers;
|
|
|
|
use std::env;
|
|
|
|
use arrow_array::{Array, StringArray};
|
|
use lance::index::DatasetIndexExt;
|
|
use lance_index::is_system_index;
|
|
use serial_test::serial;
|
|
|
|
use omnigraph::db::Omnigraph;
|
|
use omnigraph::loader::{LoadMode, load_jsonl};
|
|
use omnigraph_compiler::query::ast::Literal;
|
|
use omnigraph_compiler::result::QueryResult;
|
|
|
|
use helpers::*;
|
|
|
|
const SEARCH_SCHEMA: &str = include_str!("fixtures/search.pg");
|
|
const SEARCH_DATA: &str = include_str!("fixtures/search.jsonl");
|
|
const SEARCH_QUERIES: &str = include_str!("fixtures/search.gq");
|
|
const MOCK_SEARCH_SCHEMA: &str = r#"
|
|
node Doc {
|
|
slug: String @key
|
|
title: String @index
|
|
embedding: Vector(4) @index
|
|
}
|
|
"#;
|
|
const MOCK_SEARCH_QUERIES: &str = r#"
|
|
query vector_search_vector($q: Vector(4)) {
|
|
match { $d: Doc }
|
|
return { $d.slug, $d.title }
|
|
order { nearest($d.embedding, $q) }
|
|
limit 3
|
|
}
|
|
|
|
query vector_search_string($q: String) {
|
|
match { $d: Doc }
|
|
return { $d.slug, $d.title }
|
|
order { nearest($d.embedding, $q) }
|
|
limit 3
|
|
}
|
|
|
|
query vector_search_literal() {
|
|
match { $d: Doc }
|
|
return { $d.slug, $d.title }
|
|
order { nearest($d.embedding, "alpha") }
|
|
limit 3
|
|
}
|
|
|
|
query hybrid_search_vector($vq: Vector(4), $tq: String) {
|
|
match { $d: Doc }
|
|
return { $d.slug, $d.title }
|
|
order { rrf(nearest($d.embedding, $vq), bm25($d.title, $tq)) }
|
|
limit 3
|
|
}
|
|
|
|
query hybrid_search_string($vq: String, $tq: String) {
|
|
match { $d: Doc }
|
|
return { $d.slug, $d.title }
|
|
order { rrf(nearest($d.embedding, $vq), bm25($d.title, $tq)) }
|
|
limit 3
|
|
}
|
|
"#;
|
|
const SEARCH_MUTATIONS: &str = r#"
|
|
query insert_doc($slug: String, $title: String, $body: String, $embedding: Vector(4)) {
|
|
insert Doc {
|
|
slug: $slug,
|
|
title: $title,
|
|
body: $body,
|
|
embedding: $embedding
|
|
}
|
|
}
|
|
"#;
|
|
|
|
async fn init_search_db(dir: &tempfile::TempDir) -> Omnigraph {
|
|
let uri = dir.path().to_str().unwrap();
|
|
let mut db = Omnigraph::init(uri, SEARCH_SCHEMA).await.unwrap();
|
|
load_jsonl(&mut db, SEARCH_DATA, LoadMode::Overwrite)
|
|
.await
|
|
.unwrap();
|
|
db
|
|
}
|
|
|
|
async fn init_mock_embedding_search_db(dir: &tempfile::TempDir) -> Omnigraph {
|
|
let uri = dir.path().to_str().unwrap();
|
|
let mut db = Omnigraph::init(uri, MOCK_SEARCH_SCHEMA).await.unwrap();
|
|
load_jsonl(&mut db, &mock_embedding_seed_data(), LoadMode::Overwrite)
|
|
.await
|
|
.unwrap();
|
|
db
|
|
}
|
|
|
|
fn mock_embedding_seed_data() -> String {
|
|
[
|
|
("alpha-doc", "alpha guide", mock_embedding("alpha", 4)),
|
|
("beta-doc", "beta guide", mock_embedding("beta", 4)),
|
|
("gamma-doc", "gamma handbook", mock_embedding("gamma", 4)),
|
|
]
|
|
.into_iter()
|
|
.map(|(slug, title, embedding)| {
|
|
format!(
|
|
r#"{{"type":"Doc","data":{{"slug":"{}","title":"{}","embedding":[{}]}}}}"#,
|
|
slug,
|
|
title,
|
|
format_vector(&embedding)
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn format_vector(values: &[f32]) -> String {
|
|
values
|
|
.iter()
|
|
.map(|value| format!("{:.8}", value))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
fn mock_embedding(input: &str, dim: usize) -> Vec<f32> {
|
|
let mut seed = fnv1a64(input.as_bytes());
|
|
let mut out = Vec::with_capacity(dim);
|
|
for _ in 0..dim {
|
|
seed = xorshift64(seed);
|
|
let ratio = (seed as f64 / u64::MAX as f64) as f32;
|
|
out.push((ratio * 2.0) - 1.0);
|
|
}
|
|
normalize_vector(out)
|
|
}
|
|
|
|
fn normalize_vector(mut values: Vec<f32>) -> Vec<f32> {
|
|
let norm = values
|
|
.iter()
|
|
.map(|value| (*value as f64) * (*value as f64))
|
|
.sum::<f64>()
|
|
.sqrt() as f32;
|
|
if norm > f32::EPSILON {
|
|
for value in &mut values {
|
|
*value /= norm;
|
|
}
|
|
}
|
|
values
|
|
}
|
|
|
|
fn fnv1a64(bytes: &[u8]) -> u64 {
|
|
let mut hash = 14695981039346656037u64;
|
|
for byte in bytes {
|
|
hash ^= *byte as u64;
|
|
hash = hash.wrapping_mul(1099511628211u64);
|
|
}
|
|
hash
|
|
}
|
|
|
|
fn xorshift64(mut x: u64) -> u64 {
|
|
x ^= x << 13;
|
|
x ^= x >> 7;
|
|
x ^= x << 17;
|
|
x
|
|
}
|
|
|
|
fn result_slugs(result: &QueryResult) -> Vec<String> {
|
|
let batch = result.concat_batches().unwrap();
|
|
let slugs = batch
|
|
.column(0)
|
|
.as_any()
|
|
.downcast_ref::<StringArray>()
|
|
.unwrap();
|
|
(0..slugs.len())
|
|
.map(|index| slugs.value(index).to_string())
|
|
.collect()
|
|
}
|
|
|
|
async fn doc_user_index_count(db: &Omnigraph) -> usize {
|
|
let ds = snapshot_main(db)
|
|
.await
|
|
.unwrap()
|
|
.open("node:Doc")
|
|
.await
|
|
.unwrap();
|
|
ds.load_indices()
|
|
.await
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|idx| !is_system_index(idx))
|
|
.count()
|
|
}
|
|
|
|
struct EnvGuard {
|
|
saved: Vec<(&'static str, Option<String>)>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn set(vars: &[(&'static str, Option<&str>)]) -> Self {
|
|
let saved = vars
|
|
.iter()
|
|
.map(|(name, _)| (*name, env::var(name).ok()))
|
|
.collect::<Vec<_>>();
|
|
for (name, value) in vars {
|
|
unsafe {
|
|
match value {
|
|
Some(value) => env::set_var(name, value),
|
|
None => env::remove_var(name),
|
|
}
|
|
}
|
|
}
|
|
Self { saved }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
for (name, value) in self.saved.drain(..) {
|
|
unsafe {
|
|
match value {
|
|
Some(value) => env::set_var(name, value),
|
|
None => env::remove_var(name),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Text search (match_tokens) ─────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn text_search_filters_results() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
|
|
// "Learning" appears in: ml-intro, dl-basics, rl-intro titles
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"text_search",
|
|
¶ms(&[("$q", "Learning")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
result.num_rows() > 0,
|
|
"expected at least 1 result for 'Learning'"
|
|
);
|
|
let batch = result.concat_batches().unwrap();
|
|
let slugs = batch
|
|
.column(0)
|
|
.as_any()
|
|
.downcast_ref::<StringArray>()
|
|
.unwrap();
|
|
let slug_values: Vec<&str> = (0..slugs.len()).map(|i| slugs.value(i)).collect();
|
|
// Should contain ML and RL intro docs
|
|
assert!(
|
|
slug_values.contains(&"ml-intro") || slug_values.contains(&"rl-intro"),
|
|
"expected learning-related docs, got {:?}",
|
|
slug_values
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn text_search_no_results() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"text_search",
|
|
¶ms(&[("$q", "xyznonexistent")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result.num_rows(), 0);
|
|
}
|
|
|
|
// ─── Fuzzy search (match_tokens with fuzzy_max_edits) ───────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn fuzzy_search_tolerates_typos() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
|
|
// "Introductio" (missing 'n') should fuzzy-match "Introduction" with max_edits=2
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"fuzzy_search",
|
|
¶ms(&[("$q", "Introductio")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Fuzzy matching may not work with the default tokenizer on all terms;
|
|
// at minimum verify it doesn't error
|
|
// If it returns results, great — it matched despite the typo
|
|
let _ = result.num_rows();
|
|
}
|
|
|
|
// ─── Phrase search (match_phrase) ───────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn phrase_search_matches_exact_phrase() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
|
|
// "neural networks" appears in dl-basics body
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"phrase_search",
|
|
¶ms(&[("$q", "neural networks")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
result.num_rows() > 0,
|
|
"expected match for 'neural networks'"
|
|
);
|
|
let batch = result.concat_batches().unwrap();
|
|
let slugs = batch
|
|
.column(0)
|
|
.as_any()
|
|
.downcast_ref::<StringArray>()
|
|
.unwrap();
|
|
let slug_values: Vec<&str> = (0..slugs.len()).map(|i| slugs.value(i)).collect();
|
|
assert!(
|
|
slug_values.contains(&"dl-basics"),
|
|
"expected dl-basics for 'neural networks', got {:?}",
|
|
slug_values
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn phrase_search_is_documented_fts_fallback() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"phrase_search",
|
|
¶ms(&[("$q", "networks layers")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
result.num_rows() > 0,
|
|
"match_text fallback should still match FTS tokens"
|
|
);
|
|
let batch = result.concat_batches().unwrap();
|
|
let slugs = batch
|
|
.column(0)
|
|
.as_any()
|
|
.downcast_ref::<StringArray>()
|
|
.unwrap();
|
|
let slug_values: Vec<&str> = (0..slugs.len()).map(|i| slugs.value(i)).collect();
|
|
assert!(
|
|
slug_values.contains(&"dl-basics"),
|
|
"expected FTS fallback to match dl-basics, got {:?}",
|
|
slug_values
|
|
);
|
|
}
|
|
|
|
// ─── Vector search (nearest) ────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn nearest_returns_k_closest() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
|
|
// Query vector [0.1, 0.2, 0.3, 0.4] is identical to ml-intro's embedding
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"vector_search",
|
|
&vector_param("$q", &[0.1, 0.2, 0.3, 0.4]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// limit 3 → should return exactly 3
|
|
assert_eq!(result.num_rows(), 3);
|
|
|
|
// ml-intro should be the closest (distance=0)
|
|
let batch = result.concat_batches().unwrap();
|
|
let slugs = batch
|
|
.column(0)
|
|
.as_any()
|
|
.downcast_ref::<StringArray>()
|
|
.unwrap();
|
|
assert_eq!(slugs.value(0), "ml-intro", "closest should be ml-intro");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn nearest_string_param_matches_explicit_vector_under_mock_embeddings() {
|
|
let _guard = EnvGuard::set(&[
|
|
("OMNIGRAPH_EMBEDDINGS_MOCK", Some("1")),
|
|
("GEMINI_API_KEY", None),
|
|
]);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_mock_embedding_search_db(&dir).await;
|
|
|
|
let explicit = query_main(
|
|
&mut db,
|
|
MOCK_SEARCH_QUERIES,
|
|
"vector_search_vector",
|
|
&vector_param("$q", &mock_embedding("alpha", 4)),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let embedded = query_main(
|
|
&mut db,
|
|
MOCK_SEARCH_QUERIES,
|
|
"vector_search_string",
|
|
¶ms(&[("$q", "alpha")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result_slugs(&embedded), result_slugs(&explicit));
|
|
assert_eq!(result_slugs(&embedded)[0], "alpha-doc");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn nearest_string_literal_works_under_mock_embeddings() {
|
|
let _guard = EnvGuard::set(&[
|
|
("OMNIGRAPH_EMBEDDINGS_MOCK", Some("1")),
|
|
("GEMINI_API_KEY", None),
|
|
]);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_mock_embedding_search_db(&dir).await;
|
|
|
|
let result = query_main(
|
|
&mut db,
|
|
MOCK_SEARCH_QUERIES,
|
|
"vector_search_literal",
|
|
¶ms(&[]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result_slugs(&result)[0], "alpha-doc");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn rrf_with_string_nearest_matches_explicit_vector_under_mock_embeddings() {
|
|
let _guard = EnvGuard::set(&[
|
|
("OMNIGRAPH_EMBEDDINGS_MOCK", Some("1")),
|
|
("GEMINI_API_KEY", None),
|
|
]);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_mock_embedding_search_db(&dir).await;
|
|
|
|
let explicit = query_main(
|
|
&mut db,
|
|
MOCK_SEARCH_QUERIES,
|
|
"hybrid_search_vector",
|
|
&vector_and_string_params("$vq", &mock_embedding("alpha", 4), "$tq", "alpha"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let embedded = query_main(
|
|
&mut db,
|
|
MOCK_SEARCH_QUERIES,
|
|
"hybrid_search_string",
|
|
¶ms(&[("$vq", "alpha"), ("$tq", "alpha")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result_slugs(&embedded), result_slugs(&explicit));
|
|
assert_eq!(result_slugs(&embedded)[0], "alpha-doc");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn explicit_vector_nearest_does_not_require_gemini_credentials() {
|
|
let _guard = EnvGuard::set(&[
|
|
("OMNIGRAPH_EMBEDDINGS_MOCK", None),
|
|
("GEMINI_API_KEY", None),
|
|
]);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_mock_embedding_search_db(&dir).await;
|
|
|
|
let result = query_main(
|
|
&mut db,
|
|
MOCK_SEARCH_QUERIES,
|
|
"vector_search_vector",
|
|
&vector_param("$q", &mock_embedding("alpha", 4)),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result_slugs(&result)[0], "alpha-doc");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn string_nearest_requires_gemini_credentials_when_mock_is_disabled() {
|
|
let _guard = EnvGuard::set(&[
|
|
("OMNIGRAPH_EMBEDDINGS_MOCK", None),
|
|
("GEMINI_API_KEY", None),
|
|
]);
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_mock_embedding_search_db(&dir).await;
|
|
|
|
let err = query_main(
|
|
&mut db,
|
|
MOCK_SEARCH_QUERIES,
|
|
"vector_search_string",
|
|
¶ms(&[("$q", "alpha")]),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert!(err.to_string().contains("GEMINI_API_KEY"));
|
|
}
|
|
|
|
// ─── BM25 search ────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn bm25_returns_ranked_results() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
|
|
// "Learning" appears in multiple titles
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"bm25_search",
|
|
¶ms(&[("$q", "Learning")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
result.num_rows() > 0,
|
|
"bm25 should return results for 'Learning'"
|
|
);
|
|
assert!(result.num_rows() <= 3, "bm25 should respect limit 3");
|
|
}
|
|
|
|
// Full rank-ORDER golden (not just top-1 / non-empty): pins ranks 2..k so a
|
|
// regression corrupting the tail or reversing the sort direction fails loudly.
|
|
// nearest skips apply_ordering (is_search_ordered) and returns Lance native
|
|
// order, so result_slugs row order == rank order.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn nearest_full_rank_order() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"vector_search",
|
|
&vector_param("$q", &[0.1, 0.2, 0.3, 0.4]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// [0.1,0.2,0.3,0.4] == ml-intro's embedding (dist 0); the rest by ascending L2.
|
|
assert_eq!(result_slugs(&result), vec!["ml-intro", "nlp-guide", "rl-intro"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn bm25_full_rank_order() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"bm25_search",
|
|
¶ms(&[("$q", "Learning")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Descending BM25 score order.
|
|
assert_eq!(result_slugs(&result), vec!["rl-intro", "ml-intro", "dl-basics"]);
|
|
}
|
|
|
|
// Characterization: fuzzy() does NOT match under the default tokenizer/index in
|
|
// this setup — a one-edit typo ("Introductio" for "Introduction") returns no
|
|
// rows. (`search`/`match_text` DO work, so FTS itself is fine; fuzzy term
|
|
// queries specifically are inert here.) This pins that documented limitation
|
|
// instead of leaving fuzzy silently unasserted: if a Lance/tokenizer change
|
|
// makes fuzzy match, this turns red and should be promoted to a real
|
|
// matched-set + exclusion golden.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn fuzzy_does_not_match_under_default_tokenizer() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
let r = query_main(&mut db, SEARCH_QUERIES, "fuzzy_search", ¶ms(&[("$q", "Introductio")]))
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
result_slugs(&r).is_empty(),
|
|
"fuzzy now matches — promote this to a real matched-set/exclusion golden"
|
|
);
|
|
}
|
|
|
|
// match_text is a FILTER on the body: assert the exact matched set, not contains.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn match_text_matches_exact_set_excludes_unrelated() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
// "neural" appears only in dl-basics's body ("neural networks").
|
|
let r = query_main(&mut db, SEARCH_QUERIES, "phrase_search", ¶ms(&[("$q", "neural")]))
|
|
.await
|
|
.unwrap();
|
|
let mut got = result_slugs(&r);
|
|
got.sort();
|
|
assert_eq!(got, vec!["dl-basics"]);
|
|
}
|
|
|
|
// RRF fuses arms OTHER than the default nearest+bm25: two FTS arms (title+body).
|
|
// Proves primary_var resolves when neither arm is `nearest`, and fusion runs.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn rrf_fuses_two_fts_fields() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
let r = query_main(&mut db, SEARCH_QUERIES, "rrf_two_fts", ¶ms(&[("$q", "learning")]))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result_slugs(&r), vec!["dl-basics", "ml-intro", "rl-intro"]);
|
|
}
|
|
|
|
// RRF fuses two vector arms (no embedding creds — explicit vectors). A doc near
|
|
// BOTH query vectors out-ranks one near only one.
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn rrf_fuses_two_vector_queries() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
let r = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"rrf_two_vectors",
|
|
&two_vector_params("$q1", &[0.1, 0.2, 0.3, 0.4], "$q2", &[0.5, 0.6, 0.7, 0.8]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result_slugs(&r), vec!["rl-intro", "ml-intro", "dl-basics"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn mutation_commit_refreshes_search_indices_without_manual_ensure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
assert_eq!(doc_user_index_count(&db).await, 4);
|
|
|
|
let mut mutation_params = vector_param("$embedding", &[0.9, 0.1, 0.1, 0.1]);
|
|
mutation_params.insert(
|
|
"slug".to_string(),
|
|
Literal::String("quasar-notes".to_string()),
|
|
);
|
|
mutation_params.insert(
|
|
"title".to_string(),
|
|
Literal::String("Quasar Notes".to_string()),
|
|
);
|
|
mutation_params.insert(
|
|
"body".to_string(),
|
|
Literal::String("Quasar observations and telescope notes".to_string()),
|
|
);
|
|
|
|
db.mutate("main", SEARCH_MUTATIONS, "insert_doc", &mutation_params)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
doc_user_index_count(&db).await,
|
|
4,
|
|
"mutation commit should refresh required indices without duplicating them"
|
|
);
|
|
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"text_search",
|
|
¶ms(&[("$q", "Quasar")]),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
result_slugs(&result).contains(&"quasar-notes".to_string()),
|
|
"newly inserted row should be searchable without an explicit ensure_indices step"
|
|
);
|
|
}
|
|
|
|
// ─── RRF hybrid search ─────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn rrf_fuses_vector_and_text() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut db = init_search_db(&dir).await;
|
|
|
|
let result = query_main(
|
|
&mut db,
|
|
SEARCH_QUERIES,
|
|
"hybrid_search",
|
|
&vector_and_string_params("$vq", &[0.1, 0.2, 0.3, 0.4], "$tq", "Learning"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(result.num_rows() > 0, "rrf should return results");
|
|
assert!(result.num_rows() <= 3, "rrf should respect limit 3");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn load_commit_creates_vector_index_for_vector_annotations() {
|
|
let schema = r#"
|
|
node Doc {
|
|
slug: String @key
|
|
embedding: Vector(4) @index
|
|
}
|
|
"#;
|
|
let data = r#"{"type": "Doc", "data": {"slug": "a", "embedding": [0.1, 0.2, 0.3, 0.4]}}
|
|
{"type": "Doc", "data": {"slug": "b", "embedding": [0.5, 0.6, 0.7, 0.8]}}"#;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let uri = dir.path().to_str().unwrap();
|
|
let mut db = Omnigraph::init(uri, schema).await.unwrap();
|
|
load_jsonl(&mut db, data, LoadMode::Overwrite)
|
|
.await
|
|
.unwrap();
|
|
|
|
let ds = snapshot_main(&db)
|
|
.await
|
|
.unwrap()
|
|
.open("node:Doc")
|
|
.await
|
|
.unwrap();
|
|
let indices = ds.load_indices().await.unwrap();
|
|
let user_indices: Vec<_> = indices.iter().filter(|idx| !is_system_index(idx)).collect();
|
|
assert_eq!(
|
|
user_indices.len(),
|
|
3,
|
|
"expected id BTree index plus key-property and vector indices"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[serial]
|
|
async fn load_commit_creates_inverted_indices_for_string_annotations() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let db = init_search_db(&dir).await;
|
|
|
|
let ds = snapshot_main(&db)
|
|
.await
|
|
.unwrap()
|
|
.open("node:Doc")
|
|
.await
|
|
.unwrap();
|
|
let indices = ds.load_indices().await.unwrap();
|
|
let user_indices: Vec<_> = indices.iter().filter(|idx| !is_system_index(idx)).collect();
|
|
assert_eq!(
|
|
user_indices.len(),
|
|
4,
|
|
"expected id BTree index plus key-property and title/body inverted indices"
|
|
);
|
|
}
|