Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
184 changes: 183 additions & 1 deletion integration/test_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ def _vec(*floats):
# =====================================================================

# General-purpose index: two TEXT fields + NUMERIC + TAG + VECTOR.
# WITHSUFFIXTRIE on `body` enables the suffix expansion queries; it is a lookup
# structure only and changes neither tokenization nor scores, so every verified
# constant below applies unchanged.
IDX_MAIN = [
"FT.CREATE", "idxMain", "ON", "HASH", "PREFIX", "1", "doc:",
"SCHEMA", "body", "TEXT", "NOSTEM", "title", "TEXT", "NOSTEM",
"SCHEMA", "body", "TEXT", "NOSTEM", "WITHSUFFIXTRIE",
"title", "TEXT", "NOSTEM",
"rank", "NUMERIC", "cat", "TAG",
"vec", "VECTOR", "FLAT", "6", "TYPE", "FLOAT32", "DIM", "2",
"DISTANCE_METRIC", "L2",
Expand All @@ -52,6 +56,29 @@ def _vec(*floats):
"SCHEMA", "rank", "NUMERIC", "cat", "TAG",
]

# Single TEXT field with a suffix trie, for prefix / suffix / fuzzy EXPANSION
# scoring on a corpus where several terms expand from one pattern.
IDX_EXPANSION = [
"FT.CREATE", "idxExpansion", "ON", "HASH", "PREFIX", "1", "exp:",
"SCHEMA", "body", "TEXT", "NOSTEM", "WITHSUFFIXTRIE",
]

# TEXT (so doc lengths are non-zero and tag terms can score) + TAG + NUMERIC,
# for TAG PREFIX expansion scoring.
IDX_TAG_PREFIX = [
"FT.CREATE", "idxTagPrefix", "ON", "HASH", "PREFIX", "1", "tpx:",
"SCHEMA", "body", "TEXT", "NOSTEM", "cat", "TAG", "rank", "NUMERIC",
]

# Two TEXT fields share one posting tree, so which field a term occurred in is
# visible only through the field mask. NUMERIC forces the extra-step scoring path;
# WITHSUFFIXTRIE enables the suffix expansion.
IDX_FIELD_SCOPE = [
"FT.CREATE", "idxFieldScope", "ON", "HASH", "PREFIX", "1", "fs:",
"SCHEMA", "body", "TEXT", "NOSTEM", "WITHSUFFIXTRIE",
"title", "TEXT", "NOSTEM", "rank", "NUMERIC",
]


# =====================================================================
# Documents
Expand Down Expand Up @@ -100,6 +127,44 @@ def _vec(*floats):
# else, so the same value tells the two apart.
TEN_WORDS = "one two three four five six seven eight nine ten"

# Expansion corpus. dt: cat=1, category=3, catalog=1, running=1, jogging=1.
# exp:multi matches cat* through two terms, every other doc through exactly one.
EXPANSION_DOCS = {
"exp:cat": {"body": "cat"},
"exp:multi": {"body": "category catalog"},
"exp:cat2": {"body": "category"},
"exp:cat3": {"body": "category"},
"exp:run": {"body": "running"},
"exp:jog": {"body": "jogging"},
"exp:dog": {"body": "dog"},
}

# Field-scope corpus: fs:1 carries `alxta` in title and `alzta` in body, the rest
# carry `alxta` in body. dt: alxta=4, alzta=1. Every doc_len is 2 = avg_doc_len,
# so the TF factor is exactly 1 and each score equals its term's IDF. `alxta`
# sorts ahead of `alzta` in the forward AND the reversed trie, so a field-blind
# expansion would credit fs:1 with alxta -- the wrong term, at 1/11th the score.
FIELD_SCOPE_DOCS = {
"fs:1": {"body": "alzta", "title": "alxta", "rank": "1"},
"fs:2": {"body": "alxta", "title": "zed", "rank": "2"},
"fs:3": {"body": "alxta", "title": "zed", "rank": "3"},
"fs:4": {"body": "alxta", "title": "zed", "rank": "4"},
}
IDF_ALZTA = 1.203973
IDF_ALXTA = 0.105361

# Tag prefix corpus: cat dt redis=4 (a,b,c,multi), redcap=2 (d,multi), so the two
# values matching `red*` carry distinct IDFs and the value a multi-match doc is
# scored on is observable. Identical one-token bodies keep doc_len constant.
TAG_PREFIX_DOCS = {
"tpx:a": {"body": "aa", "cat": "redis", "rank": "1"},
"tpx:b": {"body": "aa", "cat": "redis", "rank": "2"},
"tpx:c": {"body": "aa", "cat": "redis", "rank": "3"},
"tpx:d": {"body": "aa", "cat": "redcap", "rank": "4"},
"tpx:multi": {"body": "aa", "cat": "redis,redcap", "rank": "5"},
"tpx:green": {"body": "aa", "cat": "green", "rank": "6"},
}


