Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ce46925
feat(kividb): filter multi-valued labels on KiviDB >= 1.0.5; CI on v1…
claude Oct 4, 2026
5fe5fe9
Merge origin/master into kividb-1.0.5-multivalued
fcostaoliveira Oct 29, 2026
96ddb9d
fix(kividb): declare every TAG `SEPARATOR ;` on KiviDB >= 1.0.5
fcostaoliveira Oct 29, 2026
85d2401
ci(kividb): keep pre-1.0.5 KiviDB live-tested; README re-measurement
fcostaoliveira Oct 29, 2026
5d77967
refactor(kividb_caps): drop a digit guard the build split made unreac…
fcostaoliveira Oct 29, 2026
58030e4
style: cargo fmt
fcostaoliveira Oct 29, 2026
acff404
style(tests): clippy manual_is_multiple_of in the comma fixture
fcostaoliveira Oct 29, 2026
efbe527
docs(kividb): create_index comment: the TAG declaration matches redis…
fcostaoliveira Oct 29, 2026
1b6138d
fix(kividb): refuse a reused index whose TAGs a >= 1.0.5 server split…
fcostaoliveira Oct 29, 2026
7f7c268
docs(kividb): measured shipped-data exposure for the TAG separator
fcostaoliveira Oct 29, 2026
bc48753
refactor(kividb): one TAG-type predicate for create_index, validation…
fcostaoliveira Oct 29, 2026
945da24
docs(kividb): the `;` exposure check covers the over-match direction too
fcostaoliveira Oct 29, 2026
1d14b41
fix(kividb): encode TAG filter values per server version
fcostaoliveira Oct 29, 2026
51e1b49
docs+test(kividb): correct the emitter docs; #375 review follow-ups
fcostaoliveira Oct 29, 2026
82c40c5
Merge remote-tracking branch 'origin/master' into kividb-1.0.5-multiv…
fcostaoliveira Oct 29, 2026
b7dda91
kividb: test and support KiviDB 1.1.2, the current stable release
murtazayusuf Oct 29, 2026
8aad89c
fix(kividb): TAG value encoding in the library; exact-count live fixt…
fcostaoliveira Oct 29, 2026
ac2b1f6
tmp: MUTATION - TAG escaping reverted (throwaway, do not merge)
fcostaoliveira Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@ help:
@echo " make integration-test-mongodb - MongoDB Atlas Local 8.0.4"
@echo " make integration-test-valkey - Valkey Bundle (latest)"
@echo " make integration-test-dragonfly - Dragonfly"
@echo " make integration-test-kividb - KiviDB"
@echo " make integration-test-kividb - KiviDB 1.1.2"
@echo " make integration-test-chroma - Chroma"
@echo " make integration-test-vertex - Vertex AI (cloud-only, needs GCP creds)"
@echo ""
Expand Down
62 changes: 53 additions & 9 deletions README.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/bin/vector_db_benchmark/engine/filter_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,11 @@ fn engines() -> Vec<(&'static str, EngineResolver)> {
// "produced nothing" before resolving, and so does this.
("kividb", |c, _| {
through("KiviDB", c, |v| {
let rendered = super::kividb::kividb_filter::parse_conditions(v)?;
// A server TAG fields are supported on (KiviDB >= 1.1.2, the
// CI pin). What this matrix checks — a filter produced, with
// every leaf kept — does not depend on how a TAG value is
// encoded; the encoding is pinned in kividb_filter's own tests.
let rendered = super::kividb::kividb_filter::parse_conditions(v, true)?;
Ok((rendered != super::kividb::kividb_filter::MATCH_ALL).then_some(rendered))
})
}),
Expand Down
838 changes: 579 additions & 259 deletions src/bin/vector_db_benchmark/engine/kividb.rs

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions src/indexing_wait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ impl FtInfoProgress {
}
}

