blob: a80ef347a238845d3191ae1534813a95a04056bd [file] [edit]
// Copyright (C) 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#include "icing/scoring/bm25f-calculator.h"
#include <cstdint>
#include <cstdlib>
#include <string>
#include <unordered_set>
#include <vector>
#include "icing/index/hit/doc-hit-info.h"
#include "icing/index/iterator/doc-hit-info-iterator.h"
#include "icing/store/corpus-associated-scoring-data.h"
#include "icing/store/corpus-id.h"
#include "icing/store/document-associated-score-data.h"
#include "icing/store/document-filter-data.h"
#include "icing/store/document-id.h"
namespace icing {
namespace lib {
// Smoothing parameter, determines the relevance of higher term frequency
// documents. The higher k1, the higher their relevance. 1.2 is the default
// value in the BM25F literature and works well in most corpora.
constexpr float k1_ = 1.2f;
// Smoothing parameter, determines the weight of the document length on the
// final score. The higher b, the higher the influence of the document length.
// 0.7 is the default value in the BM25F literature and works well in most
// corpora.
constexpr float b_ = 0.7f;
// TODO(b/158603900): add tests for Bm25fCalculator
Bm25fCalculator::Bm25fCalculator(const DocumentStore* document_store,
SectionWeights* section_weights,
int64_t current_time_ms)
: document_store_(document_store),
section_weights_(*section_weights),
current_time_ms_(current_time_ms) {}
// During initialization, Bm25fCalculator iterates through
// hit-iterators for each query term to pre-compute n(q_i) for each corpus under
// consideration.
void Bm25fCalculator::PrepareToScore(
std::unordered_map<std::string, std::unique_ptr<DocHitInfoIterator>>*
query_term_iterators) {
Clear();
TermId term_id = 0;
for (auto& iter : *query_term_iterators) {
const std::string& term = iter.first;
if (term_id_map_.find(term) != term_id_map_.end()) {
continue;
}
term_id_map_[term] = ++term_id;
DocHitInfoIterator* term_it = iter.second.get();
while (term_it->Advance().ok()) {
auto status_or = document_store_->GetDocumentAssociatedScoreData(
term_it->doc_hit_info().document_id());
if (!status_or.ok()) {
ICING_LOG(ERROR) << "No document score data";
continue;
}
DocumentAssociatedScoreData data = status_or.ValueOrDie();
CorpusId corpus_id = data.corpus_id();
CorpusTermInfo corpus_term_info(corpus_id, term_id);
corpus_nqi_map_[corpus_term_info.value]++;
}
}
}
void Bm25fCalculator::Clear() {
term_id_map_.clear();
corpus_avgdl_map_.clear();
corpus_nqi_map_.clear();
corpus_idf_map_.clear();
}
// Computes BM25F relevance score for query terms matched in document D.
//
// BM25F = \sum_i IDF(q_i) * tf(q_i, D)
//
// where IDF(q_i) is the Inverse Document Frequency (IDF) weight of the query
// term q_i in the corpus with document D, and tf(q_i, D) is the weighted and
// normalized term frequency of query term q_i in the document D.
float Bm25fCalculator::ComputeScore(const DocHitInfoIterator* query_it,
const DocHitInfo& hit_info,
double default_score) {
auto status_or =
document_store_->GetDocumentAssociatedScoreData(hit_info.document_id());
if (!status_or.ok()) {
ICING_LOG(ERROR) << "No document score data";
return default_score;
}
DocumentAssociatedScoreData data = status_or.ValueOrDie();
std::vector<TermMatchInfo> matched_terms_stats;
query_it->PopulateMatchedTermsStats(&matched_terms_stats);
float score = 0;
for (const TermMatchInfo& term_match_info : matched_terms_stats) {
float idf_weight =
GetCorpusIdfWeightForTerm(term_match_info.term, data.corpus_id());
float normalized_tf =
ComputedNormalizedTermFrequency(term_match_info, hit_info, data);
score += idf_weight * normalized_tf;
}
ICING_VLOG(1) << "BM25F: corpus_id:" << data.corpus_id()
<< " docid:" << hit_info.document_id() << " score:" << score;
return score;
}
// Compute inverse document frequency (IDF) weight for query term in the given
// corpus, and cache it in the map.
//
// N - n(q_i) + 0.5
// IDF(q_i) = ln(1 + ------------------)
// n(q_i) + 0.5
//
// where N is the number of documents in the corpus, and n(q_i) is the number
// of documents in the corpus containing the query term q_i.
float Bm25fCalculator::GetCorpusIdfWeightForTerm(std::string_view term,
CorpusId corpus_id) {
TermId term_id = term_id_map_[term];
CorpusTermInfo corpus_term_info(corpus_id, term_id);
auto iter = corpus_idf_map_.find(corpus_term_info.value);
if (iter != corpus_idf_map_.end()) {
return iter->second;
}
// First, figure out corpus scoring data.
auto status_or = document_store_->GetCorpusAssociatedScoreData(corpus_id);
if (!status_or.ok()) {
ICING_LOG(ERROR) << "No scoring data for corpus [" << corpus_id << "]";
return 0;
}
CorpusAssociatedScoreData csdata = status_or.ValueOrDie();
uint32_t num_docs = csdata.num_docs();
uint32_t nqi = corpus_nqi_map_[corpus_term_info.value];
float idf =
nqi != 0 ? log(1.0f + (num_docs - nqi + 0.5f) / (nqi + 0.5f)) : 0.0f;
corpus_idf_map_.insert({corpus_term_info.value, idf});
ICING_VLOG(1) << "corpus_id:" << corpus_id << " term:" << term
<< " N:" << num_docs << "nqi:" << nqi << " idf:" << idf;
return idf;
}
// Get per corpus average document length and cache the result in the map.
// The average doc length is calculated as:
//
// total_tokens_in_corpus
// Avg Doc Length = -------------------------
// num_docs_in_corpus + 1
float Bm25fCalculator::GetCorpusAvgDocLength(CorpusId corpus_id) {
auto iter = corpus_avgdl_map_.find(corpus_id);
if (iter != corpus_avgdl_map_.end()) {
return iter->second;
}
// First, figure out corpus scoring data.
auto status_or = document_store_->GetCorpusAssociatedScoreData(corpus_id);
if (!status_or.ok()) {
ICING_LOG(ERROR) << "No scoring data for corpus [" << corpus_id << "]";
return 0;
}
CorpusAssociatedScoreData csdata = status_or.ValueOrDie();
corpus_avgdl_map_[corpus_id] = csdata.average_doc_length_in_tokens();
return csdata.average_doc_length_in_tokens();
}
// Computes normalized term frequency for query term q_i in document D.
//
// f(q_i, D) * (k1 + 1)
// Normalized TF = --------------------------------------------
// f(q_i, D) + k1 * (1 - b + b * |D| / avgdl)
//
// where f(q_i, D) is the frequency of query term q_i in document D,
// |D| is the #tokens in D, avgdl is the average document length in the corpus,
// k1 and b are smoothing parameters.
float Bm25fCalculator::ComputedNormalizedTermFrequency(
const TermMatchInfo& term_match_info, const DocHitInfo& hit_info,
const DocumentAssociatedScoreData& data) {
uint32_t dl = data.length_in_tokens();
float avgdl = GetCorpusAvgDocLength(data.corpus_id());
float f_q = ComputeTermFrequencyForMatchedSections(
data.corpus_id(), term_match_info, hit_info.document_id());
float normalized_tf =
f_q * (k1_ + 1) / (f_q + k1_ * (1 - b_ + b_ * dl / avgdl));
ICING_VLOG(1) << "corpus_id:" << data.corpus_id()
<< " docid:" << hit_info.document_id() << " dl:" << dl
<< " avgdl:" << avgdl << " f_q:" << f_q
<< " norm_tf:" << normalized_tf;
return normalized_tf;
}
float Bm25fCalculator::ComputeTermFrequencyForMatchedSections(
CorpusId corpus_id, const TermMatchInfo& term_match_info,
DocumentId document_id) const {
float sum = 0.0f;
SectionIdMask sections = term_match_info.section_ids_mask;
SchemaTypeId schema_type_id = GetSchemaTypeId(document_id);
while (sections != 0) {
SectionId section_id = __builtin_ctzll(sections);
sections &= ~(UINT64_C(1) << section_id);
Hit::TermFrequency tf = term_match_info.term_frequencies[section_id];
double weighted_tf = tf * section_weights_.GetNormalizedSectionWeight(
schema_type_id, section_id);
if (tf != Hit::kNoTermFrequency) {
sum += weighted_tf;
}
}
return sum;
}
SchemaTypeId Bm25fCalculator::GetSchemaTypeId(DocumentId document_id) const {
auto filter_data_optional = document_store_->GetAliveDocumentFilterData(
document_id, current_time_ms_);
if (!filter_data_optional) {
// This should never happen. The only failure case for
// GetAliveDocumentFilterData is if the document_id is outside of the range
// of allocated document_ids, which shouldn't be possible since we're
// getting this document_id from the posting lists.
ICING_LOG(WARNING) << "No document filter data for document ["
<< document_id << "]";
return kInvalidSchemaTypeId;
}
return filter_data_optional.value().schema_type_id();
}
} // namespace lib
} // namespace icing