# =====================================================================
# Helpers
Expand Down Expand Up @@ -325,6 +390,28 @@ def test_doc_wide_term_frequency(self):
assert title == pytest.approx({"doc:1": scoped["doc:1"]},
abs=SCORE_ABS_TOL)

# TF is doc-wide, but ADMISSION stays per-field: a term the doc carries
# only in another field must contribute nothing. fs:1 has `alxta` in title
# alone, so the OR admits it on rank while the @body leaf scores 0.
load(client, IDX_FIELD_SCOPE, FIELD_SCOPE_DOCS)
keys, or_scoped = search(client, IDX_FIELD_SCOPE,
"(@body:alxta)|(@rank:[1 1])")
assert keys == ["fs:2", "fs:3", "fs:4", "fs:1"]
assert or_scoped == pytest.approx(
{"fs:2": IDF_ALXTA, "fs:3": IDF_ALXTA, "fs:4": IDF_ALXTA,
"fs:1": 0.0}, abs=SCORE_ABS_TOL)

# Scoping the same leaf to the field fs:1 does carry admits only fs:1.
_, title_scoped = search(client, IDX_FIELD_SCOPE,
"(@title:alxta)|(@rank:[1 1])")
assert title_scoped == pytest.approx({"fs:1": IDF_ALXTA},
abs=SCORE_ABS_TOL)

# Unscoped, the mask covers both fields, so every doc scores on alxta.
_, all_fields = search(client, IDX_FIELD_SCOPE, "alxta @rank:[0 100]")
assert all_fields == pytest.approx(
{f"fs:{i}": IDF_ALXTA for i in range(1, 5)}, abs=SCORE_ABS_TOL)

# Group 6: an exact phrase narrows admission by adjacency without changing scores.
def test_exact_phrase(self):
client = self.server.get_new_client()
Expand Down Expand Up @@ -574,3 +661,98 @@ def test_scores_follow_mutations(self):
wait_indexed(client, IDX_MAIN, 8)
_, restored = search(client, IDX_MAIN, "hello")
assert restored == pytest.approx(before, abs=SCORE_ABS_TOL)

# Group 15: prefix / suffix / fuzzy expansions score ONE matched term.
# No reference values pinned: which term represents a multi-match doc is
# unspecified and we pick differently, so assert against our own scores.
def test_expansion_scoring(self):
client = self.server.get_new_client()
load(client, IDX_EXPANSION, EXPANSION_DOCS)

# Each pattern expands to several terms, but the asserted doc carries
# exactly one, so it must score the same as the exact-term query.
for pattern, term, key in [("cat*", "cat", "exp:cat"),
("@body:*ing", "running", "exp:run"),
("%cat%", "cat", "exp:cat")]:
_, expanded = search(client, IDX_EXPANSION, pattern)
_, exact = search(client, IDX_EXPANSION, term)
assert expanded[key] > 0.0, pattern
assert expanded[key] == pytest.approx(exact[key],
abs=SCORE_ABS_TOL), pattern

# exp:multi matches cat* via "category" (dt=3) and "catalog" (dt=1), so
# the pick is observable: one of them, and strictly below their sum.
_, prefix = search(client, IDX_EXPANSION, "cat*")
_, category = search(client, IDX_EXPANSION, "category")
_, catalog = search(client, IDX_EXPANSION, "catalog")
got = prefix["exp:multi"]
one, two = category["exp:multi"], catalog["exp:multi"]
assert got < one + two - SCORE_ABS_TOL
assert (got == pytest.approx(one, abs=SCORE_ABS_TOL)
or got == pytest.approx(two, abs=SCORE_ABS_TOL)), (
f"prefix={got} category={one} catalog={two}")

# A text+numeric/tag query takes the extra-step path. Each pattern
# single-matches "hello", so these are the verified "hello @cat:{a}"
# values; a dropped expansion would leave the text leaf at 0.
load(client, IDX_MAIN, PARTIAL_TEXT_DOCS)
for pattern in ("hell*", "@body:*llo", "@body:%helo%"):
keys, scores = search(client, IDX_MAIN,
f"{pattern} @cat:{{a}} @rank:[0 100]")
assert keys == ["doc:3", "doc:1"], pattern
assert scores == pytest.approx(
{"doc:3": 2.234903, "doc:1": 1.492684},
abs=SCORE_ABS_TOL), pattern

