diff --git a/Makefile b/Makefile
index 112f0263..b7a8773c 100644
--- a/Makefile
+++ b/Makefile
@@ -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 ""
diff --git a/README.md b/README.md
index ba4af664..b8963a2c 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ Which shipped dataset/engine pairs this stops today:
| `random-geo-radius-100-angular-filters`, `random-geo-radius-2048-angular-filters` | turbopuffer, chroma | neither filter DSL has a geo operator, an attribute-vs-attribute comparison, or any arithmetic, so an exact radius has no representation at all (#223). A bounding box is a *widening*, not a substitute. |
| the same two | dragonfly, valkey | neither `configure()` declares a GEO field, so the shared RediSearch geo clause targeted a field that does not exist. Previously it was emitted anyway (#223) — Dragonfly's is a builder limitation (it rejects `$param` placeholders and integer literals, not geo itself), Valkey's is an engine one (no GEO field type). |
| the same two | kividb, vertex | already aborted before #219 |
-| `arxiv-titles-384-angular-filters` | kividb | multi-valued `labels` (see the KiviDB note) |
+| every dataset that declares a TAG field (12 shipped, `arxiv-titles-384-angular-filters` among them) | kividb (server older than 1.1.2, or reporting no parseable `kividb_version`) | TAG declarations and TAG value encoding are tested on KiviDB 1.1.2 only (KiviDB note, row 10) |
Two operational consequences worth knowing:
@@ -74,19 +74,63 @@ Two operational consequences worth knowing:
\*\*\*\*\*\* **KiviDB note:** [KiviDB](https://kividb.io) is a Redis-wire-compatible (RESP2) data store that implements a RediSearch-compatible `FT.*` subset (`FT.CREATE`/`FT.SEARCH`/`FT.INFO`/`FT.DROPINDEX`, `VECTOR` HNSW/FLAT, `*=>[KNN k @field $blob AS score]`). Supports **vector KNN + metadata filtering**: TAG/NUMERIC/TEXT datatypes, `match`/`match_any`/`range` (inclusive **and** exclusive bounds), and AND/OR/nested boolean, all verified live against filtered ground truth. KiviDB genuinely **pre-filters** (measured recall ~1.0 down to ~2% filter selectivity), so its filtered numbers are meaningful. KiviDB's `FT.CREATE` supports only the **float32** vector type.
-The filter path is **not** the shared RediSearch builder used by Redis/Valkey/Dragonfly — KiviDB's `FT.SEARCH` diverges from RediSearch in ways that would otherwise produce silently wrong recall (issue #205), so the engine has its own emitter (`kividb_filter` in `kividb.rs`, where every divergence is documented with the measurement behind it). The headline one: **KiviDB does not substitute `$param` placeholders in a filter expression, in any query form** — a plain `FT.SEARCH` fails identically to a hybrid one, so this is not a prefilter-specific quirk — and all filter values are therefore inlined as literals. It also does not accept RediSearch backslash escaping, RediSearch's `(` exclusive-range marker, or `-` negation, and it reads a spaced intra-brace TAG-OR (`@f:{a | b}`) as match-all.
+The filter path is **not** the shared RediSearch builder used by Redis/Valkey/Dragonfly. The early KiviDB release this engine was first written against diverged from RediSearch's `FT.SEARCH` in ways that would otherwise have produced silently wrong recall (issue #205), so the engine got its own emitter (`kividb_filter` in `kividb.rs`, where every divergence is documented with the measurement behind it). The headline one was that `$param` placeholders were not substituted in a filter expression, in any query form, so all filter values are inlined as literals. That release also rejected RediSearch backslash escaping, RediSearch's `(` exclusive-range marker and `-` negation, and read a spaced intra-brace TAG-OR (`@f:{a | b}`) as match-all.
+
+**Re-measured on KiviDB 1.1.2.** Every divergence probe was re-run against `kividb/kividb:v1.1.2-full`, the image CI runs, with Redis Stack 7.4.7 (`redis-stack-server:7.4.0-v8`) and Redis 8.8.0 answering the same queries on the same fixture. The expected counts are computed from the fixture, not taken from any server. KiviDB 1.1.2 returned the expected count on every probe, as both Redis servers did, except for GEO:
+
+| divergence (as numbered in `kividb_filter`) | probes | KiviDB 1.1.2 |
+|---|---|---|
+| 1. `$param` in a filter | TAG, NUMERIC and TEXT, plain and hybrid (`=>[KNN …]`) | substituted |
+| 2. backslash-escaped TAG value | `@t:{hello\ world}` | correct |
+| 3. `\|` `@` `(` `)` in a TAG value | escaped, alone and inside a group | correct |
+| 4. TAG-OR | `@t:{red \| blue}` and `@t:{red\|blue}` | correct |
+| 5. exclusive bounds | `[-inf (5]`, `[(5 +inf]` | correct |
+| 6. `-` negation | alone, and with a positive clause | correct |
+| 7. multi-term TEXT | `@body:(quick brown)` | correct |
+| 8. GEO field type | `FT.CREATE … loc GEO` | **absent**: `GEO fields are not supported` |
+| 9. grouping | `((X))`, `A (B)` | correct |
+| 10. TAG declared without `SEPARATOR` (bare) | `@s:{Smith}` and `@s:{John}` against a stored `Smith, John`; `@t:{b}` against a stored `a;b;c` under `SEPARATOR ;` | splits on `,`, the RediSearch default; `SEPARATOR` accepted and split on |
+
+`FT.INFO` on 1.1.2 reports `num_docs`, `percent_indexed` and an `attributes` section in which a bare TAG shows `SEPARATOR ,`, as RediSearch does, and `HELLO 3` (RESP3) succeeds.
+
+Row 10 is the one that decides how this tool declares TAG fields. A bare TAG indexes a stored `Smith, John` as `Smith` + `John`, so `@f:{Smith}` also matches it, exactly as on Redis, which is why `redis.rs` declares every TAG `SEPARATOR ;`. The KiviDB engine therefore reads `kividb_version` from `INFO server` and declares **every** TAG field (keyword, uuid, bool, and `labels`) `SEPARATOR ;`. It does so on KiviDB **1.1.2**, the current stable release and the one CI runs, and newer; a pre-release is gated on its core version (`1.1.2-rc1` counts as 1.1.2). An older server, or one whose version cannot be read, gets no TAG declaration at all: a dataset that declares a TAG field is refused, naming what `INFO server` reported. Vector-only and numeric/text-only datasets still run. That is a support boundary, not the point where KiviDB's behaviour changed. Older images were measured during #375: 1.0.2, 1.0.3, 1.0.4-rc3 and 1.0.4 reject `SEPARATOR`, keep a bare TAG atomic and match only raw query values, while 1.0.5-rc1, 1.0.5 and 1.0.6-rc4 already behave like 1.1.2. 1.0.7 to 1.1.1 were not measured (1.1.1 was withdrawn). Only 1.1.2 is tested, so nothing older is declared on.
+
+Row 10 matters for shipped data. Streaming every shipped dataset with a TAG-typed field (h-and-m, arxiv, the three random-keyword sets, `random-768-100-tenants`, MS MARCO 100K and 1M; `synthetic-filter-32` is generated locally, and `random-768-25-tenants` and MS MARCO 10M/100M have no download link) shows that h-and-m has a comma in **99 query filter values** (`section_name`, `garment_group_name`, e.g. `Womens Nightwear, Socks & Tigh`) and ~17,000 corpus values; with bare TAGs those filters would silently under-match. Under `SEPARATOR ;`, a `;` inside a value is split instead, as on Redis/Valkey/Dragonfly. No shipped query filter value contains one. In the corpus only 12 arxiv `submitter` values (HTML entities such as `ł`) and 10 MS MARCO `url`s do. Neither direction is hit: none of arxiv's 2,928 distinct `submitter` query values equals a stored `;`-value or any of its 20 fragments, compared case-insensitively and trimmed as a TAG matches, and MS MARCO has no filtered vector queries (#377). `search()` also reads the index's TAG declarations back from `FT.INFO` and refuses a reused (`--skip-upload`) index whose TAG fields the server would split on `,`, such as one built bare by an older binary.
+
+**Row 2 decides how the filter emitter writes a TAG value.** The emitter's literal, minimally parenthesised *structure* is unchanged and correct on 1.1.2. The TAG *value* is not sent raw: 1.1.2 honours RediSearch backslash escapes, so a raw value is read differently. Measured on 1.1.2, a raw `a\b` matched the stored `ab`, a raw `J\"org Hennig` matched `J"org Hennig`, a raw `x|y` matched `x` and `y`, and a raw `}` or a leading `"` is a syntax error. Every TAG value is therefore escaped by the RediSearch rule, a backslash before every ASCII punctuation character and space (`kividb_caps::escape_tag_value`). On 1.1.2 that was measured exact for all 32 ASCII punctuation characters plus space, at the start, middle and end of a value, bare, inside a group and AND'd; non-ASCII characters are exact unescaped. This matters for shipped data. Of the TAG-bearing datasets listed above, arxiv `submitter` is the only exposed field:
+
+- **77 of its 10,000 queries** use a value containing `\` (69 distinct values, e.g. `J\"org Hennig`), 14 of them also with `{`/`}` (e.g. `Rasmus Bj{\o}rk`), and 4 use parentheses (`Francisco (Paco) Guinea`, `Chun Ning (Jeanie) Lau`).
+- No other shipped query value contains `\ { } ( ) | @ ;`.
+
+Sent raw (measured on 1.0.5, which handles escapes the same way as 1.1.2), the backslash values returned 0 documents and the brace values failed per query. Escaped, they return the exact corpus counts (`J\"org Hennig` gives 31, `Ulf-G. Mei{\ss}ner` gives 108).
+
+Some values have no exact encoding, and each is a **hard error** when the filter is built, never a silent miss (all measured on 1.1.2):
+
+- leading or trailing whitespace, which the query parser trims (`@t:{\ sp}` matched the stored `sp`, ` sp` and `sp `);
+- `;`, the declared separator, so the stored value was split and nothing matches it whole;
+- a trailing `*`, read as a prefix wildcard even when escaped (`@t:{zz\*}` matched 73 documents);
+- a leading `$`, read as a query parameter even when escaped (`No such parameter`).
+
+No shipped query value hits any of these. CI runs the KiviDB suite against `kividb/kividb:v1.1.2-full` (`make integration-test-kividb`). It includes four live TAG fixtures:
+
+- a comma in a keyword value, which fails if a TAG is declared bare;
+- backslash, brace and quote values, each next to the decoy a raw value would match instead;
+- `(`, `)`, `|` and `@` values, each next to its decoys;
+- an exact-count fixture: values containing `\`, `{`, `}`, `"`, `,`, `(`, `)` and `|`, each stored on a different number of documents, queried on the engine's own index with the literal the engine sends. Every value must return exactly its own documents, bare and inside a group.
+
+**TAG matching is case-insensitive** (measured on 1.1.2: `@t:{mixed}` matches a stored `MiXeD`), as it is in `redis.rs`'s `vector` layout, while the shipped ground truth matches exactly. So a filter value that differs from another stored value only in case over-matches, on KiviDB and on Redis. Measured by a reviewer on 1.0.5, which folds case the same way, that affects 31 of h-and-m's 10,000 queries (e.g. `department_name` `Jersey fancy`: 3,148 documents vs 1,397 exact) and 30 of arxiv's. The cross-engine question is tracked in #377. Stored whitespace is trimmed too, so `@t:{sp}` also matches a stored ` sp `.
**Not supported on KiviDB**, and therefore a hard **error** rather than a silently dropped condition (following `vertex.rs`; a dropped clause would run kNN over the whole corpus and publish a recall for a filter that was never applied):
-- **GEO** — KiviDB's index schema has no GEO field type at all, so the field is never indexed (unlike Dragonfly, which has a GEO type but rejects this tool's `$param` geo bounds at the query-parser level).
-- **Multi-valued `labels` arrays** — a KiviDB TAG value is *atomic*, never split on a separator (`FT.CREATE` rejects the `SEPARATOR` modifier, and `@f:{b}` does not match a stored `a;b;c`), so `match_any` over a labels field could only ever match the whole joined string.
-- **Multi-term or non-alphanumeric full-text terms** — KiviDB's TEXT matcher handles a single alphanumeric term per `@field:(...)` clause and returns zero documents otherwise.
-- **TAG filter values containing `|`, `@`, `(` or `)`** — inexpressible with no escaping mechanism. `|` is always the TAG-OR separator (under-match), `@` degrades the whole query to match-all (over-match), and `(`/`)` are parsed as grouping as soon as the clause sits inside a group, which every non-trivial filter does (`@t:{zz(zz} @u:{blue}` returns 0; `(@t:{zz)zz})` returns the whole corpus). This is a **query-side** limit only: a *stored* value containing one of these is inert — measured, a stored `a|b` is not split, and a corpus seeded with such values returns identical counts to a clean one for every clause shape the builder emits — so uploads are never rejected for it. That matters in practice: h-and-m stores a paren in `prod_name` on 3,900 documents and benchmarks correctly, because none of the 586 distinct filter values in its shipped queries contains one.
+- **GEO** — KiviDB's index schema has no GEO field type at all (1.1.2: `FT.CREATE` answers `GEO fields are not supported`), so the field is never indexed (unlike Dragonfly, which has a GEO type but rejects this tool's `$param` geo bounds at the query-parser level).
+- **TAG fields of any kind, on a server older than 1.1.2 or one that reports no parseable `kividb_version`** — see row 10 above. On 1.1.2 a multi-valued `labels` field is declared `TAG SEPARATOR ;` exactly as `redis.rs` does, and `match_any` has contains-any semantics (recall 1.000 on the multi-valued fixture). As on Redis, a `;` inside a single label (or inside any keyword value) is read as the separator, so that value is split.
+- **Multi-term or non-alphanumeric full-text terms** — the TEXT matcher of the release this engine was written against handled a single alphanumeric term per `@field:(...)` clause and returned zero documents otherwise; kept as a conservative refusal (#376).
+- **TAG filter values with no exact encoding**: leading or trailing whitespace, `;`, a trailing `*` or a leading `$` (see the escaping paragraph above). This is a **query-side** limit only. A *stored* value of one of these shapes does not perturb other queries (measured on 1.1.2: the stored `zz*`, `$zz` and `zz*zz` leave `@t:{zz}` at exactly its own documents), so uploads are never rejected for it. The two corpus-side effects that do reach other queries, case folding and trimmed whitespace, are described above.
-The first two are decidable from the dataset schema, so KiviDB rejects them in the **configure** phase — before the index is created and before the corpus is read — leaving no partial upload, no orphan result file and no populated keyspace behind.
+The first two are decidable from the dataset schema and the server version, so KiviDB rejects them in the **configure** phase — before the index is created and before the corpus is read — leaving no partial upload, no orphan result file and no populated keyspace behind. GEO needs no server at all and is rejected before `configure()` even connects.
-> **Which shipped datasets this excludes.** **3 of the 61** datasets in `datasets/datasets.json` cannot run on KiviDB at all and now abort instead of producing a result file: `random-geo-radius-100-angular-filters` and `random-geo-radius-2048-angular-filters` (geo), and `arxiv-titles-384-angular-filters` (multi-valued `labels` — the only shipped dataset that declares a `labels` field). Before this was fixed these three *did* run and published a recall near 0.0, which was visibly wrong; they now publish nothing at all, and no result file records the rejection, so `--plot` simply shows a gap for KiviDB on those datasets. If you are sweeping KiviDB across everything, either exclude the three from `--datasets` (`arxiv-titles-384-angular-no-filters` is the pure-KNN twin of the arxiv one) or pass `--exit-on-error false` so the rest of the sweep continues. Every other dataset — including all filtered ones — is unaffected.
+> **Which shipped datasets this excludes.** On **KiviDB 1.1.2, 2 of the 62** datasets in `datasets/datasets.json` cannot run: the two geo ones, `random-geo-radius-100-angular-filters` and `random-geo-radius-2048-angular-filters`. They abort instead of producing a result file. `arxiv-titles-384-angular-filters` runs and is filtered on 1.1.2: `labels` is split on `;`, and none of its 2,928 distinct `submitter` query values hits a hard error above. The escaped encoding was verified on that data on 1.0.5, which handles escapes as 1.1.2 does: with all 2,138,591 `submitter` values loaded, every escaped query value returned exactly the count expected under case-insensitive TAG matching (25 of them differ from the exact-case count only through that case folding). On an older server, or one that reports no parseable version, every dataset that declares a TAG field (12 shipped, arxiv-filters among them) aborts as well. Before this was fixed such datasets *did* run and published a recall near 0.0, which was visibly wrong; they now publish nothing at all. No result file records the rejection, so `--plot` simply shows a gap for KiviDB on those datasets. If you are sweeping KiviDB across everything, either exclude them from `--datasets` (`arxiv-titles-384-angular-no-filters` is the pure-KNN twin of the arxiv one) or pass `--exit-on-error false` so the rest of the sweep continues. Every other dataset runs, including all the other filtered ones, with the case-insensitive TAG matching described above.
-One real protocol difference worth knowing: KiviDB's `FT.INFO` does not expose RediSearch's `num_docs`/`percent_indexed` — it reports HNSW graph state directly instead (`hnsw_live_count`, `hnsw_compaction_in_progress`), because it builds each vector's HNSW entry synchronously inside the `HSET` that stores it (no async backfill phase exists to report progress on); `wait_for_indexing` polls those fields instead and returns immediately as a result. RESP2 only (no RESP3 opt-in). Set the host port with `KIVIDB_PORT` (default `6380` — KiviDB's own default listen port, **not** Redis's 6379).
+One protocol detail worth knowing: the early KiviDB release this engine was written against did not expose RediSearch's `num_docs`/`percent_indexed` in `FT.INFO`; it reported HNSW graph state directly instead (`hnsw_live_count`, `hnsw_compaction_in_progress`), because KiviDB builds each vector's HNSW entry synchronously inside the `HSET` that stores it (no async backfill phase exists to report progress on). `wait_for_indexing` therefore judges an HNSW index by `hnsw_live_count` (falling back to `num_docs`) and a FLAT index, which has no graph, by `num_docs` (falling back to `hnsw_live_count`; see `indexing_wait::kividb_ready`), and returns on the first poll in practice. KiviDB 1.1.2 reports `num_docs`/`percent_indexed` and speaks RESP3; the engine stays on RESP2 (no RESP3 opt-in), which every KiviDB release speaks. Set the host port with `KIVIDB_PORT` (default `6380` — KiviDB's own default listen port, **not** Redis's 6379).
\*\*\*\*\*\*\*\* **MongoDB note — which knobs MongoDB actually honours:** MongoDB Vector Search exposes **build-time** HNSW tuning and **no query-time `ef`**.
diff --git a/src/bin/vector_db_benchmark/engine/filter_guard.rs b/src/bin/vector_db_benchmark/engine/filter_guard.rs
index 4f1195e9..056aa21f 100644
--- a/src/bin/vector_db_benchmark/engine/filter_guard.rs
+++ b/src/bin/vector_db_benchmark/engine/filter_guard.rs
@@ -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))
})
}),
diff --git a/src/bin/vector_db_benchmark/engine/kividb.rs b/src/bin/vector_db_benchmark/engine/kividb.rs
index f73d1601..4cdec44f 100644
--- a/src/bin/vector_db_benchmark/engine/kividb.rs
+++ b/src/bin/vector_db_benchmark/engine/kividb.rs
@@ -39,13 +39,15 @@
//!
//! # RESP2 only
//!
-//! KiviDB does not implement RESP3 — there is no protocol opt-in here (unlike
-//! dragonfly.rs's `DRAGONFLY_PROTOCOL=resp3`); every connection stays RESP2.
+//! The KiviDB release this engine was first written against did not implement
+//! RESP3; 1.1.2 does (`HELLO 3` succeeds, measured). There is no protocol
+//! opt-in here (unlike dragonfly.rs's `DRAGONFLY_PROTOCOL=resp3`); every
+//! connection stays RESP2, which every KiviDB release speaks.
//!
-//! # FT.INFO dialect: no `num_docs` / `percent_indexed`
+//! # FT.INFO dialect: no `num_docs` / `percent_indexed` (KiviDB 1.0.2)
//!
-//! This is the detail that actually motivated this engine. KiviDB's `FT.INFO`
-//! does not expose RediSearch's `num_docs` / `indexing` / `percent_indexed`
+//! This is the detail that actually motivated this engine. KiviDB 1.0.2's
+//! `FT.INFO` does not expose RediSearch's `num_docs` / `indexing` / `percent_indexed`
//! fields at all — a benchmark client that polls for those (as the generic
//! wait-for-indexing loops in this repo, and in the legacy `v0/` Python tool,
//! do) sees `num_docs` default to 0 forever and stalls until its own timeout,
@@ -55,7 +57,10 @@
//! tombstone compaction). `wait_for_indexing` below polls those fields
//! instead. Because KiviDB builds each vector's HNSW entry synchronously
//! inside the `HSET` that stores it, this in practice returns immediately —
-//! there is no async backfill phase to wait out.
+//! there is no async backfill phase to wait out. KiviDB 1.1.2 (and 1.0.5
+//! before it) also reports `num_docs` and `percent_indexed`;
+//! `indexing_wait::kividb_ready` accepts either shape, so nothing here depends
+//! on the version.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
@@ -71,6 +76,9 @@ use crate::dataset::Dataset;
use crate::engine::index_naming::{derive_index_name, derive_key_prefix};
use crate::engine::{CorpusCount, Engine, SearchResults, UploadStats};
use crate::metrics::compute_metrics;
+use vector_db_benchmark::kividb_caps::{
+ check_index_tag_separators, ft_info_tag_separators, KividbServer, TAG_MIN_VERSION_STR,
+};
use vector_db_benchmark::parsers::{datetime_to_epoch_secs, doc_key_to_id, doc_key_to_id_opt};
use vector_db_benchmark::readers::metadata::{is_multivalued_keyword_field, MetadataItem};
use vector_db_benchmark::start_gate::WorkerPool;
@@ -117,7 +125,7 @@ impl KividbEngine {
pub fn new(engine_config: &EngineConfig, host: &str) -> Result {
// KiviDB's own default listen port is 6380, NOT Redis's 6379 (its
// banner reads `Listening on 0.0.0.0:6380`, and the official
- // `quay.io/kividbio/kividb` image exposes 6380). Defaulting to 6379
+ // `kividb/kividb` image exposes 6380). Defaulting to 6379
// meant an out-of-the-box run could not connect at all, or — worse —
// silently benchmarked a real Redis that happened to be on 6379.
let port: u16 = crate::effective_config::env_parsed("KIVIDB_PORT", 6380);
@@ -222,7 +230,24 @@ impl KividbEngine {
Ok(conn)
}
- fn create_index(&self, conn: &mut Connection, dataset: &Dataset) -> Result<(), String> {
+ /// What the connected server reports about itself (`kividb_version` from
+ /// `INFO server`), which decides how its TAG fields must be declared — see
+ /// `vector_db_benchmark::kividb_caps`. One round trip; every caller is
+ /// outside the timed windows.
+ fn server_identity(conn: &mut Connection) -> Result {
+ let info: String = redis::cmd("INFO")
+ .arg("server")
+ .query(conn)
+ .map_err(|e| format!("KiviDB: INFO server failed: {e}"))?;
+ Ok(KividbServer::from_info(&info))
+ }
+
+ fn create_index(
+ &self,
+ conn: &mut Connection,
+ dataset: &Dataset,
+ tags_supported: bool,
+ ) -> Result<(), String> {
let distance = dataset.distance();
let vector_size = dataset.vector_size();
@@ -259,16 +284,12 @@ impl KividbEngine {
// keyword/uuid/bool exact strings -> TAG; int/float/datetime (stored as
// epoch) -> NUMERIC; full-text -> TEXT.
//
- // Two KiviDB-specific departures from the RediSearch schema (both
- // verified live against the FT.CREATE parser):
- // * NO `SEPARATOR` modifier. KiviDB's FT.CREATE rejects it outright
- // ("unknown field type"), and a KiviDB TAG value is atomic — it is
- // never split on any separator (a query `@f:{b}` does not match a
- // stored `a;b;c`). So we declare a bare TAG (exact whole-string
- // match). Scalar keyword/uuid/bool filtering is unaffected; a
- // genuinely multi-valued `labels` array is caught and rejected at
- // upload time rather than indexed into a filter that silently
- // matches nothing (see `upload_batch_internal`).
+ // Two KiviDB-specific points (both verified live against the
+ // FT.CREATE parser):
+ // * Every TAG is declared redis.rs's `TAG SEPARATOR ;`, on a server
+ // the tool supports TAG fields on (KiviDB >= 1.1.2, see
+ // `tag_declaration` and `kividb_caps`); on any other server no
+ // TAG is declared at all.
// * GEO is never declared: KiviDB's schema (`FieldType`) has no Geo
// variant at all, unlike Dragonfly's parser-level rejection of
// `$param` geo bounds — there is simply nothing to declare here.
@@ -277,8 +298,15 @@ impl KividbEngine {
if let Some(schema) = dataset.config.schema.as_ref().and_then(|s| s.as_object()) {
for (field_name, field_type) in schema {
match field_type.as_str().unwrap_or("") {
- "keyword" | "uuid" | "bool" => {
- cmd.arg(field_name).arg("TAG");
+ t if is_tag_type(t) => {
+ // `validate_dataset_support` has already rejected every
+ // combination `tag_declaration` refuses; it refuses them
+ // again here so a caller that skips validation errors
+ // instead of declaring a TAG that matches the wrong docs.
+ cmd.arg(field_name);
+ for arg in tag_declaration(field_name, t, tags_supported)? {
+ cmd.arg(*arg);
+ }
}
"int" | "float" | "datetime" => {
cmd.arg(field_name).arg("NUMERIC");
@@ -489,9 +517,30 @@ impl KividbEngine {
///
/// This deliberately does NOT reuse redis.rs's `parse_conditions`. That builder
/// is correct for RediSearch/ValkeySearch/Dragonfly and is left untouched; the
-/// list below is what was measured against a real `quay.io/kividbio/kividb:v1.0.2-full`
-/// server and is why KiviDB needs its own emitter. Every item was reproduced by
-/// hand with `FT.SEARCH` against a seeded index before being encoded here.
+/// list below is what was measured against the early KiviDB release this engine
+/// was first written against, and is why KiviDB got its own emitter. Every item
+/// was reproduced by hand with `FT.SEARCH` against a seeded index before being
+/// encoded here.
+///
+/// Re-measured on KiviDB 1.1.2 (README, "Re-measured on KiviDB 1.1.2"): items
+/// 1-7 and 9 no longer reproduce, and every probe returns the same count as
+/// RediSearch; 8 (no GEO field type) still does. The literal,
+/// minimally-parenthesised STRUCTURE is kept because it is correct on 1.1.2
+/// too. The TAG VALUE inside the braces is not: 1.1.2 honours RediSearch's
+/// backslash escapes, so a raw `\` is read as an escape (`@t:{a\b}` matches the
+/// stored `ab`, not `a\b`), a raw `}` or leading `"` is a syntax error, and a
+/// raw `|` is the TAG-OR separator. Every TAG value is therefore escaped
+/// (`kividb_caps::escape_tag_value`: a backslash before every ASCII punctuation character
+/// and space), which was measured exact on 1.1.2 for all 32 of them plus space,
+/// bare, inside a group and AND'd. [`parse_conditions`] takes whether the
+/// server is one TAG fields are supported on (KiviDB >= 1.1.2, see
+/// `kividb_caps`) and refuses every TAG value anywhere else, since the escaped
+/// form is only known to be right there.
+///
+/// What a value cannot express is a hard error (`kividb_caps::tag_value_problem`), never a
+/// silent miss. On the index side a bare TAG splits on `,` (as on RediSearch),
+/// so `create_index` declares every TAG `SEPARATOR ;` (`tag_declaration`), and
+/// `@f:{Smith\,\ John}` matches exactly.
///
/// 1. **`$param` placeholders are NOT substituted in a filter expression, in
/// ANY query form.** This is the root cause of issue #205. The failure is
@@ -511,18 +560,19 @@ impl KividbEngine {
/// wrapped OR arm degrades the same way — which is why fixing this one alone
/// would not have fixed them. So this builder inlines every value as a
/// LITERAL and binds no filter params at all.
-/// 2. **No escaping.** KiviDB TAG values are matched raw. RediSearch-style
-/// backslash escaping matches nothing (`@uid:{3f2a\-9c}` → 0 docs, while
-/// `@uid:{3f2a-9c}` → the document). Spaces, `-`, `$`, `{`, `}`, `:`, `;`,
-/// `.`, `/`, quotes and backslashes all round-trip verbatim, even inside a
-/// compound clause.
-/// 3. **Four characters are inexpressible in a TAG value** — see
-/// [`tag_value_offender`], which carries the measurement. `|` is always read
-/// as the TAG-OR separator (under-match), `@` degrades the whole query to
-/// match-all (over-match), and `(`/`)` are parsed as grouping the moment the
-/// clause sits inside a group (`@t:{zz(zz} @u:{blue}` → 0;
-/// `(@t:{zz)zz})` → the whole corpus), which every non-trivial filter does.
-/// All four are hard errors here rather than a silent wrong recall.
+/// 2. **No escaping (on the early release only).** KiviDB 1.0.2 matched TAG
+/// values raw: a RediSearch-style backslash escape matched nothing
+/// (`@uid:{3f2a\-9c}` → 0 docs, while `@uid:{3f2a-9c}` → the document), and
+/// spaces, `-`, `$`, `{`, `}`, `:`, `;`, `.`, `/`, quotes and backslashes all
+/// round-tripped verbatim. From 1.0.5, and on 1.1.2, this is reversed:
+/// escapes are honoured and the raw forms of `\`, `|`, `}` and a leading `"`
+/// are wrong, so every TAG value is escaped (`kividb_caps::escape_tag_value`).
+/// 3. **Four characters were inexpressible in a TAG value on the early
+/// release**: `|` (always the TAG-OR separator), `@` (degraded the query to
+/// match-all) and `(`/`)` (parsed as grouping inside a group), with no
+/// escape to fall back on. Escaped, all four are exact on 1.1.2, bare,
+/// grouped and AND'd. What is still inexpressible is listed in
+/// `kividb_caps::tag_value_problem`.
/// 4. **`|` spacing is inverted between the two positions it appears in.**
/// INSIDE braces it must NOT be spaced: `@f:{a | b}` matches EVERY document
/// (a parser divergence) while `@f:{a|b}` is correct. BETWEEN clauses it
@@ -575,6 +625,7 @@ impl KividbEngine {
/// selectivity, so the filtered numbers this engine reports are meaningful.
pub(crate) mod kividb_filter {
use serde_json::{Map, Value};
+ use vector_db_benchmark::kividb_caps::tag_literal;
use vector_db_benchmark::parsers::datetime_to_epoch_secs;
/// Sentinel TAG/TEXT value used to express "matches nothing".
@@ -715,14 +766,14 @@ pub(crate) mod kividb_filter {
/// `QueryConditions::try_resolve_all` reject it. Keeping that check at the
/// call site rather than here is what stops the sentinel laundering a
/// dropped filter into an unfiltered run.
- pub fn parse_conditions(conditions: &Value) -> Result {
+ pub fn parse_conditions(conditions: &Value, tags_supported: bool) -> Result {
let Some(obj) = conditions.as_object() else {
return Ok(MATCH_ALL.to_string());
};
if obj.is_empty() {
return Ok(MATCH_ALL.to_string());
}
- Ok(build_group(obj)?
+ Ok(build_group(obj, tags_supported)?
.map(|node| node.render())
.unwrap_or_else(|| MATCH_ALL.to_string()))
}
@@ -730,13 +781,13 @@ pub(crate) mod kividb_filter {
/// Build one boolean group (`{and:[...], or:[...]}`). Recursive with
/// [`build_subfilters`], mirroring the shape of redis.rs's builder so the
/// same dataset conditions parse identically.
- fn build_group(obj: &Map) -> Result