diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 60c96adc..6e3a9949 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -20,8 +20,8 @@ env:
# The size of the shipped code, rounded up to 500 lines, and the number of tests, rounded up
# to 10. A change that crosses either number edits it here, in the same change, where its
# reader sees that the code grew or shrank. Neither ever passes 150,000 lines or 1,000 tests.
- SHIPPED_LINES: 11000
- TESTS: 180
+ SHIPPED_LINES: 15500
+ TESTS: 260
jobs:
lint:
diff --git a/v2/AGENTS.md b/v2/AGENTS.md
index ed6ac801..2664c8a5 100644
--- a/v2/AGENTS.md
+++ b/v2/AGENTS.md
@@ -23,22 +23,28 @@ The library is twelve parts, one folder each under `contextdb/src/`:
| `sql` | grammar, parser, planner: text to a statement, a statement to a plan | touches storage or knows what a table holds |
| `store` | the folder and data file, how each record is keyed, a row's large text, JSON and vectors kept beside the row in slices (text and JSON compressed there when that saves an eighth, `store/record.rs`), views of one moment, the reader files (one in `readers/` per reader that is inside a statement, naming the commit it reads) and the locks | interprets SQL or a table's rules |
| `schema` | table declarations: checking, storing, printing them back | changes a row |
-| `query` | answers a SELECT from one table; holds the one expression evaluator; counts the rows it reads | changes anything |
+| `query` | answers a SELECT from one table; holds the one expression evaluator; counts the rows it reads and a search's distance evaluations; answers `SHOW VECTOR_INDEXES`, `SHOW VECTOR_PARTITIONS` and `explain` from the vector part's small records | changes anything |
| `write` | applies INSERT, UPDATE and DELETE to a change set; checks types, NOT NULL, the primary key and UNIQUE rules; keeps index entries and row counts | returns a query's result to a caller |
-| `database` | the handle a store is opened for writing with, its transactions, the one commit function | has a second way to commit |
-| `read` | a session that reads a store without holding it for writing, from any process | changes anything a writer stored |
-| `vector` | the blocks of a VECTOR column (a one-byte copy of every vector), the lists of vectors not yet in a block, the column's partitions and their count, exact search over them, and sealing | decides which rows a reader may see: `query` tells it, row by row, and a row is in an answer only with the distance `query` worked out from the row's stored vector |
-| `upkeep` | one cycle of background work when it is called: sealing at most 4,096 waiting vectors into blocks, in one commit; the 4,096 is `SEAL_ROWS` in `upkeep/mod.rs` | starts a thread, reads a clock, or runs unless called |
+| `database` | the handle a store is opened for writing with, its transactions, the one commit function; `run_maintenance_cycle` (the sealing commit, then one step for every partition with work, each worked out with no lock held and committed through the one commit function); `explain` | has a second way to commit |
+| `read` | a session that reads a store without holding it for writing, from any process; its `explain` | changes anything a writer stored |
+| `vector` | the blocks of a VECTOR column (a one-byte copy of every vector), the lists of vectors not yet in a block, the column's partitions and their count, the grouping of a partition (its centres, a one-bit sketch of every slot, the partition's mean), search over them (every slot, or the sketches of the nearest groups and of the tail, then the slots those keep), building a grouping in steps and putting it in force, sealing, and what inspection reads | decides which rows a reader may see: `query` tells it, row by row, and a row is in an answer only with the distance `query` worked out from the row's stored vector; reads a clock, takes the commit lock, or starts a thread |
+| `upkeep` | the sealing of one cycle, at most 4,096 waiting vectors moved into blocks in one commit (the 4,096 is `SEAL_ROWS` in `upkeep/mod.rs`), and the report of a cycle; `database` drives the grouping steps that follow it | starts a thread, reads a clock, or runs unless called |
The files of `vector/`, each as its opening comment says:
-- `slot.rs`: one vector as a block keeps it (a scale, a rounding error and a signed byte for each number), the range a search gets from it, and the distance every answer is ordered by.
+- `slot.rs`: one vector as a block keeps it (a scale, a rounding error and a signed byte for each number), the range a search gets from it, and the distance every answer is ordered by; `Reduced`, the one place that turns a vector into one byte a number for a column declared `SQ8`.
- `block.rs`: the slots of as many vectors of one partition as fit in the block's bytes, each with the key of its row.
- `directory.rs`: the partitions of a VECTOR column, one record for each key tuple under which a row has a vector.
-- `search.rs`: the one search: scores the rows in no block exactly, screens the slots of the selected partitions, asks for the true distance of the rows that can reach the answer, and proves the answer.
+- `grouping.rs`: the grouping of a partition (its slots sorted by the centre each is nearest, packed into full blocks, with the centres, the place where each group begins and a sketch of every slot) and the partition's mean; written once by the build that made it and never changed.
+- `sketch.rs`: one vector as a sketch keeps it (a bit for each number and a factor, taken from a reference: the centre of its group, or the partition's mean for a tail block), and the range a search gets from a sketch; the counting loop.
+- `train.rs`: finding centres, the arithmetic of a build over slots alone; nothing there reads a store.
+- `build.rs`: when a grouping is due, the record of how far a build has come, and the steps that find centres and say which group each slot goes to.
+- `pack.rs`: the last steps of a build: packing the slots of each group into the blocks of the new grouping with a sketch of each, measuring how far a search scans and how wide a sketch's estimate is taken to be, and putting the grouping in force.
+- `search.rs`: the one search, and the plan it runs: scores the rows in no block exactly, screens the slots of the selected partitions (every slot of a partition searched whole; of one searched by its nearest groups, the slots its sketches keep), asks for the true distance of the rows that can reach the answer, and proves the answer.
- `seal.rs`: moving the vectors on the waiting lists into blocks.
- `stage.rs`: what a commit's row changes mean for the vector part, worked out once inside the commit function.
- `tally.rs`: the partitions a transaction's statements have made and emptied, so that the statement that would give a column one partition too many is refused when it runs.
+- `inspect.rs`: what inspection reads of a VECTOR column, its partitions as their records describe them; no block, no centre and no row.
## Where the parts meet
@@ -56,11 +62,14 @@ A part reaches another only through what that part makes visible: the `pub(crate
| `eval`, `matching` | `query` | `write` finds the rows of an UPDATE or DELETE with `query::matching` and works out values with `query::eval` |
| `QueryResult` | `query` | `database` and `read` build it and hand it to the caller |
| `PhysicalPlan::VectorSearch`; `Expr::Distance`, `Expr::Row`, `Expr::InList` | `sql` | `query` runs the search, puts its distance wherever the select list writes `<=>`, and evaluates `IN` lists and row values |
-| `VectorColumn` (a VECTOR column's number, partition key, limit and mode), from `Table::vectors`, `Table::vector` and `Catalog::vector` | `schema` | `query`, `write` and `vector` |
+| `VectorColumn` (a VECTOR column's number, partition key, limit, mode, quantization, `AUTO_INDEX_AT` and consolidation, with the numbers in force), from `Table::vectors`, `Table::vector` and `Catalog::vector` | `schema` | `query`, `write` and `vector` |
| `staged`, `vectors` and `vector_number` on `Read`; `put_vector`, `delete_vector` and `put_vector_number` on `ChangeSet`; `VectorRecord`, `VectorNumber`, `VectorId` | `store` | `vector` reads and stages its blocks, waiting entries, partition entries and counts through them; `store` keeps them and never interprets one |
-| `search`, `Nearest`, `distance` | `vector` | `query` calls `search` with the partitions the filter selects and answers it, row by row, with `distance` of the row's stored vector, or with nothing for a row that is gone, filtered out or has no vector |
+| `search`, `plan`, `Nearest`, `Route`, `distance` | `vector` | `query` calls `search` with the partitions the filter selects, the mode in force, the column's declaration and whether the statement has a filter on rows, and answers it, row by row, with `distance` of the row's stored vector, or with nothing for a row that is gone, filtered out or has no vector; `search` answers nothing at all only when INDEXED is refused (a selected partition is not usable, or a filter on rows leaves the index nothing to choose among), which `query` turns into the refusal. `plan` settles what a search does with each partition, from small records alone, and `search` runs exactly that plan, so `explain` cannot disagree with it |
| `Tally`, the partitions a transaction has started and emptied | `vector` | `database` holds one beside each change set and forgets a refused statement's part of it; `write` notes every row it stages and settles the tally as a statement's last act, which is where a statement over `MAX_PARTITIONS` is refused |
| `stage`, `seal` | `vector` | `database` calls `stage` inside the one commit function, after the commit's other rules have passed; `upkeep` calls `seal` |
+| `waiting`, `prepare`, `stage_step`, `stage_refusal`, `remaining` (and the values they return) | `vector` | `database` lists the partitions with work, has a step worked out with no lock held, and stages it through the one commit function; a step the memory limit refuses is recorded, not returned |
+| `partitions`, `Inspected`, `State` | `vector` | `query` answers the two SHOW statements and `explain` with them |
+| `Reduced` | `vector` | `write` gives a column declared `SQ8` the vector `Reduced` makes of the one written, and hands the store its scale and codes with the row (`store::Compact`) |
| `cycle`, `MaintenanceReport` | `upkeep` | `database` runs one cycle for `run_maintenance_cycle` and commits it through the one commit function |
| `Store::count_cache_under` | `store` | `database` has the store check its page cache against a new limit, on the one path that sets the memory limit |
@@ -79,12 +88,26 @@ Every path in this guide is written from `v2/`.
| The memory limit holds | `contextdb/tests/memory_ceiling.rs`: `a_million_rows_load_and_scan_under_64_mib` |
| A refused statement changes nothing | `contextdb/tests/memory.rs`: `a_statement_that_would_cross_the_limit_is_refused_by_name_and_committed_rows_stay`; `contextdb/tests/transactions.rs`: `a_refused_statement_changes_nothing_and_leaves_the_transaction_usable` |
| A damaged page is reported as damage | `contextdb/tests/crash/main.rs`: `a_damaged_page_is_refused_as_damage_by_name`; `redb/tests/contextdb_changes.rs`: `a_page_with_a_wrong_checksum_is_refused_when_read` |
-| The stored bytes of a large value, a vector record or a table's vector declaration that disagree with themselves are reported as damage, never a panic or another value | `contextdb/src/store/tests.rs`: `a_plain_text_whose_slices_hold_fewer_bytes_than_its_row_records_is_damage`, `a_compressed_text_that_decodes_to_bytes_that_are_not_text_is_damage`; `contextdb/src/vector/tests.rs`: `a_pending_record_of_a_row_with_no_vector_is_reported_as_damage`, `a_write_to_a_vector_column_that_is_no_column_of_its_table_is_reported_as_damage` |
+| The stored bytes of a large value, a vector record or a table's vector declaration that disagree with themselves are reported as damage, never a panic or another value | `contextdb/src/store/tests.rs`: `a_plain_text_whose_slices_hold_fewer_bytes_than_its_row_records_is_damage`, `a_compressed_text_that_decodes_to_bytes_that_are_not_text_is_damage`; `contextdb/src/vector/tests.rs`: `a_pending_record_of_a_row_with_no_vector_is_reported_as_damage`, `a_write_to_a_vector_column_that_is_no_column_of_its_table_is_reported_as_damage`; a grouping, centres, a build, assigned groups, a sketch record or a partition entry that disagrees with itself or with its partition, likewise: `a_grouping_that_disagrees_with_its_partition_is_damage`, `centres_that_do_not_come_to_their_groups_are_damage`, `a_build_whose_cursor_is_past_its_blocks_is_damage`, `assigned_groups_that_do_not_match_their_block_are_damage`, `a_sketch_record_that_does_not_match_its_block_is_damage`, `a_partition_entry_that_disagrees_with_itself_is_damage`; a reduced vector's record of another length: `contextdb/src/store/tests.rs`: `a_reduced_record_of_another_length_is_damage` |
| Counted work: a count reads no rows, a key lookup reads one | `contextdb/tests/counted_work.rs`: `count_of_a_whole_table_reads_no_rows`, `a_lookup_by_primary_key_reads_one_row` |
-| Every row with a vector is found by a search, whether its vector waits on a list or is in a block, and after a kill inside a commit or a seal | `contextdb/tests/vectors.rs`: `exact_search_returns_the_true_nearest_vectors`; `contextdb/tests/vectors_upkeep.rs`: `sealing_advances_only_when_asked_and_in_bounded_cycles`; `contextdb/tests/crash/main.rs`: `a_kill_inside_a_commit_of_vectors_keeps_rows_and_vectors_together`, `a_kill_at_any_storage_call_of_a_seal_leaves_every_vector_found` |
+| Every row with a vector is found by an exact search, whether its vector waits on a list, is in a block written since the last grouping or is in the partition's grouping once and not as a stale slot, and after a kill inside a commit, a seal or a build | `contextdb/tests/vectors.rs`: `exact_search_returns_the_true_nearest_vectors`; `contextdb/tests/vectors_upkeep.rs`: `sealing_advances_only_when_asked_and_in_bounded_cycles`, `a_grouping_holds_every_live_vector_once_and_no_stale_slot`, `vectors_sealed_while_a_first_build_starts_are_found_after_the_switch`; `contextdb/tests/crash/main.rs`: `a_kill_inside_a_commit_of_vectors_keeps_rows_and_vectors_together`, `a_kill_at_any_storage_call_of_a_seal_leaves_every_vector_found`, `a_kill_at_any_storage_call_of_a_build_leaves_every_vector_found`, `a_killed_build_goes_on_from_its_record` |
| A row whose vector is NULL, and a deleted row, is never in an answer | `contextdb/tests/vectors.rs`: `exact_search_returns_the_true_nearest_vectors`, `exact_search_reads_its_own_snapshot` |
| A partition exists exactly while a row with a vector is in it, and `MAX_PARTITIONS` counts those and nothing else | `contextdb/tests/vectors_partitions.rs`: `vector_partition_limit_counts_and_refuses`, `the_partition_cap_counts_live_partitions_while_a_reader_holds_an_earlier_state` |
| No answer holds a row twice, whatever the row's vector has been | `contextdb/tests/vectors.rs`: `a_row_is_in_an_answer_once_after_its_vector_changes` |
+| A search under INDEXED or AUTO returns only rows an exact search of the same statement could return, each with its true distance and none twice; it is never answered short: it returns `LIMIT` rows whenever the selected partitions hold that many rows the filter passes | `contextdb/tests/vectors_index.rs`: `indexed_is_served_at_every_size`, `a_search_that_widens_returns_no_row_twice`, `a_nearer_allowed_row_in_a_group_taken_later_is_in_the_answer`, `a_search_with_a_filter_on_rows_widens_until_it_is_whole` |
+| A partition with no grouping, a grouping of one group, or sketches that cannot tell its vectors apart is searched as an exact search searches it; below its size a column pays for no index | `contextdb/tests/vectors_index.rs`: `a_column_below_its_size_pays_nothing_and_is_searched_whole`, `a_grouping_whose_sketches_cannot_tell_its_vectors_apart_answers_as_exact` |
+| On the fixed vectors of the tests an indexed search counts fewer distance evaluations than an exact one (on other data it can count more, each an eighth of the bytes), reads sketches before blocks, and never costs more row visits than an exact one | `contextdb/tests/vectors_index.rs`: `an_indexed_search_does_fewer_distance_evaluations_than_exact`, `an_indexed_search_reads_sketches_before_blocks`, `an_indexed_search_that_takes_every_group_does_the_exact_evaluations` |
+| INDEXED is refused by name, before the filter would be asked of the whole table, when a partition is far behind its writes or a filter leaves the index nothing to choose among; AUTO is never refused for a grouping and answers exactly where one is behind or damaged | `contextdb/tests/vectors_index.rs`: `indexed_is_refused_whole_when_one_partition_is_behind`, `an_indexed_search_short_of_its_limit_is_refused_before_the_whole_table_is_read`, `an_indexed_search_whose_first_pass_takes_every_slot_is_refused_before_a_row_is_asked`, `auto_answers_exactly_where_a_grouping_is_behind_or_damaged`; `contextdb/src/vector/tests.rs`: `auto_answers_exactly_where_a_grouping_is_damaged` |
+| AUTO is exact until the selected partitions hold `AUTO_INDEX_AT` vectors, then indexed in each partition that has a grouping and is not behind | `contextdb/tests/vectors_index.rs`: `auto_uses_groupings_from_auto_index_at_on`, `auto_searches_each_partition_by_what_its_grouping_saves` |
+| A write after a grouping is found at once, before any cycle and after sealing | `contextdb/tests/vectors_index.rs`: `writes_after_a_grouping_are_found_at_once` |
+| A grouping is built in bounded cycles and switched in by one commit; the old grouping serves until then, and a write or search runs between any two cycles | `contextdb/tests/vectors_upkeep.rs`: `a_grouping_is_built_in_bounded_cycles_and_switched_in_one`; `contextdb/tests/transactions.rs`: `a_statement_commits_while_a_cycle_works_out_a_step` |
+| Every block of a grouping, and every block sealed after the partition's mean exists, has a sketch for each slot; the index takes the bytes the layout says | `contextdb/tests/vectors_upkeep.rs`: `a_grouping_keeps_a_sketch_of_every_slot_and_measures_its_width`, `sealing_after_a_grouping_keeps_a_sketch_of_every_slot` |
+| A partition is grouped again only when its changes reach the declared share | `contextdb/tests/vectors_upkeep.rs`: `a_partition_is_grouped_again_only_when_its_changes_reach_the_declared_share` |
+| A partition that loses its last vector leaves no record of its grouping or its build | `contextdb/src/vector/tests.rs`: `a_partition_that_empties_leaves_no_record_of_its_grouping_or_its_build` |
+| A statement reads one grouping whole, the one in force at its commit, in another process too; a new process searches a built grouping at once, and a grouping is there after reopen | `contextdb/tests/readers.rs`: `a_reader_sees_one_grouping_whole_while_the_writer_builds_and_switches`, `a_new_process_searches_a_built_grouping_at_once`; `contextdb/tests/store.rs`: `a_grouping_is_there_after_close_and_reopen` |
+| A build or an indexed search under the memory limit stays under it; a step the limit refuses is recorded as a stall and the old grouping serves; a commit with no room for its vector records changes nothing | `contextdb/tests/memory_ceiling.rs`: `an_indexed_search_and_a_build_stay_under_the_limit`; `contextdb/tests/vectors_upkeep.rs`: `a_cycle_under_a_tight_limit_records_the_stall_and_the_old_grouping_serves`; `contextdb/tests/vectors_memory.rs`: `a_commit_with_no_room_for_its_vector_records_changes_nothing`; `contextdb/tests/vectors_index.rs`: `a_search_with_no_room_for_its_sketches_is_refused_by_the_memory_limit` |
+| Inspection and `explain` read no block and change nothing; each partition is described as its records say | `contextdb/tests/vectors_inspection.rs`: `inspection_and_explain_read_no_block_and_change_nothing`, `show_vector_indexes_describes_every_vector_column`, `show_vector_partitions_lists_partitions_by_key_with_limit_and_offset`, `explain_names_how_each_partition_is_searched` |
+| A column declared `SQ8` keeps the vector rounded to one byte a number, reads it back number for number, ranks by it, and is grouped and searched as any other | `contextdb/tests/vectors_reduced.rs`: `a_reduced_column_keeps_the_nearest_one_byte_vector`, `exact_on_a_reduced_column_ranks_by_the_kept_vectors`, `a_reduced_column_is_grouped_and_searched_as_any_other`; `contextdb/src/store/tests.rs`: `a_reduced_vector_reads_back_number_for_number_in_a_quarter_of_the_room` |
| A store's data file is no longer than a recorded number of bytes for a fixed content | `contextdb/tests/vectors_upkeep.rs`: `a_small_fixed_store_stays_within_its_recorded_size` |
| A text or JSON value reads back byte for byte whether or not it is stored compressed; one that does not shrink by an eighth takes the room it takes uncompressed, and text that repeats takes less than its own bytes | `contextdb/tests/large_values.rs`: `large_text_and_json_come_back_byte_for_byte`, `a_value_that_does_not_shrink_is_kept_as_it_is`, `a_store_of_repeating_text_is_smaller_than_the_text` |
| No value is produced past the memory limit: a compressed value whose real size does not fit is refused before its bytes exist, and compression never refuses a commit | `contextdb/tests/large_values.rs`: `a_compressed_value_too_large_for_the_limit_is_refused`, `a_commit_with_no_room_to_compress_is_written_plain`; `contextdb/tests/memory_ceiling.rs`: `a_refused_compressed_value_is_never_produced` |
@@ -96,6 +119,7 @@ Every path in this guide is written from `v2/`.
|---|---|---|
| A new SQL statement or clause | `contextdb/src/sql/` (`grammar.pest`, `parser/`, the `Statement` type in `ast.rs`, `plan.rs`, `planner.rs`; take its first words out of the grammar's list of what a later version reads), then the part that runs it: `schema`, `write` or `query` | a script under `contextdb/tests/sql/` |
| A new vector clause | `contextdb/src/sql/` reads it, as any clause; `contextdb/src/schema/` checks, stores and prints back one that is declared with the column; `contextdb/src/query/search.rs` settles what a search is asked (mode, vector, partitions, filter); `contextdb/src/vector/` only when blocks, partitions or the search itself change | `contextdb/tests/vectors_statements.rs` for what is read and refused, `contextdb/tests/vectors.rs` for the rows a search returns |
+| When or how a partition is grouped (the size at which a column is grouped, the share of change that groups it again, the steps or the numbers a build measures) | `contextdb/src/vector/build.rs` (`due`, `usable`, the steps), `contextdb/src/vector/pack.rs` (packing and measuring), `contextdb/src/vector/train.rs` (centres); the numbers in force from `VectorColumn` in `contextdb/src/schema/`. A new record for a grouping is a `VectorRecord` kind in `contextdb/src/store/`, which keeps it and never reads it. A change that moves what a build measures changes how a search uses it in `contextdb/src/vector/search.rs`, in the same change | `contextdb/tests/vectors_upkeep.rs` for what a build does and when, `contextdb/tests/vectors_index.rs` for what a search returns and does, `contextdb/tests/crash/main.rs` for a kill inside a build; the cycle counts and sizes the tests record change only with the design they state |
| A new refusal (error kind) | `Error` in `contextdb/src/types/mod.rs`; how the program prints it in `contextdb-cli/src/output.rs` | `contextdb/tests/refusals.rs` for its fields and that nothing changed; `contextdb/tests/sql/refusals.sql` for its name |
| What a statement returns | `contextdb/src/query/` for a SELECT, `contextdb/src/write/` for a row count | the expected output of the scripts under `contextdb/tests/sql/`, rewritten and the diff read |
| How a store is laid out on disk | `contextdb/src/store/` (keys, records, the data file, the folder's files) | `contextdb/tests/values.rs` and `contextdb/tests/store.rs` for reopen, `contextdb/tests/crash/` for a kill |
diff --git a/v2/README.md b/v2/README.md
index d4022b40..d97ad9dc 100644
--- a/v2/README.md
+++ b/v2/README.md
@@ -64,3 +64,10 @@ UPDATE_EXPECTED=1 cargo test --manifest-path v2/Cargo.toml -p contextdb --test s
`v2/numbers.sh` prints the numbers in the library's README for the commit it is run at. It only
measures: it never passes or fails, and the check does not run it.
+
+The lines about vector search are taken by the programs in `contextdb/examples/`, the generated
+vectors always and the groups of lines about real search passages only on data given in the
+environment variables named at the head of the script (without them each of those lines prints
+`unavailable`). Every time of a search is taken by one thread, in a read session under the memory
+limit that a line of the output states; `NUMBERS_LARGE=1` adds five million generated vectors and
+takes the better part of an hour. Run it on a machine that is doing nothing else.
diff --git a/v2/contextdb-cli/src/main.rs b/v2/contextdb-cli/src/main.rs
index 40a0c493..cb84062c 100644
--- a/v2/contextdb-cli/src/main.rs
+++ b/v2/contextdb-cli/src/main.rs
@@ -67,13 +67,14 @@ MEMORY:
`none` and 9437184 bytes used, the 1M and the 8M page cache.
JSON (--json), one document a line:
- a SELECT or SHOW MEMORY_LIMIT {"result":{"columns":[...],"rows":[{...}]}}
+ a SELECT or any SHOW statement {"result":{"columns":[...],"rows":[{...}]}}
a vector is an array of numbers, each at the full
precision of the stored 32-bit value, so a number
may print with more digits than were typed
any other statement {"rows_affected":N}
.tables {"tables":{"items":[...]}}
.schema
{"schema":{"table":...,"ddl":...}}
+ .explain {"explain":{"lines":[...]}}
.help {"help":[...]}
a refusal {"error":{"class":...,"message":...,"detail":{"kind":...}}}
on the error stream; "detail" is left out when the
diff --git a/v2/contextdb-cli/src/output.rs b/v2/contextdb-cli/src/output.rs
index 35870b3c..31b202f3 100644
--- a/v2/contextdb-cli/src/output.rs
+++ b/v2/contextdb-cli/src/output.rs
@@ -40,6 +40,32 @@ impl Output {
}
}
+ /// The rows of an inspection statement: one `name: value` to a line, with an empty line
+ /// between rows.
+ pub fn fields(self, result: &QueryResult) {
+ if self.json {
+ return self.result(result);
+ }
+ for (at, row) in result.rows().iter().enumerate() {
+ if at > 0 {
+ println!();
+ }
+ for (name, value) in result.columns().iter().zip(row) {
+ println!("{name}: {}", text(value));
+ }
+ }
+ }
+
+ /// What `.explain` answers: its lines.
+ pub fn explain(self, lines: &str) {
+ if self.json {
+ let lines: Vec<&str> = lines.lines().collect();
+ println!("{}", json!({ "explain": { "lines": lines } }));
+ } else {
+ println!("{lines}");
+ }
+ }
+
/// A statement that returns no rows succeeded and changed this many.
pub fn rows_affected(self, rows: u64) {
if self.json {
diff --git a/v2/contextdb-cli/src/shell.rs b/v2/contextdb-cli/src/shell.rs
index cdee5824..ca7dca57 100644
--- a/v2/contextdb-cli/src/shell.rs
+++ b/v2/contextdb-cli/src/shell.rs
@@ -18,11 +18,13 @@ pub enum Store {
Write(Database),
}
-const HELP: [&str; 6] = [
+const HELP: [&str; 8] = [
"; Run a SQL statement. It may span lines and ends at its `;`.",
"BEGIN; ... COMMIT; Run statements as one transaction; ROLLBACK; undoes them, except SET MEMORY_LIMIT. Needs --write.",
".tables \\dt List the tables.",
".schema
\\d
Show a table's declaration as SQL.",
+ ".explain Show how a similarity search would be answered, without running it.",
+ "SHOW VECTOR_INDEXES; SHOW VECTOR_PARTITIONS; Describe the VECTOR columns and their partitions.",
".help \\? Show this list.",
".quit \\q End the session.",
];
@@ -151,6 +153,15 @@ impl<'a> Shell<'a> {
Store::Write(db) => db.declaration(table),
}
.map(|declaration| self.output.schema(table, &declaration)),
+ (Some(".explain"), Some(_), _) => {
+ let sql = line[".explain".len()..].trim();
+ let sql = sql.strip_suffix(';').unwrap_or(sql);
+ match self.store {
+ Store::Read(reader) => reader.explain(sql),
+ Store::Write(db) => db.explain(sql),
+ }
+ .map(|lines| self.output.explain(&lines))
+ }
(Some(".help" | "\\?"), None, _) => {
self.output.help(&HELP);
Ok(())
@@ -186,7 +197,17 @@ impl<'a> Shell<'a> {
Ok(())
})
} else {
- self.execute(sql).map(|result| self.output.result(&result))
+ // The rows of the two inspection statements are too wide for a table.
+ let mut words = sql.split_whitespace().map(str::to_ascii_uppercase);
+ let inspection = words.next().as_deref() == Some("SHOW")
+ && words.next().is_some_and(|word| word.starts_with("VECTOR_"));
+ self.execute(sql).map(|result| {
+ if inspection {
+ self.output.fields(&result);
+ } else {
+ self.output.result(&result);
+ }
+ })
};
if let Err(error) = ran {
self.refused = true;
diff --git a/v2/contextdb-cli/tests/shell.rs b/v2/contextdb-cli/tests/shell.rs
index 69001312..4829643b 100644
--- a/v2/contextdb-cli/tests/shell.rs
+++ b/v2/contextdb-cli/tests/shell.rs
@@ -894,8 +894,8 @@ VALUES (1, 'a', [1.0, 0.0, 0.0]), (2, 'a', [0.0, 1.0, 0.0]), (3, 'b', [-1.0, 0.0
}
#[test]
-fn indexed_search_is_refused_by_name_through_the_program() {
- let dir = folder("indexed-search-is-refused");
+fn an_indexed_search_runs_through_the_program() {
+ let dir = folder("an-indexed-search-runs");
let store = dir.join("store");
let made = shell(
&dir,
@@ -905,7 +905,8 @@ fn indexed_search_is_refused_by_name_through_the_program() {
);
assert_eq!(made.code, Some(0), "{}", made.stderr);
- // The same search asked for INDEXED and then for EXACT.
+ // The same search asked for INDEXED and then for EXACT: no maintenance cycle has grouped
+ // these rows, so INDEXED searches every one of them and both give the same table.
let search = |mode: &str| {
format!(
"SELECT id FROM passages ORDER BY embedding <=> [1.0, 0.0, 0.0] USE VECTOR {mode} \
@@ -913,55 +914,259 @@ fn indexed_search_is_refused_by_name_through_the_program() {
)
};
let input = format!("{}{}", search("INDEXED"), search("EXACT"));
- let exact = "+----+\n| id |\n+----+\n| 2 |\n| 1 |\n+----+\n(2 rows)\n";
+ let table = "+----+\n| id |\n+----+\n| 2 |\n| 1 |\n+----+\n(2 rows)\n";
+ let document = json!({"result": {"columns": ["id"], "rows": [{"id": 2}, {"id": 1}]}});
- // Reading or writing, this version keeps no index and says so.
+ // Reading or writing, for a person and for a program.
for mode in [&[][..], &["--write"][..]] {
let mut args = vec![arg(&store)];
args.extend_from_slice(mode);
-
- // For a person: one line on the error stream, the refusal's sentence naming the column,
- // and a piped run that fails.
let run = shell(&dir, &args, &input);
- assert_eq!(run.code, Some(1), "{mode:?}: {}", run.stderr);
- let lines: Vec<&str> = run.stderr.lines().collect();
- assert_eq!(lines.len(), 1, "{mode:?}: {}", run.stderr);
- assert!(
- lines[0].starts_with("Error: ")
- && lines[0].contains("passages.embedding")
- && lines[0].contains("INDEXED"),
- "{mode:?}: {}",
- run.stderr
- );
- // INDEXED gave no rows under any name, and the statement after it still ran.
- assert_eq!(run.stdout, exact, "{mode:?}");
+ assert_eq!(run.code, Some(0), "{mode:?}: {}", run.stderr);
+ assert_eq!(run.errors(), Vec::<&str>::new(), "{mode:?}");
+ assert_eq!(run.stdout, format!("{table}{table}"), "{mode:?}");
- // For a program: the refusal's kind.
args.push("--json");
let run = shell(&dir, &args, &input);
- assert_eq!(run.code, Some(1), "{mode:?}: {}", run.stderr);
- let refusals = documents(&run.stderr);
- assert_eq!(refusals.len(), 1, "{mode:?}: {}", run.stderr);
- let error = &refusals[0]["error"];
- assert_eq!(error["class"], "sql", "{mode:?}: {error}");
- assert_eq!(
- error["detail"]["kind"], "vector_indexed_route_unavailable",
- "{mode:?}: {error}"
- );
- assert!(
- error["message"]
- .as_str()
- .is_some_and(|message| message.contains("passages.embedding")),
- "{mode:?}: {error}"
- );
+ assert_eq!(run.code, Some(0), "{mode:?}: {}", run.stderr);
assert_eq!(
documents(&run.stdout),
- [json!({"result": {"columns": ["id"], "rows": [{"id": 2}, {"id": 1}]}})],
+ [document.clone(), document.clone()],
"{mode:?}"
);
}
}
+/// What the two tests below search and inspect: a column with a partition for each `kind`, two
+/// vectors in each, and no maintenance cycle run.
+const KINDS: &str = "\
+CREATE TABLE notes (id INTEGER PRIMARY KEY, kind TEXT NOT NULL, v VECTOR(3) PARTITION_KEY (kind));
+INSERT INTO notes (id, kind, v)
+VALUES (1, 'a', [1.0, 0.0, 0.0]), (2, 'a', [0.0, 1.0, 0.0]), (3, 'b', [-1.0, 0.0, 0.0]),
+ (4, 'b', NULL), (5, 'b', [0.1, 0.2, 0.3]);
+";
+
+/// The fields of `SHOW VECTOR_INDEXES`, in the order they are printed.
+const INDEX_FIELDS: [&str; 31] = [
+ "table",
+ "column",
+ "dimension",
+ "quantization",
+ "vector_count",
+ "bytes",
+ "partition_key_columns",
+ "max_partitions",
+ "live_partitions",
+ "search_mode",
+ "declared_auto_index_at",
+ "effective_auto_index_at",
+ "declared_consolidation_mode",
+ "declared_consolidation_change_percent",
+ "declared_consolidation_tombstone_percent",
+ "effective_consolidation_mode",
+ "effective_consolidation_change_percent",
+ "effective_consolidation_tombstone_percent",
+ "pending_inserts",
+ "tombstones",
+ "durable_vector_bytes",
+ "durable_index_bytes",
+ "indexed_available",
+ "maintenance_state",
+ "maintenance_vectors_total",
+ "maintenance_vectors_done",
+ "maintenance_vectors_remaining",
+ "unavailable_partitions",
+ "stalled_partitions",
+ "broad_route",
+ "recovery_action",
+];
+
+/// The fields of `SHOW VECTOR_PARTITIONS`, in the order they are printed.
+const PARTITION_FIELDS: [&str; 18] = [
+ "table",
+ "column",
+ "partition_key",
+ "live_rows",
+ "pending_inserts",
+ "tombstones",
+ "durable_vector_bytes",
+ "durable_index_bytes",
+ "indexed_available",
+ "availability_reason",
+ "maintenance_state",
+ "maintenance_reason",
+ "maintenance_vectors_total",
+ "maintenance_vectors_done",
+ "maintenance_vectors_remaining",
+ "lists",
+ "probes",
+ "recovery_action",
+];
+
+#[test]
+fn vector_inspection_prints_one_field_to_a_line() {
+ let dir = folder("vector-inspection-prints");
+ let store = dir.join("store");
+ let more = "CREATE TABLE other (id INTEGER PRIMARY KEY, w VECTOR(2));\n";
+ let made = shell(&dir, &[arg(&store), "--write"], &format!("{KINDS}{more}"));
+ assert_eq!(made.code, Some(0), "{}", made.stderr);
+
+ // Each statement with the fields it prints and, for each row, the values of three of them.
+ let indexes = [["notes", "v", "3"], ["other", "w", "2"]];
+ let partitions = [
+ ["notes", "v", r#"{"kind":"a"}"#],
+ ["notes", "v", r#"{"kind":"b"}"#],
+ ["other", "w", "{}"],
+ ];
+ let cases = [
+ ("SHOW VECTOR_INDEXES;\n", &INDEX_FIELDS[..], &indexes[..]),
+ (
+ "SHOW VECTOR_PARTITIONS;\n",
+ &PARTITION_FIELDS[..],
+ &partitions[..],
+ ),
+ ];
+ for (statement, fields, rows) in cases {
+ // For a person: rows with an empty line between them, each `name: value` lines in the
+ // statement's order, and no line too long for a terminal.
+ let run = shell(&dir, &[arg(&store)], statement);
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ assert!(
+ run.stdout.lines().all(|line| line.chars().count() <= 120),
+ "{}",
+ run.stdout
+ );
+ let printed: Vec> = run
+ .stdout
+ .split("\n\n")
+ .map(|row| {
+ row.lines()
+ .filter(|line| !line.starts_with('('))
+ .map(|line| {
+ line.split_once(": ")
+ .unwrap_or_else(|| panic!("not `name: value`: {line:?}"))
+ })
+ .collect::>()
+ })
+ .filter(|row| !row.is_empty())
+ .collect();
+ assert_eq!(printed.len(), rows.len(), "{}", run.stdout);
+ for (row, first) in printed.iter().zip(rows) {
+ let names: Vec<&str> = row.iter().map(|(name, _)| *name).collect();
+ assert_eq!(names, fields, "{}", run.stdout);
+ let values: Vec<&str> = row[..3].iter().map(|(_, value)| *value).collect();
+ assert_eq!(values, first, "{}", run.stdout);
+ }
+
+ // For a program: the usual result document, its columns the same names.
+ let run = shell(&dir, &[arg(&store), "--json"], statement);
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ let results = documents(&run.stdout);
+ assert_eq!(results.len(), 1, "{}", run.stdout);
+ assert_eq!(results[0]["result"]["columns"], json!(fields));
+ let found = results[0]["result"]["rows"].as_array().unwrap();
+ assert_eq!(found.len(), rows.len(), "{}", run.stdout);
+ assert_eq!(found[0]["table"], "notes", "{}", run.stdout);
+ }
+}
+
+#[test]
+fn explain_prints_how_each_partition_is_searched() {
+ let dir = folder("explain-prints");
+ let store = dir.join("store");
+ let made = shell(&dir, &[arg(&store), "--write"], KINDS);
+ assert_eq!(made.code, Some(0), "{}", made.stderr);
+
+ // Four vectors in two partitions, far below the 100,000 from which AUTO uses a grouping,
+ // so the search asked for under AUTO is the exact one, and none grouped. A line ending in
+ // `*` is checked by its name alone: with no grouping the partition has no count of groups
+ // to print.
+ let lines = [
+ "requested_mode: AUTO",
+ "resolved_mode: EXACT",
+ "effective_auto_index_at: 100000",
+ "aggregate_allowed_vectors: 4",
+ r#"partition_key_columns: ["kind"]"#,
+ "partitions: 2",
+ r#"partition: {"kind":"a"}"#,
+ " route: exact",
+ " lists: *",
+ " probes: *",
+ " live_rows: 2",
+ " outside_grouping: 2",
+ r#"partition: {"kind":"b"}"#,
+ " route: exact",
+ " lists: *",
+ " probes: *",
+ " live_rows: 2",
+ " outside_grouping: 2",
+ "refusal: none",
+ ];
+ let same = |printed: &[&str]| {
+ printed.len() == lines.len()
+ && lines.iter().zip(printed).all(|(expected, line)| {
+ expected
+ .strip_suffix('*')
+ .map_or(expected == line, |name| line.starts_with(name))
+ })
+ };
+ let search = ".explain SELECT id FROM notes ORDER BY v <=> '[1,0,0]' LIMIT 3\n";
+
+ // The lines and nothing else: the search is not run, so no table of rows is printed.
+ let run = shell(&dir, &[arg(&store)], search);
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ assert!(
+ same(&run.stdout.lines().collect::>()),
+ "{}",
+ run.stdout
+ );
+
+ // Any other statement is refused in one line, and the session goes on to the next.
+ let other = ".explain SELECT id FROM notes ORDER BY id\n";
+ let next = "SELECT id FROM notes WHERE id = 5;\n";
+ let run = shell(&dir, &[arg(&store)], &format!("{other}{next}"));
+ assert_eq!(run.code, Some(1), "{}", run.stderr);
+ let refusals: Vec<&str> = run.stderr.lines().collect();
+ assert_eq!(refusals.len(), 1, "{}", run.stderr);
+ assert!(
+ refusals[0].starts_with("Error: ")
+ && refusals[0].contains(
+ "explaining this statement is not supported in this version of the library"
+ ),
+ "{}",
+ run.stderr
+ );
+ assert_eq!(tables(&run.stdout), [[["id"], ["5"]]], "{}", run.stdout);
+
+ // For a program: one document that holds the same lines.
+ let run = shell(&dir, &[arg(&store), "--json"], search);
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ let results = documents(&run.stdout);
+ assert_eq!(results.len(), 1, "{}", run.stdout);
+ let printed: Vec<&str> = results[0]["explain"]["lines"]
+ .as_array()
+ .unwrap_or_else(|| panic!("no explain.lines: {}", results[0]))
+ .iter()
+ .map(|line| line.as_str().unwrap())
+ .collect();
+ assert!(same(&printed), "{}", run.stdout);
+}
+
+#[test]
+fn help_lists_explain() {
+ let dir = folder("help-lists-explain");
+ let store = dir.join("store");
+ let made = shell(&dir, &[arg(&store), "--write"], CREATE_NOTES);
+ assert_eq!(made.code, Some(0), "{}", made.stderr);
+ let run = shell(&dir, &[arg(&store)], ".help\n");
+ assert_eq!(run.code, Some(0), "{}", run.stderr);
+ assert!(
+ run.stdout.lines().any(|line| line.starts_with(".explain")),
+ "{}",
+ run.stdout
+ );
+}
+
#[test]
fn a_read_shell_searches_while_a_writer_holds_the_store() {
let dir = folder("a-read-shell-searches-while-a-writer-holds");
diff --git a/v2/contextdb/README.md b/v2/contextdb/README.md
index 42032c29..b209699d 100644
--- a/v2/contextdb/README.md
+++ b/v2/contextdb/README.md
@@ -10,14 +10,15 @@ This is the first version of contextdb 2. What it has:
- tables with typed columns, a primary key, `NOT NULL`, `UNIQUE` and `DEFAULT`, and indexes;
- `INSERT`, `UPDATE`, `DELETE` and `SELECT` over one table;
-- vector search: the rows nearest a vector by cosine distance, each with its distance, exact,
- over vectors that a column may keep in partitions;
+- vector search: the rows nearest a vector by cosine distance, each with its distance, over
+ vectors that a column may keep in partitions; exact on request, and through an index that
+ `run_maintenance_cycle` builds and brings up to date once a column holds many vectors;
- transactions;
- one process writing a store while any number of other processes read it;
- a memory limit that holds.
-What it does not have yet: an index for vector search (every search is exact), relationships
-between rows, joins, grouping, and sync between machines. A statement that uses one of them is
+What it does not have yet: relationships between rows, joins, grouping, and sync between
+machines. A statement that uses one of them is
refused and changes nothing.
It is not on crates.io yet. Clone and use it from that checkout:
@@ -65,9 +66,9 @@ fn main() -> contextdb::Result<()> {
```
It prints `buy oat milk: 0.006` and then `call the plumber: 1.000`. After writing vectors, a host
-calls `db.run_maintenance_cycle()?` until its report's `vector.maintenance_vectors_remaining` is
-0, because this version does not do it by itself; a search before that returns the same rows,
-slower ([Sealing](#vector-search) below). This page is also the crate's
+calls `db.run_maintenance_cycle()?` again and again, as the paragraph on upkeep under Vector search says, because
+this version does not do it by itself; a search before that still finds every vector, and reads
+more to do it. This page is also the crate's
documentation, and the documentation tests compile this program as it is written here. Rust 1.89 or newer; Linux and macOS. Licence:
Apache-2.0. It is developed in the open at by the context-graph-ai organisation.
@@ -76,8 +77,11 @@ Apache-2.0. It is developed in the open at vector [USE VECTOR AUTO | EXACT | INDEXED] LIMIT k;
SET MEMORY_LIMIT 512M; SET MEMORY_LIMIT NONE; SHOW MEMORY_LIMIT;
+SHOW VECTOR_INDEXES;
+SHOW VECTOR_PARTITIONS [FOR table.column] [LIMIT count [OFFSET start]];
```
- Every table has exactly one `PRIMARY KEY` column. A default is a literal or `NOW()`.
@@ -102,12 +108,13 @@ SET MEMORY_LIMIT 512M; SET MEMORY_LIMIT NONE; SHOW MEMORY_LIMIT;
match, and `AS` to name a column of the result.
- `ORDER BY` puts NULL last ascending and first descending.
- The second `SELECT` is a vector search; [Vector search](#vector-search) below says what it
- returns.
+ returns. The clauses of a `VECTOR` column are described under Vector search (quantization, modes, upkeep),
+ and the two `SHOW` statements there too, under inspection.
- `--` and `/* */` comments are read past.
Not in this version, and refused: `JOIN`, `GROUP BY`, `DISTINCT`, `LIKE`, `BETWEEN`,
`OFFSET`, `UNION`, subqueries, table aliases, `ALTER`, `DROP`, and a primary key over several
-columns. Most are refused by name; this is the sentence the refusal prints:
+columns; and the vector clauses `IVF`, `SEARCH (...)` and `WITH (quantization = 'SQ4')`. Most are refused by name; this is the sentence the refusal prints:
```text
plan error: LIKE is not supported in this version of the library
@@ -124,7 +131,7 @@ plan error: LIKE is not supported in this version of the library
| `UUID` | a UUID, written as quoted text | `Value::Uuid` |
| `TIMESTAMP` | milliseconds since the first moment of 1970, UTC; written as ISO 8601 text (`'2026-01-01T00:00:01Z'`) or taken from `NOW()` | `Value::Timestamp` |
| `JSON` | a JSON value, written as quoted text; the value is kept, not the text's spacing | `Value::Json` |
-| `VECTOR(n)` | a vector of exactly `n` finite numbers, each kept as an `f32`, written `[0.1, 0.2, 0.3]`; searched with `<=>` | `Value::Vector` |
+| `VECTOR(n)` | a vector of exactly `n` finite numbers, each kept as an `f32` (a column declared `SQ8` keeps a rounded vector, see the paragraph on quantization under Vector search), written `[0.1, 0.2, 0.3]`; searched with `<=>` | `Value::Vector` |
A value must be of its column's type: text into an `INTEGER` column is refused, and so is NULL
into a `NOT NULL` column, on `INSERT` and on `UPDATE` alike. A whole number is accepted into a
@@ -151,8 +158,10 @@ SELECT id, embedding <=> $q AS distance FROM notes ORDER BY distance LIMIT 10;
the same as quoted text, `'[1, 0, 0]'`. It has the column's dimension and finite numbers.
- `WHERE` is applied first: the answer is the `k` nearest of the rows that pass it. A row whose
vector is NULL is in no answer. The distance to a vector of all zeros is 1.
-- Every search is exact: it returns the rows that comparing `$q` with every stored vector
- returns.
+- How much of the column a search compares with `$q` is the search's mode: `EXACT` compares
+ every stored vector and returns the true nearest rows; `INDEXED` chooses among them through
+ the column's index; `AUTO`, the default, is `EXACT` until a column is large and then uses the
+ index (the paragraph on modes below).
Only `<=>`, cosine, is here: pgvector's `<->` and `<#>` are a parse error. `<=>` has a value
only for the rows of a search, so it is refused everywhere else: in `WHERE`,
@@ -160,13 +169,35 @@ in the select list of a statement that does not order by it, in an `INSERT`, `UP
`DELETE`, with a vector other than the one the statement orders by, and in an `ORDER BY` that
is `DESC`, has a second item or has no `LIMIT`; a search takes no `OFFSET` either.
-**Sealing.** Beside every stored vector the engine keeps a one-byte copy of each of its numbers
+**Upkeep.** Beside every stored vector the engine keeps a one-byte copy of each of its numbers
and packs the copies of many vectors into a block, which a search screens quickly to rule most
rows out without reading them. A vector that was just written is on the waiting list, the
-vectors not yet in a block, which a search compares one by one by reading their rows.
-`Database::run_maintenance_cycle` seals them, which is to move them from the list into blocks:
-one call seals at most 4,096 in one commit and returns how many it sealed and how many still
-wait; nothing in this version calls it on its own. A search returns the same rows with the same distances whether or not vectors are sealed; sealed,
+vectors not yet in a block, which a search compares one by one by reading their rows. Once a
+column is large enough (the paragraph on modes below) the engine also groups a partition's vectors:
+it finds the centres of groups of near vectors, files each vector under its nearest centre, and
+keeps a one-bit sketch of every vector, an eighth of the size of its one-byte copy. A grouping
+is made once and replaced whole; a vector written after it waits in blocks of its own, each
+with its sketches, until the partition is grouped again.
+
+`Database::run_maintenance_cycle` does this work in bounded steps, and nothing in this version
+calls it on its own. One call first seals: it moves at most 4,096 waiting vectors into blocks
+in one commit. Then it takes one step for each partition that has a grouping due or being
+built, each step in a commit of its own: begin, ten rounds that find the centres, file the
+vectors under them, pack the groups into blocks, measure (twice), and put the new grouping in
+force. A partition is grouped again when the vectors written, removed or changed since reach
+`CHANGE_PERCENT` of its grouping (20 when left out) or the removed or changed ones reach
+`TOMBSTONE_PERCENT` (10); `CONSOLIDATION NONE` never groups it again. The call returns a
+`MaintenanceReport`, and its `vector` field says what is left:
+`maintenance_vectors_done` and `maintenance_vectors_remaining` (vectors), `remaining_partitions`
+(partitions with a grouping due or being built) and `stalled_partitions` (those whose last step
+the memory limit refused; a further cycle does nothing for them until the limit is raised, and
+with no limit declared none stall). A host runs cycles while `remaining_partitions` is above
+`stalled_partitions` or `maintenance_vectors_remaining` is not zero, as the program in the paragraph on the index below does.
+
+A step is worked out with no lock held, so a write waits only for a step's commit and a reader
+not at all, and a search made between two cycles finds every vector: it sees the grouping in
+force when the statement began, whole, or the one before it, whole, never a mix. An exact
+search returns the same rows with the same distances whether or not vectors are sealed; sealed,
it reads far fewer rows, and the result's `QueryResult::rows_examined` says how many it read.
**Partitions.** `PARTITION_KEY (column, ...)` on a `VECTOR` column lays its vectors out by the
@@ -186,15 +217,258 @@ column and its limit; the statement changes nothing, and inside a transaction th
goes on. A column with no `PARTITION_KEY` is one partition and has no limit.
**Modes.** `SEARCH_MODE` on the column says how its searches run when a statement does not, and
-`USE VECTOR`, written after the `ORDER BY` item, says it for one statement. `AUTO`, which is
-the default, and `EXACT` both run the exact search. `INDEXED` asks for an index, and this
-version has none: a column may be declared with it, and a search under it is refused before
-anything is read, unless the statement itself says `USE VECTOR EXACT` or `AUTO`.
+`USE VECTOR`, written after the `ORDER BY` item, says it for one statement. The mode in force is
+the statement's, else the column's, else `AUTO`.
+
+- `EXACT` compares the search vector with every stored vector of every partition the statement
+ selects: the grouping's blocks, the blocks written since and the waiting list. It returns the
+ true nearest rows, and reads no centre and no sketch.
+- `INDEXED` searches the waiting vectors of each selected partition and, of the rest, only
+ the vectors the index chooses (the paragraph on the index below). A partition with no grouping,
+ or with a grouping of one group, has nothing to choose among, and is searched as `EXACT`
+ searches it. `INDEXED` is refused, before anything is read, when a selected partition's
+ writes are far ahead of upkeep (for a partition with no grouping or a grouping of one group,
+ the vectors outside its grouping number at least twice `AUTO_INDEX_AT`; for one with more
+ groups, at least twice what makes it due to be grouped again, which under
+ `CONSOLIDATION NONE` is never), and when a filter on rows leaves it nothing to choose among, as the
+ paragraph on filters below says.
+- `AUTO`, which is the default, adds up the vectors of the partitions the statement selects.
+ Below `AUTO_INDEX_AT` it is `EXACT`. From there on each selected partition that has a
+ grouping and is not far behind is searched as `INDEXED` searches it, and every other
+ partition as `EXACT` does. `AUTO` is never refused for a grouping.
+
+`AUTO_INDEX_AT` is 100,000 when not written. Until a column holds that many vectors in all its
+partitions together (or declares `SEARCH_MODE INDEXED`), no centre, sketch or mean is
+written, and `durable_index_bytes` is 0 unless removed or changed rows (10% by default) have
+given a partition a grouping of one group, which takes 28 bytes. In a column that has reached it, a partition is grouped once it
+holds a hundredth of it, 1,000 vectors when nothing is written, so a store of many middling
+partitions is indexed too; a partition of a few hundred vectors is not cut into groups. A
+declared `AUTO_INDEX_AT` of any size is served. A column that should never be grouped declares
+one it will not reach.
+
+**The index.** An indexed search of a partition that has groups compares the search vector with
+the centres of its groups and takes the groups whose centre is nearly as near as the nearest
+centre; then it estimates from the sketches of those groups' vectors, and of the vectors
+written since the grouping, which of them can be among the nearest; only those go on to the
+one-byte copy and then to the row. So every row in an indexed answer has its true distance,
+the answer is in the order of those distances, no row comes twice, and a search is never
+answered short: it returns `LIMIT` rows whenever the selected partitions hold that many rows
+the statement's filter passes. What a sketch can cost is a row that an exact search would have
+put among the nearest.
+
+An indexed search returns rows that an exact search would rank among its nearest, each with
+its true distance. How far from the nearest group it scans and how far it trusts a sketch
+are measured when the index is built, on questions made from the stored vectors: half are
+stored vectors themselves, half lie between two stored vectors, and a sketch counts as kept
+only if it would be kept both as a grouped row and as a row written after the build. That is
+a measure over those questions, not a promise for any one question: a question unlike both
+kinds, rows written after the build that are unlike the rows the build saw, and a `LIMIT`
+other than ten are not what the measure was taken on. `USE VECTOR EXACT` or `SEARCH_MODE EXACT`
+is exact.
+
+A search with a filter on rows uses the index too. The filter is asked of each row the search
+visits, and the search widens until it holds as many allowed rows as were asked for: a filter
+that passes most rows costs what the unfiltered search costs, and one that passes few costs
+more, up to the work of `EXACT`. `INDEXED` is refused by name only when a filter passes too few
+rows for the index to choose among, because being sure of the answer would mean asking the filter
+of the whole table under the index's name; `AUTO` does that work and returns every row there
+is.
```text
-no vector index ships in this version, so notes.embedding cannot be searched INDEXED; use EXACT or AUTO
+notes.embedding cannot be searched INDEXED for this statement now: its index is behind its writes, or the statement's filter leaves the index nothing to choose among; run a maintenance cycle, or use EXACT or AUTO
```
+`QueryResult::distance_evaluations` counts the work of a similarity search: the centres it
+compared with the search vector, the sketches it estimated, the one-byte copies it screened and
+the rows whose distance it worked out. It is an exact count, the same on every run over the
+same store, and 0 for any other statement. An indexed search usually counts far fewer than an
+exact one, and can count more when it leaves few groups out, because it estimates a sketch of
+every vector it keeps; each of those counts an eighth of the bytes. `Database::explain`, and
+`ReadSession::explain`, return what a similarity search would do, without running it. This
+program builds a store of 2,000 vectors, runs upkeep as above, and searches it both ways:
+
+```rust,no_run
+use contextdb::{params, Database};
+
+// A number from -1 to 1 for each seed, the same on every machine.
+fn spread(seed: u64) -> f32 {
+ let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15);
+ x ^= x >> 29;
+ x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
+ x ^= x >> 32;
+ (x % 2001) as f32 / 1000.0 - 1.0
+}
+
+// Eight numbers for each id: the topic's (fifty topics) and a little of the id's own.
+fn vector(id: u64) -> Vec {
+ (0..8u64)
+ .map(|j| spread((id % 50) * 8 + j + 1) + 0.15 * spread(id * 8 + j + 100_000))
+ .collect()
+}
+
+fn main() -> contextdb::Result<()> {
+ let db = Database::open("./notes-index")?;
+ db.execute(
+ "CREATE TABLE notes (id INTEGER PRIMARY KEY, embedding VECTOR(8) AUTO_INDEX_AT 1000)",
+ ¶ms! {},
+ )?;
+ let tx = db.transaction()?;
+ for id in 0..2000u64 {
+ tx.execute(
+ "INSERT INTO notes (id, embedding) VALUES ($id, $embedding)",
+ ¶ms! { "id" => id as i64, "embedding" => vector(id) },
+ )?;
+ }
+ tx.commit()?;
+
+ // Upkeep: seal the new vectors and group them, a bounded step at a time.
+ let mut cycles = 0;
+ loop {
+ let report = db.run_maintenance_cycle()?.vector;
+ cycles += 1;
+ if report.remaining_partitions <= report.stalled_partitions
+ && report.maintenance_vectors_remaining == 0
+ {
+ break;
+ }
+ }
+ println!("{cycles} cycles");
+
+ let mut ids = Vec::new();
+ for mode in ["EXACT", "INDEXED"] {
+ let sql = format!(
+ "SELECT id FROM notes ORDER BY embedding <=> $q USE VECTOR {mode} LIMIT 10"
+ );
+ let found = db.execute(&sql, ¶ms! { "q" => vector(5000) })?;
+ println!("{mode}: {} distance evaluations", found.distance_evaluations());
+ ids.push(found.rows().to_vec());
+ }
+ println!("the same ten rows: {}", ids[0] == ids[1]);
+ println!("{}", db.explain("SELECT id FROM notes ORDER BY embedding <=> $q LIMIT 10")?);
+ db.close()
+}
+```
+
+It prints, for the first run (`./notes-index` must not exist):
+
+```text
+16 cycles
+EXACT: 2019 distance evaluations
+INDEXED: 225 distance evaluations
+the same ten rows: true
+requested_mode: AUTO
+resolved_mode: AUTO
+effective_auto_index_at: 1000
+aggregate_allowed_vectors: 2000
+partition_key_columns: []
+partitions: 1
+partition: {}
+ route: indexed
+ lists: 45
+ probes: 8
+ live_rows: 2000
+ outside_grouping: 0
+refusal: none
+```
+
+`lists` is the groups of the grouping in force and `probes` the most of them a search scanned
+for the questions its build made, `outside_grouping` the slots in blocks written since (stale ones
+included) plus the vectors still waiting, and `route` is `exact` or `indexed`; `refusal` is `none` or, for INDEXED over a partition that is
+behind its writes, the sentence the search would be refused with (the refusal for a filter is
+known only when the search runs). A statement that names a partition with a `$name` has nothing to select
+with, since `explain` binds nothing: it lists every partition of the column and says so,
+`partitions: every partition ($t is not bound)`. `explain` of a statement that is not a
+similarity search is refused as a later version's.
+
+**Quantization.** `WITH (quantization = 'SQ8')` on a column keeps each vector as one scale and
+one whole number from -127 to 127 for each of its numbers, so a vector of `n` numbers takes
+`n + 13` bytes in place of `4n + 9`: in the two stores of real passages under Numbers below a passage
+takes about 2.0 KB in place of 4.4 and 4.9, and the store 0.45 times the room. The cost is in the answers. The vector the row keeps is the rounded one, and it is the
+one a `SELECT` returns and every search, `EXACT` included, ranks by (`[0.1, 0.2, 0.3]` is
+read back as `[0.0992126, 0.2007874, 0.3]`); and on those stores, searched exactly, the ten nearest rows of an
+`SQ8` column differ from the full-precision column's on 12 to 21 questions in 100, by whether the
+search covers one, several or all of its partitions. It is an option a table chooses and never a default: a
+column keeps `F32` unless it says otherwise. `SQ4` is refused by name.
+
+**Inspection.** `SHOW VECTOR_INDEXES` returns one row for each `VECTOR` column of the store,
+in the order of table name and column, and `SHOW VECTOR_PARTITIONS` one row for each partition
+of the column after `FOR`, or of every `VECTOR` column, in the order of table, column and key
+tuple; `LIMIT` takes the first rows and `OFFSET`, only after `LIMIT`, skips some. Both read
+the vector part's small records and no block, no centre and no row, change nothing, and work in
+a writer, a transaction and a read session alike. The shell prints their rows one field to a
+line, with an empty line between rows. For the store above, `contextdb ./notes-index` prints
+these (the lines of `SHOW VECTOR_INDEXES` that tell the column's declaration, size and
+state, with `…` for the lines left out):
+
+```text
+table: notes
+column: embedding
+dimension: 8
+quantization: F32
+vector_count: 2000
+…
+search_mode: AUTO
+declared_auto_index_at: 1000
+effective_auto_index_at: 1000
+…
+durable_vector_bytes: 32000
+durable_index_bytes: 7856
+indexed_available: true
+maintenance_state: idle
+…
+recovery_action: none
+```
+
+and for `SHOW VECTOR_PARTITIONS`:
+
+```text
+table: notes
+column: embedding
+partition_key: {}
+live_rows: 2000
+pending_inserts: 0
+tombstones: 0
+durable_vector_bytes: 32000
+durable_index_bytes: 7856
+indexed_available: true
+availability_reason: none
+maintenance_state: idle
+maintenance_reason: none
+maintenance_vectors_total: 0
+maintenance_vectors_done: 0
+maintenance_vectors_remaining: 0
+lists: 45
+probes: 8
+recovery_action: none
+```
+
+`.explain ` in the shell prints what `Database::explain` returns; `.explain SELECT id FROM notes
+ORDER BY embedding <=> $q LIMIT 10` prints:
+
+```text
+requested_mode: AUTO
+resolved_mode: AUTO
+effective_auto_index_at: 1000
+aggregate_allowed_vectors: 2000
+partition_key_columns: []
+partitions: 1
+partition: {}
+ route: indexed
+ lists: 45
+ probes: 8
+ live_rows: 2000
+ outside_grouping: 0
+refusal: none
+```
+
+Inspection tells the groups a partition has (`lists`) and the most groups a search scanned
+while the index was measured (`probes`); it does not print the measured distance or width. A
+column with no `PARTITION_KEY` has one row in `SHOW VECTOR_PARTITIONS`, with `partition_key`
+`{}`, and a partitioned column with no vector has none. A partition's `maintenance_state` is `idle`, `pending` (a grouping is due and not begun),
+`building` or `stalled`, and its `maintenance_reason` says why upkeep has work: `first_grouping`,
+`changes`, `tombstones`, `damaged` or `memory_limit`. For `pending` and `building` the
+`recovery_action` is `run a maintenance cycle`, and for `stalled` it is `raise the memory limit`.
+
## Binding values
`$name` in a statement is the entry `"name"` of the map passed with it. `params!` writes the
@@ -350,7 +624,9 @@ contextdb ./notes --write read and write it, creating it if it is missing
A statement ends at `;`. `BEGIN;`, `COMMIT;` and `ROLLBACK;` run a transaction, which needs
`--write`; `.tables` lists
-the tables, `.schema
` prints a table's declaration and `.help` lists the commands. With
+the tables, `.schema
` prints a table's declaration, `.explain ` prints how a
+similarity search would be answered without running it (anything else it refuses like a statement
+of a later version), and `.help` lists the commands. With
statements piped in there is no prompt, and a refusal does not stop what comes after it. A
refused statement or command is one line on the error stream, `Error: ` and then the refusal's
sentence, or the program's own sentence where one of its options is the way on; a line that
@@ -368,22 +644,56 @@ options, the exit statuses and the JSON forms.
Measured at one commit by `v2/numbers.sh`, which prints them again on any machine: a release
build on a 16-core AMD Ryzen 9 9955HX with 64 GB of memory and an NVMe drive, running Linux and
-nothing else.
+nothing else. Every search time is the 95th percentile of 100 questions asked one after the
+other by one thread, in a read session under a 1 GiB memory limit unless a row says 64 MiB, in
+an ordinary release build with no processor flag (the counting loop uses the processor's
+bit-count instruction, which this processor has). Recall is the share of an exact search's ten
+nearest rows that the search returned, averaged over the 100 questions, and the next number is
+how many of the 100 questions got all ten.
| What | Measured |
|---|---|
-| 20,000 rows of 1.6 KB, one commit per row: time to insert | 11.0 s |
+| 20,000 rows of 1.6 KB, one commit per row: time to insert | 11.1 s |
| That store on disk (its rows repeat their text, which compresses far better than real text) | 2.4 MiB |
-| Open that store | 0.606 ms |
-| `SELECT COUNT(*)` over it | 0.025 ms, 0 rows read |
-| A million rows under a 64 MiB limit: time to load | 9.7 s |
-| Scan the million rows | 239.167 ms |
+| Open that store | 0.561 ms |
+| `SELECT COUNT(*)` over it | 0.030 ms, 0 rows read |
+| A million rows under a 64 MiB limit: time to load | 10.5 s |
+| Scan the million rows | 236.582 ms |
| Most memory the engine had allocated during that load and scan | 16.4 MiB |
-| Most memory the process held, as the operating system reports it | 21.3 MiB |
-| First build of a program that uses the library | 6 s, 47 packages, a 3.0 MiB program |
-| From a fresh clone to a passing `cargo test` | 2.7 min |
-| Ten nearest of 20,000 vectors, sealed, through SQL: 95th percentile | 1.500 ms |
+| Most memory the process held, as the operating system reports it | 21.5 MiB |
+| First build of a program that uses the library | 8 s, 47 packages, a 3.2 MiB program |
+| From a fresh clone to a passing `cargo test` | 3.2 min |
+| Ten nearest of 20,000 vectors, sealed, through SQL, exact: 95th percentile | 1.548 ms |
| That vector store on disk, and how many times the bytes it holds | 44.3 MiB, 1.50 times |
-| Lines of Rust and grammar in the library and the program | 10,809 |
-| Tests | 171 |
+| Lines of Rust and grammar in the library and the program | 15,440 |
+| Tests | 253 |
| Lines that differ from redb 4.1.0 in the storage library under the engine, which is redb 4.1.0 with the changes listed in `v2/redb/README.md` | 626 |
+
+Searches for the ten nearest, indexed beside exact from the same run. Where a store is below
+`AUTO_INDEX_AT` nothing is grouped and an indexed search is the exact one.
+
+| Search | Indexed | Exact | Recall, questions with all ten |
+|---|---|---|---|
+| 10,000 generated vectors, one partition, 100 groups (`AUTO_INDEX_AT 1000`) | 0.541 ms | 1.185 ms | 0.9960, 96 of 100 |
+| 100,008 real passages, eight partitions, 884 groups: one partition | 0.682 ms | 1.411 ms | 1.0000, 100 of 100 |
+| the same: several partitions | 1.537 ms | 3.841 ms | 0.9990, 99 of 100 |
+| the same: all eight | 4.052 ms | 10.880 ms | 0.9990, 99 of 100 |
+| the same: all eight, under 64 MiB | 11.383 ms | not taken | 0.9990, 99 of 100 |
+| the same: all eight, a filter that passes 992 of the 100,008 rows | 32.411 ms | 38.453 ms | 1.0000, 100 of 100 |
+| the same, 9,996 more passages written after the build and sealed | 3.749 ms | 10.781 ms | 1.0000, 100 of 100 |
+| 1,000,000 generated vectors of 768 numbers around 300 hidden centres, one partition, 1,000 groups | 1.109 ms | 180.929 ms | 1.0000, 100 of 100 |
+| 1,000,000 generated camera sightings, one partition, 1,000 groups | 30.348 ms | 175.409 ms | 1.0000, 100 of 100 |
+| 11,588 real passages, eight partitions, below the size: one / several / all | 0.303 / 0.724 / 1.440 ms | 0.302 / 0.724 / 1.440 ms | 1.0000, 100 of 100 each |
+
+Getting a store to those groupings takes `run_maintenance_cycle` calls: 34 cycles and 1.8 s for
+the 10,000, 56 cycles and 26.9 s for the 100,008 passages, 1,150 cycles and 362.8 s for the
+million. The index takes 129, 125 and 101 bytes a vector there. Right after a build a store's
+file is 1.20, 1.13 and 1.31 times the size it had sealed, which is more than the index holds,
+because the pages the build freed stay in the file in this version; a store that is never
+grouped stays at 1.00 times (the 11,588 passages).
+
+With `WITH (quantization = 'SQ8')` the same two stores of real passages take 0.45 times the room
+(a passage 1,968 bytes in place of 4,411 in the 11,588, and 1,992 in place of 4,946 in the
+100,008). Searched exactly, the same ten rows come back as from the full-precision store on 88,
+84 and 79 questions of 100 in the 11,588 (one, several, all partitions) and on 86, 83 and 85 in
+the 100,008.
diff --git a/v2/contextdb/examples/measure.rs b/v2/contextdb/examples/measure.rs
index f59e3875..e1fbded1 100644
--- a/v2/contextdb/examples/measure.rs
+++ b/v2/contextdb/examples/measure.rs
@@ -14,6 +14,23 @@
//! not empty of the first `count` files the list names, 500 rows to a commit, and prints the
//! files' bytes, the bytes put in (each line's and 8 for its number) and the store's size.
//! It prints nothing of a file but those counts.
+//! - `measure index ` measures similarity search under each mode on a million generated
+//! vectors of 768 numbers in one partition, a row being an INTEGER key and the vector, and
+//! then on a million generated sightings: lines `synthetic_1000000_...` and
+//! `sightings_1000000_...`. `measure synthetic ` and `measure sightings
+//! ` do one of the two at another size. The first are the index pilot's vectors, to
+//! the number: a xorshift from `0x9E37_79B9_7F4A_7C15`, 300 hidden centres, each vector a
+//! centre plus 0.06 of noise a number, and 100 questions with four thirds of that noise. The
+//! second stand in for a camera store: three classes, a subject for every 2,500 rows with
+//! eight directions of its own for its appearance, tracks of ten sightings whose appearance
+//! drifts, a little noise on every number; 100 questions, eighty new sightings of stored
+//! subjects and twenty of subjects never stored. Each case loads two stores, 500 rows to a
+//! commit: one whose column declares `AUTO_INDEX_AT 1000000000`, so that nothing is grouped,
+//! sealed and searched under EXACT; and one that declares nothing, with every maintenance
+//! cycle run, opened and searched once in a new process (`measure first ...`, which the
+//! program starts itself as) and then searched, in one read session under 1 GiB, under
+//! EXACT, INDEXED and AUTO in turn: every question once after 5 searches of each mode that
+//! are not timed. On sightings a hundred stored vectors are then each searched for by itself.
//!
//! The store's folder must be missing or empty. A store's size is the bytes of every file in
//! its folder after it was closed.
@@ -23,12 +40,16 @@
mod support;
+use std::ffi::OsStr;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use contextdb::{Database, DatabaseOpenOptions, Result, Uuid, params};
-use support::{bytes_on_disk, line, mebibytes, milliseconds, percentile, ratio, seconds, timed};
+use support::{
+ EXACT, Fallible, READER_LIMIT, Values, bytes_on_disk, line, mebibytes, milliseconds,
+ percentile, ratio, seconds, timed,
+};
#[global_allocator]
static ALLOCATOR: support::Counting = support::Counting;
@@ -266,29 +287,347 @@ fn sessions(corpus: &Path, list: &Path, count: usize, folder: &Path) -> Result<(
Ok(())
}
+/// The index pilot's generator of numbers: a xorshift, and a number near a normal one made of
+/// six of its draws.
+struct Random(u64);
+
+impl Random {
+ fn next(&mut self) -> u64 {
+ self.0 ^= self.0 << 13;
+ self.0 ^= self.0 >> 7;
+ self.0 ^= self.0 << 17;
+ self.0
+ }
+
+ fn below(&mut self, n: usize) -> usize {
+ (self.next() % n as u64) as usize
+ }
+
+ fn g(&mut self) -> f32 {
+ let s: f32 = (0..6)
+ .map(|_| (self.next() >> 40) as f32 / (1u64 << 24) as f32)
+ .sum();
+ (s - 3.0) * 1.414
+ }
+}
+
+const DIMENSIONS: usize = 768;
+
+/// What a case's rows are handed to, one vector after another.
+type Each<'a> = &'a mut dyn FnMut(Vec) -> Fallible<()>;
+
+/// The index pilot's vectors, to the number: hands `each` every row in order and answers the
+/// 100 questions, which the generator makes after the rows.
+fn synthetic(rows: usize, each: Each<'_>) -> Fallible>> {
+ fn norm(v: &mut [f32]) {
+ let n = v.iter().map(|x| x * x).sum::().sqrt();
+ v.iter_mut().for_each(|x| *x /= n);
+ }
+ let mut rng = Random(0x9E37_79B9_7F4A_7C15);
+ let (nc, noise) = (300_u64, 0.06_f32);
+ let mut centers = Vec::new();
+ for _ in 0..nc {
+ let mut c: Vec = (0..DIMENSIONS).map(|_| rng.g()).collect();
+ norm(&mut c);
+ centers.push(c);
+ }
+ for _ in 0..rows {
+ let c = ¢ers[(rng.next() % nc) as usize];
+ let mut v: Vec = c.iter().map(|x| x + noise * rng.g()).collect();
+ norm(&mut v);
+ each(v)?;
+ }
+ let mut questions = Vec::new();
+ for _ in 0..100 {
+ let c = ¢ers[(rng.next() % nc) as usize];
+ let mut q: Vec = c.iter().map(|x| x + noise * 4.0 / 3.0 * rng.g()).collect();
+ norm(&mut q);
+ questions.push(q);
+ }
+ Ok(questions)
+}
+
+/// Generated sightings, to the number as the second measurement of the index made them. A
+/// subject is a direction of its own beside its class's; its appearances vary along eight
+/// directions of its own; a track is ten sightings in a row whose appearance drifts; every
+/// sighting has a little noise on every number.
+struct Sightings {
+ subjects: usize,
+ classes: Vec>,
+ bases: Vec>,
+ axes: Vec>,
+ /// The generator's state after the directions, from which the questions are made.
+ after: u64,
+}
+
+impl Sightings {
+ const AXES: usize = 8;
+ const TRACK: usize = 10;
+
+ fn unit(v: &mut [f32]) {
+ let n = v.iter().map(|x| f64::from(*x) * f64::from(*x));
+ let n = n.sum::().sqrt();
+ if n > 0.0 {
+ for x in v {
+ *x = (f64::from(*x) / n) as f32;
+ }
+ }
+ }
+
+ fn new(rows: usize) -> Sightings {
+ let subjects = (rows / 2500).max(8);
+ let mut random = Random(0x51A7_1265_0BAD_CAFE);
+ let mut directions = |count: usize| -> Vec> {
+ let direction = |_| {
+ let mut c: Vec = (0..DIMENSIONS).map(|_| random.g()).collect();
+ Sightings::unit(&mut c);
+ c
+ };
+ (0..count).map(direction).collect()
+ };
+ Sightings {
+ subjects,
+ classes: directions(3),
+ bases: directions(subjects + 20),
+ axes: directions((subjects + 20) * Sightings::AXES),
+ after: random.0,
+ }
+ }
+
+ /// One sighting of a subject with this appearance.
+ fn make(&self, subject: usize, appearance: &[f32; 8], random: &mut Random) -> Vec {
+ let class = &self.classes[subject % 3];
+ let base = &self.bases[subject];
+ let mut out: Vec = (0..DIMENSIONS)
+ .map(|d| {
+ 0.5 * class[d] + 0.6 * base[d] + 0.25 * random.g() / (DIMENSIONS as f32).sqrt()
+ })
+ .collect();
+ for (j, a) in appearance.iter().enumerate() {
+ let axis = &self.axes[subject * Sightings::AXES + j];
+ let weight = 0.5 * a / (Sightings::AXES as f32).sqrt();
+ for (x, along) in out.iter_mut().zip(axis) {
+ *x += weight * along;
+ }
+ }
+ Sightings::unit(&mut out);
+ out
+ }
+
+ /// The first `count` sightings of one track.
+ fn track(&self, track: usize, count: usize) -> Vec> {
+ let seed = (track as u64 + 1).wrapping_mul(0x2545_F491_4F6C_DD1D);
+ let mut random = Random(0x51A7_1265_0BAD_CAFE ^ seed);
+ random.next();
+ let subject = random.below(self.subjects);
+ let mut appearance = [0f32; 8];
+ for a in &mut appearance {
+ *a = random.g();
+ }
+ let mut sightings = Vec::with_capacity(count);
+ for _ in 0..count {
+ sightings.push(self.make(subject, &appearance, &mut random));
+ for a in &mut appearance {
+ *a = 0.9 * *a + 0.436 * random.g();
+ }
+ }
+ sightings
+ }
+
+ /// Hands `each` the first `rows` sightings, track after track.
+ fn rows(&self, rows: usize, each: Each<'_>) -> Fallible<()> {
+ for track in 0..rows.div_ceil(Sightings::TRACK) {
+ let count = Sightings::TRACK.min(rows - track * Sightings::TRACK);
+ for sighting in self.track(track, count) {
+ each(sighting)?;
+ }
+ }
+ Ok(())
+ }
+
+ /// The 100 questions: eighty new sightings of stored subjects, twenty of subjects never
+ /// stored.
+ fn questions(&self) -> Vec> {
+ let mut random = Random(self.after);
+ let mut questions = Vec::new();
+ for j in 0..100 {
+ let subject = if j % 5 == 4 {
+ self.subjects + j / 5
+ } else {
+ random.below(self.subjects)
+ };
+ let mut appearance = [0f32; 8];
+ for a in &mut appearance {
+ *a = random.g();
+ }
+ questions.push(self.make(subject, &appearance, &mut random));
+ }
+ questions
+ }
+}
+
+/// The search of a generated case: what follows `ORDER BY distance`, and the vector.
+fn nearest(tail: &str, question: &[f32]) -> (String, Values) {
+ let sql =
+ format!("SELECT id, embedding <=> $query AS distance FROM items ORDER BY distance{tail}");
+ (sql, params! { "query" => question.to_vec() })
+}
+
+/// A new store of the generated cases' table, its vector column declared with `clauses`, and
+/// every row that `rows` hands over inserted, 500 to a commit, under the keys 0, 1, 2 and so
+/// on. Answers the store and what `rows` answers.
+fn filled(
+ store: &Path,
+ clauses: &str,
+ rows: &dyn Fn(Each<'_>) -> Fallible,
+) -> Fallible<(Database, T)> {
+ let db = Database::open(store)?;
+ db.execute(
+ &format!("CREATE TABLE items (id INTEGER PRIMARY KEY, embedding VECTOR(768){clauses})"),
+ ¶ms! {},
+ )?;
+ let mut tx = db.transaction()?;
+ let mut id = 0_i64;
+ let answered = rows(&mut |vector| {
+ tx.execute(
+ "INSERT INTO items (id, embedding) VALUES ($id, $embedding)",
+ ¶ms! { "id" => id, "embedding" => vector },
+ )?;
+ id += 1;
+ if id % 500 == 0 {
+ std::mem::replace(&mut tx, db.transaction()?).commit()?;
+ }
+ Ok(())
+ })?;
+ tx.commit()?;
+ Ok((db, answered))
+}
+
+/// One generated case: the two stores and every line taken on them. `rows` hands over the
+/// case's rows and answers its questions; `stored` are rows to search for by themselves, each
+/// with its key.
+fn index(
+ prefix: &str,
+ folder: &Path,
+ count: usize,
+ rows: &dyn Fn(Each<'_>) -> Fallible>>,
+ stored: &[(u128, Vec)],
+) -> Fallible<()> {
+ support::must_be_empty(folder);
+ std::fs::create_dir_all(folder)?;
+ let store = folder.join("sealed");
+ let ((db, questions), load) = {
+ let (filled, load) = timed(|| filled(&store, " AUTO_INDEX_AT 1000000000", rows));
+ (filled?, load)
+ };
+ line(&format!("{prefix}load"), seconds(load), "s");
+ let sealed = support::sealed(prefix, &db, &store)?;
+ let reader = support::session(&store, READER_LIMIT)?;
+ let before = support::ask(&reader, &nearest, &[EXACT], &questions, 1)?;
+ let name = format!("{prefix}find_exact_before");
+ support::setting(&name, READER_LIMIT);
+ support::times(&name, &before[0]);
+ support::counted(&name, &before[0]);
+ drop(reader);
+
+ let store = folder.join("built");
+ let (db, _) = filled(&store, "", rows)?;
+ let held = count as u64 * (8 + 4 * DIMENSIONS as u64);
+ let parts = support::built(prefix, &db, &store, (held, sealed, DIMENSIONS))?;
+ let question = folder.join("question.f32");
+ let bytes = questions[0].iter().flat_map(|number| number.to_le_bytes());
+ std::fs::write(&question, bytes.collect::>())?;
+ let first = [OsStr::new("first"), store.as_os_str(), OsStr::new(prefix)];
+ support::in_new_process(&[first[0], first[1], first[2], question.as_os_str()])?;
+ let reader = support::session(&store, READER_LIMIT)?;
+ let find = format!("{prefix}find");
+ let every = support::selected(&parts, &[]);
+ support::modes(&find, &reader, &nearest, &questions, (1, &every[..]))?;
+ if !stored.is_empty() {
+ let name = format!("{find}_own_grouped_found");
+ support::own(&name, &reader, &nearest, stored)?;
+ }
+ Ok(())
+}
+
+/// The index pilot's vectors in one partition.
+fn synthetic_case(rows: usize, folder: &Path) -> Fallible<()> {
+ let prefix = format!("synthetic_{rows}_");
+ index(&prefix, folder, rows, &|each| synthetic(rows, each), &[])
+}
+
+/// Generated sightings in one partition, and a hundred of them, evenly spread, each searched
+/// for by itself.
+fn sightings_case(rows: usize, folder: &Path) -> Fallible<()> {
+ let sightings = Sightings::new(rows);
+ let mut stored = Vec::new();
+ for row in (0..100).map(|i| i * rows / 100) {
+ let (track, place) = (row / Sightings::TRACK, row % Sightings::TRACK);
+ if let Some(vector) = sightings.track(track, place + 1).pop() {
+ stored.push((row as u128, vector));
+ }
+ }
+ let load = |each: Each<'_>| {
+ sightings.rows(rows, each)?;
+ Ok(sightings.questions())
+ };
+ index(&format!("sightings_{rows}_"), folder, rows, &load, &stored)
+}
+
+/// What the program does when it starts itself: opens the built store and runs one search for
+/// the vector the file holds.
+fn first(store: &Path, prefix: &str, question: &Path) -> Fallible<()> {
+ let question = std::fs::read(question)?;
+ let (numbers, _) = question.as_chunks::<4>();
+ let question: Vec = numbers
+ .iter()
+ .map(|four| f32::from_le_bytes(*four))
+ .collect();
+ let names = (format!("{prefix}open"), format!("{prefix}find_auto_first"));
+ support::first(
+ store,
+ (&names.0, &names.1),
+ &nearest(support::AUTO, &question),
+ )
+}
+
fn usage() -> ! {
eprintln!(
- "usage: measure readme|million|vectors \n \
- measure sessions "
+ "usage: measure readme|million|vectors|index \n \
+ measure sessions \n \
+ measure synthetic|sightings "
);
std::process::exit(2);
}
-fn main() -> Result<()> {
+fn main() -> Fallible<()> {
let arguments: Vec = std::env::args().skip(1).collect();
let arguments: Vec<&str> = arguments.iter().map(String::as_str).collect();
+ if let ["first", store, prefix, question] = arguments.as_slice() {
+ return first(Path::new(store), prefix, Path::new(question));
+ }
let Some(folder) = arguments.last().map(Path::new) else {
usage();
};
support::must_be_empty(folder);
match arguments.as_slice() {
- ["readme", _] => readme(folder),
- ["million", _] => million(folder),
- ["vectors", _] => vectors(folder),
+ ["readme", _] => readme(folder)?,
+ ["million", _] => million(folder)?,
+ ["vectors", _] => vectors(folder)?,
["sessions", corpus, list, count, _] => match count.parse() {
- Ok(count) => sessions(Path::new(corpus), Path::new(list), count, folder),
+ Ok(count) => sessions(Path::new(corpus), Path::new(list), count, folder)?,
Err(_) => usage(),
},
+ ["index", _] => {
+ synthetic_case(1_000_000, &folder.join("synthetic"))?;
+ sightings_case(1_000_000, &folder.join("sightings"))?;
+ }
+ [which @ ("synthetic" | "sightings"), rows, _] => match (*which, rows.parse()) {
+ ("synthetic", Ok(rows)) => synthetic_case(rows, folder)?,
+ (_, Ok(rows)) => sightings_case(rows, folder)?,
+ _ => usage(),
+ },
_ => usage(),
}
+ Ok(())
}
diff --git a/v2/contextdb/examples/passages.rs b/v2/contextdb/examples/passages.rs
index 2b27bf7b..55c1a8b7 100644
--- a/v2/contextdb/examples/passages.rs
+++ b/v2/contextdb/examples/passages.rs
@@ -1,7 +1,15 @@
-//! Loads a dump of search passages into a store and measures searches on them, printing one
-//! number per line as `name value unit`.
+//! Loads a dump of search passages into stores and measures searches on them under each mode,
+//! printing one number per line as `name value unit`.
//!
-//! `passages `
+//! The program is called in three ways:
+//!
+//! - `passages [sq8][,tail]`: every passage of the
+//! dump, declared as Context Graph declares its table. Lines `passages__...`.
+//! - `passages single `: the first 10,000 passages, in dump order, of
+//! the dump's (Context, generation) pair of index 1, in a table whose vector column also
+//! declares `AUTO_INDEX_AT 1000`. Lines `single__...`.
+//! - `passages first single|store `: what the program starts
+//! itself as, to open a built store in a new process and run one search.
//!
//! The dump folder holds `rows.bin` (36 bytes for each passage, numbers low byte first: a row
//! number `u64`, the passage's id, the index `u32` of its Context, the index `u32` of its
@@ -11,66 +19,82 @@
//! `queries.f32` (768 `f32` for each question). The answers folder holds `engine-one.jsonl`,
//! `engine-several.jsonl` and `engine-all.jsonl`: on each line the ten answers recorded for one
//! question, `{"q": n, "ids": [...], "scores": [...]}`, a score being one minus the distance,
-//! rounded to 32 bits. The store folder must be missing or empty.
+//! rounded to 32 bits. The folder must be missing or empty; each store is a folder inside it.
+//!
+//! What it does, in order, each store loaded 500 passages to a commit with no memory limit
+//! and every read session opened under 1 GiB unless a line's name says another limit:
//!
-//! What it does, in order:
+//! 1. `sealed`: a store whose column declares `AUTO_INDEX_AT 1000000000`, so that no partition
+//! is ever grouped. One search of every question with every vector still waiting; cycles
+//! until nothing waits; its size; EXACT in each scope (`exact_before`).
+//! 2. `built`: the store as its case declares it. Cycles until nothing remains; what
+//! inspection says of the index; its size; in a new process, opening it and one search;
+//! then, in one session, each scope under EXACT, INDEXED and AUTO in turn within each of
+//! five rounds, the two indexed modes compared with EXACT of the same run; the answers
+//! beside the recorded ones; INDEXED again under 64 MiB; the statement with one condition
+//! more that passes one passage in a hundred, under EXACT and INDEXED; and a hundred stored
+//! vectors each searched for by itself.
+//! 3. with `sq8`: a store whose column is declared `WITH (quantization = 'SQ8')` and never
+//! grouped, searched under EXACT, its answers beside the first store's.
+//! 4. with `tail`: a store built from all but the last tenth of each partition's passages, in
+//! dump order, which are written after the build and sealed, so that they stay in each
+//! partition's tail; EXACT and INDEXED over all of it, and a hundred of the tail's vectors
+//! each searched for by itself.
//!
-//! 1. opens the store with no memory limit and inserts the passages, 500 to a commit;
-//! 2. searches all Contexts for every question once through a read session, with every vector
-//! still waiting to be sealed;
-//! 3. calls `run_maintenance_cycle` until nothing waits, closes the store and takes its size;
-//! 4. through a read session with no memory limit, then one under 1 GiB, then one under
-//! 64 MiB, searches one Context (the first), several (the first three) and all of them: 5
-//! searches that are not timed, then every question, each `execute` call timed whole;
-//! 5. compares the answers of the session with no limit with the recorded ones.
+//! The scopes are one Context (the first), several (the first three) and all of them; the
+//! second way of calling has the one scope of its pair and leaves the scope out of its names.
+//! The search is Context Graph's statement as it sends it, its two flags included. The added
+//! condition is on the column `id`: sixteen ranges of ids, evenly spread through the ids in
+//! order, that hold a hundredth of the passages together.
//!
-//! A time named `p50` or `p95` is the one at position `round((count - 1) * share)` of the
-//! times in order. "Held" is the bytes of the values put in: 50 for a passage's five other
-//! columns and 3,072 for its vector.
+//! A time named `p50` or `p95` is the middle of the five rounds' values, each the time at
+//! position `round((count - 1) * share)` of a round's times in order; `p95_spread` is the
+//! largest of the five less the smallest. "Held" is the bytes of the values put in: 50 for a
+//! passage's five other columns and 3,072 for its vector.
//!
//! This program measures. It never passes or fails, no test reads what it prints, and
//! `numbers.sh` is what runs it. It prints nothing of a passage but counts, sizes and times.
mod support;
-use std::collections::{BTreeSet, HashMap};
+use std::collections::BTreeSet;
+use std::ffi::OsStr;
use std::fs::File;
-use std::io::{BufReader, Read};
-use std::path::Path;
-use std::time::Duration;
+use std::io::{BufReader, Read, Seek, SeekFrom};
+use std::path::{Path, PathBuf};
use contextdb::serde_json::Value as Json;
-use contextdb::{Database, ReadSession, ReadSessionOptions, Uuid, Value, params};
-use support::{bytes_on_disk, line, mebibytes, milliseconds, percentile, ratio, seconds, timed};
+use contextdb::{Database, Uuid, Value, params};
+use support::{
+ AUTO, Asked, EXACT, Fallible, INDEXED, READER_LIMIT, Values, against, ask, counted, line,
+ mebibytes, ratio, seconds, selected, session, setting, timed, times, work,
+};
#[global_allocator]
static ALLOCATOR: support::Counting = support::Counting;
-type Fallible = Result>;
-
const DIMENSIONS: usize = 768;
const PER_COMMIT: usize = 500;
-const UNTIMED: usize = 5;
-const SCOPES: [&str; 3] = ["one", "several", "all"];
+const ROUNDS: usize = 5;
+/// The clause of a column no partition of which is ever grouped.
+const NEVER_GROUPED: &str = " AUTO_INDEX_AT 1000000000";
+
+struct Passage {
+ id: Uuid,
+ pair: usize,
+ flags: u32,
+}
-/// What the dump says beside its passages.
+/// A dump: its passages without their vectors, and what it says beside them.
struct Dump {
+ folder: PathBuf,
/// Each Context's id, by index.
contexts: Vec,
/// Each pair's Context index, generation, and whether it is the Context's active one.
pairs: Vec<(usize, Uuid, bool)>,
/// The question vectors.
questions: Vec>,
-}
-
-/// What the searches of one scope gave: for each question the time, the rows read, and the
-/// ids and distances returned.
-#[derive(Default)]
-struct Finds {
- first: Duration,
- times: Vec,
- examined: Vec,
- answers: Vec>,
+ passages: Vec,
}
fn floats(bytes: &[u8]) -> Vec {
@@ -94,76 +118,207 @@ fn read_dump(folder: &Path) -> Fallible {
}
let questions = floats(&std::fs::read(folder.join("queries.f32"))?);
let questions = questions.chunks_exact(DIMENSIONS).map(<[f32]>::to_vec);
+ let mut passages = Vec::new();
+ for record in std::fs::read(folder.join("rows.bin"))?.chunks_exact(36) {
+ let number = |at: usize| {
+ u32::from_le_bytes([record[at], record[at + 1], record[at + 2], record[at + 3]])
+ };
+ passages.push(Passage {
+ id: Uuid::from_slice(&record[8..24])?,
+ pair: number(28) as usize,
+ flags: number(32),
+ });
+ }
Ok(Dump {
+ folder: folder.to_owned(),
contexts,
pairs,
questions: questions.collect(),
+ passages,
})
}
-/// Inserts every passage of the dump. Returns how many there were and the bytes held.
-fn load(db: &Database, folder: &Path, dump: &Dump) -> Fallible<(u64, u64)> {
- let rows = File::open(folder.join("rows.bin"))?;
- let passages = rows.metadata()?.len() / 36;
- let mut rows = BufReader::new(rows);
- let mut vectors = BufReader::new(File::open(folder.join("vectors.f32"))?);
- let (mut record, mut vector) = ([0u8; 36], vec![0u8; 4 * DIMENSIONS]);
- let mut held = 0;
+impl Dump {
+ /// The Context and generation of a pair.
+ fn pair(&self, pair: usize) -> (Uuid, Uuid) {
+ (self.contexts[self.pairs[pair].0], self.pairs[pair].1)
+ }
+
+ /// The active pairs of these Contexts, each once, in the order of their ids.
+ fn active(&self, contexts: &[usize]) -> Vec<(Uuid, Uuid)> {
+ let active = self.pairs.iter().filter(|pair| pair.2);
+ let wanted = active.filter(|pair| contexts.contains(&pair.0));
+ let pairs: BTreeSet<(Uuid, Uuid)> =
+ wanted.map(|pair| (self.contexts[pair.0], pair.1)).collect();
+ pairs.into_iter().collect()
+ }
+
+ /// As many as `count` of these passages that have a vector, evenly spread, each with its
+ /// id as a number and its vector.
+ fn stored(&self, places: &[usize], count: usize) -> Fallible)>> {
+ let with = places.iter().copied();
+ let with: Vec = with
+ .filter(|at| self.passages[*at].flags & 4 != 0)
+ .collect();
+ let count = count.min(with.len());
+ let mut vectors = File::open(self.folder.join("vectors.f32"))?;
+ let mut bytes = vec![0u8; 4 * DIMENSIONS];
+ let mut stored = Vec::with_capacity(count);
+ for at in (0..count).map(|i| with[i * with.len() / count]) {
+ vectors.seek(SeekFrom::Start((at * 4 * DIMENSIONS) as u64))?;
+ vectors.read_exact(&mut bytes)?;
+ stored.push((self.passages[at].id.as_u128(), floats(&bytes)));
+ }
+ Ok(stored)
+ }
+}
+
+/// What is loaded and how it is declared and searched.
+struct Case<'a> {
+ dump: &'a Dump,
+ /// What every line's name begins with.
+ prefix: String,
+ /// The passages loaded, by their place in the dump, in its order.
+ loaded: Vec,
+ /// What the vector column declares after `SEARCH_MODE AUTO`.
+ declared: &'static str,
+ /// Each scope: what its lines' names hold after `find_`, and the pairs it selects. The
+ /// last is the widest.
+ scopes: Vec<(&'static str, Vec<(Uuid, Uuid)>)>,
+ /// The folder of recorded answers, for a case that has them.
+ answers: Option<&'a Path>,
+}
+
+impl<'a> Case<'a> {
+ /// Every passage of the dump, as Context Graph declares its table.
+ fn store(dump: &'a Dump, answers: Option<&'a Path>) -> Case<'a> {
+ let every: Vec = (0..dump.contexts.len()).collect();
+ Case {
+ dump,
+ prefix: format!("passages_{}_", dump.passages.len()),
+ loaded: (0..dump.passages.len()).collect(),
+ declared: "",
+ scopes: vec![
+ ("one_", dump.active(&[0])),
+ ("several_", dump.active(&[0, 1, 2])),
+ ("all_", dump.active(&every)),
+ ],
+ answers,
+ }
+ }
+
+ /// The first 10,000 passages of the pair of index 1, in a column that declares
+ /// `AUTO_INDEX_AT 1000`.
+ fn single(dump: &'a Dump) -> Case<'a> {
+ let places = 0..dump.passages.len();
+ let of_pair = places.filter(|at| dump.passages[*at].pair == 1);
+ let loaded: Vec = of_pair.take(10_000).collect();
+ Case {
+ dump,
+ prefix: format!("single_{}_", loaded.len()),
+ loaded,
+ declared: " AUTO_INDEX_AT 1000",
+ scopes: vec![("", vec![dump.pair(1)])],
+ answers: None,
+ }
+ }
+
+ /// The name of a scope's lines up to the mode: `find_`, or `find`.
+ fn find(&self, scope: &str) -> String {
+ format!("{}find_{scope}", self.prefix)
+ .trim_end_matches('_')
+ .to_owned()
+ }
+
+ /// The name of one line of the case.
+ fn name(&self, what: &str) -> String {
+ format!("{}{what}", self.prefix)
+ }
+
+ /// The widest scope.
+ fn widest(&self) -> &(&'static str, Vec<(Uuid, Uuid)>) {
+ &self.scopes[self.scopes.len() - 1]
+ }
+}
+
+/// Opens a new store with Context Graph's table, its vector column declared `with` this
+/// quantization clause and with these clauses after `SEARCH_MODE AUTO`.
+fn create(store: &Path, with: &str, clauses: &str) -> Fallible {
+ let db = Database::open(store)?;
+ db.execute(
+ &format!(
+ "CREATE TABLE event_search_passages (
+ id UUID PRIMARY KEY,
+ context_id UUID NOT NULL,
+ generation_id UUID NOT NULL,
+ vector_text VECTOR(768){with} PARTITION_KEY (context_id, generation_id)
+ MAX_PARTITIONS 4096 SEARCH_MODE AUTO{clauses},
+ is_current BOOLEAN NOT NULL,
+ is_searchable BOOLEAN NOT NULL DEFAULT true
+ )"
+ ),
+ ¶ms! {},
+ )?;
+ Ok(db)
+}
+
+/// Inserts these passages of the dump, given by their places in rising order. Returns the
+/// bytes held.
+fn load(db: &Database, dump: &Dump, places: &[usize]) -> Fallible {
+ let mut vectors = BufReader::new(File::open(dump.folder.join("vectors.f32"))?);
+ let mut vector = vec![0u8; 4 * DIMENSIONS];
+ let (mut held, mut next) = (0, 0);
let mut tx = db.transaction()?;
- for passage in 1..=passages as usize {
- rows.read_exact(&mut record)?;
+ for (count, at) in places.iter().enumerate() {
+ vectors.seek_relative(((at - next) * 4 * DIMENSIONS) as i64)?;
vectors.read_exact(&mut vector)?;
- let number = |at: usize| {
- u32::from_le_bytes([record[at], record[at + 1], record[at + 2], record[at + 3]])
- };
- let (context, generation, _) = dump.pairs[number(28) as usize];
- let flags = number(32);
- let embedding = if flags & 4 == 0 {
- Value::Null
- } else {
+ next = at + 1;
+ let passage = &dump.passages[*at];
+ let (context, generation) = dump.pair(passage.pair);
+ let has_vector = passage.flags & 4 != 0;
+ let embedding = if has_vector {
Value::Vector(floats(&vector))
+ } else {
+ Value::Null
};
- held += 3 * 16
- + 2
- + if flags & 4 == 0 {
- 0
- } else {
- 4 * DIMENSIONS as u64
- };
+ held += 3 * 16 + 2 + if has_vector { 4 * DIMENSIONS as u64 } else { 0 };
tx.execute(
"INSERT INTO event_search_passages \
(id, context_id, generation_id, vector_text, is_current, is_searchable) \
VALUES ($id, $context, $generation, $vector, $current, $searchable)",
¶ms! {
- "id" => Uuid::from_slice(&record[8..24])?,
- "context" => dump.contexts[context],
+ "id" => passage.id,
+ "context" => context,
"generation" => generation,
"vector" => embedding,
- "current" => flags & 1 != 0,
- "searchable" => flags & 2 != 0,
+ "current" => passage.flags & 1 != 0,
+ "searchable" => passage.flags & 2 != 0,
},
)?;
- if passage % PER_COMMIT == 0 {
+ if (count + 1) % PER_COMMIT == 0 {
std::mem::replace(&mut tx, db.transaction()?).commit()?;
}
}
tx.commit()?;
- Ok((passages, held))
+ Ok(held)
}
-/// The search statement for the active pairs of the scope's Contexts, with its values.
-fn statement(dump: &Dump, scope: &[usize], question: &[f32]) -> (String, HashMap) {
+/// Context Graph's search statement for these pairs, with its values: `tail` is what follows
+/// `ORDER BY distance`, and `among`, when it holds any, adds the condition that the passage's
+/// id lies in one of these ranges, at or after a range's first id and before its second.
+fn statement(
+ pairs: &[(Uuid, Uuid)],
+ among: &[(Uuid, Uuid)],
+ tail: &str,
+ question: &[f32],
+) -> (String, Values) {
let mut values = params! {
"query" => question.to_vec(), "is_current" => true, "is_searchable" => true,
};
- let active = dump.pairs.iter().filter(|pair| pair.2);
- let wanted = active.filter(|pair| scope.contains(&pair.0));
- let pairs: BTreeSet<(Uuid, Uuid)> =
- wanted.map(|pair| (dump.contexts[pair.0], pair.1)).collect();
let mut names = Vec::new();
- for (i, (context, generation)) in pairs.into_iter().enumerate() {
- values.insert(format!("search_context_{i}"), Value::Uuid(context));
- values.insert(format!("search_generation_{i}"), Value::Uuid(generation));
+ for (i, (context, generation)) in pairs.iter().enumerate() {
+ values.insert(format!("search_context_{i}"), Value::Uuid(*context));
+ values.insert(format!("search_generation_{i}"), Value::Uuid(*generation));
names.push(format!("($search_context_{i}, $search_generation_{i})"));
}
let filter = if names.len() == 1 {
@@ -171,65 +326,45 @@ fn statement(dump: &Dump, scope: &[usize], question: &[f32]) -> (String, HashMap
} else {
format!("(context_id, generation_id) IN ({})", names.join(", "))
};
+ let mut ranges = Vec::new();
+ for (i, (from, before)) in among.iter().enumerate() {
+ values.insert(format!("id_from_{i}"), Value::Uuid(*from));
+ values.insert(format!("id_before_{i}"), Value::Uuid(*before));
+ ranges.push(format!("(id >= $id_from_{i} AND id < $id_before_{i})"));
+ }
+ let condition = if ranges.is_empty() {
+ String::new()
+ } else {
+ format!(" AND ({})", ranges.join(" OR "))
+ };
let sql = format!(
"SELECT id, context_id, generation_id, vector_text <=> $query AS distance \
FROM event_search_passages WHERE {filter} \
- AND is_current = $is_current AND is_searchable = $is_searchable \
- ORDER BY distance LIMIT 10"
+ AND is_current = $is_current AND is_searchable = $is_searchable{condition} \
+ ORDER BY distance{tail}"
);
(sql, values)
}
-/// The Contexts of a scope, by index.
-fn contexts_of(scope: &str, dump: &Dump) -> Vec {
- match scope {
- "one" => vec![0],
- "several" => vec![0, 1, 2],
- _ => (0..dump.contexts.len()).collect(),
- }
-}
-
-/// Searches a scope for every question, after `untimed` searches that are not recorded.
-fn find(session: &ReadSession, dump: &Dump, scope: &str, untimed: usize) -> Fallible {
- let scope = contexts_of(scope, dump);
- let mut finds = Finds::default();
- for run in 0..untimed + dump.questions.len() {
- let question = run.checked_sub(untimed);
- let asked = question.unwrap_or(run % dump.questions.len());
- let (sql, values) = statement(dump, &scope, &dump.questions[asked]);
- let (found, time) = timed(|| session.execute(&sql, &values));
- let found = found?;
- if run == 0 {
- finds.first = time;
- }
- if question.is_none() {
- continue;
- }
- let answer = found.rows().iter().map(|row| match (&row[0], &row[3]) {
- (Value::Uuid(id), Value::Float64(distance)) => Ok((*id, *distance)),
- _ => Err("a search row is not an id and a distance"),
- });
- finds.answers.push(answer.collect::>()?);
- finds.times.push(time);
- finds.examined.push(found.rows_examined());
- }
- Ok(finds)
-}
-
-/// The three scopes through a read session opened under `limit`.
-fn find_under(store: &Path, limit: Option, dump: &Dump) -> Fallible> {
- let mut options = ReadSessionOptions::default();
- options.memory_limit = limit;
- let session = ReadSession::open_with_options(store, options)?;
- let each = SCOPES.iter();
- each.map(|scope| find(&session, dump, scope, UNTIMED))
- .collect()
+/// The key tuples of these pairs as the store writes them: for each of the two ids the tag
+/// of a UUID and its bytes.
+fn tuples(pairs: &[(Uuid, Uuid)]) -> Vec> {
+ let tuple = |(context, generation): &(Uuid, Uuid)| {
+ [
+ &[5][..],
+ &context.as_bytes()[..],
+ &[5],
+ &generation.as_bytes()[..],
+ ]
+ .concat()
+ };
+ pairs.iter().map(tuple).collect()
}
/// Compares a scope's answers with the recorded ones: how many questions have the same ten
/// ids, how many have them in the same order, and the largest difference between a recorded
/// score and one minus the distance, rounded to 32 bits, of the same id.
-fn compare(answers: &[Vec<(Uuid, f64)>], recorded: &Path) -> Fallible<(usize, usize, f64)> {
+fn compare(answers: &[Vec<(u128, f64)>], recorded: &Path) -> Fallible<(usize, usize, f64)> {
let (mut same_ten, mut same_order, mut gap) = (0, 0, 0f64);
for text in std::fs::read_to_string(recorded)?.lines() {
let recorded: Json = contextdb::serde_json::from_str(text)?;
@@ -242,12 +377,13 @@ fn compare(answers: &[Vec<(Uuid, f64)>], recorded: &Path) -> Fallible<(usize, us
};
let ids = ids.iter().map(|id| id.as_str().unwrap_or_default().parse());
let ids: Vec = ids.collect::>()?;
+ let ids: Vec = ids.iter().map(Uuid::as_u128).collect();
let answer = answers
.get(question as usize)
.ok_or("a question out of range")?;
- let ours: Vec = answer.iter().map(|(id, _)| *id).collect();
+ let ours: Vec = answer.iter().map(|(id, _)| *id).collect();
same_order += usize::from(ours == ids);
- let as_set = |ids: &[Uuid]| ids.iter().copied().collect::>();
+ let as_set = |ids: &[u128]| ids.iter().copied().collect::>();
same_ten += usize::from(as_set(&ours) == as_set(&ids));
for (id, distance) in answer {
if let Some(at) = ids.iter().position(|recorded| recorded == id) {
@@ -259,109 +395,353 @@ fn compare(answers: &[Vec<(Uuid, f64)>], recorded: &Path) -> Fallible<(usize, us
Ok((same_ten, same_order, gap))
}
-fn main() -> Fallible<()> {
- let arguments: Vec = std::env::args().skip(1).collect();
- let [dump_folder, answers, store] = arguments.as_slice() else {
- eprintln!("usage: passages ");
- std::process::exit(2);
- };
- let (dump_folder, answers, store) =
- (Path::new(dump_folder), Path::new(answers), Path::new(store));
- support::must_be_empty(store);
- let dump = read_dump(dump_folder)?;
-
- let db = Database::open(store)?;
- db.execute(
- "CREATE TABLE event_search_passages (
- id UUID PRIMARY KEY,
- context_id UUID NOT NULL,
- generation_id UUID NOT NULL,
- vector_text VECTOR(768) PARTITION_KEY (context_id, generation_id)
- MAX_PARTITIONS 4096 SEARCH_MODE AUTO,
- is_current BOOLEAN NOT NULL,
- is_searchable BOOLEAN NOT NULL DEFAULT true
- )",
- ¶ms! {},
- )?;
+/// The first store: never grouped. Returns the bytes held, the store's bytes, and EXACT's
+/// answers in each scope.
+fn sealed(case: &Case<'_>, store: &Path) -> Fallible<(u64, u64, Vec)> {
+ let dump = case.dump;
+ let db = create(store, "", NEVER_GROUPED)?;
support::take_peak_allocated();
- let (loaded, load_time) = timed(|| load(&db, dump_folder, &dump));
- let (passages, held) = loaded?;
+ let (held, load_time) = timed(|| load(&db, dump, &case.loaded));
+ let held = held?;
+ line(&case.name("load"), seconds(load_time), "s");
let load_peak = support::take_peak_allocated();
- let name = |what: &str| format!("passages_{passages}_{what}");
- line(&name("load"), seconds(load_time), "s");
-
- let unsealed = find(&ReadSession::open(store)?, &dump, "all", 0)?;
-
- let mut cycles = 0;
- let (sealed, seal_time) = timed(|| -> contextdb::Result<()> {
- loop {
- cycles += 1;
- let report = db.run_maintenance_cycle()?;
- if report.vector.maintenance_vectors_remaining == 0 {
- return Ok(());
- }
- }
- });
- sealed?;
- db.close()?;
- let on_disk = bytes_on_disk(store)?;
- line(&name("seal"), seconds(seal_time), "s");
- line(&name("seal_cycles"), cycles, "cycles");
- line(&name("store_size"), mebibytes(on_disk), "MiB");
line(
- &name("bytes_per_passage"),
- on_disk / passages.max(1),
- "bytes",
+ &case.name("load_peak_allocated"),
+ mebibytes(load_peak),
+ "MiB",
);
- line(&name("size_over_held"), ratio(on_disk, held), "x");
- let p95 = |times: &[Duration]| milliseconds(percentile(times, 0.95));
- line(&name("find_unsealed_all_p95"), p95(&unsealed.times), "ms");
- let rows = percentile(&unsealed.examined, 0.5);
- line(&name("find_unsealed_all_rows_examined_p50"), rows, "rows");
+
+ let (scope, pairs) = case.widest();
+ let find = |tail: &str, question: &[f32]| statement(pairs, &[], tail, question);
+ let unsealed = session(store, READER_LIMIT)?;
+ let unsealed = ask(&unsealed, &find, &[AUTO], &dump.questions, 1)?;
+ let name = format!("{}find_unsealed_{scope}", case.prefix);
+ let name = name.trim_end_matches('_');
+ setting(name, READER_LIMIT);
+ times(name, &unsealed[0]);
+ counted(name, &unsealed[0]);
+
+ let bytes = support::sealed(&case.prefix, &db, store)?;
+ let reader = session(store, READER_LIMIT)?;
+ let mut exact = Vec::new();
+ for (scope, pairs) in &case.scopes {
+ let find = |tail: &str, question: &[f32]| statement(pairs, &[], tail, question);
+ let mut asked = ask(&reader, &find, &[EXACT], &dump.questions, ROUNDS)?;
+ let name = format!("{}_exact_before", case.find(scope));
+ setting(&name, READER_LIMIT);
+ times(&name, &asked[0]);
+ counted(&name, &asked[0]);
+ exact.append(&mut asked);
+ }
+ Ok((held, bytes, exact))
+}
+
+/// Ranges of ids: the first id of each and the first id after it.
+type Ranges = Vec<(Uuid, Uuid)>;
+
+/// Sixteen ranges of ids, evenly spread through the loaded passages' ids in order, that hold
+/// a hundredth of the passages together: each range's first id and the first id after it,
+/// how many passages they hold, and in how many pairs those lie. `None` for fewer than 1,600
+/// passages.
+fn hundredth(case: &Case<'_>) -> Option<(Ranges, usize, usize)> {
+ const RANGES: usize = 16;
+ let passages = case.loaded.iter().map(|at| &case.dump.passages[*at]);
+ let mut ids: Vec<(Uuid, usize)> = passages.map(|passage| (passage.id, passage.pair)).collect();
+ ids.sort_unstable();
+ let each = ids.len() / (100 * RANGES);
+ let (mut ranges, mut pairs) = (Vec::new(), BTreeSet::new());
+ for range in 0..RANGES {
+ let from = (2 * range + 1) * ids.len() / (2 * RANGES) - each / 2;
+ let inside = ids.get(from..from + each).filter(|_| each > 0)?;
+ pairs.extend(inside.iter().map(|(_, pair)| *pair));
+ ranges.push((inside[0].0, ids.get(from + each)?.0));
+ }
+ Some((ranges, each * RANGES, pairs.len()))
+}
+
+/// The second store, declared as the case says, and every line taken on it.
+fn built(case: &Case<'_>, folder: &Path, held: u64, sealed: u64) -> Fallible<()> {
+ let (dump, store) = (case.dump, folder.join("built"));
+ let db = create(&store, "", case.declared)?;
+ load(&db, dump, &case.loaded)?;
+ let parts = support::built(&case.prefix, &db, &store, (held, sealed, DIMENSIONS))?;
+ let kind = if case.answers.is_some() {
+ "store"
+ } else {
+ "single"
+ };
+ let first = [OsStr::new("first"), OsStr::new(kind)];
+ support::in_new_process(&[
+ first[0],
+ first[1],
+ dump.folder.as_os_str(),
+ store.as_os_str(),
+ ])?;
support::take_peak_allocated();
- let unlimited = find_under(store, None, &dump)?;
- for (scope, finds) in SCOPES.iter().zip(&unlimited) {
- let p50 = milliseconds(percentile(&finds.times, 0.5));
- line(&name(&format!("find_{scope}_p50")), p50, "ms");
- line(&name(&format!("find_{scope}_p95")), p95(&finds.times), "ms");
+ let reader = session(&store, READER_LIMIT)?;
+ let mut asked = Vec::new();
+ for (scope, pairs) in &case.scopes {
+ let find = |tail: &str, question: &[f32]| statement(pairs, &[], tail, question);
+ let parts = selected(&parts, &tuples(pairs));
+ let how = (ROUNDS, &parts[..]);
+ asked.push(support::modes(
+ &case.find(scope),
+ &reader,
+ &find,
+ &dump.questions,
+ how,
+ )?);
}
- let all = &unlimited[2];
- line(&name("find_all_first"), milliseconds(all.first), "ms");
+ if let Some(answers) = case.answers {
+ let mut gap = 0.0;
+ for (mode, at) in [("", 0), ("auto_", 2)] {
+ for (what, order) in [("same_ten", false), ("same_order", true)] {
+ for ((scope, _), asked) in case.scopes.iter().zip(&asked) {
+ let scope = scope.trim_end_matches('_');
+ let recorded = answers.join(format!("engine-{scope}.jsonl"));
+ let (same_ten, same_order, far) = compare(&asked[at].answers, &recorded)?;
+ let same = if order { same_order } else { same_ten };
+ line(&case.name(&format!("{mode}{what}_{scope}")), same, "of_100");
+ gap = f64::max(gap, far);
+ }
+ }
+ }
+ line(&case.name("distance_gap_max"), gap, "-");
+
+ // INDEXED again where the blocks do not stay in the cache.
+ let small = session(&store, 64 << 20)?;
+ for ((scope, pairs), asked) in case.scopes.iter().zip(&asked) {
+ let find = |tail: &str, question: &[f32]| statement(pairs, &[], tail, question);
+ let under = ask(&small, &find, &[INDEXED], &dump.questions, ROUNDS)?;
+ let name = format!("{}_indexed_under_64m", case.find(scope));
+ setting(&name, 64 << 20);
+ times(&name, &under[0]);
+ counted(&name, &under[0]);
+ against(&name, &under[0], &asked[0]);
+ }
+
+ // The statement with a condition that passes a passage in a hundred. INDEXED is
+ // refused where the filter leaves the index nothing to choose among, as in a store
+ // none of whose partitions is grouped: EXACT is then timed alone.
+ let (scope, pairs) = case.widest();
+ let find = case.find(scope);
+ if let Some((among, passing, in_pairs)) = hundredth(case) {
+ let name = format!("{find}_filtered");
+ line(&format!("{name}_rows_passing"), passing, "rows");
+ let in_pairs = (format!("{name}_pairs_with_passing_rows"), in_pairs);
+ line(&in_pairs.0, in_pairs.1, "pairs");
+ let filtered = |tail: &str, question: &[f32]| statement(pairs, &among, tail, question);
+ let both = ask(
+ &reader,
+ &filtered,
+ &[EXACT, INDEXED],
+ &dump.questions,
+ ROUNDS,
+ );
+ let asked = match both {
+ Ok(asked) => asked,
+ Err(error) => {
+ eprintln!("{find}_indexed_filtered: {error}");
+ ask(&reader, &filtered, &[EXACT], &dump.questions, ROUNDS)?
+ }
+ };
+ setting(&name, READER_LIMIT);
+ let name = format!("{find}_exact_filtered");
+ times(&name, &asked[0]);
+ counted(&name, &asked[0]);
+ let name = format!("{find}_indexed_filtered");
+ match asked.get(1) {
+ Some(indexed) => {
+ times(&name, indexed);
+ counted(&name, indexed);
+ against(&name, indexed, &asked[0]);
+ }
+ None => line(&name, "refused", "-"),
+ }
+ } else {
+ line(&format!("{find}_filtered"), "unavailable", "-");
+ }
+ }
+
+ let (scope, pairs) = case.widest();
+ let find = |tail: &str, question: &[f32]| statement(pairs, &[], tail, question);
+ let name = format!("{}_own_grouped_found", case.find(scope));
+ support::own(&name, &reader, &find, &dump.stored(&case.loaded, 100)?)?;
+ let find_peak = support::take_peak_allocated();
line(
- &name("find_all_max"),
- milliseconds(percentile(&all.times, 1.0)),
- "ms",
+ &case.name("find_peak_allocated"),
+ mebibytes(find_peak),
+ "MiB",
);
- for (limit, bytes) in [("1g", 1 << 30), ("64m", 64 << 20)] {
- let limited = find_under(store, Some(bytes), &dump)?;
- let name = name(&format!("find_all_p95_limit_{limit}"));
- line(&name, p95(&limited[2].times), "ms");
+ Ok(())
+}
+
+/// A store with a tail: built from all but the last tenth of each pair's passages, which are
+/// written after the build and sealed.
+fn tail(case: &Case<'_>, store: &Path) -> Fallible<()> {
+ let dump = case.dump;
+ let mut of_pair = vec![0_usize; dump.pairs.len()];
+ for at in &case.loaded {
+ of_pair[dump.passages[*at].pair] += 1;
+ }
+ let mut seen = vec![0_usize; dump.pairs.len()];
+ let (mut first, mut last) = (Vec::new(), Vec::new());
+ for at in &case.loaded {
+ let pair = dump.passages[*at].pair;
+ seen[pair] += 1;
+ if seen[pair] > of_pair[pair] - of_pair[pair] / 10 {
+ last.push(*at);
+ } else {
+ first.push(*at);
+ }
+ }
+ let db = create(store, "", case.declared)?;
+ load(&db, dump, &first)?;
+ support::upkeep(&db)?;
+ load(&db, dump, &last)?;
+ let cycles = support::upkeep(&db)?;
+ db.close()?;
+ line(&case.name("tail_passages"), last.len(), "passages");
+ line(&case.name("tail_cycles"), cycles, "cycles");
+
+ let parts = support::parts(store, DIMENSIONS)?;
+ let (scope, pairs) = case.widest();
+ let parts = selected(&parts, &tuples(pairs));
+ let find = |tail: &str, question: &[f32]| statement(pairs, &[], tail, question);
+ let reader = session(store, READER_LIMIT)?;
+ let asked = ask(&reader, &find, &[EXACT, INDEXED], &dump.questions, ROUNDS)?;
+ let scope_find = case.find(scope);
+ setting(&format!("{scope_find}_tail"), READER_LIMIT);
+ let name = format!("{scope_find}_exact_tail");
+ times(&name, &asked[0]);
+ counted(&name, &asked[0]);
+ if let Some(answers) = case.answers {
+ let scope = scope.trim_end_matches('_');
+ let recorded = answers.join(format!("engine-{scope}.jsonl"));
+ let same_ten = compare(&asked[0].answers, &recorded)?.0;
+ line(&format!("{name}_same_ten"), same_ten, "of_100");
+ }
+ let name = format!("{scope_find}_indexed_tail");
+ times(&name, &asked[1]);
+ counted(&name, &asked[1]);
+ against(&name, &asked[1], &asked[0]);
+ work(&name, &asked[1], &parts, &dump.questions);
+ let name = format!("{scope_find}_own_tail_found");
+ support::own(&name, &reader, &find, &dump.stored(&last, 100)?)
+}
+
+/// A store that keeps only the reduced vectors, never grouped, searched under EXACT beside
+/// the first store's answers under EXACT.
+fn reduced(case: &Case<'_>, store: &Path, sealed: u64, exact: &[Asked]) -> Fallible<()> {
+ let dump = case.dump;
+ let db = create(store, " WITH (quantization = 'SQ8')", NEVER_GROUPED)?;
+ load(&db, dump, &case.loaded)?;
+ support::upkeep(&db)?;
+ db.close()?;
+ let reader = session(store, READER_LIMIT)?;
+ let mut compared = Vec::new();
+ for ((_, pairs), exact) in case.scopes.iter().zip(exact) {
+ let find = |tail: &str, question: &[f32]| statement(pairs, &[], tail, question);
+ let asked = ask(&reader, &find, &[EXACT], &dump.questions, 1)?;
+ let (mut same_ten, mut same_order, mut shared) = (0, 0, 0);
+ for (ours, theirs) in asked[0].answers.iter().zip(&exact.answers) {
+ let ids = |answer: &[(u128, f64)]| answer.iter().map(|row| row.0).collect::>();
+ let (ours, theirs) = (ids(ours), ids(theirs));
+ let found = theirs.iter().filter(|id| ours.contains(id)).count();
+ shared += found;
+ same_ten += usize::from(found == theirs.len() && ours.len() == theirs.len());
+ same_order += usize::from(ours == theirs);
+ }
+ compared.push((same_ten, same_order, shared));
}
- for (what, share) in [("p50", 0.5), ("p95", 0.95), ("max", 1.0)] {
- let rows = percentile(&all.examined, share);
+ let scopes = || {
+ case.scopes
+ .iter()
+ .map(|scope| scope.0.trim_end_matches('_'))
+ };
+ for (scope, (same_ten, _, _)) in scopes().zip(&compared) {
line(
- &name(&format!("find_all_rows_examined_{what}")),
- rows,
- "rows",
+ &case.name(&format!("sq8_same_ten_{scope}")),
+ same_ten,
+ "of_100",
);
}
+ for (scope, (_, same_order, _)) in scopes().zip(&compared) {
+ line(
+ &case.name(&format!("sq8_same_order_{scope}")),
+ same_order,
+ "of_100",
+ );
+ }
+ for (scope, (_, _, shared)) in scopes().zip(&compared) {
+ line(
+ &case.name(&format!("sq8_answers_shared_{scope}")),
+ shared,
+ "of_1000",
+ );
+ }
+ let bytes = support::bytes_on_disk(store)?;
+ let passages = case.loaded.len().max(1) as u64;
+ line(&case.name("sq8_store_size"), mebibytes(bytes), "MiB");
+ line(
+ &case.name("sq8_bytes_per_passage"),
+ bytes / passages,
+ "bytes",
+ );
+ line(&case.name("sq8_size_over_f32"), ratio(bytes, sealed), "x");
+ Ok(())
+}
- let mut compared = Vec::new();
- for (scope, finds) in SCOPES.iter().zip(&unlimited) {
- let recorded = answers.join(format!("engine-{scope}.jsonl"));
- compared.push(compare(&finds.answers, &recorded)?);
+/// Every store of a case, each a folder inside `folder`.
+fn measure(case: &Case<'_>, folder: &Path, extras: &str) -> Fallible<()> {
+ support::must_be_empty(folder);
+ std::fs::create_dir_all(folder)?;
+ if case.declared == " AUTO_INDEX_AT 1000" {
+ line(&case.name("auto_index_at"), 1000, "vectors");
}
- for (scope, (same_ten, _, _)) in SCOPES.iter().zip(&compared) {
- line(&name(&format!("same_ten_{scope}")), same_ten, "of_100");
+ let (held, sealed_bytes, exact) = sealed(case, &folder.join("sealed"))?;
+ built(case, folder, held, sealed_bytes)?;
+ if extras.split(',').any(|extra| extra == "sq8") {
+ reduced(case, &folder.join("sq8"), sealed_bytes, &exact)?;
}
- for (scope, (_, same_order, _)) in SCOPES.iter().zip(&compared) {
- line(&name(&format!("same_order_{scope}")), same_order, "of_100");
+ if extras.split(',').any(|extra| extra == "tail") {
+ tail(case, &folder.join("tail"))?;
}
- let gap = compared.iter().map(|(_, _, gap)| *gap).fold(0.0, f64::max);
- line(&name("distance_gap_max"), gap, "-");
- line(&name("load_peak_allocated"), mebibytes(load_peak), "MiB");
- let find_peak = support::take_peak_allocated();
- line(&name("find_peak_allocated"), mebibytes(find_peak), "MiB");
Ok(())
}
+
+fn main() -> Fallible<()> {
+ let arguments: Vec = std::env::args().skip(1).collect();
+ let arguments: Vec<&str> = arguments.iter().map(String::as_str).collect();
+ match arguments.as_slice() {
+ ["first", kind, dump, store] => {
+ let dump = read_dump(Path::new(dump))?;
+ let case = if *kind == "single" {
+ Case::single(&dump)
+ } else {
+ Case::store(&dump, None)
+ };
+ let (scope, pairs) = case.widest();
+ let first = format!("{}_auto_first", case.find(scope));
+ let search = statement(pairs, &[], AUTO, &dump.questions[0]);
+ support::first(Path::new(store), (&case.name("open"), &first), &search)
+ }
+ ["single", dump, folder] => {
+ let dump = read_dump(Path::new(dump))?;
+ measure(&Case::single(&dump), Path::new(folder), "")
+ }
+ [dump, answers, folder] | [dump, answers, folder, _] => {
+ let dump = read_dump(Path::new(dump))?;
+ let case = Case::store(&dump, Some(Path::new(answers)));
+ measure(&case, Path::new(folder), arguments.get(3).unwrap_or(&""))
+ }
+ _ => {
+ eprintln!(
+ "usage: passages [sq8][,tail]\n \
+ passages single "
+ );
+ std::process::exit(2);
+ }
+ }
+}
diff --git a/v2/contextdb/examples/support/mod.rs b/v2/contextdb/examples/support/mod.rs
index 19e57b98..f738f46a 100644
--- a/v2/contextdb/examples/support/mod.rs
+++ b/v2/contextdb/examples/support/mod.rs
@@ -1,12 +1,45 @@
//! What the measuring programs of this folder share: an allocator that counts, the one timer,
-//! the size of a folder, and the `name value unit` lines they print.
+//! the size of a folder, the `name value unit` lines they print, and the measuring of a
+//! similarity search under each mode beside what the search worked through.
+//!
+//! One thing here reads a store's data file itself and not through the library: [`parts`],
+//! which reads the vector part's partition, grouping, centre and sketch records with the
+//! storage library, because the library's inspection gives a grouping's group count and
+//! nothing of its gap or its width, and counts a search's work as one number. The layout it
+//! reads is the one `contextdb/src/store/keys.rs` and `contextdb/src/vector/` write; a change
+//! there changes it here, and a record it cannot read stops the program.
use std::alloc::{GlobalAlloc, Layout, System};
+use std::collections::HashMap;
+use std::ffi::OsStr;
use std::fmt::Display;
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
+use contextdb::{Database, QueryResult, ReadSession, ReadSessionOptions, Value};
+use redb::{ReadOnlyDatabase, ReadableDatabase, TableDefinition};
+
+pub type Fallible = Result>;
+
+/// The values a statement is run with, by name.
+pub type Values = HashMap;
+
+/// What makes the search statement of a case: given what follows `ORDER BY distance` and the
+/// vector searched for, the statement and its values.
+pub type Statement<'a> = &'a dyn Fn(&str, &[f32]) -> (String, Values);
+
+/// The memory limit a read session is opened under: what Context Graph gives a reader.
+pub const READER_LIMIT: usize = 1 << 30;
+
+/// Searches of each mode that are run and not timed before the timed ones.
+pub const UNTIMED: usize = 5;
+
+/// What follows `ORDER BY distance` under each mode, for the ten nearest.
+pub const EXACT: &str = " USE VECTOR EXACT LIMIT 10";
+pub const INDEXED: &str = " USE VECTOR INDEXED LIMIT 10";
+pub const AUTO: &str = " LIMIT 10";
+
/// The system allocator, counting the bytes handed out and not yet given back. A program makes
/// it its global allocator.
pub struct Counting;
@@ -103,3 +136,607 @@ pub fn must_be_empty(folder: &Path) {
std::process::exit(2);
}
}
+
+/// The sum of the numbers over how many there are, to one place.
+fn mean(numbers: impl Iterator) -> String {
+ let (mut sum, mut count) = (0_i64, 0_i64);
+ for number in numbers {
+ sum += number;
+ count += 1;
+ }
+ format!("{:.1}", sum as f64 / count.max(1) as f64)
+}
+
+// ---------------------------------------------------------------------------------------------
+// A store built for a case
+
+/// Runs maintenance cycles until one reports nothing remaining, and answers how many it ran:
+/// no vector waiting, and no partition with a grouping due or being built that is not stalled.
+pub fn upkeep(db: &Database) -> Fallible {
+ for cycle in 1..=10_000_000 {
+ let report = db.run_maintenance_cycle()?.vector;
+ if report.maintenance_vectors_remaining == 0
+ && report.remaining_partitions <= report.stalled_partitions
+ {
+ if report.stalled_partitions > 0 {
+ return Err("a partition's build is stalled for memory".into());
+ }
+ return Ok(cycle);
+ }
+ }
+ Err("ten million maintenance cycles left work remaining".into())
+}
+
+/// The first store of a case, in which no partition is ever grouped: runs its cycles, closes
+/// it and prints `seal`, `seal_cycles` and `store_size_sealed`. Answers the store's bytes.
+pub fn sealed(prefix: &str, db: &Database, store: &Path) -> Fallible {
+ let (cycles, time) = timed(|| upkeep(db));
+ let cycles = cycles?;
+ db.close()?;
+ let bytes = bytes_on_disk(store)?;
+ line(&format!("{prefix}seal"), seconds(time), "s");
+ line(&format!("{prefix}seal_cycles"), cycles, "cycles");
+ line(
+ &format!("{prefix}store_size_sealed"),
+ mebibytes(bytes),
+ "MiB",
+ );
+ Ok(bytes)
+}
+
+/// One whole number of the first row of an inspection statement, by the name of its column,
+/// or the sum of it over every row. NULL counts as nothing.
+fn inspected(found: &QueryResult, field: &str) -> Fallible {
+ let at = found.columns().iter().position(|name| name == field);
+ let at = at.ok_or("an inspection statement has no such field")?;
+ let mut sum = 0;
+ for row in found.rows() {
+ if let Value::Int64(number) = row[at] {
+ sum += number as u64;
+ }
+ }
+ Ok(sum)
+}
+
+/// The second store of a case, declared as the case says: runs cycles until nothing remains,
+/// closes it and prints `upkeep`, `upkeep_cycles`, `lists`, `probes`, the index's bytes, the
+/// store's size beside what was `held` and beside the first store's bytes, and the least and
+/// the most gap and width of its groupings. Answers its partitions as their records describe
+/// them.
+pub fn built(
+ prefix: &str,
+ db: &Database,
+ store: &Path,
+ (held, sealed, dimension): (u64, u64, usize),
+) -> Fallible> {
+ let (cycles, time) = timed(|| upkeep(db));
+ let cycles = cycles?;
+ let name = |what: &str| format!("{prefix}{what}");
+ line(&name("upkeep"), seconds(time), "s");
+ line(&name("upkeep_cycles"), cycles, "cycles");
+ let partitions = db.execute("SHOW VECTOR_PARTITIONS", &Values::new())?;
+ line(&name("lists"), inspected(&partitions, "lists")?, "groups");
+ line(&name("probes"), inspected(&partitions, "probes")?, "groups");
+ let indexes = db.execute("SHOW VECTOR_INDEXES", &Values::new())?;
+ let index_bytes = inspected(&indexes, "durable_index_bytes")?;
+ let vectors = inspected(&indexes, "vector_count")?;
+ line(&name("index_bytes"), index_bytes, "bytes");
+ let each = index_bytes / vectors.max(1);
+ line(&name("index_bytes_per_vector"), each, "bytes");
+ db.close()?;
+ let bytes = bytes_on_disk(store)?;
+ line(&name("store_size"), mebibytes(bytes), "MiB");
+ line(&name("size_over_held"), ratio(bytes, held), "x");
+ line(&name("size_over_sealed"), ratio(bytes, sealed), "x");
+ line(&name("bytes_per_passage"), bytes / vectors.max(1), "bytes");
+ let parts = parts(store, dimension)?;
+ let grouped = parts.iter().filter(|part| part.groups.is_some()).count();
+ line(&name("partitions"), parts.len(), "partitions");
+ line(&name("partitions_grouped"), grouped, "partitions");
+ measured(prefix, &parts.iter().collect::>());
+ Ok(parts)
+}
+
+/// Starts this program again with these arguments and waits for it: what a new process
+/// measures, it prints itself.
+pub fn in_new_process(arguments: &[&OsStr]) -> Fallible<()> {
+ let program = std::env::current_exe()?;
+ let status = std::process::Command::new(program)
+ .args(arguments)
+ .status()?;
+ if status.success() {
+ Ok(())
+ } else {
+ Err("the new process did not finish".into())
+ }
+}
+
+/// What a new process does first: opens a read session on a built store under the reader's
+/// limit and runs one search, and prints the time of each under these two names.
+pub fn first(store: &Path, names: (&str, &str), (sql, values): &(String, Values)) -> Fallible<()> {
+ let (opened, open) = timed(|| session(store, READER_LIMIT));
+ let opened = opened?;
+ let (found, find) = timed(|| opened.execute(sql, values));
+ found?;
+ line(names.0, milliseconds(open), "ms");
+ line(names.1, milliseconds(find), "ms");
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------------------------
+// Searches under each mode
+
+/// A read session on a store under a memory limit.
+pub fn session(store: &Path, limit: usize) -> Fallible {
+ let mut options = ReadSessionOptions::default();
+ options.memory_limit = Some(limit);
+ Ok(ReadSession::open_with_options(store, options)?)
+}
+
+/// What the questions gave under one mode: for each round the middle and the 95th percentile
+/// of the questions' times, and from the first round, for each question, the search's counted
+/// work and its answer (each row's id as a number, and its distance).
+#[derive(Default)]
+pub struct Asked {
+ pub p50: Vec,
+ pub p95: Vec,
+ pub evaluations: Vec,
+ pub examined: Vec,
+ pub answers: Vec>,
+}
+
+/// The id and the distance of every row of a search's answer: the id is its first column, a
+/// UUID or an INTEGER, and the distance its last.
+fn answer(found: &QueryResult) -> Fallible> {
+ let rows = found
+ .rows()
+ .iter()
+ .map(|row| match (row.first(), row.last()) {
+ (Some(Value::Uuid(id)), Some(Value::Float64(distance))) => {
+ Ok((id.as_u128(), *distance))
+ }
+ (Some(Value::Int64(id)), Some(Value::Float64(distance))) => {
+ Ok((*id as u128, *distance))
+ }
+ _ => Err("a search row is not an id and a distance".into()),
+ });
+ rows.collect()
+}
+
+/// Asks every question under each of `tails` (what follows `ORDER BY distance`: a mode and a
+/// limit), in one session: first [`UNTIMED`] searches under each that are not timed, then
+/// `rounds` rounds, and within each round every question under the first tail, then under
+/// the next. Each `execute` call is timed whole.
+pub fn ask(
+ session: &ReadSession,
+ statement: Statement<'_>,
+ tails: &[&str],
+ questions: &[Vec],
+ rounds: usize,
+) -> Fallible> {
+ let search = |tail: &str, question: &[f32]| {
+ let (sql, values) = statement(tail, question);
+ timed(|| session.execute(&sql, &values))
+ };
+ for tail in tails {
+ for question in questions.iter().take(UNTIMED) {
+ search(tail, question).0?;
+ }
+ }
+ let mut asked: Vec = tails.iter().map(|_| Asked::default()).collect();
+ for round in 0..rounds {
+ for (tail, asked) in tails.iter().zip(&mut asked) {
+ let mut times = Vec::with_capacity(questions.len());
+ for question in questions {
+ let (found, time) = search(tail, question);
+ let found = found?;
+ times.push(time);
+ if round == 0 {
+ asked.evaluations.push(found.distance_evaluations());
+ asked.examined.push(found.rows_examined());
+ asked.answers.push(answer(&found)?);
+ }
+ }
+ asked.p50.push(percentile(×, 0.5));
+ asked.p95.push(percentile(×, 0.95));
+ }
+ }
+ Ok(asked)
+}
+
+/// Prints the memory limit and the count of threads the lines after it were taken under.
+pub fn setting(name: &str, limit: usize) {
+ let value = format!("memory_limit_{limit}_bytes_threads_1");
+ line(&format!("{name}_session"), value, "-");
+}
+
+/// Prints `_p50` and `_p95`, each the middle of the rounds' values, and with more rounds than
+/// one `_p95_spread`, the largest of the rounds' 95th percentiles less the smallest.
+pub fn times(name: &str, asked: &Asked) {
+ let p50 = milliseconds(percentile(&asked.p50, 0.5));
+ line(&format!("{name}_p50"), p50, "ms");
+ let p95 = milliseconds(percentile(&asked.p95, 0.5));
+ line(&format!("{name}_p95"), p95, "ms");
+ if asked.p95.len() > 1 {
+ let spread = percentile(&asked.p95, 1.0) - percentile(&asked.p95, 0.0);
+ line(&format!("{name}_p95_spread"), milliseconds(spread), "ms");
+ }
+}
+
+/// Prints `_distance_evaluations_mean` and `_rows_examined_p50`.
+pub fn counted(name: &str, asked: &Asked) {
+ let evaluations = mean(asked.evaluations.iter().map(|count| *count as i64));
+ let name = |what: &str| format!("{name}_{what}");
+ line(
+ &name("distance_evaluations_mean"),
+ evaluations,
+ "evaluations",
+ );
+ let rows = percentile(&asked.examined, 0.5);
+ line(&name("rows_examined_p50"), rows, "rows");
+}
+
+/// Prints `_recall` (the share of `exact`'s answers, over all questions, that this mode
+/// returned), `_all_ten` (questions whose answer holds every row of `exact`'s) and
+/// `_not_fewer_evaluations` (questions on which it did as many evaluations as `exact` or
+/// more).
+pub fn against(name: &str, asked: &Asked, exact: &Asked) {
+ let (mut shared, mut wanted, mut all) = (0, 0, 0);
+ for (ours, theirs) in asked.answers.iter().zip(&exact.answers) {
+ let found = theirs
+ .iter()
+ .filter(|(id, _)| ours.iter().any(|row| row.0 == *id));
+ let found = found.count();
+ shared += found;
+ wanted += theirs.len();
+ all += usize::from(found == theirs.len());
+ }
+ let recall = format!("{:.4}", shared as f64 / wanted.max(1) as f64);
+ let of = format!("of_{}", exact.answers.len());
+ let pairs = asked.evaluations.iter().zip(&exact.evaluations);
+ let not_fewer = pairs.filter(|(ours, theirs)| ours >= theirs).count();
+ line(&format!("{name}_recall"), recall, "-");
+ line(&format!("{name}_all_ten"), all, &of);
+ line(&format!("{name}_not_fewer_evaluations"), not_fewer, &of);
+}
+
+/// Asks the questions under EXACT, INDEXED and AUTO in turn within each round, in one session
+/// under the reader's limit, and prints every line of the three for `name`: the times, the
+/// counted work, the two indexed modes against EXACT of this run, and under INDEXED what the
+/// price is read from ([`work`]). `parts` are the partitions the statement selects. Answers
+/// what the three gave, in that order.
+pub fn modes(
+ name: &str,
+ session: &ReadSession,
+ statement: Statement<'_>,
+ questions: &[Vec],
+ (rounds, parts): (usize, &[&Part]),
+) -> Fallible> {
+ let asked = ask(
+ session,
+ statement,
+ &[EXACT, INDEXED, AUTO],
+ questions,
+ rounds,
+ )?;
+ setting(name, READER_LIMIT);
+ for (mode, of) in ["exact", "indexed", "auto"].iter().zip(&asked) {
+ let name = format!("{name}_{mode}");
+ times(&name, of);
+ counted(&name, of);
+ if *mode != "exact" {
+ against(&name, of, &asked[0]);
+ }
+ if *mode == "indexed" {
+ work(&name, of, parts, questions);
+ }
+ }
+ Ok(asked)
+}
+
+/// How many of these stored vectors come back first when each is searched for by itself,
+/// under INDEXED and beside it under EXACT, with `LIMIT 1` and with `LIMIT 10`. Under EXACT a
+/// vector that another row holds too may come back second.
+pub fn own(
+ name: &str,
+ session: &ReadSession,
+ statement: Statement<'_>,
+ stored: &[(u128, Vec)],
+) -> Fallible<()> {
+ setting(name, READER_LIMIT);
+ for (mode, clause) in [("", "INDEXED"), ("_exact", "EXACT")] {
+ for limit in [1, 10] {
+ let tail = format!(" USE VECTOR {clause} LIMIT {limit}");
+ let mut found = 0;
+ for (id, vector) in stored {
+ let (sql, values) = statement(&tail, vector);
+ let rows = answer(&session.execute(&sql, &values)?)?;
+ found += usize::from(rows.first().is_some_and(|row| row.0 == *id));
+ }
+ let name = format!("{name}{mode}_limit_{limit}");
+ line(&name, found, &format!("of_{}", stored.len()));
+ }
+ }
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------------------------
+// What a search's price is read from: the groupings as their records hold them
+
+const VECTORS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("vectors");
+const BLOCKS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("vector_blocks");
+
+/// One partition of a VECTOR column as its records describe it.
+pub struct Part {
+ /// Its key tuple as the store writes it: for each value a tag and the value's bytes.
+ tuple: Vec,
+ /// The slots of its grouping and of its tail together.
+ slots: u64,
+ /// The slots of its tail.
+ tail: u64,
+ /// Its grouping, when it has one of more groups than one.
+ groups: Option,
+}
+
+struct Groups {
+ /// How far a search scans, in ten-thousandths of a product.
+ gap: u32,
+ /// How wide a search takes a sketch, in quarters of its factor; 0 when it reads none.
+ width: u32,
+ /// Every centre, one after another.
+ centres: Vec,
+ /// The slots of each group.
+ sizes: Vec,
+}
+
+fn short(bytes: &[u8], at: usize) -> u32 {
+ u32::from_le_bytes(std::array::from_fn(|byte| bytes[at + byte]))
+}
+
+fn long(bytes: &[u8], at: usize) -> u64 {
+ u64::from_le_bytes(std::array::from_fn(|byte| bytes[at + byte]))
+}
+
+/// The partitions of the one VECTOR column of a closed store, read from its data file.
+pub fn parts(store: &Path, dimension: usize) -> Fallible> {
+ let data = ReadOnlyDatabase::open(store.join("data"))?;
+ let read = data.begin_read()?;
+ let (vectors, blocks) = (read.open_table(VECTORS)?, read.open_table(BLOCKS)?);
+ let mut parts = Vec::new();
+ // A partition's record is kept under the byte 1, its column and its key tuple.
+ for record in vectors.range::<&[u8]>(&[1_u8][..]..&[2_u8][..])? {
+ let (key, entry) = record?;
+ let (key, entry) = (key.value(), entry.value());
+ if entry.len() != 73 || key.len() < 5 {
+ return Err("a partition record is not as this program reads it".into());
+ }
+ let (tail, flags) = (long(entry, 24), entry[40]);
+ let (number, blocks_held, groups) = (short(entry, 41), short(entry, 45), short(entry, 49));
+ let grouped = if flags & 1 == 0 { 0 } else { long(entry, 53) };
+ let mut part = Part {
+ tuple: key[5..].to_vec(),
+ slots: grouped + tail,
+ tail,
+ groups: None,
+ };
+ if flags & 1 != 0 && groups > 1 {
+ // The grouping's record: the gap, the width, the probes, then where each group
+ // begins and where the last ends, a block and a slot each.
+ let under = [&key[1..5], &number.to_be_bytes()[..]].concat();
+ let record = vectors.get([&[3_u8][..], &under].concat().as_slice())?;
+ let record = record.ok_or("a grouping has no record")?;
+ let record = record.value();
+ if record.len() != 20 + 8 * groups as usize {
+ return Err("a grouping record is not as this program reads it".into());
+ }
+ // How many slots lie before each block: a block's count opens its sketch record.
+ let mut before = vec![0_u64];
+ for block in 0..blocks_held {
+ let key = [&[7_u8][..], &under, &block.to_be_bytes()].concat();
+ let sketches = blocks.get(key.as_slice())?;
+ let sketches = sketches.ok_or("a block of a grouping has no sketches")?;
+ before.push(before[block as usize] + u64::from(short(sketches.value(), 0)));
+ }
+ let place = |group: usize| {
+ let (block, slot) = (short(record, 12 + 8 * group), short(record, 16 + 8 * group));
+ before
+ .get(block as usize)
+ .map(|slots| slots + u64::from(slot))
+ };
+ let mut sizes = Vec::with_capacity(groups as usize);
+ for group in 0..groups as usize {
+ let (Some(from), Some(to)) = (place(group), place(group + 1)) else {
+ return Err("a grouping's places are past its blocks".into());
+ };
+ sizes.push(to - from);
+ }
+ let mut centres = Vec::with_capacity(groups as usize * dimension);
+ let from = [&[4_u8][..], &under].concat();
+ let to = [&[4_u8][..], &key[1..5], &(number + 1).to_be_bytes()].concat();
+ for record in blocks.range::<&[u8]>(from.as_slice()..to.as_slice())? {
+ let (_, value) = record?;
+ let (numbers, _) = value.value().as_chunks::<4>();
+ centres.extend(numbers.iter().map(|number| f32::from_le_bytes(*number)));
+ }
+ if centres.len() != groups as usize * dimension {
+ return Err("a grouping's centres are not as many as its groups".into());
+ }
+ part.groups = Some(Groups {
+ gap: short(record, 0),
+ width: short(record, 4),
+ centres,
+ sizes,
+ });
+ }
+ parts.push(part);
+ }
+ Ok(parts)
+}
+
+/// Those of `parts` whose key tuple is one of `tuples`, or every one when none is given.
+pub fn selected<'a>(parts: &'a [Part], tuples: &[Vec]) -> Vec<&'a Part> {
+ let wanted = |part: &&Part| tuples.is_empty() || tuples.contains(&part.tuple);
+ parts.iter().filter(wanted).collect()
+}
+
+/// Prints the least and the most gap and width among the groupings of these partitions: the
+/// gap as a difference of products, the width as a multiple of a sketch's factor. `none` when
+/// no partition of them has a grouping of more groups than one.
+fn measured(name: &str, parts: &[&Part]) {
+ let groupings = || parts.iter().filter_map(|part| part.groups.as_ref());
+ let gaps = groupings().map(|groups| groups.gap);
+ let widths = groupings().map(|groups| groups.width);
+ let name = |what: &str| format!("{name}{what}");
+ let (Some(gap_least), Some(gap_most)) = (gaps.clone().min(), gaps.max()) else {
+ for what in ["gap_least", "gap_most", "width_least", "width_most"] {
+ line(&name(what), "none", "-");
+ }
+ return;
+ };
+ let gap = |gap: u32| format!("{:.4}", f64::from(gap) / 10_000.0);
+ line(&name("gap_least"), gap(gap_least), "product");
+ line(&name("gap_most"), gap(gap_most), "product");
+ let width = |width: Option| format!("{:.2}", f64::from(width.unwrap_or(0)) / 4.0);
+ line(&name("width_least"), width(widths.clone().min()), "factors");
+ line(&name("width_most"), width(widths.max()), "factors");
+}
+
+/// The numbers of one vector multiplied by another's and added up as the library adds them:
+/// in 32 bits, sixteen sums side by side.
+fn product(one: &[f32], other: &[f32]) -> f32 {
+ let mut lanes = [0.0_f32; 16];
+ let (one_runs, one_rest) = one.as_chunks::<16>();
+ let (other_runs, other_rest) = other.as_chunks::<16>();
+ for (one, other) in one_runs.iter().zip(other_runs) {
+ for ((lane, one), other) in lanes.iter_mut().zip(one).zip(other) {
+ *lane += one * other;
+ }
+ }
+ for ((lane, one), other) in lanes.iter_mut().zip(one_rest).zip(other_rest) {
+ *lane += one * other;
+ }
+ lanes.iter().sum()
+}
+
+/// What an unfiltered search under INDEXED does with these partitions for one question, worked
+/// out from their records as `vector/search.rs` works it out: the centres and means it
+/// compares, the groups inside the gap, the slots of those groups and of the tails beside
+/// them, how many of those slots it estimates a sketch of, and the slots it screens with no
+/// sketch read (a partition with no groups, and a grouping of width 0).
+#[derive(Default)]
+struct Reached {
+ centres: i64,
+ groups: i64,
+ slots: i64,
+ sketches: i64,
+ unsketched: i64,
+}
+
+fn reached(parts: &[&Part], question: &[f32]) -> Reached {
+ let length = question.iter().map(|x| f64::from(*x) * f64::from(*x));
+ let length = length.sum::().sqrt();
+ let unit = question.iter().map(|x| (f64::from(*x) / length) as f32);
+ let unit: Vec = unit.collect();
+ let mut reached = Reached::default();
+ for part in parts {
+ let Some(groups) = &part.groups else {
+ reached.unsketched += part.slots as i64;
+ continue;
+ };
+ let centres = groups.centres.chunks(unit.len());
+ let products: Vec = centres.map(|centre| product(centre, &unit)).collect();
+ let best = products.iter().copied().fold(f32::MIN, f32::max);
+ let apart =
+ |product: f32| ((f64::from(best) - f64::from(product)) * 10_000.0).min(20_000.0);
+ let mut slots = part.tail as i64;
+ for (size, product) in groups.sizes.iter().zip(&products) {
+ if apart(*product) - f64::from(groups.gap) <= 0.0 {
+ reached.groups += 1;
+ slots += *size as i64;
+ }
+ }
+ reached.centres += products.len() as i64;
+ reached.slots += slots;
+ if groups.width == 0 {
+ reached.unsketched += slots;
+ } else {
+ // The partition's mean is compared too.
+ reached.centres += 1;
+ reached.sketches += slots;
+ }
+ }
+ reached
+}
+
+/// Prints, for the unfiltered searches of `asked` under INDEXED over `parts`, what their price
+/// is read from: the least and the most gap and width of the groupings, and at the mean
+/// question the passes a search made, the centres compared, the groups inside the gap, the
+/// slots of those groups and of the tails, the sketches estimated, the slots screened and the
+/// rows asked for.
+///
+/// The library counts one number a search, `distance_evaluations`: centres and means compared,
+/// sketches estimated, slots screened and rows asked for. The rows asked for are the rows the
+/// statement examined less the rows it returned. The rest is worked out here. A search makes
+/// one pass, and another whenever the last row of its answer lies below what its sketches
+/// ended with; every pass compares the centres and estimates the sketches of the groups
+/// inside the gap again, and a slot is screened once. So with `c` centres and `s` sketches a
+/// pass ([`reached`]), a search of `p` passes counts from `p (c + s)` up to `p (c + s) + s`,
+/// ranges that do not meet: the passes are the count over `c + s`, rounded down, and the slots
+/// screened are what is left. `_work_out_of_range` counts the questions on which what is left
+/// is fewer than the slots screened with no sketch or more than those and every sketched
+/// slot: there a later pass took in more groups than the first, or this program and the
+/// library disagree about a group at the gap's edge, and the three numbers are not exact.
+pub fn work(name: &str, asked: &Asked, parts: &[&Part], questions: &[Vec]) {
+ measured(&format!("{name}_"), parts);
+ let mut reaches = Vec::with_capacity(questions.len());
+ let (mut asked_for, mut screened, mut passes) = (Vec::new(), Vec::new(), Vec::new());
+ let (mut out, mut again) = (0, 0);
+ for (at, question) in questions.iter().enumerate() {
+ let mut reached = reached(parts, question);
+ let rows = asked.examined[at] as i64 - asked.answers[at].len() as i64;
+ let counted = asked.evaluations[at] as i64 - rows;
+ let a_pass = reached.centres + reached.sketches;
+ let made = match reached.sketches {
+ 0 => 1,
+ _ => (counted / (a_pass + reached.unsketched)).max(1),
+ };
+ let left = counted - made * a_pass;
+ let most = reached.unsketched + reached.sketches;
+ out += usize::from(left < reached.unsketched || left > most);
+ again += usize::from(made > 1);
+ reached.centres *= made;
+ reached.sketches *= made;
+ asked_for.push(rows);
+ screened.push(left);
+ passes.push(made);
+ reaches.push(reached);
+ }
+ let of = |what: fn(&Reached) -> i64| mean(reaches.iter().map(what));
+ let name = |what: &str| format!("{name}_{what}");
+ let hundred = format!("of_{}", questions.len());
+ line(&name("passes_mean"), mean(passes.into_iter()), "passes");
+ line(&name("searches_of_more_passes"), again, &hundred);
+ line(&name("centres_compared_mean"), of(|r| r.centres), "centres");
+ line(&name("groups_scanned_mean"), of(|r| r.groups), "groups");
+ line(
+ &name("slots_in_scanned_groups_mean"),
+ of(|r| r.slots),
+ "slots",
+ );
+ line(
+ &name("sketches_estimated_mean"),
+ of(|r| r.sketches),
+ "sketches",
+ );
+ line(
+ &name("slots_screened_mean"),
+ mean(screened.into_iter()),
+ "slots",
+ );
+ line(
+ &name("rows_asked_mean"),
+ mean(asked_for.into_iter()),
+ "rows",
+ );
+ line(&name("work_out_of_range"), out, &hundred);
+}
diff --git a/v2/contextdb/src/database/mod.rs b/v2/contextdb/src/database/mod.rs
index 939ed370..b21f800e 100644
--- a/v2/contextdb/src/database/mod.rs
+++ b/v2/contextdb/src/database/mod.rs
@@ -1,8 +1,9 @@
//! database: the handle a store is opened for writing with, its transactions, and the one
//! commit function.
//!
-//! It never has a second way to commit: a statement run on its own, a transaction and a change
-//! of the memory limit each build a change set and hand it to the same function.
+//! It never has a second way to commit: a statement run on its own, a transaction, a change of
+//! the memory limit and every step of a maintenance cycle each build a change set and hand it
+//! to the same function.
#[cfg(test)]
mod tests;
@@ -496,38 +497,99 @@ impl Database {
locked(&self.must_be_open().newest).commit
}
+ /// What a statement would do as of the newest commit, without running it, as text of one
+ /// `name: value` to a line. For a similarity search it names the mode asked for and the one
+ /// in force, and for each partition the filter selects how it is searched: every vector of
+ /// it, or the groups of vectors nearest the one searched for. Nothing is read but the
+ /// store's small records, and nothing changes. A transaction has no such call.
+ ///
+ /// # Errors
+ ///
+ /// Whatever refuses the statement before it reads a row, by its own kind;
+ /// [`crate::Error::StoreIo`] or [`crate::Error::StoreCorrupted`] when the store cannot be
+ /// read; [`crate::Error::DatabaseClosed`] on a handle that was closed.
+ pub fn explain(&self, sql: &str) -> Result {
+ let plan = planned(sql)?;
+ let open = self.opened()?;
+ let (view, catalog) = open.newest()?;
+ let now = system::now_millis(&*open.clock);
+ query::explain(&plan, &catalog, &view, &open.budget, now)
+ }
+
/// Runs one cycle of upkeep and returns what it did.
///
- /// A cycle moves at most 4,096 vectors from the pending lists of VECTOR columns into blocks,
- /// in one commit, and reports how many it moved and how many still wait. Searches give the
- /// same rows before and after a cycle; they read fewer rows after it, because a vector in a
- /// block is ruled in or out from its one-byte copy and a pending vector is read from its
- /// row. A store with nothing pending is left as it is, and no commit is made.
+ /// A cycle first moves at most 4,096 vectors from the pending lists of VECTOR columns into
+ /// blocks, in one commit. Then every partition that has a grouping due or being built is
+ /// advanced by one step, each step in a commit of its own: a build begins, moves its centres
+ /// once over a sample of at most 64 vectors a group, gives the vectors of whole blocks their
+ /// groups in at most 4,194,304 comparisons, packs whole groups of at most 4,096 vectors
+ /// together into new blocks, measures at most 4,194,304 vectors or sketches against its
+ /// questions, or puts the new grouping in force. A step that goes past its bound does so
+ /// by one block, one group or one question. A store with nothing to do is left as it is,
+ /// and no commit is made.
///
- /// Under a memory limit with no room for all of that, a cycle seals the partitions it has
- /// room for, at least one, and the next cycle goes on from there.
+ /// Searches give the same rows before and after a cycle; they read fewer rows after it.
+ ///
+ /// Under a memory limit with no room to seal everything, a cycle seals the partitions it has
+ /// room for, at least one. A step of a build that has no room is not an error: the cycle
+ /// records the bytes the step needed, reports the partition as stalled, and goes on.
///
/// Nothing in this version calls a cycle on its own: a host that searches a VECTOR column
- /// calls this after it has written, and again while
- /// `vector.maintenance_vectors_remaining` is not zero. Other calls on the handle wait for
- /// the cycle's commit as they wait for any other commit; readers do not wait.
+ /// calls this after it has written, and again as [`crate::VectorMaintenanceReport`] says.
+ /// A step is worked out with no lock held, so other calls on the handle wait only for a
+ /// step's commit, as they wait for any other commit, and never for its arithmetic; readers
+ /// do not wait.
///
/// # Errors
///
/// [`crate::Error::MemoryBudgetExceeded`] when the memory limit has no room to seal the
/// pending vectors of one partition: a call holds one block's bytes (256 KiB less 64) for its
/// whole run, the keys of the pending vectors it collected (at most 4,096, each key's bytes
- /// plus 24), the partition's record, and one row with its vector at a time;
- /// [`crate::Error::StoreIo`] or
- /// [`crate::Error::StoreCorrupted`] when the store cannot be read or written;
- /// [`crate::Error::DatabaseClosed`] on a handle that was closed. In each case nothing was
- /// sealed and every vector is still found by a search.
+ /// plus 24), the partition's record, and one row with its vector at a time; then nothing
+ /// was sealed. [`crate::Error::StoreIo`] or [`crate::Error::StoreCorrupted`] when the store
+ /// cannot be read or written; [`crate::Error::DatabaseClosed`] on a handle that was closed.
+ /// The commits made before the error stay, and every vector is still found by a search.
pub fn run_maintenance_cycle(&self) -> Result {
let open = self.opened()?;
- let held = locked(&open.committing);
- let mut staged = open.begin()?;
- let report = upkeep::cycle(&mut staged.changes, &staged.catalog, &open.budget)?;
- open.commit(&held, staged)?;
+ let mut report = {
+ let held = locked(&open.committing);
+ let mut staged = open.begin()?;
+ let report = upkeep::cycle(&mut staged.changes, &staged.catalog, &open.budget)?;
+ open.commit(&held, staged)?;
+ report
+ };
+ let (view, catalog) = open.newest()?;
+ let mut listed = open.budget.reserve(0, "maintenance")?;
+ for work in vector::waiting(&view, &catalog, &mut listed)? {
+ // Worked out over the newest commit with no lock held; staged under the commit
+ // lock, where the vector part checks that the build has not moved since.
+ let (view, catalog) = open.newest()?;
+ let step = vector::prepare(&work, &view, &catalog, &open.budget);
+ let held = locked(&open.committing);
+ let mut staged = open.begin()?;
+ let done = step.and_then(|step| match step {
+ Some(step) => vector::stage_step(step, &mut staged.changes),
+ None => Ok(0),
+ });
+ match done {
+ Ok(done) => report.vector.maintenance_vectors_done += done,
+ // A step that does not fit, worked out or staged, is recorded in a change set
+ // of its own and is no error of the cycle.
+ Err(Error::MemoryBudgetExceeded {
+ requested_bytes, ..
+ }) => {
+ staged = open.begin()?;
+ vector::stage_refusal(&work, requested_bytes, &mut staged.changes)?;
+ }
+ Err(other) => return Err(other),
+ }
+ open.commit(&held, staged)?;
+ }
+ let (view, catalog) = open.newest()?;
+ let left = vector::remaining(&view, &catalog)?;
+ report.vector.maintenance_vectors_remaining += left.vectors;
+ report.vector.remaining_partitions = left.partitions;
+ report.vector.stalled_partitions = left.stalled;
Ok(report)
}
diff --git a/v2/contextdb/src/query/inspect.rs b/v2/contextdb/src/query/inspect.rs
new file mode 100644
index 00000000..5f12b0bb
--- /dev/null
+++ b/v2/contextdb/src/query/inspect.rs
@@ -0,0 +1,423 @@
+//! Inspection of VECTOR columns: what `SHOW VECTOR_INDEXES` and `SHOW VECTOR_PARTITIONS` return
+//! and what explaining a similarity search says. All three read the vector part's small records
+//! and no block, no centre and no row, and none changes anything.
+
+use std::cell::Cell;
+use std::collections::HashMap;
+
+use serde_json::json;
+
+use super::QueryResult;
+use super::search::Scope;
+use crate::memory::Budget;
+use crate::schema::{Catalog, Consolidation, Quantization, Table, VectorColumn};
+use crate::sql::{Plan, not_supported};
+use crate::store::{Key, Read, VectorNumber};
+use crate::types::{Error, Result, Value, VectorIndexRef, VectorSearchMode};
+use crate::vector::{self, Inspected, Nearest, Route, State};
+
+const INDEX_FIELDS: [&str; 31] = [
+ "table",
+ "column",
+ "dimension",
+ "quantization",
+ "vector_count",
+ "bytes",
+ "partition_key_columns",
+ "max_partitions",
+ "live_partitions",
+ "search_mode",
+ "declared_auto_index_at",
+ "effective_auto_index_at",
+ "declared_consolidation_mode",
+ "declared_consolidation_change_percent",
+ "declared_consolidation_tombstone_percent",
+ "effective_consolidation_mode",
+ "effective_consolidation_change_percent",
+ "effective_consolidation_tombstone_percent",
+ "pending_inserts",
+ "tombstones",
+ "durable_vector_bytes",
+ "durable_index_bytes",
+ "indexed_available",
+ "maintenance_state",
+ "maintenance_vectors_total",
+ "maintenance_vectors_done",
+ "maintenance_vectors_remaining",
+ "unavailable_partitions",
+ "stalled_partitions",
+ "broad_route",
+ "recovery_action",
+];
+
+const PARTITION_FIELDS: [&str; 18] = [
+ "table",
+ "column",
+ "partition_key",
+ "live_rows",
+ "pending_inserts",
+ "tombstones",
+ "durable_vector_bytes",
+ "durable_index_bytes",
+ "indexed_available",
+ "availability_reason",
+ "maintenance_state",
+ "maintenance_reason",
+ "maintenance_vectors_total",
+ "maintenance_vectors_done",
+ "maintenance_vectors_remaining",
+ "lists",
+ "probes",
+ "recovery_action",
+];
+
+/// Answers `SHOW VECTOR_INDEXES` and `SHOW VECTOR_PARTITIONS`; `None` for any other plan.
+///
+/// `SHOW VECTOR_INDEXES` gives one row for each VECTOR column of the store, in the order of
+/// table name and column; `SHOW VECTOR_PARTITIONS` one row for each partition, of the column
+/// after `FOR` or of every column, in the order of table, column and key tuple, after `OFFSET`
+/// rows and at most `LIMIT`.
+pub(super) fn show(
+ plan: &Plan,
+ catalog: &Catalog,
+ read: &dyn Read,
+ budget: &Budget,
+) -> Result