# A field-scoped expansion must represent a doc by a term it carries in
# THAT field. fs:1 holds alzta in body and alxta in title, so despite
# alxta sorting first it may only be scored on alzta -- one term matches
# per field here, so unlike above the pick is determined and pinnable.
load(client, IDX_FIELD_SCOPE, FIELD_SCOPE_DOCS)
for pattern in ("@body:al*", "@body:*ta", "@body:%alata%"):
keys, scores = search(client, IDX_FIELD_SCOPE,
f"{pattern} @rank:[0 100]")
assert keys == ["fs:1", "fs:2", "fs:3", "fs:4"], pattern
assert scores == pytest.approx(
{"fs:1": IDF_ALZTA, "fs:2": IDF_ALXTA,
"fs:3": IDF_ALXTA, "fs:4": IDF_ALXTA},
abs=SCORE_ABS_TOL), pattern

# Scoped to title, the same patterns reach only fs:1, and only via alxta.
# No suffix pattern: only `body` has WITHSUFFIXTRIE.
for pattern in ("@title:al*", "@title:%alata%"):
_, scores = search(client, IDX_FIELD_SCOPE,
f"{pattern} @rank:[0 100]")
assert scores == pytest.approx({"fs:1": IDF_ALXTA},
abs=SCORE_ABS_TOL), pattern

# Group 16: a tag prefix scores ONE matched value, an explicit union sums.
def test_tag_prefix_scoring(self):
client = self.server.get_new_client()
load(client, IDX_TAG_PREFIX, TAG_PREFIX_DOCS)
_, prefix = search(client, IDX_TAG_PREFIX, "@cat:{red*}")
_, redis = search(client, IDX_TAG_PREFIX, "@cat:{redis}")
_, redcap = search(client, IDX_TAG_PREFIX, "@cat:{redcap}")

# tpx:a carries only `redis`, so red* resolves to that one value.
assert prefix["tpx:a"] > 0.0
assert prefix["tpx:a"] == pytest.approx(redis["tpx:a"],
abs=SCORE_ABS_TOL)

# tpx:multi carries both values red* matches, with distinct IDFs. An
# explicit union sums them...
_, both = search(client, IDX_TAG_PREFIX, "@cat:{redis|redcap}")
got = prefix["tpx:multi"]
one, two = redis["tpx:multi"], redcap["tpx:multi"]
assert both["tpx:multi"] == pytest.approx(one + two,
abs=SCORE_ABS_TOL)
# ...while the prefix contributes exactly one of them.
assert got < both["tpx:multi"] - SCORE_ABS_TOL
assert (got == pytest.approx(one, abs=SCORE_ABS_TOL)
or got == pytest.approx(two, abs=SCORE_ABS_TOL)), (
f"prefix={got} redis={one} redcap={two}")

# The numeric adds 0, so the combined query must equal the prefix alone.
_, combined = search(client, IDX_TAG_PREFIX,
"@cat:{red*} @rank:[0 100]")
assert combined == pytest.approx(prefix, abs=SCORE_ABS_TOL)
27 changes: 27 additions & 0 deletions src/indexes/tag.cc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "absl/strings/ascii.h"
#include "absl/strings/match.h"
#include "absl/strings/str_cat.h"
#include "absl/strings/str_split.h"
#include "absl/strings/string_view.h"
Expand Down Expand Up @@ -511,6 +512,32 @@ size_t Tag::GetTagValueDocCount(absl::string_view value) const {
return count;
}

size_t Tag::GetPrefixMatchDocCount(absl::string_view prefix_value,
BorrowedInternedStringPtr key) const {
if (prefix_value.empty() || prefix_value.back() != '*') return 0;
const absl::string_view prefix =
prefix_value.substr(0, prefix_value.size() - 1);

// Scan the doc's own tags rather than the prefix's rax subtree: a doc carries
// a handful of tags while a prefix can match an unbounded slice of the index.
// Lock-free by the read-side invariant GetValue / ContainsKey rely on.
auto it = tracked_tags_by_keys_.find(key);
if (it == tracked_tags_by_keys_.end()) return 0;

for (const auto &part :
absl::StrSplit(it->second.raw_tag_string->Str(), separator_)) {
const absl::string_view tag = absl::StripAsciiWhitespace(part);
// Empty tags are never indexed (ParseRecordTags drops them), and a bare `*`
// query gives an empty prefix that would otherwise match one.
if (tag.empty()) continue;
if (case_sensitive_ ? absl::StartsWith(tag, prefix)
: absl::StartsWithIgnoreCase(tag, prefix)) {
return GetTagValueDocCount(tag);
}
}
return 0;
}