fn value_text(v: &redis::Value) -> Option<String> {
pub(crate) fn value_text(v: &redis::Value) -> Option<String> {
match v {
redis::Value::BulkString(b) => Some(String::from_utf8_lossy(b).into_owned()),
redis::Value::SimpleString(s) => Some(s.clone()),
Expand Down Expand Up @@ -239,7 +239,7 @@ pub fn parse_ft_info(info: &redis::Value) -> Result<FtInfoProgress, String> {
}

/// Top-level (field, value) pairs of an `FT.INFO` reply, RESP2 or RESP3.
fn ft_info_pairs(info: &redis::Value) -> Result<Vec<(String, &redis::Value)>, String> {
pub(crate) fn ft_info_pairs(info: &redis::Value) -> Result<Vec<(String, &redis::Value)>, String> {
match info {
redis::Value::Array(a) => Ok(a
.chunks(2)
Expand Down Expand Up @@ -354,8 +354,9 @@ pub fn kividb_ready(algorithm: &str, p: &KividbProgress, expected: u64) -> Resul
Ok(match p.num_docs.or(p.hnsw_live_count) {
Some(n) => n >= expected,
// Verified against KiviDB source (kividbio/kividb src/commands/search.rs,
// v1.1.0 and main: `hnsw_*` only for HNSW fields) and CI on the pinned
// quay.io/kividbio/kividb:v1.0.2-full image (#361, d682fb6).
// v1.1.0 and main: `hnsw_*` only for HNSW fields) and in CI (#361,
// d682fb6). KiviDB 1.1.2 reports `num_docs` (measured), so this arm is
// only reached by a server that reports neither.
None => true,
})
} else {
Expand Down
767 changes: 767 additions & 0 deletions src/kividb_caps.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! - `results_dump` - per-query result records and the `--dump-results` format
//! - `compare_results` - scores one engine's dumps against a declared reference
//! - `config` - Redis configuration from environment
//! - `kividb_caps` - version-gated KiviDB server capabilities
//! - `redis_client` - Redis connection management

pub mod build_info;
Expand All @@ -21,6 +22,7 @@ pub mod fulltext;
pub mod hybrid;
pub mod indexing_wait;
pub mod ingest;
pub mod kividb_caps;
pub mod metrics;
pub mod msmarco;
pub mod opensearch_maintenance;
Expand Down
246 changes: 243 additions & 3 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,233 @@ fn write_filter_project_multi(
}
}

/// Scalar keyword values that CONTAIN A COMMA: field `author` (schema type
/// `keyword`) holds `Smith, John` (id % 3 == 0), `Smith` (== 1) or `John`
/// (== 2). Even queries filter `author == "Smith, John"`, odd ones
/// `author == "Smith"`.
///
/// Guards the TAG declaration, not the filter syntax: an engine whose index
/// splits a scalar on `,` (a bare RediSearch-style TAG, whose default
/// separator is `,`, as is KiviDB's) indexes `Smith, John` as `Smith` +
/// `John`, so the even queries match nothing and the odd ones also match every
/// `Smith, John` document. Either way recall collapses.
pub fn write_comma_keyword_project(
dataset_name: &str,
engine_configs_json: &str,
dim: usize,
) -> FilterProject {
let author = |id: usize| ["Smith, John", "Smith", "John"][id % 3];
let wanted = |q: usize| {
if q.is_multiple_of(2) {
"Smith, John"
} else {
"Smith"
}
};
write_filter_project_multi(
dataset_name,
engine_configs_json,
dim,
GtMetric::L2,
N_QUERIES,
serde_json::json!({ "author": "keyword" }),
move |id| serde_json::json!({ "author": author(id) }),
move |q| serde_json::json!({ "and": [ { "author": { "match": { "value": wanted(q) } } } ] }),
move |q, id| author(id) == wanted(q),
)
}

/// Keyword values carrying the backslash / brace / comma / quote characters
/// that KiviDB 1.1.2 only matches exactly once escaped, each next to the decoy
/// a raw value would hit instead. Shaped on the shipped arxiv `submitter`
/// values (`J\"org Hennig`, `Rasmus Bj{\o}rk`). Sent raw (measured on 1.1.2),
/// `J\"org Hennig` matches the decoy `J"org Hennig`, `a\b` matches `ab`, and
/// `Bj{\o}rk` is a syntax error (a dropped query).
///
/// Every query targets one of those values (one is a `match_any` over two of
/// them), so any wrong encoding fails at least one of the ten queries outright.
pub const TAG_ESCAPE_VALUES: [&str; 9] = [
r#"J\"org Hennig"#,
r#"J"org Hennig"#,
r"Rasmus Bj{\o}rk",
"Rasmus Bjork",
"Smith, John",
"Smith",
r"a\b",
"ab",
r"C:\dir",
];

pub fn write_tag_escape_project(
dataset_name: &str,
engine_configs_json: &str,
dim: usize,
) -> FilterProject {
write_tag_values_project(
dataset_name,
engine_configs_json,
dim,
&TAG_ESCAPE_VALUES,
&[&[0], &[2], &[4], &[6], &[8], &[0], &[2], &[4], &[6, 8]],
)
}

/// Keyword values with `(`, `)`, `|` and `@`, next to the decoys a mis-parse
/// would hit. KiviDB 1.1.2 expresses all four exactly once escaped (raw, `x|y`
/// matches `x` and `y`). `Francisco (Paco) Guinea` is a shipped arxiv
/// `submitter` value.
pub const TAG_PAREN_PIPE_VALUES: [&str; 7] = [
"Francisco (Paco) Guinea",
"Francisco",
"x|y",
"x",
"y",
"c@d",
"c",
];

pub fn write_tag_paren_pipe_project(
dataset_name: &str,
engine_configs_json: &str,
dim: usize,
) -> FilterProject {
write_tag_values_project(
dataset_name,
engine_configs_json,
dim,
&TAG_PAREN_PIPE_VALUES,
&[&[0], &[2], &[5], &[0], &[2], &[5], &[0], &[2, 5]],
)
}

/// Values for the exact-count fixture: one of each character the TAG encoding
/// has to handle (`\`, `{`, `}`, `"`, `,`, `(`, `)`, `|`), next to the decoy a
/// raw or mis-parsed value would hit. Value `i` is stored on exactly
/// [`tag_count_docs`]`(i)` documents, a DISTINCT count per value, so a clause
/// that matches a decoy instead can never return the right count by accident.
pub const TAG_COUNT_VALUES: [&str; 14] = [
r#"J\"org Hennig"#,
r#"J"org Hennig"#,
r"Rasmus Bj{\o}rk",
"Rasmus Bjork",
r"a\b",
"ab",
"close}brace",
"Smith, John",
"Smith",
"Francisco (Paco) Guinea",
"Francisco",
"f(x)",
"x|y",
"x",
];

/// Stored on every document no [`TAG_COUNT_VALUES`] bucket covers.
pub const TAG_COUNT_FILLER: &str = "filler";

/// How many documents carry `TAG_COUNT_VALUES[i]`: 12, 14, …, 38 (350 of the
/// 400; the rest carry [`TAG_COUNT_FILLER`]). All >= `TOP`, so every value can
/// also be a recall query.
pub fn tag_count_docs(i: usize) -> usize {
12 + 2 * i
}

/// The [`TAG_COUNT_VALUES`] index document `id` carries, or `None` for filler.
pub fn tag_count_value_index(id: usize) -> Option<usize> {
let mut start = 0;
for i in 0..TAG_COUNT_VALUES.len() {
let end = start + tag_count_docs(i);
if id < end {
return Some(i);
}
start = end;
}
None
}

/// The exact-count fixture: field `author` (keyword) per
/// [`tag_count_value_index`]; the ten recall queries cover `\`, `{ }`, `,`,
/// `( )` and `|` values, two of them as a `match_any`.
pub fn write_tag_count_project(
dataset_name: &str,
engine_configs_json: &str,
dim: usize,
) -> FilterProject {
const WANTED: [&[usize]; 10] = [
&[0],
&[2],
&[4],
&[6],
&[7],
&[9],
&[11],
&[12],
&[0, 9],
&[2, 6],
];
let author = |id: usize| {
tag_count_value_index(id)
.map(|i| TAG_COUNT_VALUES[i])
.unwrap_or(TAG_COUNT_FILLER)
};
write_filter_project_multi(
dataset_name,
engine_configs_json,
dim,
GtMetric::L2,
N_QUERIES,
serde_json::json!({ "author": "keyword" }),
move |id| serde_json::json!({ "author": author(id) }),
move |q| {
let w = WANTED[q % WANTED.len()];
let m = if w.len() == 1 {
serde_json::json!({ "value": TAG_COUNT_VALUES[w[0]] })
} else {
serde_json::json!({
"any": w.iter().map(|&i| TAG_COUNT_VALUES[i]).collect::<Vec<_>>()
})
};
serde_json::json!({ "and": [ { "author": { "match": m } } ] })
},
move |q, id| {
tag_count_value_index(id).is_some_and(|i| WANTED[q % WANTED.len()].contains(&i))
},
)
}

/// Field `author` (keyword) cycles through `values`; query `q` filters on
/// `wanted[q % wanted.len()]` — an exact `match` for one index, a `match_any`
/// for several — with exact-string ground truth.
fn write_tag_values_project(
dataset_name: &str,
engine_configs_json: &str,
dim: usize,
values: &'static [&'static str],
wanted: &'static [&'static [usize]],
) -> FilterProject {
let author = move |id: usize| values[id % values.len()];
let wanted_for = move |q: usize| wanted[q % wanted.len()];
write_filter_project_multi(
dataset_name,
engine_configs_json,
dim,
GtMetric::L2,
N_QUERIES,
serde_json::json!({ "author": "keyword" }),
move |id| serde_json::json!({ "author": author(id) }),
move |q| {
let w = wanted_for(q);
let m = if w.len() == 1 {
serde_json::json!({ "value": values[w[0]] })
} else {
serde_json::json!({ "any": w.iter().map(|&i| values[i]).collect::<Vec<_>>() })
};
serde_json::json!({ "and": [ { "author": { "match": m } } ] })
},
move |q, id| wanted_for(q).contains(&(id % values.len())),
)
}

/// bool filter: field `flag` (schema type `bool`), value `id % 2 == 0`. The
/// query filters `flag == true`, so half the corpus matches.
pub fn write_bool_project(
Expand Down Expand Up @@ -1872,6 +2099,18 @@ pub fn run_binary_capture(
dataset: &str,
host: &str,
envs: &[(&str, &str)],
) -> BinaryRun {
run_binary_capture_extra(root, engine, dataset, host, envs, &[])
}

/// [`run_binary_capture`] with extra CLI args (e.g. `--skip-upload`).
pub fn run_binary_capture_extra(
root: &Path,
engine: &str,
dataset: &str,
host: &str,
envs: &[(&str, &str)],
extra: &[&str],
) -> BinaryRun {
let mut cmd = std::process::Command::new(binary_path());
cmd.args([
Expand All @@ -1884,6 +2123,7 @@ pub fn run_binary_capture(
"--skip-if-exists",
"false",
])
.args(extra)
.current_dir(root);
for (k, v) in envs {
cmd.env(k, v);
Expand Down Expand Up @@ -2313,7 +2553,7 @@ fn display_id(id: &str) -> &str {
/// redis:8.8.0 (`db0:keys=1,expires=0,avg_ttl=0,subexpiry=0`), valkey-bundle
/// (`...,keys_with_volatile_items=0`), dragonfly df-v1.40.1
/// (`db0:keys=1,expires=0,hits=0,misses=0,hit_ratio=0.00,avg_ttl=-1`) and kividb
/// v1.0.2-full (`db0:keys=1,expires=0,avg_ttl=0`) — so parse by name. All four
/// 1.1.2 (`db0:keys=1,expires=0,avg_ttl=0`) — so parse by name. All four
/// emit the `# Keyspace` header (measured).
pub fn sum_keyspace_keys(info: &str) -> Option<i64> {
if !info
Expand Down Expand Up @@ -2357,7 +2597,7 @@ pub fn info_field(info: &str, field: &str) -> String {
/// is not honoured for a different one that later took the port.
///
/// `run_id` from `INFO server` where available (redis:8.8.0, valkey-bundle,
/// kividb v1.0.2-full), falling back to `master_replid` from `INFO replication`
/// kividb 1.1.2), falling back to `master_replid` from `INFO replication`
/// (dragonfly df-v1.40.1 reports no `run_id`; all four checked live). Empty when
/// neither is present — an empty identity never matches a prior claim.
///
Expand Down Expand Up @@ -2426,7 +2666,7 @@ pub fn probe_server(host: &str, port: u16, wait: std::time::Duration) -> Probe {
// still filling, so "0 keys" would be a lie.
//
// Best-effort by design: an engine whose `INFO persistence` errors, or that
// omits `loading` entirely (kividb v1.0.2-full does — measured), simply gets
// omits `loading` entirely (an early kividb release did; 1.1.2 reports it), simply gets
// no loading check. Making a missing section fatal would refuse those engines
// outright, and a server so locked down that INFO fails is caught two lines
// below by `INFO keyspace` anyway.
Expand Down
17 changes: 8 additions & 9 deletions tests/docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -186,17 +186,16 @@ services:
retries: 10

kividb:
image: quay.io/kividbio/kividb:v1.0.2-full
# KiviDB listens on 6380 internally (not 6379). The image ships no
# redis-cli/valkey-cli, but it does include bash, so the healthcheck opens
# a raw TCP connection to the server port to confirm readiness.
# The current stable KiviDB release, `full` variant (Lua + TLS). Published
# to Docker Hub (`kividb/kividb`) and GHCR (`ghcr.io/kividbio/kividb`).
image: kividb/kividb:v1.1.2-full
# KiviDB listens on 6380 internally (not 6379). The image is distroless:
# no shell and no redis-cli, so there is nothing to run a healthcheck with.
# As with Chroma below, `up --wait` therefore waits only for the container
# to be running, and the suite's wait_for_kividb() polls PING (30s
# deadline) for actual readiness.
ports:
- "6386:6380"
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/6380"]
interval: 1s
timeout: 3s
retries: 15

chroma:
image: chromadb/chroma:1.0.0
Expand Down
2 changes: 1 addition & 1 deletion tests/harness_invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1315,7 +1315,7 @@ fn keyspace_sum_covers_every_database_on_all_four_engines() {
1,
),
(
"kividb v1.0.2-full",
"kividb 1.1.2",
"# Keyspace\r\ndb0:keys=1,expires=0,avg_ttl=0\r\n",
1,
),
Expand Down
Loading
Loading