Skip to content

Redis-wire TAG semantics (; in scalar values, case folding) + deferred KiviDB test/CI items from #375 #377

Open

Description

@fcostaoliveira

Follow-ups from the #375 / #381 reviews: TAG semantics on the Redis-wire engines, plus the deferred KiviDB test and CI items. #381 (KiviDB 1.1.2, SEPARATOR ;) is merged; #375 adds TAG value escaping on top.

A. ; inside a scalar TAG value

Redis, Valkey, Dragonfly and KiviDB (>= 1.1.2, #381) declare every TAG field TAG SEPARATOR ; (redis.rs ~L500, valkey.rs, dragonfly.rs, kividb.rs::tag_declaration). The reason is that a bare TAG splits on ,, and real values contain commas. The cost is that a ; inside a scalar keyword/uuid value becomes a separator:

  • Query side: a filter value a;b matches nothing (a silent under-match). On KiviDB, kividb: exact TAG filter values on 1.1.2 (escaping + live fixture), follow-up to #381 #375's kividb_caps::tag_value_problem makes this a hard error. Redis, Valkey and Dragonfly still send it.
  • Corpus side: a stored a;b indexes as a + b, so its own value can't reach it and a filter on a matches it (a silent over-match). This is open on every engine, and no query-side check can see it.

Measured exposure on shipped data (every dataset with a TAG-typed field, streamed):

dataset query filter values with ; corpus values with ;
h-and-m-2048-angular-filters (13 TAG fields) 0 / ~10k 0
arxiv-titles-384-angular-filters (labels, submitter) 0 / 6.6k 12 submitter values (HTML entities, e.g. Mikołaj Bojańczyk), never queried
random-match-keyword-100-angular-filters 0 / 16.6k 0
msmarco-cohere-1024-100K-cosine (docid, url, category) no filtered vector queries 10 urls, never queried

Over-match: none of arxiv's 2,928 distinct submitter query values equals any of the 20 ;-fragments of those 12 stored values (compared case-insensitively and trimmed). Nothing shipped is affected today; this is a latent hazard for new datasets.

Proposal:

  1. In the Redis, Valkey and Dragonfly filter builders, refuse a TAG filter value that contains the declared separator, as KiviDB now does (ROOT CAUSE: no engine distinguishes "no filter" from "filter that parsed to nothing" — silent unfiltered runs scored against filtered ground truth #219: "error rather than under-match").
  2. At upload, for scalar TAG fields, refuse or count stored values that contain the separator. Alternatively, pick a separator that can't occur in the data and record it in the results.
  3. Add a fixture with a ;-bearing scalar value for each engine, and mutation-check each guard.
  4. Run tests/common::write_comma_keyword_project in the Redis, Valkey and Dragonfly suites too. No live test there goes red if one of them reverts to a bare TAG, the declaration that collapsed KiviDB recall to 0.270 in kividb: exact TAG filter values on 1.1.2 (escaping + live fixture), follow-up to #381 #375's mutation table.
  5. Consider the --skip-upload FT.INFO separator read-back (kividb_caps::check_index_tag_separators) for the Redis family as well.

B. TAG matching is case-insensitive, but ground truth matches exactly

On KiviDB (SEPARATOR ;, measured on 1.0.5 and 1.1.2) and on the Redis vector layout, @t:{ginha} matches giNHA and GINHA, and stored edge whitespace is trimmed. Ground truth uses exact matching, so these filters over-match:

  • h-and-m: 31 of 10,000 queries. For example, department_name "Jersey fancy" matches 3,148 docs instead of 1,397, "Denim trousers" 900 instead of 397, and product_type_name "Dog wear" 27 instead of 7.
  • arxiv submitter: 30 queries, covering 25 distinct values. For example, "Zhi-Zhong Xing" matches 97 docs instead of 56.
  • random-match-keyword and labels: no collisions.

Possible fix: declare TAG SEPARATOR ; CASESENSITIVE on both engines. On KiviDB 1.0.5, @t:{giNHA} returned 1 and @t:{ginha} returned 0; re-measure on 1.1.2. Then require it in the reused-index FT.INFO read-back and record it in params.index_layout.

C. Deferred from the #375 final review (KiviDB tests and CI)

  • Unmeasured characters inside a TAG value (f1). kividb: exact TAG filter values on 1.1.2 (escaping + live fixture), follow-up to #381 #375 hard-errors on an empty value and on interior whitespace other than a space or a control character, because the 1.1.2 probe covered only the 32 ASCII punctuation characters and space. A zz<c>zz probe on 1.1.2 for each of \t \n \r \x0B \x0C U+00A0 U+3000, and an empty stored TAG, would show whether any of them can be allowed. No shipped query value has any of these.
  • The CI log doesn't record which server answered (f2 opt 2). kividb/kividb:v1.1.2-full is a movable tag, and test output is captured. Pin the image by digest or print kividb_version before the suite.
  • The mutation stopped before the per-value count loop (f2 opt 1). With escaping reverted, test_binary_kividb_tag_values_exact_counts failed at the engine run (--fail-on-dropped-queries), not at the exact-id loop. Add a case that runs the loop on its own.
  • The pre-release gate (f3 opt 1). 1.1.2-rc1 passes on its core version and was never measured. Decide whether to require a release build at TAG_MIN_VERSION.
  • A live red test for search()'s version check under --skip-upload (f5 opt 1). The legacy 1.0.2 job is gone. A unit test that drives the guard with a KividbServer parsed from a kividb_version:1.0.5 reply and a labels dataset would cover it without bringing back a legacy image.
  • Re-labelled comments that were never re-measured (f5 opt 2). tests/harness_invariants.rs ("kividb 1.1.2" keyspace sample) and tests/common/mod.rs ("1.1.2 reports loading"). Re-measure them on 1.1.2, or label them as 1.0.2 samples.
  • Docker Hub pull limits (f5 opt 3). If anonymous pulls start to bite, ghcr.io/kividbio/kividb is a drop-in replacement.
  • The exact-count fixture asserts >= 1.1.2, not exactly 1.1.2 (f7 opt 2). Its panic text names 1.1.2. Make it version-neutral when the image is bumped.

Related: #376 (KiviDB TEXT queries that are still refused).

Activity

  1. fcostaoliveira commented on Oct 29, 2026

    @fcostaoliveira
    CollaboratorAuthor

    Added from the #375 reviews: TAG matching is case-insensitive, but ground truth matches exactly. README.md (KiviDB note) points here.

    KiviDB TAG SEPARATOR ; (measured on 1.0.5) ignores case and trims spaces. For example, @t:{ginha} matches giNHA and GINHA, and @t:{ sp } matches sp. The dataset ground truth uses exact matching, so these filters return extra documents:

    • h-and-m: 31 of 10,000 queries over-match.
      • department_name "Jersey fancy" matches 3,148 docs instead of exactly 1,397.
      • "Denim trousers" matches 900 instead of 397.
      • product_type_name "Dog wear" matches 27 instead of 7.
    • arxiv submitter: 30 queries over-match. For example, "Zhi-Zhong Xing" matches 97 docs instead of exactly 56.
    • random-match-keyword and labels: no collisions.

    Not KiviDB-only. The Redis engine's vector layout behaves the same way (redis.rs ~500, workload.rs ~62-66), so KiviDB and Redis stay comparable, but both report recall on these queries against exact ground truth.

    Possible fix: KiviDB 1.0.5 accepts TAG SEPARATOR ; CASESENSITIVE, and it matched exactly (@t:{giNHA} → 1, @t:{ginha} → 0). RediSearch supports CASESENSITIVE too. The fix:

    • declare it on both engines;
    • require it in the reused-index read-back (check_index_tag_separators / FT.INFO);
    • record it in params.index_layout.

    Before the fix: re-measure it on KiviDB 1.1.2.

    🤖 Generated with Claude Code

  2. changed the title [-]Redis-wire engines: a `;` inside a scalar TAG value is split by `SEPARATOR ;` (query under-match, corpus over-match)[/-] [+]Redis-wire TAG semantics (`;` in scalar values, case folding) + deferred KiviDB test/CI items from #375[/+] on Oct 29, 2026
  3. fcostaoliveira commented on Oct 29, 2026

    @fcostaoliveira
    CollaboratorAuthor

    C, added from the final #375 bot pass (comment 6496170566):

    • Empty match_any arm differs between engines. KiviDB now refuses an empty TAG value anywhere, including one arm of a match_any, as a hard error for the whole dataset (kividb.rs ~947-949, kividb_caps::tag_value_problem). redis.rs skips the empty arm. Both are loud or harmless today, and no shipped query has an empty value. Whoever next touches redis.rs's empty-string handling should pick one behaviour for both engines.
    • The exact-count fixture checks the TAG filter on its own, not in hybrid form. The engine sends (…)=>[KNN $K @vec $vec_param]. Today the hybrid shape is covered only by recall >= 0.95 (1.0 in practice) in the same runs. Add one FT.SEARCH per value with a KNN tail and K >= 38, since divergence 1 showed KiviDB's parser can treat the two positions differently.

    🤖 Generated with Claude Code

  4. fcostaoliveira commented on Oct 29, 2026

    @fcostaoliveira
    CollaboratorAuthor
    • Unicode format characters (Cf) in TAG values are not measured. U+00AD, U+200B–U+200F, U+202A–U+202E, U+2060–U+2064 and U+FEFF are neither whitespace nor control characters, so tag_value_problem lets them through unescaped. No shipped query value contains one. Either probe them on 1.1.2, or refuse them alongside the interior-whitespace check. (From the final kividb: exact TAG filter values on 1.1.2 (escaping + live fixture), follow-up to #381 #375 review, g1.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions