Files
2026-07-13 12:47:42 +08:00

1860 lines
67 KiB
C++

// Copyright 2025-present the zvec project
//
// 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 <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <gflags/gflags.h>
#include <roaring/roaring.h>
#include <zvec/ailego/encoding/json.h>
#include <zvec/ailego/utility/time_helper.h>
#include <zvec/db/collection.h>
#include <zvec/db/doc.h>
#include <zvec/db/index_params.h>
#include <zvec/db/options.h>
#include <zvec/db/schema.h>
#include "db/common/constants.h"
#include "db/common/file_helper.h"
#include "db/common/rocksdb_context.h"
#include "db/index/column/fts_column/fts_column_indexer.h"
#include "db/index/column/fts_column/fts_pipeline.h"
#include "db/index/column/fts_column/fts_query_ast.h"
#include "db/index/column/fts_column/fts_rocksdb_merge.h"
#include "db/index/column/fts_column/fts_rocksdb_reducer.h"
#include "db/index/column/fts_column/fts_types.h"
#include "db/index/column/fts_column/fts_utils.h"
#include "db/index/column/fts_column/posting/bitpacked_posting_list.h"
namespace {
// Helper: build a public FtsIndexParams from FLAGS_extra_params JSON string.
// The JSON may contain a "tokenizer" key that specifies the tokenizer name;
// the remaining JSON is passed through as extra_params verbatim.
static std::shared_ptr<zvec::FtsIndexParams> build_fts_index_params(
const std::string &extra_params_json) {
std::string tokenizer_name = "standard";
zvec::ailego::JsonValue jv;
if (jv.parse(extra_params_json) && jv.is_object()) {
const auto &obj = jv.as_object();
zvec::ailego::JsonValue tok_val = obj["tokenizer"];
if (tok_val.is_string()) {
tokenizer_name = tok_val.as_string().as_stl_string();
}
}
return std::make_shared<zvec::FtsIndexParams>(
std::move(tokenizer_name), std::vector<std::string>{"lowercase"},
extra_params_json);
}
// Helper: build a transient FieldSchema for FTS field with index params.
static zvec::FieldSchema::Ptr make_fts_field_schema(
const std::string &field_name,
std::shared_ptr<zvec::FtsIndexParams> fts_params = nullptr) {
if (!fts_params) {
fts_params = std::make_shared<zvec::FtsIndexParams>();
}
return std::make_shared<zvec::FieldSchema>(field_name, zvec::DataType::STRING,
false, fts_params);
}
} // namespace
// ---------------------------------------------------------------------------
// gflags
// ---------------------------------------------------------------------------
DEFINE_string(cmd, "",
"Command to execute: build, search, stats. "
"If empty, auto-detect from -corpus / -query flags.");
DEFINE_string(index, "", "Path to FTS index directory");
DEFINE_string(corpus, "", "Path to BEIR corpus.jsonl (build mode)");
DEFINE_string(query, "", "Path to BEIR queries.jsonl (search mode)");
DEFINE_string(qrels, "", "Path to BEIR qrels directory (search mode)");
DEFINE_int32(topk, 10, "Top-K results to retrieve per query");
DEFINE_string(extra_params, R"({"tokenizer":"standard"})",
"Extra params JSON for tokenizer pipeline");
DEFINE_string(field, "text", "FTS field name");
DEFINE_int32(threads, 16, "Number of threads for multi-threaded search");
DEFINE_int32(max_queries, 0,
"Maximum number of queries to run in search mode. "
"0 means all queries (default).");
DEFINE_bool(reduce, false,
"After build, run FtsRocksdbReducer to convert postings to "
"BitPacked format. Reduced index is written to <index>-reduce.");
DEFINE_string(default_operator, "or",
"Default operator used to combine query tokens when searching "
"match_string-style queries. Valid values: 'or' (union, default) "
"or 'and' (intersection).");
DEFINE_string(mode, "raw",
"Execution mode: 'raw' (default) operates directly on RocksDB "
"via FtsColumnIndexer; 'db' operates through "
"the zvec Collection API (CreateAndOpen / Insert / Query).");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
static const std::string kForwardCfName = "forward";
using namespace zvec;
using namespace zvec::fts;
// ---------------------------------------------------------------------------
// Query AST builder: combine tokens with the configured default operator.
// Returns nullptr when tokens is empty.
// ---------------------------------------------------------------------------
template <typename TokenContainer>
static FtsAstNodePtr build_query_ast_from_tokens(
const TokenContainer &tokens, const std::string &default_operator) {
if (tokens.empty()) {
return nullptr;
}
if (default_operator == "and") {
auto and_node = std::make_unique<AndNode>();
for (const auto &token : tokens) {
and_node->children.push_back(std::make_unique<TermNode>(token.text));
}
return and_node;
}
// Default: OR
auto or_node = std::make_unique<OrNode>();
for (const auto &token : tokens) {
or_node->children.push_back(std::make_unique<TermNode>(token.text));
}
return or_node;
}
// Validate -default_operator flag value. Returns true if valid.
static bool validate_default_operator(const std::string &op) {
return op == "or" || op == "and";
}
// ---------------------------------------------------------------------------
// Helper: open RocksdbStore with FTS column families.
//
// `with_side_cfs` controls whether the build-time only side CFs
// ($TF / $MAX_TF / $DOC_LEN) are listed in the open args. These three CFs
// are dropped at the end of build (after convert_postings_to_bitpacked()
// inlines their payloads into BitPacked postings), mirroring
// MutableSegment::dump_fts_column_indexers(). Search/stats paths therefore
// open the store without them so that the open call doesn't fail with
// "column family not found" against a built index.
// ---------------------------------------------------------------------------
static bool open_fts_store(RocksdbContext *store, const std::string &field_name,
bool existing, const std::string &index_path = "",
bool with_side_cfs = true,
bool with_forward_cf = true) {
const std::string &data_dir = index_path.empty() ? FLAGS_index : index_path;
const std::string max_tf_cf = field_name + zvec::kFtsMaxTfSuffix;
std::vector<std::string> cf_names = {
field_name,
field_name + zvec::kFtsPositionsSuffix,
zvec::kFtsStatCfName,
};
if (with_forward_cf) {
cf_names.push_back(kForwardCfName);
}
if (with_side_cfs) {
cf_names.push_back(field_name + zvec::kFtsTfSuffix);
cf_names.push_back(max_tf_cf);
cf_names.push_back(field_name + zvec::kFtsDocLenSuffix);
}
// Build per-CF merge operators map
std::unordered_map<std::string, std::shared_ptr<rocksdb::MergeOperator>>
per_cf_merge_ops;
per_cf_merge_ops[field_name] = std::make_shared<FtsPostingsMerge>();
if (with_side_cfs) {
per_cf_merge_ops[max_tf_cf] = std::make_shared<FtsMaxTfMerge>();
}
Status status;
if (existing) {
status = store->open(
RocksdbContext::Args{data_dir, cf_names, nullptr, per_cf_merge_ops},
false);
} else {
status = store->create(RocksdbContext::Args{data_dir, cf_names, nullptr,
per_cf_merge_ops, true});
}
if (!status.ok()) {
fprintf(stderr, "ERROR: Failed to open RocksdbStore at [%s], status[%s]\n",
data_dir.c_str(), status.message().c_str());
return false;
}
return true;
}
// ---------------------------------------------------------------------------
// Helper: drop $TF / $MAX_TF / $DOC_LEN CFs after convert_postings_to_bitpacked
// has inlined their payloads into BitPacked postings. Mirrors
// MutableSegment::dump_fts_column_indexers(). The dumped immutable index is
// significantly smaller because these CFs no longer occupy SST space.
// Logs and ignores per-CF failures so that a partial drop (e.g. CF already
// missing on retry) does not abort the whole build.
// ---------------------------------------------------------------------------
static void drop_fts_side_cfs(RocksdbContext *store,
const std::string &field_name) {
const std::vector<std::string> side_cf_names = {
field_name + zvec::kFtsTfSuffix,
field_name + zvec::kFtsMaxTfSuffix,
field_name + zvec::kFtsDocLenSuffix,
};
for (const auto &cf_name : side_cf_names) {
Status drop_status = store->drop_cf(cf_name);
if (!drop_status.ok()) {
fprintf(stderr,
"WARN: Drop column family[%s] failed, status[%s] (ignored)\n",
cf_name.c_str(), drop_status.message().c_str());
}
}
}
// ---------------------------------------------------------------------------
// Helper: parse a JSONL line and extract a string field
// ---------------------------------------------------------------------------
static bool parse_jsonl_line(
const std::string &line,
std::unordered_map<std::string, std::string> *out) {
zvec::ailego::JsonValue jv;
if (!jv.parse(line) || !jv.is_object()) {
return false;
}
const auto &obj = jv.as_object();
for (const auto &kv : obj) {
if (kv.value().is_string()) {
(*out)[kv.key().as_stl_string()] = kv.value().as_string().as_stl_string();
}
}
return true;
}
// ---------------------------------------------------------------------------
// Latency statistics helper
// ---------------------------------------------------------------------------
struct LatencyStats {
std::vector<uint64_t> samples; // microseconds
void add(uint64_t us) {
samples.push_back(us);
}
void print(const std::string &label) const {
if (samples.empty()) {
std::cout << label << ": no samples" << std::endl;
return;
}
std::vector<uint64_t> sorted = samples;
std::sort(sorted.begin(), sorted.end());
uint64_t sum = 0;
for (auto v : sorted) sum += v;
double avg = static_cast<double>(sum) / sorted.size();
auto percentile = [&](double p) -> uint64_t {
size_t idx = static_cast<size_t>(p * sorted.size());
if (idx >= sorted.size()) idx = sorted.size() - 1;
return sorted[idx];
};
std::cout << label << " latency (us):" << std::endl;
std::cout << " Count : " << sorted.size() << std::endl;
std::cout << " Average: " << static_cast<uint64_t>(avg) << std::endl;
std::cout << " Min : " << sorted.front() << std::endl;
std::cout << " P50 : " << percentile(0.50) << std::endl;
std::cout << " P95 : " << percentile(0.95) << std::endl;
std::cout << " P99 : " << percentile(0.99) << std::endl;
std::cout << " Max : " << sorted.back() << std::endl;
}
};
// ---------------------------------------------------------------------------
// REDUCE MODE: convert Roaring Bitmap postings to BitPacked format
// ---------------------------------------------------------------------------
static int do_reduce(const std::string &src_index_path, uint32_t total_docs) {
const std::string dst_index_path = src_index_path + "-reduce";
std::cout << std::endl;
std::cout << "=== REDUCE MODE ===" << std::endl;
std::cout << " Source : " << src_index_path << std::endl;
std::cout << " Dest : " << dst_index_path << std::endl;
// Create destination directory
if (!zvec::FileHelper::DirectoryExists(dst_index_path)) {
if (!zvec::FileHelper::CreateDirectory(dst_index_path)) {
fprintf(stderr, "ERROR: Failed to create reduce output directory: %s\n",
dst_index_path.c_str());
return -1;
}
}
// Open source store (existing). $TF/$MAX_TF/$DOC_LEN were dropped at
// build time after convert_postings_to_bitpacked(), so we open without
// them. The reducer never consumed these CFs anyway (BitPacked postings
// already carry inline tf/doc_len/max_score payloads).
RocksdbContext src_store;
if (!open_fts_store(&src_store, FLAGS_field, /*existing=*/true,
src_index_path, /*with_side_cfs=*/false)) {
fprintf(stderr, "ERROR: Failed to open source store for reduce\n");
return -1;
}
// Open destination store (new) — same shape as a freshly-dumped immutable
// index: no side CFs.
RocksdbContext dst_store;
if (!open_fts_store(&dst_store, FLAGS_field, /*existing=*/false,
dst_index_path, /*with_side_cfs=*/false)) {
fprintf(stderr, "ERROR: Failed to open destination store for reduce\n");
src_store.close();
return -1;
}
// Get source column families
rocksdb::ColumnFamilyHandle *src_postings = src_store.get_cf(FLAGS_field);
rocksdb::ColumnFamilyHandle *src_positions =
src_store.get_cf(FLAGS_field + zvec::kFtsPositionsSuffix);
rocksdb::ColumnFamilyHandle *src_stat =
src_store.get_cf(zvec::kFtsStatCfName);
rocksdb::ColumnFamilyHandle *src_forward = src_store.get_cf(kForwardCfName);
// Get destination column families
rocksdb::ColumnFamilyHandle *dst_postings = dst_store.get_cf(FLAGS_field);
rocksdb::ColumnFamilyHandle *dst_positions =
dst_store.get_cf(FLAGS_field + zvec::kFtsPositionsSuffix);
rocksdb::ColumnFamilyHandle *dst_stat =
dst_store.get_cf(zvec::kFtsStatCfName);
rocksdb::ColumnFamilyHandle *dst_forward = dst_store.get_cf(kForwardCfName);
if (!src_postings || !src_positions || !src_stat || !dst_postings ||
!dst_positions || !dst_stat) {
fprintf(stderr, "ERROR: Failed to get column families for reduce\n");
src_store.close();
dst_store.close();
return -1;
}
zvec::ailego::ElapsedTime reduce_timer;
// Initialize reducer. Side CFs ($TF/$MAX_TF/$DOC_LEN) are no longer
// consumed by the reducer; they remain in the schema for SST compatibility
// but the bench tool does not need to wire them in.
FtsRocksdbReducer reducer;
auto init_result = reducer.init(FLAGS_field, &dst_store, dst_postings,
dst_positions, dst_stat);
if (!init_result.has_value()) {
fprintf(stderr, "ERROR: FtsRocksdbReducer init failed, status[%s]\n",
init_result.error().message().c_str());
src_store.close();
dst_store.close();
return -1;
}
// Feed source as a single segment: doc_id range [0, total_docs-1]
FtsSegmentStats seg_stats;
seg_stats.min_doc_id = 0;
seg_stats.max_doc_id = total_docs > 0 ? total_docs - 1 : 0;
auto feed_result =
reducer.feed(seg_stats, &src_store, src_postings, src_positions);
if (!feed_result.has_value()) {
fprintf(stderr, "ERROR: FtsRocksdbReducer feed failed, status[%s]\n",
feed_result.error().message().c_str());
src_store.close();
dst_store.close();
return -1;
}
// Run reduce with no-delete filter (empty bitmap = nothing deleted).
std::cout << " Running reduce..." << std::endl;
roaring::Roaring no_delete_bitmap;
auto reduce_result = reducer.reduce(no_delete_bitmap);
if (!reduce_result.has_value()) {
fprintf(stderr, "ERROR: FtsRocksdbReducer reduce failed, status[%s]\n",
reduce_result.error().message().c_str());
src_store.close();
dst_store.close();
return -1;
}
// Copy forward CF (doc_id -> corpus_id mapping)
if (src_forward && dst_forward) {
std::cout << " Copying forward CF..." << std::endl;
auto iter = std::unique_ptr<rocksdb::Iterator>(
src_store.db_->NewIterator(src_store.read_opts_, src_forward));
while (iter->Valid()) {
dst_store.db_->Put(dst_store.write_opts_, dst_forward,
iter->key().ToString(), iter->value().ToString());
iter->Next();
}
// iter auto-closes via unique_ptr
}
// Flush and compact destination. Side CFs are not present here.
dst_store.flush();
// compact not available in RocksdbContext
uint64_t reduce_ms = reduce_timer.milli_seconds();
std::cout << "=== REDUCE COMPLETE ===" << std::endl;
std::cout << " Reduce time : " << reduce_ms << " ms" << std::endl;
std::cout << " Output path : " << dst_index_path << std::endl;
(void)reducer.cleanup();
src_store.close();
dst_store.close();
return 0;
}
struct CorpusEntry {
uint32_t doc_id;
std::string corpus_id;
std::string content;
};
static int do_build() {
const int num_threads = std::max(1, FLAGS_threads);
std::cout << "=== BUILD MODE ===" << std::endl;
std::cout << "Index : " << FLAGS_index << std::endl;
std::cout << "Corpus : " << FLAGS_corpus << std::endl;
std::cout << "Field : " << FLAGS_field << std::endl;
std::cout << "Threads: " << num_threads << std::endl;
std::cout << "ExtraParams: " << FLAGS_extra_params << std::endl;
// Remove existing index directory so that RocksdbContext::create() starts
// fresh (it requires the path to NOT exist).
if (zvec::FileHelper::DirectoryExists(FLAGS_index)) {
std::cout << "Removing existing index directory: " << FLAGS_index
<< std::endl;
zvec::FileHelper::RemoveDirectory(FLAGS_index);
}
// Open RocksDB (new)
RocksdbContext store;
if (!open_fts_store(&store, FLAGS_field, /*existing=*/false)) {
return -1;
}
// Get column families
const std::string max_tf_cf_name = FLAGS_field + zvec::kFtsMaxTfSuffix;
rocksdb::ColumnFamilyHandle *postings_cf = store.get_cf(FLAGS_field);
rocksdb::ColumnFamilyHandle *positions_cf =
store.get_cf(FLAGS_field + zvec::kFtsPositionsSuffix);
rocksdb::ColumnFamilyHandle *term_freq_cf =
store.get_cf(FLAGS_field + zvec::kFtsTfSuffix);
rocksdb::ColumnFamilyHandle *max_tf_cf = store.get_cf(max_tf_cf_name);
rocksdb::ColumnFamilyHandle *doc_len_cf =
store.get_cf(FLAGS_field + zvec::kFtsDocLenSuffix);
rocksdb::ColumnFamilyHandle *stat_cf = store.get_cf(zvec::kFtsStatCfName);
rocksdb::ColumnFamilyHandle *forward_cf = store.get_cf(kForwardCfName);
if (!postings_cf || !positions_cf || !term_freq_cf || !max_tf_cf ||
!doc_len_cf || !stat_cf || !forward_cf) {
fprintf(stderr, "ERROR: Failed to get column families\n");
return -1;
}
// Pre-load all corpus entries into memory with pre-assigned doc_ids
std::vector<CorpusEntry> corpus_entries;
uint64_t parse_failed_count = 0;
{
std::ifstream corpus_file(FLAGS_corpus);
if (!corpus_file.is_open()) {
fprintf(stderr, "ERROR: Failed to open corpus file: %s\n",
FLAGS_corpus.c_str());
return -1;
}
uint32_t doc_id = 0;
std::string line;
while (std::getline(corpus_file, line)) {
if (line.empty()) continue;
std::unordered_map<std::string, std::string> fields;
if (!parse_jsonl_line(line, &fields)) {
fprintf(stderr, "WARN: Failed to parse line: %s\n",
line.substr(0, 100).c_str());
++parse_failed_count;
continue;
}
const std::string &corpus_id = fields["_id"];
if (corpus_id.empty()) {
++parse_failed_count;
continue;
}
std::string content;
if (!fields["title"].empty()) {
content = fields["title"] + " " + fields["text"];
} else {
content = fields["text"];
}
corpus_entries.push_back(
{doc_id, std::move(corpus_id), std::move(content)});
++doc_id;
}
}
std::cout << "Loaded " << corpus_entries.size() << " corpus entries."
<< std::endl;
if (parse_failed_count > 0) {
std::cout << " Warning: " << parse_failed_count
<< " entries failed to parse." << std::endl;
}
auto fts_params = build_fts_index_params(FLAGS_extra_params);
auto field_meta = make_fts_field_schema(FLAGS_field, fts_params);
FtsColumnIndexer indexer;
auto open_result = indexer.open(field_meta, &store, postings_cf, positions_cf,
term_freq_cf, max_tf_cf, doc_len_cf, stat_cf);
if (!open_result.has_value()) {
fprintf(stderr, "ERROR: Failed to open FtsColumnIndexer, status[%s]\n",
open_result.error().message().c_str());
return -1;
}
// Shared atomic index for work-stealing across threads
std::atomic<size_t> next_entry_index{0};
// Per-thread result accumulators
struct ThreadResult {
uint64_t indexed_count{0};
uint64_t failed_count{0};
};
std::vector<ThreadResult> thread_results(num_threads);
std::cout << "Building index with " << num_threads << " thread(s)..."
<< std::endl;
zvec::ailego::ElapsedTime timer;
auto worker = [&](int thread_id) {
ThreadResult &result = thread_results[thread_id];
while (true) {
size_t entry_idx =
next_entry_index.fetch_add(1, std::memory_order_relaxed);
if (entry_idx >= corpus_entries.size()) break;
const CorpusEntry &entry = corpus_entries[entry_idx];
auto insert_result = indexer.insert(entry.doc_id, entry.content);
if (!insert_result.has_value()) {
fprintf(stderr,
"WARN: Thread[%d] failed to insert doc_id[%u] corpus_id[%s], "
"status[%s]\n",
thread_id, entry.doc_id, entry.corpus_id.c_str(),
insert_result.error().message().c_str());
++result.failed_count;
continue;
}
// Write forward mapping: doc_id -> corpus_id
std::string doc_id_key;
fts::encode_uint32_big_endian(entry.doc_id, &doc_id_key);
store.db_->Put(store.write_opts_, forward_cf, doc_id_key,
entry.corpus_id);
++result.indexed_count;
// Progress reporting (only from thread 0 to avoid interleaving)
if (thread_id == 0 && result.indexed_count % 1000 == 0) {
size_t total_done = 0;
for (const auto &tr : thread_results) {
total_done += tr.indexed_count + tr.failed_count;
}
std::cout << "\r Indexed ~" << total_done << " / "
<< corpus_entries.size() << " docs..." << std::flush;
}
}
};
// Launch threads
std::vector<std::thread> threads;
threads.reserve(num_threads);
for (int thread_id = 0; thread_id < num_threads; ++thread_id) {
threads.emplace_back(worker, thread_id);
}
for (auto &thread : threads) {
thread.join();
}
uint64_t build_ms = timer.milli_seconds();
// Merge per-thread results
uint64_t total_indexed = 0;
uint64_t total_failed = 0;
for (const auto &result : thread_results) {
total_indexed += result.indexed_count;
total_failed += result.failed_count;
}
std::cout << "\r Indexed " << total_indexed << " docs total." << std::endl;
if (total_failed > 0) {
std::cout << " Warning: " << total_failed << " docs failed to index."
<< std::endl;
}
// Flush statistics — single indexer tracks all docs/tokens atomically
std::cout << "Flushing statistics (total_docs=" << indexer.total_docs()
<< ", total_tokens=" << indexer.total_tokens() << ")..."
<< std::endl;
auto flush_result = indexer.flush();
if (!flush_result.has_value()) {
fprintf(stderr, "WARN: FtsColumnIndexer flush failed, status[%s]\n",
flush_result.error().message().c_str());
}
// Convert Roaring postings to BitPacked before close/dump, mirroring
// MutableSegment::dump_fts_column_indexers(). Must run before close()
// for symmetry with the single-threaded path; convert itself does not
// depend on the tokenizer pipeline.
std::cout << "Converting postings to BitPacked..." << std::endl;
zvec::ailego::ElapsedTime bitpacked_timer2;
auto bitpacked_result = indexer.convert_postings_to_bitpacked();
if (!bitpacked_result.has_value()) {
fprintf(stderr,
"WARN: FtsColumnIndexer convert_postings_to_bitpacked failed, "
"status[%s]\n",
bitpacked_result.error().message().c_str());
}
std::cout << "convert_postings_to_bitpacked took "
<< bitpacked_timer2.micro_seconds() / 1000.0 << " ms" << std::endl;
// Drop $TF / $MAX_TF / $DOC_LEN CFs after their payloads have been inlined
// into BitPacked postings. Mirrors MutableSegment::dump_fts_column_
// indexers(): reset_side_cfs() first so any concurrent reader-path access
// through the indexer falls back to default tf=1/doc_len=1 instead of
// touching a dropped handle, then drop the CFs from the underlying store.
indexer.reset_side_cfs();
drop_fts_side_cfs(&store, FLAGS_field);
// Local pointers are now dangling; null them out so accidental use becomes
// an obvious crash instead of a use-after-free.
term_freq_cf = nullptr;
max_tf_cf = nullptr;
doc_len_cf = nullptr;
(void)indexer.close();
// Flush RocksDB memtables + dump checkpoint
zvec::ailego::ElapsedTime dump_timer;
store.flush();
// Trigger compaction + checkpoint
std::cout << "Running compaction..." << std::endl;
store.compact();
uint64_t dump_ms = dump_timer.milli_seconds();
uint64_t elapsed_ms = timer.milli_seconds();
std::cout << "=== BUILD COMPLETE ===" << std::endl;
std::cout << " Total docs : " << total_indexed << std::endl;
std::cout << " Threads : " << num_threads << std::endl;
std::cout << " Build time : " << build_ms << " ms" << std::endl;
std::cout << " Dump time : " << dump_ms << " ms (flush + compaction)"
<< std::endl;
std::cout << " Total time : " << elapsed_ms << " ms" << std::endl;
std::cout << " Throughput : "
<< (total_indexed > 0
? total_indexed * 1000ULL / (build_ms > 0 ? build_ms : 1)
: 0)
<< " docs/s (build only)" << std::endl;
store.close();
// Optional: run reduce to convert postings to BitPacked format
if (FLAGS_reduce) {
int reduce_ret = do_reduce(FLAGS_index, total_indexed);
if (reduce_ret != 0) {
fprintf(stderr, "ERROR: Reduce step failed, ret[%d]\n", reduce_ret);
return reduce_ret;
}
}
return 0;
}
// ---------------------------------------------------------------------------
// BUILD MODE (db): use zvec Collection API
// ---------------------------------------------------------------------------
static int do_build_db() {
const int num_threads = std::max(1, FLAGS_threads);
std::cout << "=== BUILD MODE (db) ===" << std::endl;
std::cout << "Index : " << FLAGS_index << std::endl;
std::cout << "Corpus : " << FLAGS_corpus << std::endl;
std::cout << "Field : " << FLAGS_field << std::endl;
std::cout << "Threads: " << num_threads << std::endl;
// Remove existing collection directory
if (zvec::FileHelper::DirectoryExists(FLAGS_index)) {
std::cout << "Removing existing collection directory: " << FLAGS_index
<< std::endl;
zvec::FileHelper::RemoveDirectory(FLAGS_index);
}
// Build schema: pk (implicit) + FTS field.
// Build FtsIndexParams from FLAGS_extra_params so that the tokenizer
// pipeline configuration (e.g. enable_simple_closet) matches raw mode.
auto db_fts_params = build_fts_index_params(FLAGS_extra_params);
CollectionSchema schema("fts_bench");
schema.add_field(std::make_shared<FieldSchema>(FLAGS_field, DataType::STRING,
false, db_fts_params));
CollectionOptions options;
options.read_only_ = false;
auto create_result = Collection::CreateAndOpen(FLAGS_index, schema, options);
if (!create_result.has_value()) {
fprintf(stderr, "ERROR: Failed to create collection at [%s]: %s\n",
FLAGS_index.c_str(), create_result.error().message().c_str());
return -1;
}
auto collection = create_result.value();
// Pre-load corpus entries
std::vector<CorpusEntry> corpus_entries;
uint64_t parse_failed_count = 0;
{
std::ifstream corpus_file(FLAGS_corpus);
if (!corpus_file.is_open()) {
fprintf(stderr, "ERROR: Failed to open corpus file: %s\n",
FLAGS_corpus.c_str());
return -1;
}
uint32_t doc_id = 0;
std::string line;
while (std::getline(corpus_file, line)) {
if (line.empty()) continue;
std::unordered_map<std::string, std::string> fields;
if (!parse_jsonl_line(line, &fields)) {
++parse_failed_count;
continue;
}
const std::string &corpus_id = fields["_id"];
if (corpus_id.empty()) {
++parse_failed_count;
continue;
}
std::string content;
if (!fields["title"].empty()) {
content = fields["title"] + " " + fields["text"];
} else {
content = fields["text"];
}
corpus_entries.push_back(
{doc_id, std::move(corpus_id), std::move(content)});
++doc_id;
}
}
std::cout << "Loaded " << corpus_entries.size() << " corpus entries."
<< std::endl;
if (parse_failed_count > 0) {
std::cout << " Warning: " << parse_failed_count
<< " entries failed to parse." << std::endl;
}
// Insert in batches via Collection::Insert
const size_t batch_size = 1000;
uint64_t total_indexed = 0;
uint64_t total_failed = 0;
std::cout << "Inserting documents via Collection API..." << std::endl;
zvec::ailego::ElapsedTime timer;
for (size_t offset = 0; offset < corpus_entries.size();
offset += batch_size) {
size_t end = std::min(offset + batch_size, corpus_entries.size());
std::vector<Doc> docs;
docs.reserve(end - offset);
for (size_t i = offset; i < end; ++i) {
const CorpusEntry &entry = corpus_entries[i];
Doc doc;
doc.set_pk(entry.corpus_id);
doc.set<std::string>(FLAGS_field, entry.content);
docs.push_back(std::move(doc));
}
auto insert_result = collection->Insert(docs);
if (!insert_result.has_value()) {
fprintf(stderr, "WARN: Batch insert failed at offset[%zu]: %s\n", offset,
insert_result.error().message().c_str());
total_failed += (end - offset);
} else {
total_indexed += (end - offset);
}
if (total_indexed % 10000 < batch_size) {
std::cout << "\r Inserted " << total_indexed << " / "
<< corpus_entries.size() << " docs..." << std::flush;
}
}
uint64_t build_ms = timer.milli_seconds();
// Flush collection
auto flush_status = collection->Flush();
if (!flush_status.ok()) {
fprintf(stderr, "WARN: Collection flush failed: %s\n",
flush_status.message().c_str());
}
// Optimize triggers segment dump which converts Roaring postings to
// BitPacked format (with inline tf/doc_len payloads). Without this step
// the immutable reader path falls back to tf=1/doc_len=1 because the
// side CFs (_tf/_doc_len/_max_tf) are not opened for read-only segments.
auto optimize_status = collection->Optimize();
if (!optimize_status.ok()) {
fprintf(stderr, "WARN: Collection optimize failed: %s\n",
optimize_status.message().c_str());
}
std::cout << "\r Inserted " << total_indexed << " docs total." << std::endl;
if (total_failed > 0) {
std::cout << " Warning: " << total_failed << " docs failed to insert."
<< std::endl;
}
std::cout << "=== BUILD COMPLETE (db) ===" << std::endl;
std::cout << " Total docs : " << total_indexed << std::endl;
std::cout << " Build time : " << build_ms << " ms" << std::endl;
std::cout << " Throughput : "
<< (total_indexed > 0
? total_indexed * 1000ULL / (build_ms > 0 ? build_ms : 1)
: 0)
<< " docs/s" << std::endl;
return 0;
}
// ---------------------------------------------------------------------------
// SEARCH MODE
// ---------------------------------------------------------------------------
// Parse qrels TSV file: returns map of query_id -> set<corpus_id>
static std::unordered_map<std::string, std::unordered_set<std::string>>
load_qrels(const std::string &qrels_dir) {
std::unordered_map<std::string, std::unordered_set<std::string>> qrels;
// Try test.tsv first, then train.tsv
std::vector<std::string> candidates = {qrels_dir + "/test.tsv",
qrels_dir + "/train.tsv"};
std::string qrels_file;
for (const auto &f : candidates) {
if (FileHelper::FileExists(f)) {
qrels_file = f;
break;
}
}
if (qrels_file.empty()) {
fprintf(stderr, "ERROR: No qrels file found in directory: %s\n",
qrels_dir.c_str());
return qrels;
}
std::cout << "Loading qrels from: " << qrels_file << std::endl;
std::ifstream f(qrels_file);
if (!f.is_open()) {
fprintf(stderr, "ERROR: Failed to open qrels file: %s\n",
qrels_file.c_str());
return qrels;
}
std::string line;
bool first_line = true;
while (std::getline(f, line)) {
if (first_line) {
first_line = false;
continue; // skip header
}
if (line.empty()) continue;
std::istringstream ss(line);
std::string query_id, corpus_id, score_str;
if (!std::getline(ss, query_id, '\t') ||
!std::getline(ss, corpus_id, '\t') ||
!std::getline(ss, score_str, '\t')) {
continue;
}
// Only include relevant docs (score > 0)
int score = std::stoi(score_str);
if (score > 0) {
qrels[query_id].insert(corpus_id);
}
}
std::cout << "Loaded qrels for " << qrels.size() << " queries." << std::endl;
return qrels;
}
// ---------------------------------------------------------------------------
// Unified single-/multi-threaded search:
// * Always pre-loads queries into memory and dispatches them to
// FLAGS_threads workers via an atomic index counter.
// * FtsColumnIndexer::search() and the shared TokenizerPipeline are both
// read-only / fork-safe, so a single shared reader and pipeline are
// reused across workers.
// * When FLAGS_threads == 1 the path collapses to a single worker,
// behaving equivalently to a sequential single-threaded search.
// ---------------------------------------------------------------------------
struct QueryEntry {
std::string query_id;
std::string match_text;
};
struct RecallCounter {
double sum{0.0};
uint64_t total{0};
void add(double recall_value) {
sum += recall_value;
total++;
}
double ratio() const {
return total > 0 ? sum / static_cast<double>(total) : 0.0;
}
};
static int do_search() {
if (!validate_default_operator(FLAGS_default_operator)) {
fprintf(stderr,
"ERROR: Invalid -default_operator[%s]. Must be 'or' or 'and'.\n",
FLAGS_default_operator.c_str());
return -1;
}
const int num_threads = std::max(1, FLAGS_threads);
const std::string fts_index_path = FLAGS_index;
std::cout << "=== SEARCH MODE ===" << std::endl;
std::cout << "Index : " << fts_index_path << std::endl;
std::cout << "Query : " << FLAGS_query << std::endl;
std::cout << "Qrels : " << FLAGS_qrels << std::endl;
std::cout << "TopK : " << FLAGS_topk << std::endl;
std::cout << "Field : " << FLAGS_field << std::endl;
std::cout << "Threads : " << num_threads << std::endl;
std::cout << "Default operator : " << FLAGS_default_operator << std::endl;
// Open FTS RocksDB (existing) — shared across threads (RocksDB reads are
// thread-safe at the CF level). Open without $TF/$MAX_TF/$DOC_LEN since
// those CFs were dropped at build time after convert_postings_to_bitpacked().
RocksdbContext store;
if (!open_fts_store(&store, FLAGS_field, /*existing=*/true,
/*index_path=*/fts_index_path,
/*with_side_cfs=*/false,
/*with_forward_cf=*/true)) {
return -1;
}
rocksdb::ColumnFamilyHandle *postings_cf = store.get_cf(FLAGS_field);
rocksdb::ColumnFamilyHandle *positions_cf =
store.get_cf(FLAGS_field + zvec::kFtsPositionsSuffix);
rocksdb::ColumnFamilyHandle *stat_cf = store.get_cf(zvec::kFtsStatCfName);
rocksdb::ColumnFamilyHandle *forward_cf = store.get_cf(kForwardCfName);
if (!postings_cf || !positions_cf || !stat_cf || !forward_cf) {
fprintf(stderr, "ERROR: Failed to get column families\n");
return -1;
}
// Load qrels
auto qrels = load_qrels(FLAGS_qrels);
// Pre-load all queries into memory so threads can access them without I/O
// contention
std::vector<QueryEntry> queries;
{
std::ifstream query_file(FLAGS_query);
if (!query_file.is_open()) {
fprintf(stderr, "ERROR: Failed to open query file: %s\n",
FLAGS_query.c_str());
return -1;
}
std::string line;
while (std::getline(query_file, line)) {
if (line.empty()) continue;
std::unordered_map<std::string, std::string> fields;
if (!parse_jsonl_line(line, &fields)) continue;
const std::string &query_id = fields["_id"];
const std::string &query_text = fields["text"];
if (query_id.empty() || query_text.empty()) continue;
queries.push_back({query_id, query_text});
}
}
std::cout << "Loaded " << queries.size() << " queries." << std::endl;
// Limit the number of queries if configured: first keep only queries that
// have qrels entries (relevant results), then truncate to max_queries.
if (FLAGS_max_queries > 0) {
std::vector<QueryEntry> filtered;
for (auto &q : queries) {
if (qrels.count(q.query_id) > 0) {
filtered.push_back(std::move(q));
}
}
queries = std::move(filtered);
if (static_cast<size_t>(FLAGS_max_queries) < queries.size()) {
queries.resize(FLAGS_max_queries);
}
std::cout << "Limited to " << queries.size()
<< " queries with qrels (--max_queries)." << std::endl;
}
// Shared atomic index for work-stealing across threads
std::atomic<size_t> next_query_index{0};
// Per-thread result accumulators, merged after all threads finish
struct ThreadResult {
LatencyStats latency_stats;
RecallCounter recall1;
RecallCounter recall5;
RecallCounter recall10;
RecallCounter recallK;
uint64_t no_result_count{0};
uint64_t query_count{0};
};
std::vector<ThreadResult> thread_results(num_threads);
auto query_fts_params = build_fts_index_params(FLAGS_extra_params);
auto pipeline_result = zvec::detail::AcquireFtsPipeline(*query_fts_params);
if (!pipeline_result.has_value()) {
fprintf(stderr,
"ERROR: Failed to create tokenizer pipeline for "
"extra_params[%s]: %s\n",
FLAGS_extra_params.c_str(),
pipeline_result.error().message().c_str());
return -1;
}
auto &query_pipeline = pipeline_result.value();
std::cout << "Running queries with " << num_threads << " thread(s)..."
<< std::endl;
// Create a single shared FtsColumnIndexer in read-only mode. search() is a
// const method that only performs read-only RocksDB lookups, so it is safe
// to share across threads.
FtsColumnIndexer reader;
{
// $TF/$MAX_TF/$DOC_LEN are dropped at build time; pass nullptr — the
// BitPacked path doesn't need them and the Roaring fallback degrades
// to default tf=1/doc_len=1 when these pointers are null.
auto open_result =
reader.open_reader(FLAGS_field, &store, postings_cf, positions_cf,
/*term_freq_cf=*/nullptr,
/*max_tf_cf=*/nullptr,
/*doc_len_cf=*/nullptr, stat_cf);
if (!open_result.has_value()) {
fprintf(stderr, "ERROR: Failed to open FtsColumnIndexer, status[%s]\n",
open_result.error().message().c_str());
return -1;
}
}
auto worker = [&](int thread_id) {
ThreadResult &result = thread_results[thread_id];
while (true) {
size_t query_idx =
next_query_index.fetch_add(1, std::memory_order_relaxed);
if (query_idx >= queries.size()) break;
const QueryEntry &entry = queries[query_idx];
std::vector<FtsResult> results;
bool search_ok = true;
uint64_t elapsed_us = 0;
{
zvec::ailego::ElapsedTime timer;
// Tokenize query text (match_string style: tokenize then build AST
// combining tokens with the configured default operator).
auto tokens = query_pipeline->process(entry.match_text);
auto ast_root =
build_query_ast_from_tokens(tokens, FLAGS_default_operator);
if (ast_root) {
fts::FtsQueryParams query_params;
query_params.topk = static_cast<uint32_t>(FLAGS_topk);
auto search_result = reader.search(*ast_root, query_params);
if (!search_result.has_value()) {
fprintf(stderr,
"WARN: Thread[%d] search failed for query_id[%s], "
"status[%s]\n",
thread_id, entry.query_id.c_str(),
search_result.error().message().c_str());
search_ok = false;
} else {
results = std::move(search_result.value());
}
}
elapsed_us = timer.micro_seconds();
}
if (!search_ok) {
continue;
}
result.latency_stats.add(elapsed_us);
++result.query_count;
if (results.empty()) {
++result.no_result_count;
}
// Resolve doc_id -> corpus_id (a.k.a. pk) via the forward CF.
std::vector<std::string> retrieved_corpus_ids;
retrieved_corpus_ids.reserve(results.size());
for (const auto &r : results) {
std::string corpus_id;
std::string doc_id_key;
fts::encode_uint32_big_endian(r.doc_id, &doc_id_key);
if (!store.db_
->Get(store.read_opts_, forward_cf, doc_id_key, &corpus_id)
.ok()) {
corpus_id = "";
}
retrieved_corpus_ids.push_back(corpus_id);
}
// Compute recall at various cutoffs
const auto qrels_it = qrels.find(entry.query_id);
if (qrels_it == qrels.end()) continue;
const auto &relevant = qrels_it->second;
// Standard IR Recall@K = |retrieved_topK ∩ relevant| / |relevant|
auto compute_recall = [&](int cutoff) -> double {
if (relevant.empty()) return 0.0;
int limit =
std::min(cutoff, static_cast<int>(retrieved_corpus_ids.size()));
int hits = 0;
for (int i = 0; i < limit; ++i) {
if (relevant.count(retrieved_corpus_ids[i]) > 0) {
hits++;
}
}
return static_cast<double>(hits) / static_cast<double>(relevant.size());
};
result.recall1.add(compute_recall(1));
result.recall5.add(compute_recall(5));
result.recall10.add(compute_recall(10));
result.recallK.add(compute_recall(FLAGS_topk));
}
};
// Launch threads and measure total wall-clock time
auto wall_start = std::chrono::steady_clock::now();
std::vector<std::thread> threads;
threads.reserve(num_threads);
for (int thread_id = 0; thread_id < num_threads; ++thread_id) {
threads.emplace_back(worker, thread_id);
}
for (auto &thread : threads) {
thread.join();
}
auto wall_end = std::chrono::steady_clock::now();
uint64_t wall_ms = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(wall_end -
wall_start)
.count());
// Merge per-thread results
LatencyStats merged_latency;
RecallCounter merged_recall1, merged_recall5, merged_recall10, merged_recallK;
uint64_t total_query_count = 0;
uint64_t total_no_result_count = 0;
for (const auto &result : thread_results) {
for (uint64_t sample : result.latency_stats.samples) {
merged_latency.add(sample);
}
merged_recall1.sum += result.recall1.sum;
merged_recall1.total += result.recall1.total;
merged_recall5.sum += result.recall5.sum;
merged_recall5.total += result.recall5.total;
merged_recall10.sum += result.recall10.sum;
merged_recall10.total += result.recall10.total;
merged_recallK.sum += result.recallK.sum;
merged_recallK.total += result.recallK.total;
total_query_count += result.query_count;
total_no_result_count += result.no_result_count;
}
// Output results
std::cout << std::endl;
std::cout << "=== SEARCH RESULTS ===" << std::endl;
std::cout << "Threads : " << num_threads << std::endl;
std::cout << "Total queries : " << total_query_count << std::endl;
std::cout << "No-result queries: " << total_no_result_count << std::endl;
std::cout << "Wall-clock time : " << wall_ms << " ms" << std::endl;
if (wall_ms > 0) {
std::cout << "Throughput : " << total_query_count * 1000ULL / wall_ms
<< " queries/s" << std::endl;
}
std::cout << std::endl;
merged_latency.print("Search (per-query)");
std::cout << std::endl;
if (merged_recall1.total > 0) {
std::cout << "=== RECALL ===" << std::endl;
std::cout << " Recall@1 : " << merged_recall1.ratio() << " (evaluated on "
<< merged_recall1.total << " queries)" << std::endl;
std::cout << " Recall@5 : " << merged_recall5.ratio() << " (evaluated on "
<< merged_recall5.total << " queries)" << std::endl;
std::cout << " Recall@10 : " << merged_recall10.ratio()
<< " (evaluated on " << merged_recall10.total << " queries)"
<< std::endl;
if (FLAGS_topk > 10) {
std::cout << " Recall@" << FLAGS_topk << " : " << merged_recallK.ratio()
<< " (evaluated on " << merged_recallK.total << " queries)"
<< std::endl;
}
} else {
std::cout << "No qrels matched for evaluated queries." << std::endl;
}
store.close();
return 0;
}
// ---------------------------------------------------------------------------
// SEARCH MODE (db): use zvec Collection::Query(Fts)
// ---------------------------------------------------------------------------
static int do_search_db() {
const int num_threads = std::max(1, FLAGS_threads);
std::cout << "=== SEARCH MODE (db) ===" << std::endl;
std::cout << "Index : " << FLAGS_index << std::endl;
std::cout << "Query : " << FLAGS_query << std::endl;
std::cout << "Qrels : " << FLAGS_qrels << std::endl;
std::cout << "TopK : " << FLAGS_topk << std::endl;
std::cout << "Field : " << FLAGS_field << std::endl;
std::cout << "Threads : " << num_threads << std::endl;
// Open existing collection in read-only mode
CollectionOptions options;
options.read_only_ = true;
auto open_result = Collection::Open(FLAGS_index, options);
if (!open_result.has_value()) {
fprintf(stderr, "ERROR: Failed to open collection at [%s]: %s\n",
FLAGS_index.c_str(), open_result.error().message().c_str());
return -1;
}
auto collection = open_result.value();
// Load qrels
auto qrels = load_qrels(FLAGS_qrels);
// Pre-load queries
std::vector<QueryEntry> queries;
{
std::ifstream query_file(FLAGS_query);
if (!query_file.is_open()) {
fprintf(stderr, "ERROR: Failed to open query file: %s\n",
FLAGS_query.c_str());
return -1;
}
std::string line;
while (std::getline(query_file, line)) {
if (line.empty()) continue;
std::unordered_map<std::string, std::string> fields;
if (!parse_jsonl_line(line, &fields)) continue;
const std::string &query_id = fields["_id"];
const std::string &query_text = fields["text"];
if (query_id.empty() || query_text.empty()) continue;
queries.push_back({query_id, query_text});
}
}
std::cout << "Loaded " << queries.size() << " queries." << std::endl;
// Limit the number of queries if configured: first keep only queries that
// have qrels entries (relevant results), then truncate to max_queries.
if (FLAGS_max_queries > 0) {
std::vector<QueryEntry> filtered;
for (auto &q : queries) {
if (qrels.count(q.query_id) > 0) {
filtered.push_back(std::move(q));
}
}
queries = std::move(filtered);
if (static_cast<size_t>(FLAGS_max_queries) < queries.size()) {
queries.resize(FLAGS_max_queries);
}
std::cout << "Limited to " << queries.size()
<< " queries with qrels (--max_queries)." << std::endl;
}
// Per-thread result accumulators
std::atomic<size_t> next_query_index{0};
std::atomic<bool> fatal_error{false};
struct ThreadResult {
LatencyStats latency_stats;
RecallCounter recall1;
RecallCounter recall5;
RecallCounter recall10;
RecallCounter recallK;
uint64_t no_result_count{0};
uint64_t query_count{0};
};
std::vector<ThreadResult> thread_results(num_threads);
std::cout << "Running queries via Collection API with " << num_threads
<< " thread(s)..." << std::endl;
auto worker = [&](int thread_id) {
ThreadResult &result = thread_results[thread_id];
while (true) {
if (fatal_error.load(std::memory_order_relaxed)) break;
size_t query_idx =
next_query_index.fetch_add(1, std::memory_order_relaxed);
if (query_idx >= queries.size()) break;
const QueryEntry &entry = queries[query_idx];
SearchQuery vq;
vq.target_.field_name_ = FLAGS_field;
vq.topk_ = FLAGS_topk;
FtsClause fts;
fts.match_string_ = entry.match_text;
vq.target_.clause_ = fts;
uint64_t elapsed_us = 0;
std::vector<std::string> retrieved_corpus_ids;
{
zvec::ailego::ElapsedTime query_timer;
auto query_result = collection->Query(vq);
elapsed_us = query_timer.micro_seconds();
if (query_result.has_value()) {
const auto &doc_list = query_result.value();
retrieved_corpus_ids.reserve(doc_list.size());
for (const auto &doc_ptr : doc_list) {
retrieved_corpus_ids.push_back(doc_ptr->pk());
}
} else {
fprintf(stderr, "ERROR: Thread[%d] Fts failed for query_id[%s]: %s\n",
thread_id, entry.query_id.c_str(),
query_result.error().message().c_str());
fatal_error.store(true, std::memory_order_relaxed);
break;
}
}
result.latency_stats.add(elapsed_us);
++result.query_count;
if (retrieved_corpus_ids.empty()) {
++result.no_result_count;
}
// Compute recall
const auto qrels_it = qrels.find(entry.query_id);
if (qrels_it == qrels.end()) continue;
const auto &relevant = qrels_it->second;
auto compute_recall = [&](int cutoff) -> double {
if (relevant.empty()) return 0.0;
int limit =
std::min(cutoff, static_cast<int>(retrieved_corpus_ids.size()));
int hits = 0;
for (int i = 0; i < limit; ++i) {
if (relevant.count(retrieved_corpus_ids[i]) > 0) {
hits++;
}
}
return static_cast<double>(hits) / static_cast<double>(relevant.size());
};
result.recall1.add(compute_recall(1));
result.recall5.add(compute_recall(5));
result.recall10.add(compute_recall(10));
result.recallK.add(compute_recall(FLAGS_topk));
}
};
auto wall_start = std::chrono::steady_clock::now();
std::vector<std::thread> threads;
threads.reserve(num_threads);
for (int thread_id = 0; thread_id < num_threads; ++thread_id) {
threads.emplace_back(worker, thread_id);
}
for (auto &thread : threads) {
thread.join();
}
if (fatal_error.load()) {
fprintf(stderr, "ERROR: Aborting: Fts failed during search\n");
return -1;
}
auto wall_end = std::chrono::steady_clock::now();
uint64_t wall_ms = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(wall_end -
wall_start)
.count());
// Merge per-thread results
LatencyStats merged_latency;
RecallCounter merged_recall1, merged_recall5, merged_recall10, merged_recallK;
uint64_t total_query_count = 0;
uint64_t total_no_result_count = 0;
for (const auto &result : thread_results) {
for (uint64_t sample : result.latency_stats.samples) {
merged_latency.add(sample);
}
merged_recall1.sum += result.recall1.sum;
merged_recall1.total += result.recall1.total;
merged_recall5.sum += result.recall5.sum;
merged_recall5.total += result.recall5.total;
merged_recall10.sum += result.recall10.sum;
merged_recall10.total += result.recall10.total;
merged_recallK.sum += result.recallK.sum;
merged_recallK.total += result.recallK.total;
total_query_count += result.query_count;
total_no_result_count += result.no_result_count;
}
std::cout << std::endl;
std::cout << "=== SEARCH RESULTS (db) ===" << std::endl;
std::cout << "Threads : " << num_threads << std::endl;
std::cout << "Total queries : " << total_query_count << std::endl;
std::cout << "No-result queries: " << total_no_result_count << std::endl;
std::cout << "Wall-clock time : " << wall_ms << " ms" << std::endl;
if (wall_ms > 0) {
std::cout << "Throughput : " << total_query_count * 1000ULL / wall_ms
<< " queries/s" << std::endl;
}
std::cout << std::endl;
merged_latency.print("Search (per-query)");
std::cout << std::endl;
if (merged_recall1.total > 0) {
std::cout << "=== RECALL ===" << std::endl;
std::cout << " Recall@1 : " << merged_recall1.ratio() << " (evaluated on "
<< merged_recall1.total << " queries)" << std::endl;
std::cout << " Recall@5 : " << merged_recall5.ratio() << " (evaluated on "
<< merged_recall5.total << " queries)" << std::endl;
std::cout << " Recall@10 : " << merged_recall10.ratio()
<< " (evaluated on " << merged_recall10.total << " queries)"
<< std::endl;
if (FLAGS_topk > 10) {
std::cout << " Recall@" << FLAGS_topk << " : " << merged_recallK.ratio()
<< " (evaluated on " << merged_recallK.total << " queries)"
<< std::endl;
}
} else {
std::cout << "No qrels matched for evaluated queries." << std::endl;
}
return 0;
}
// ---------------------------------------------------------------------------
// STATS MODE
// ---------------------------------------------------------------------------
static int do_stats() {
std::cout << "=== STATS MODE ===" << std::endl;
std::cout << "Index : " << FLAGS_index << std::endl;
std::cout << "Field : " << FLAGS_field << std::endl;
// Open RocksDB (existing). $TF/$MAX_TF/$DOC_LEN are dropped at build
// time, so open without them. Sections that scan these CFs are now
// gated on the corresponding pointers being non-null (always null here
// post-drop) and simply skipped with an explanatory message.
RocksdbContext store;
if (!open_fts_store(&store, FLAGS_field, /*existing=*/true,
/*index_path=*/"", /*with_side_cfs=*/false)) {
return -1;
}
rocksdb::ColumnFamilyHandle *postings_cf = store.get_cf(FLAGS_field);
rocksdb::ColumnFamilyHandle *stat_cf = store.get_cf(zvec::kFtsStatCfName);
// $MAX_TF/$DOC_LEN are not opened above; keep nullptrs so the
// doc-length / max-tf scan sections degrade gracefully.
rocksdb::ColumnFamilyHandle *max_tf_cf = nullptr;
rocksdb::ColumnFamilyHandle *doc_len_cf = nullptr;
if (!postings_cf || !stat_cf) {
fprintf(stderr, "ERROR: Failed to get required column families\n");
return -1;
}
// ---------------------------------------------------------------
// 1. Segment-level statistics (total_docs, total_tokens)
// ---------------------------------------------------------------
uint64_t total_docs = 0;
uint64_t total_tokens = 0;
{
const std::string total_docs_key = FLAGS_field + "_total_docs";
const std::string total_tokens_key = FLAGS_field + "_total_tokens";
std::string value;
if (store.db_->Get(store.read_opts_, stat_cf, total_docs_key, &value)
.ok() &&
value.size() >= sizeof(uint64_t)) {
std::memcpy(&total_docs, value.data(), sizeof(uint64_t));
}
value.clear();
if (store.db_->Get(store.read_opts_, stat_cf, total_tokens_key, &value)
.ok() &&
value.size() >= sizeof(uint64_t)) {
std::memcpy(&total_tokens, value.data(), sizeof(uint64_t));
}
}
double avg_doc_len = total_docs > 0 ? static_cast<double>(total_tokens) /
static_cast<double>(total_docs)
: 0.0;
std::cout << std::endl;
std::cout << "--- Segment Statistics ---" << std::endl;
std::cout << " Total documents : " << total_docs << std::endl;
std::cout << " Total tokens : " << total_tokens << std::endl;
std::cout << " Avg doc length : " << avg_doc_len << std::endl;
// ---------------------------------------------------------------
// 2. Vocabulary & posting list statistics
// ---------------------------------------------------------------
std::cout << std::endl;
std::cout << "--- Vocabulary & Posting List Statistics ---" << std::endl;
std::cout << " Scanning postings CF..." << std::flush;
uint64_t vocab_size = 0;
uint64_t total_postings_entries = 0; // sum of all posting list lengths
uint64_t total_postings_bytes = 0; // sum of serialized bitmap sizes
uint64_t max_posting_len = 0;
std::string max_posting_term;
// Posting list length distribution buckets
// [1], [2-10], [11-100], [101-1K], [1K-10K], [10K-100K], [100K+]
uint64_t bucket_1 = 0;
uint64_t bucket_2_10 = 0;
uint64_t bucket_11_100 = 0;
uint64_t bucket_101_1k = 0;
uint64_t bucket_1k_10k = 0;
uint64_t bucket_10k_100k = 0;
uint64_t bucket_100k_plus = 0;
// Format counters
uint64_t roaring_count = 0;
uint64_t bitpacked_count = 0;
{
auto iter = std::unique_ptr<rocksdb::Iterator>(
store.db_->NewIterator(store.read_opts_, postings_cf));
while (iter->Valid()) {
const std::string term = iter->key().ToString();
const std::string posting_data = iter->value().ToString();
++vocab_size;
total_postings_bytes += posting_data.size();
uint64_t cardinality = 0;
if (BitPackedPostingList::is_bitpacked_format(posting_data.data(),
posting_data.size())) {
// BitPacked format: read num_docs from FileHeader
++bitpacked_count;
fts::BitPackedPostingIterator bp_iter;
if (bp_iter.open(posting_data.data(), posting_data.size()) == 0) {
cardinality = bp_iter.cost();
}
} else {
// Roaring Bitmap format
++roaring_count;
roaring_bitmap_t *bitmap = roaring_bitmap_portable_deserialize_safe(
posting_data.data(), posting_data.size());
if (bitmap) {
cardinality = roaring_bitmap_get_cardinality(bitmap);
roaring_bitmap_free(bitmap);
}
}
total_postings_entries += cardinality;
if (cardinality > max_posting_len) {
max_posting_len = cardinality;
max_posting_term = term;
}
// Bucket distribution
if (cardinality <= 1) {
++bucket_1;
} else if (cardinality <= 10) {
++bucket_2_10;
} else if (cardinality <= 100) {
++bucket_11_100;
} else if (cardinality <= 1000) {
++bucket_101_1k;
} else if (cardinality <= 10000) {
++bucket_1k_10k;
} else if (cardinality <= 100000) {
++bucket_10k_100k;
} else {
++bucket_100k_plus;
}
if (vocab_size % 10000 == 0) {
std::cout << "\r Scanning postings CF... " << vocab_size << " terms"
<< std::flush;
}
iter->Next();
}
// iter auto-closes via unique_ptr
}
std::cout << "\r Scanning postings CF... done. " << std::endl;
std::cout << " Posting format : " << roaring_count << " Roaring, "
<< bitpacked_count << " BitPacked" << std::endl;
std::cout << " Vocabulary size : " << vocab_size << std::endl;
std::cout << " Total postings entries : " << total_postings_entries
<< std::endl;
std::cout << " Total postings bytes : " << total_postings_bytes / 1024
<< " KB" << std::endl;
if (vocab_size > 0) {
std::cout << " Avg posting list len : "
<< static_cast<double>(total_postings_entries) / vocab_size
<< std::endl;
std::cout << " Avg posting bytes : "
<< static_cast<double>(total_postings_bytes) / vocab_size << " B"
<< std::endl;
}
std::cout << " Max posting list len : " << max_posting_len;
if (!max_posting_term.empty()) {
std::cout << " (term: \"" << max_posting_term << "\")";
}
std::cout << std::endl;
std::cout << std::endl;
std::cout << " Posting list length distribution:" << std::endl;
std::cout << " [1] : " << bucket_1 << std::endl;
std::cout << " [2-10] : " << bucket_2_10 << std::endl;
std::cout << " [11-100] : " << bucket_11_100 << std::endl;
std::cout << " [101-1K] : " << bucket_101_1k << std::endl;
std::cout << " [1K-10K] : " << bucket_1k_10k << std::endl;
std::cout << " [10K-100K] : " << bucket_10k_100k << std::endl;
std::cout << " [100K+] : " << bucket_100k_plus << std::endl;
// ---------------------------------------------------------------
// 3. Document length distribution
// ---------------------------------------------------------------
std::cout << std::endl;
std::cout << "--- Document Length Distribution ---" << std::endl;
uint64_t doc_count = 0;
uint64_t sum_doc_len = 0;
uint32_t min_doc_len = UINT32_MAX;
uint32_t max_doc_len = 0;
std::vector<uint32_t> all_doc_lens;
if (doc_len_cf) {
auto iter = std::unique_ptr<rocksdb::Iterator>(
store.db_->NewIterator(store.read_opts_, doc_len_cf));
while (iter->Valid()) {
const std::string value = iter->value().ToString();
if (value.size() >= sizeof(uint32_t)) {
uint32_t doc_len = 0;
std::memcpy(&doc_len, value.data(), sizeof(uint32_t));
++doc_count;
sum_doc_len += doc_len;
if (doc_len < min_doc_len) min_doc_len = doc_len;
if (doc_len > max_doc_len) max_doc_len = doc_len;
all_doc_lens.push_back(doc_len);
}
iter->Next();
}
// iter auto-closes via unique_ptr
} else {
std::cout << " $DOC_LEN CF was dropped at build time after"
<< " convert_postings_to_bitpacked()." << std::endl
<< " Per-doc length info is now inlined in BitPacked"
<< " postings; skipping distribution scan." << std::endl;
}
if (doc_count > 0) {
std::sort(all_doc_lens.begin(), all_doc_lens.end());
auto percentile = [&](double p) -> uint32_t {
size_t idx = static_cast<size_t>(p * all_doc_lens.size());
if (idx >= all_doc_lens.size()) idx = all_doc_lens.size() - 1;
return all_doc_lens[idx];
};
std::cout << " Doc count : " << doc_count << std::endl;
std::cout << " Avg doc length: "
<< static_cast<double>(sum_doc_len) / doc_count << std::endl;
std::cout << " Min doc length: " << min_doc_len << std::endl;
std::cout << " P25 doc length: " << percentile(0.25) << std::endl;
std::cout << " P50 doc length: " << percentile(0.50) << std::endl;
std::cout << " P75 doc length: " << percentile(0.75) << std::endl;
std::cout << " P95 doc length: " << percentile(0.95) << std::endl;
std::cout << " P99 doc length: " << percentile(0.99) << std::endl;
std::cout << " Max doc length: " << max_doc_len << std::endl;
} else {
std::cout << " No documents found in $DOC_LEN CF." << std::endl;
}
// ---------------------------------------------------------------
// 4. Max-TF statistics (top terms by max term frequency)
// ---------------------------------------------------------------
if (max_tf_cf) {
std::cout << std::endl;
std::cout << "--- Top Terms by Max Term Frequency ---" << std::endl;
struct TermMaxTf {
std::string term;
uint32_t max_tf;
};
// Collect all and sort by max_tf descending, show top 20
std::vector<TermMaxTf> term_max_tfs;
{
auto iter = std::unique_ptr<rocksdb::Iterator>(
store.db_->NewIterator(store.read_opts_, max_tf_cf));
while (iter->Valid()) {
const std::string term = iter->key().ToString();
const std::string value = iter->value().ToString();
uint32_t max_tf = 0;
if (value.size() >= sizeof(uint32_t)) {
std::memcpy(&max_tf, value.data(), sizeof(uint32_t));
}
term_max_tfs.push_back({term, max_tf});
iter->Next();
}
// iter auto-closes via unique_ptr
}
std::sort(term_max_tfs.begin(), term_max_tfs.end(),
[](const TermMaxTf &a, const TermMaxTf &b) {
return a.max_tf > b.max_tf;
});
size_t show_count = std::min<size_t>(20, term_max_tfs.size());
for (size_t i = 0; i < show_count; ++i) {
std::cout << " " << (i + 1) << ". \"" << term_max_tfs[i].term
<< "\" max_tf=" << term_max_tfs[i].max_tf << std::endl;
}
}
// ---------------------------------------------------------------
// 5. Storage size summary
// ---------------------------------------------------------------
std::cout << std::endl;
std::cout << "--- Storage Size Summary ---" << std::endl;
std::cout << " Postings CF ($POSTINGS) : " << total_postings_bytes / 1024
<< " KB (serialized bitmap data)" << std::endl;
std::cout << " (Other CF sizes require RocksDB property queries or dump)"
<< std::endl;
std::cout << std::endl;
std::cout << "=== STATS COMPLETE ===" << std::endl;
store.close();
return 0;
}
int main(int argc, char *argv[]) {
gflags::ParseCommandLineFlags(&argc, &argv, false);
if (FLAGS_index.empty()) {
std::cerr << "Error: -index is required." << std::endl;
std::cerr << "Usage:" << std::endl;
std::cerr << " Build : bin/fts_bench -cmd build -index <path> -corpus "
"<corpus.jsonl>"
<< std::endl;
std::cerr << " Search : bin/fts_bench -cmd search "
"-index <path> -query <queries.jsonl> -qrels <qrels_dir>"
<< std::endl;
std::cerr << " Stats : bin/fts_bench -cmd stats -index <path>"
<< std::endl;
return 1;
}
// Determine command: explicit -cmd flag takes priority, otherwise auto-detect
std::string cmd = FLAGS_cmd;
if (cmd.empty()) {
if (!FLAGS_corpus.empty()) {
cmd = "build";
} else if (!FLAGS_query.empty()) {
cmd = "search";
} else {
std::cerr << "Error: specify -cmd (build/search/stats) or -corpus/-query."
<< std::endl;
return 1;
}
}
// Validate -mode flag
const bool db_mode = (FLAGS_mode == "db");
if (FLAGS_mode != "raw" && FLAGS_mode != "db") {
std::cerr << "Error: unknown -mode '" << FLAGS_mode
<< "'. Use 'raw' or 'db'." << std::endl;
return 1;
}
if (cmd == "build") {
if (FLAGS_corpus.empty()) {
std::cerr << "Error: -corpus is required in build mode." << std::endl;
return 1;
}
return db_mode ? do_build_db() : do_build();
} else if (cmd == "search") {
if (FLAGS_query.empty()) {
std::cerr << "Error: -query is required in search mode." << std::endl;
return 1;
}
if (FLAGS_qrels.empty()) {
std::cerr << "Error: -qrels is required in search mode." << std::endl;
return 1;
}
return db_mode ? do_search_db() : do_search();
} else if (cmd == "stats") {
if (db_mode) {
std::cerr << "Error: stats command is not supported in db mode."
<< std::endl;
return 1;
}
return do_stats();
} else {
std::cerr << "Error: unknown command '" << cmd
<< "'. Use build, search, or stats." << std::endl;
return 1;
}
}