bool Tag::IsTracked(const InternedStringPtr &key) const {
absl::MutexLock lock(&index_mutex_);
return tracked_tags_by_keys_.contains(key);
Expand Down
9 changes: 9 additions & 0 deletions src/indexes/tag.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>

#include "absl/base/thread_annotations.h"
Expand Down Expand Up @@ -163,6 +164,14 @@ class Tag : public IndexBase {
// (dt) for tag scoring. O(1) rax lookup plus a bag size read.
size_t GetTagValueDocCount(absl::string_view value) const
ABSL_LOCKS_EXCLUDED(index_mutex_);

// Document count (dt) of the first value on `key` matching prefix query value
// `prefix_value` (must end in '*') -- the value a tag prefix is scored on,
// since a prefix credits ONE matched value, never the sum. 0 if none matches.
// Lock-free like GetValue/ContainsKey (read-side invariant).
size_t GetPrefixMatchDocCount(absl::string_view prefix_value,
BorrowedInternedStringPtr key) const
ABSL_NO_THREAD_SAFETY_ANALYSIS;
static absl::StatusOr<absl::flat_hash_set<absl::string_view>> ParseSearchTags(
absl::string_view data, char separator);
static absl::flat_hash_set<absl::string_view> ParseRecordTags(
Expand Down
34 changes: 28 additions & 6 deletions src/indexes/text.cc
Original file line number Diff line number Diff line change
Expand Up @@ -236,16 +236,26 @@ std::unique_ptr<indexes::text::TextIterator> PrefixPredicate::BuildTextIterator(
absl::InlinedVector<indexes::text::Postings::KeyIterator,
indexes::text::kWordExpansionInlineCapacity>
key_iterators;
// Per-matched-term document frequency (dt), index-aligned with key_iterators,
// so the scored TermIterator contributes ONE term's own BM25 (never the sum).
absl::InlinedVector<uint32_t, indexes::text::kWordExpansionInlineCapacity>
per_term_dt;
// Limit the number of term word expansions
uint32_t max_words = options::GetMaxTermExpansions().GetValue();
uint32_t word_count = 0;
while (!word_iter.Done() && word_count < max_words) {
key_iterators.emplace_back(word_iter.GetPostingsTarget()->GetKeyIterator());
auto postings = word_iter.GetPostingsTarget();
per_term_dt.push_back(postings->GetKeyCount());
key_iterators.emplace_back(postings->GetKeyIterator());
word_iter.Next();
++word_count;
}
return std::make_unique<indexes::text::TermIterator>(
std::move(key_iterators), field_mask, require_positions);
std::move(key_iterators), field_mask, require_positions,
/*stem_field_mask=*/0, /*has_original=*/false,
GetWeight() * or_weight_multiplier,
/*num_doc_contain_term=*/0, GetTextIndexSchema().get(), GetScorer(),
std::move(per_term_dt));
}

std::unique_ptr<indexes::text::TextIterator> SuffixPredicate::BuildTextIterator(
Expand All @@ -260,16 +270,24 @@ std::unique_ptr<indexes::text::TextIterator> SuffixPredicate::BuildTextIterator(
absl::InlinedVector<indexes::text::Postings::KeyIterator,
indexes::text::kWordExpansionInlineCapacity>
key_iterators;
absl::InlinedVector<uint32_t, indexes::text::kWordExpansionInlineCapacity>
per_term_dt;
// Limit the number of term word expansions
uint32_t max_words = options::GetMaxTermExpansions().GetValue();
uint32_t word_count = 0;
while (!word_iter.Done() && word_count < max_words) {
key_iterators.emplace_back(word_iter.GetPostingsTarget()->GetKeyIterator());
auto postings = word_iter.GetPostingsTarget();
per_term_dt.push_back(postings->GetKeyCount());
key_iterators.emplace_back(postings->GetKeyIterator());
word_iter.Next();
++word_count;
}
return std::make_unique<indexes::text::TermIterator>(
std::move(key_iterators), field_mask, require_positions);
std::move(key_iterators), field_mask, require_positions,
/*stem_field_mask=*/0, /*has_original=*/false,
GetWeight() * or_weight_multiplier,
/*num_doc_contain_term=*/0, GetTextIndexSchema().get(), GetScorer(),
std::move(per_term_dt));
}

std::unique_ptr<indexes::text::TextIterator> InfixPredicate::BuildTextIterator(
Expand All @@ -285,10 +303,14 @@ std::unique_ptr<indexes::text::TextIterator> FuzzyPredicate::BuildTextIterator(
float or_weight_multiplier) const {
// Limit the number of term word expansions
uint32_t max_words = options::GetMaxTermExpansions().GetValue();
auto key_iterators = indexes::text::FuzzySearch::Search(
auto expansion = indexes::text::FuzzySearch::Search(
text_index->GetPrefix(), GetTextString(), GetDistance(), max_words);
return std::make_unique<indexes::text::TermIterator>(
std::move(key_iterators), field_mask, require_positions);
std::move(expansion.key_iterators), field_mask, require_positions,
/*stem_field_mask=*/0, /*has_original=*/false,
GetWeight() * or_weight_multiplier,
/*num_doc_contain_term=*/0, GetTextIndexSchema().get(), GetScorer(),
std::move(expansion.per_term_dt));
}

/*
Expand Down
Loading
Loading