feat: add FTS support for Collection::CreateIndex/DropIndex (#445)

* feat: add FTS support for Collection::CreateIndex/DropIndex

Enable dynamic creation and removal of FTS indexes on existing STRING
columns through the standard CreateIndex/DropIndex API, matching the
lifecycle model already used by vector and scalar (invert) indexes.

Key changes:
- New FtsIndexer class (fts_indexer.h/cc) encapsulating per-segment FTS
  RocksDB management: multi-field lifecycle, snapshot, insert, seal
- New BlockType::FTS_INDEX with block_id-based directory naming
  (fts.<block_id>.rocksdb) for crash-safe snapshot-and-swap
- Segment::create_fts_index builds FTS index on a snapshot copy by
  scanning forward store, then outputs new SegmentMeta + FtsIndexer
  for atomic reload (same pattern as create_scalar_index)
- Segment::drop_fts_index snapshots, removes field CFs, outputs updated
  meta (or nullptr when last FTS field is removed)
- Collection layer wires FTS into the existing task dispatch, version
  update, and reload loops alongside vector/invert paths
- CreateIndex/DropIndex reject unsupported index types explicitly
  instead of falling through to the wrong branch

* fixup! feat: add FTS support for Collection::CreateIndex/DropIndex

    fix: address review comments for FTS CreateIndex/DropIndex

    - Reject CreateIndex when column already has a different index type
      (e.g. FTS on an INVERT-indexed column) at both Collection and
      Segment layers
    - Allow same-type different-params CreateIndex to rebuild the index
      (remove old + create new + replay data), aligned with INVERT behavior
    - Return OK when CreateIndex is called with identical params
    - Rename operator[] to get() in both FtsIndexer and InvertedIndexer
    - Add test cases: create→drop→create→drop cycle, and params-change
      rebuild with case-sensitivity verification

* android skip Feature_CreateOrDropFtsIndex

* fixup! feat: add FTS support for Collection::CreateIndex/DropIndex
This commit is contained in:
egolearner 2026-06-03 17:56:56 +08:00 committed by GitHub
parent dbea635019
commit 443500dc45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1127 additions and 206 deletions

View File

@ -198,6 +198,13 @@ class CollectionImpl : public Collection {
std::vector<SegmentTask::Ptr> build_drop_scalar_index_task(
const std::vector<Segment::Ptr> &segments, const std::string &column);
std::vector<SegmentTask::Ptr> build_create_fts_index_task(
const std::vector<Segment::Ptr> &segments, const std::string &column,
const IndexParams::Ptr &index_params);
std::vector<SegmentTask::Ptr> build_drop_fts_index_task(
const std::vector<Segment::Ptr> &segments, const std::string &column);
Status execute_tasks(std::vector<SegmentTask::Ptr> &tasks) const;
private:
@ -471,6 +478,18 @@ Status CollectionImpl::CreateIndex(const std::string &column_name,
return Status::OK();
}
// Reject creating a non-vector index when the column already has a different
// non-vector index type (e.g. adding FTS when INVERT exists, or vice versa).
if (!field->is_vector_field() && field->index_params() != nullptr &&
field->index_params()->type() != index_params->type()) {
return Status::NotSupported(
"CreateIndex: column[", column_name, "] already has index type [",
IndexTypeCodeBook::AsString(field->index_params()->type()),
"], cannot create index type [",
IndexTypeCodeBook::AsString(index_params->type()),
"] on the same column");
}
// forbidden writing until index is ready
std::lock_guard write_lock(write_mtx_);
@ -533,6 +552,9 @@ Status CollectionImpl::CreateIndex(const std::string &column_name,
} else if (index_params->type() == IndexType::INVERT) {
tasks = build_create_scalar_index_task(persist_segments, column_name,
index_params, options.concurrency_);
} else if (index_params->type() == IndexType::FTS) {
tasks = build_create_fts_index_task(persist_segments, column_name,
index_params);
} else {
return Status::NotSupported(
"CreateIndex: index type [",
@ -570,6 +592,10 @@ Status CollectionImpl::CreateIndex(const std::string &column_name,
auto create_index_task = std::get<CreateScalarIndexTask>(task_info);
s = new_version.update_persisted_segment_meta(
create_index_task.output_segment_meta_);
} else if (std::holds_alternative<CreateFtsIndexTask>(task_info)) {
auto fts_task = std::get<CreateFtsIndexTask>(task_info);
s = new_version.update_persisted_segment_meta(
fts_task.output_segment_meta_);
}
CHECK_RETURN_STATUS(s);
}
@ -597,6 +623,11 @@ Status CollectionImpl::CreateIndex(const std::string &column_name,
s = create_index_task.input_segment_->reload_scalar_index(
*new_schema, create_index_task.output_segment_meta_,
create_index_task.output_scalar_indexer_);
} else if (std::holds_alternative<CreateFtsIndexTask>(task_info)) {
auto fts_task = std::get<CreateFtsIndexTask>(task_info);
s = fts_task.input_segment_->reload_fts_index(
*new_schema, fts_task.output_segment_meta_,
fts_task.output_fts_indexer_);
}
CHECK_RETURN_STATUS(s);
}
@ -630,6 +661,27 @@ std::vector<SegmentTask::Ptr> CollectionImpl::build_create_scalar_index_task(
return tasks;
}
std::vector<SegmentTask::Ptr> CollectionImpl::build_create_fts_index_task(
const std::vector<Segment::Ptr> &segments, const std::string &column,
const IndexParams::Ptr &index_params) {
std::vector<SegmentTask::Ptr> tasks;
for (auto &segment : segments) {
tasks.push_back(SegmentTask::CreateCreateFtsIndexTask(
CreateFtsIndexTask{segment, column, index_params}));
}
return tasks;
}
std::vector<SegmentTask::Ptr> CollectionImpl::build_drop_fts_index_task(
const std::vector<Segment::Ptr> &segments, const std::string &column) {
std::vector<SegmentTask::Ptr> tasks;
for (auto &segment : segments) {
tasks.push_back(
SegmentTask::CreateDropFtsIndexTask(DropFtsIndexTask{segment, column}));
}
return tasks;
}
Status CollectionImpl::execute_tasks(
std::vector<SegmentTask::Ptr> &tasks) const {
Status s;
@ -723,6 +775,8 @@ Status CollectionImpl::DropIndex(const std::string &column_name) {
tasks = build_drop_vector_index_task(persist_segments, column_name);
} else if (field->index_params()->type() == IndexType::INVERT) {
tasks = build_drop_scalar_index_task(persist_segments, column_name);
} else if (field->index_params()->type() == IndexType::FTS) {
tasks = build_drop_fts_index_task(persist_segments, column_name);
} else {
return Status::NotSupported(
"DropIndex: index type [",
@ -760,6 +814,10 @@ Status CollectionImpl::DropIndex(const std::string &column_name) {
auto drop_index_task = std::get<DropScalarIndexTask>(task_info);
s = new_version.update_persisted_segment_meta(
drop_index_task.output_segment_meta_);
} else if (std::holds_alternative<DropFtsIndexTask>(task_info)) {
auto fts_task = std::get<DropFtsIndexTask>(task_info);
s = new_version.update_persisted_segment_meta(
fts_task.output_segment_meta_);
}
CHECK_RETURN_STATUS(s);
}
@ -785,6 +843,11 @@ Status CollectionImpl::DropIndex(const std::string &column_name) {
s = drop_index_task.input_segment_->reload_scalar_index(
*new_schema, drop_index_task.output_segment_meta_,
drop_index_task.output_scalar_indexer_);
} else if (std::holds_alternative<DropFtsIndexTask>(task_info)) {
auto fts_task = std::get<DropFtsIndexTask>(task_info);
s = fts_task.input_segment_->reload_fts_index(
*new_schema, fts_task.output_segment_meta_,
fts_task.output_fts_indexer_);
}
CHECK_RETURN_STATUS(s);
}

View File

@ -139,14 +139,19 @@ class FileHelper {
ailego::StringHelper::Concat("scalar.index.", block_id, ".rocksdb"));
}
// e.g.: **/seg1/fts.rocksdb
// e.g.: **/seg1/fts.0.rocksdb
static const std::string MakeFtsIndexPath(const std::string &path,
uint32_t seg_id) {
return ailego::FileHelper::PathJoin(path, seg_id, "fts.rocksdb");
uint32_t seg_id,
uint32_t block_id) {
return ailego::FileHelper::PathJoin(
path, seg_id,
ailego::StringHelper::Concat("fts.", block_id, ".rocksdb"));
}
static const std::string MakeFtsIndexPath(const std::string &seg_path) {
return ailego::FileHelper::PathJoin(seg_path, "fts.rocksdb");
static const std::string MakeFtsIndexPath(const std::string &seg_path,
uint32_t block_id) {
return ailego::FileHelper::PathJoin(
seg_path, ailego::StringHelper::Concat("fts.", block_id, ".rocksdb"));
}
static const std::string MakeVectorIndexPath(const std::string &path,

View File

@ -0,0 +1,329 @@
// 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 "fts_indexer.h"
#include <zvec/ailego/logger/logger.h>
#include "db/common/constants.h"
#include "db/common/file_helper.h"
#include "fts_rocksdb_merge.h"
#include "fts_utils.h"
namespace zvec {
FtsIndexer::~FtsIndexer() {
close();
}
FtsIndexer::Ptr FtsIndexer::CreateAndOpen(const std::string &working_dir,
const FieldSchemaPtrList &fts_fields,
bool create, bool read_only) {
auto indexer = std::make_shared<FtsIndexer>(working_dir);
auto s = indexer->open(fts_fields, create, read_only);
if (!s.ok()) {
return nullptr;
}
return indexer;
}
Status FtsIndexer::open(const FieldSchemaPtrList &fts_fields, bool create,
bool read_only) {
if (fts_fields.empty()) {
return Status::OK();
}
// Collect CF names and per-CF merge operators.
std::vector<std::string> cf_names;
std::unordered_map<std::string, std::shared_ptr<rocksdb::MergeOperator>>
per_cf_merge_ops;
for (const auto &field : fts_fields) {
const auto &name = field->name();
cf_names.push_back(name);
cf_names.push_back(name + kFtsPositionsSuffix);
per_cf_merge_ops[name] = std::make_shared<fts::FtsPostingsMerge>();
per_cf_merge_ops[name + kFtsMaxTfSuffix] =
std::make_shared<fts::FtsMaxTfMerge>();
if (create) {
cf_names.push_back(name + kFtsTfSuffix);
cf_names.push_back(name + kFtsMaxTfSuffix);
cf_names.push_back(name + kFtsDocLenSuffix);
}
}
cf_names.push_back(kFtsStatCfName);
fts_ctx_ = std::make_shared<RocksdbContext>();
Status s;
if (create) {
s = fts_ctx_->create(RocksdbContext::Args{working_dir_, cf_names, nullptr,
per_cf_merge_ops, true});
} else {
// Auto-discover existing CFs via ListColumnFamilies (empty column_names).
s = fts_ctx_->open(
RocksdbContext::Args{working_dir_, {}, nullptr, per_cf_merge_ops, true},
read_only);
}
if (!s.ok()) {
LOG_ERROR("FtsIndexer: failed to %s RocksDB at [%s]: %s",
create ? "create" : "open", working_dir_.c_str(),
s.message().c_str());
fts_ctx_.reset();
return s;
}
auto *stat_cf = fts_ctx_->get_cf(kFtsStatCfName);
for (const auto &field : fts_fields) {
const auto &name = field->name();
auto *postings_cf = fts_ctx_->get_cf(name);
auto *positions_cf = fts_ctx_->get_cf(name + kFtsPositionsSuffix);
auto *term_freq_cf = fts_ctx_->get_cf(name + kFtsTfSuffix);
auto *max_tf_cf = fts_ctx_->get_cf(name + kFtsMaxTfSuffix);
auto *doc_len_cf = fts_ctx_->get_cf(name + kFtsDocLenSuffix);
auto indexer = std::make_shared<fts::FtsColumnIndexer>();
auto ret = indexer->open(field, fts_ctx_.get(), postings_cf, positions_cf,
term_freq_cf, max_tf_cf, doc_len_cf, stat_cf);
if (!ret.has_value()) {
LOG_ERROR("FtsIndexer: FtsColumnIndexer::open failed for field[%s]: %s",
name.c_str(), ret.error().message().c_str());
return Status::InternalError("FtsIndexer: open field failed: ", name, " ",
ret.error().message());
}
indexers_[name] = indexer;
}
return Status::OK();
}
fts::FtsColumnIndexerPtr FtsIndexer::get(const std::string &field_name) const {
auto it = indexers_.find(field_name);
return it != indexers_.end() ? it->second : nullptr;
}
Status FtsIndexer::flush() {
for (const auto &[name, indexer] : indexers_) {
auto ret = indexer->flush();
if (!ret.has_value()) {
return Status::InternalError("FtsIndexer: flush failed: ", name, " ",
ret.error().message());
}
}
if (fts_ctx_) {
auto s = fts_ctx_->flush();
if (!s.ok()) {
return s;
}
}
return Status::OK();
}
Status FtsIndexer::close() {
indexers_.clear();
if (fts_ctx_) {
auto s = fts_ctx_->close();
fts_ctx_.reset();
return s;
}
return Status::OK();
}
Status FtsIndexer::create_snapshot(const std::string &snapshot_path) {
auto s = flush();
if (!s.ok()) {
LOG_ERROR("FtsIndexer: flush failed during snapshot");
return s;
}
s = fts_ctx_->create_checkpoint(snapshot_path);
if (!s.ok()) {
LOG_ERROR("FtsIndexer: create_checkpoint to [%s] failed: %s",
snapshot_path.c_str(), s.message().c_str());
}
return s;
}
Status FtsIndexer::create_field_indexer(const FieldSchema &field) {
if (field.index_type() != IndexType::FTS) {
return Status::InvalidArgument(
"FtsIndexer::create_field_indexer: not FTS field");
}
const auto &name = field.name();
if (indexers_.find(name) != indexers_.end()) {
return Status::InvalidArgument(
"FtsIndexer::create_field_indexer: field already exists: ", name);
}
// Register merge operators before creating CFs.
fts_ctx_->per_cf_merge_ops_[name] = std::make_shared<fts::FtsPostingsMerge>();
fts_ctx_->per_cf_merge_ops_[name + kFtsMaxTfSuffix] =
std::make_shared<fts::FtsMaxTfMerge>();
// Create all CFs for this field.
std::vector<std::string> cf_names = {
name, name + kFtsPositionsSuffix, name + kFtsTfSuffix,
name + kFtsMaxTfSuffix, name + kFtsDocLenSuffix};
for (const auto &cf_name : cf_names) {
auto s = fts_ctx_->create_cf(cf_name);
if (!s.ok()) {
LOG_ERROR("FtsIndexer::create_field_indexer: create_cf[%s] failed: %s",
cf_name.c_str(), s.message().c_str());
return s;
}
}
// Open FtsColumnIndexer on the new CFs.
auto *postings_cf = fts_ctx_->get_cf(name);
auto *positions_cf = fts_ctx_->get_cf(name + kFtsPositionsSuffix);
auto *term_freq_cf = fts_ctx_->get_cf(name + kFtsTfSuffix);
auto *max_tf_cf = fts_ctx_->get_cf(name + kFtsMaxTfSuffix);
auto *doc_len_cf = fts_ctx_->get_cf(name + kFtsDocLenSuffix);
auto *stat_cf = fts_ctx_->get_cf(kFtsStatCfName);
auto field_schema = std::make_shared<FieldSchema>(field);
auto indexer = std::make_shared<fts::FtsColumnIndexer>();
auto ret =
indexer->open(field_schema, fts_ctx_.get(), postings_cf, positions_cf,
term_freq_cf, max_tf_cf, doc_len_cf, stat_cf);
if (!ret.has_value()) {
LOG_ERROR(
"FtsIndexer::create_field_indexer: FtsColumnIndexer::open failed: %s",
ret.error().message().c_str());
return Status::InternalError("FtsIndexer::create_field_indexer: open: ",
ret.error().message());
}
indexers_[name] = indexer;
return Status::OK();
}
Status FtsIndexer::remove_field_indexer(const std::string &field_name) {
auto it = indexers_.find(field_name);
if (it != indexers_.end()) {
indexers_.erase(it);
}
// Drop all CFs belonging to this field.
fts_ctx_->drop_cf(field_name);
fts_ctx_->drop_cf(field_name + kFtsPositionsSuffix);
fts_ctx_->drop_cf(field_name + kFtsTfSuffix);
fts_ctx_->drop_cf(field_name + kFtsMaxTfSuffix);
fts_ctx_->drop_cf(field_name + kFtsDocLenSuffix);
// Remove per-field stat keys.
auto *stat_cf = fts_ctx_->get_cf(kFtsStatCfName);
if (stat_cf) {
auto rs = fts_ctx_->db_->Delete(fts_ctx_->write_opts_, stat_cf,
fts::make_total_docs_key(field_name));
if (!rs.ok()) {
LOG_ERROR(
"FtsIndexer::remove_field_indexer: delete total_docs key "
"failed for field[%s]: %s",
field_name.c_str(), rs.ToString().c_str());
return Status::InternalError("delete total_docs key failed");
}
rs = fts_ctx_->db_->Delete(fts_ctx_->write_opts_, stat_cf,
fts::make_total_tokens_key(field_name));
if (!rs.ok()) {
LOG_ERROR(
"FtsIndexer::remove_field_indexer: delete total_tokens key "
"failed for field[%s]: %s",
field_name.c_str(), rs.ToString().c_str());
return Status::InternalError("delete total_tokens key failed");
}
}
return Status::OK();
}
Status FtsIndexer::insert(const std::string &field_name, uint32_t seg_doc_id,
const std::string &text) {
auto it = indexers_.find(field_name);
if (it == indexers_.end()) {
return Status::NotFound("FtsIndexer::insert: field not found: ",
field_name);
}
auto ret = it->second->insert(seg_doc_id, text);
if (!ret.has_value()) {
return Status::InternalError("FtsIndexer::insert failed: ", field_name, " ",
ret.error().message());
}
return Status::OK();
}
Status FtsIndexer::seal(const std::string &field_name) {
auto it = indexers_.find(field_name);
if (it == indexers_.end()) {
return Status::NotFound("FtsIndexer::seal: field not found: ", field_name);
}
auto &indexer = it->second;
auto ret = indexer->flush();
if (!ret.has_value()) {
return Status::InternalError("FtsIndexer::seal flush failed: ", field_name,
" ", ret.error().message());
}
ret = indexer->convert_postings_to_bitpacked();
if (!ret.has_value()) {
return Status::InternalError("FtsIndexer::seal convert failed: ",
field_name, " ", ret.error().message());
}
indexer->reset_side_cfs();
fts_ctx_->drop_cf(field_name + kFtsTfSuffix);
fts_ctx_->drop_cf(field_name + kFtsMaxTfSuffix);
fts_ctx_->drop_cf(field_name + kFtsDocLenSuffix);
return Status::OK();
}
Status FtsIndexer::seal_all() {
// Flush all indexers first.
for (const auto &[name, indexer] : indexers_) {
auto ret = indexer->flush();
if (!ret.has_value()) {
return Status::InternalError("FtsIndexer::seal_all flush failed: ", name,
" ", ret.error().message());
}
}
// Convert all postings to bitpacked format.
for (const auto &[name, indexer] : indexers_) {
auto ret = indexer->convert_postings_to_bitpacked();
if (!ret.has_value()) {
return Status::InternalError("FtsIndexer::seal_all convert failed: ",
name, " ", ret.error().message());
}
}
// Reset side CFs and drop them.
for (const auto &[name, indexer] : indexers_) {
indexer->reset_side_cfs();
}
for (const auto &[name, _] : indexers_) {
fts_ctx_->drop_cf(name + kFtsTfSuffix);
fts_ctx_->drop_cf(name + kFtsMaxTfSuffix);
fts_ctx_->drop_cf(name + kFtsDocLenSuffix);
}
return Status::OK();
}
} // namespace zvec

View File

@ -0,0 +1,93 @@
// 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.
#pragma once
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include <zvec/db/schema.h>
#include <zvec/db/status.h>
#include "db/common/rocksdb_context.h"
#include "fts_column_indexer.h"
namespace zvec {
// Manages the per-segment FTS RocksDB instance and all FtsColumnIndexers.
// Analogous to InvertedIndexer for scalar invert indexes.
class FtsIndexer {
public:
using Ptr = std::shared_ptr<FtsIndexer>;
FtsIndexer(const std::string &working_dir) : working_dir_(working_dir) {}
~FtsIndexer();
// Create a new fts.rocksdb or open an existing one. Returns nullptr on
// failure.
static Ptr CreateAndOpen(const std::string &working_dir,
const FieldSchemaPtrList &fts_fields, bool create,
bool read_only = false);
// Get FtsColumnIndexer by field name.
fts::FtsColumnIndexerPtr get(const std::string &field_name) const;
// Flush all indexers and the underlying RocksDB.
Status flush();
// Close all indexers and the underlying RocksDB.
Status close();
// Create a RocksDB checkpoint (hard-link snapshot) to snapshot_path.
Status create_snapshot(const std::string &snapshot_path);
// Add a new FTS field's CFs and open its FtsColumnIndexer.
Status create_field_indexer(const FieldSchema &field);
// Remove a field's CFs and stat keys from the RocksDB.
Status remove_field_indexer(const std::string &field_name);
// Insert a document's text into a specific field's indexer.
Status insert(const std::string &field_name, uint32_t seg_doc_id,
const std::string &text);
// Seal a single field: flush + convert_postings_to_bitpacked + drop side CFs.
Status seal(const std::string &field_name);
// Seal all fields (used by dump path).
Status seal_all();
const std::string &working_dir() const {
return working_dir_;
}
bool empty() const {
return indexers_.empty();
}
bool has_field(const std::string &field_name) const {
return indexers_.find(field_name) != indexers_.end();
}
private:
Status open(const FieldSchemaPtrList &fts_fields, bool create,
bool read_only);
std::string working_dir_;
std::shared_ptr<RocksdbContext> fts_ctx_;
std::unordered_map<std::string, fts::FtsColumnIndexerPtr> indexers_;
};
} // namespace zvec

View File

@ -246,6 +246,16 @@ class SegmentMeta {
persisted_blocks_ = new_persisted_blocks;
}
void remove_fts_index_block() {
std::vector<BlockMeta> new_persisted_blocks;
for (auto &b : persisted_blocks_) {
if (b.type() != BlockType::FTS_INDEX) {
new_persisted_blocks.push_back(b);
}
}
persisted_blocks_ = new_persisted_blocks;
}
void set_writing_forward_block(const BlockMeta &writing_forward_block) {
writing_forward_block_ = writing_forward_block;
}

View File

@ -220,13 +220,17 @@ Status FieldSchema::validate() const {
if (index_params_) {
if (index_params_->is_vector_index_type()) {
return Status::InvalidArgument(
"schema validate failed: scalar_field's index_params only support "
"INVERT "
"index, "
"but field[",
name_, "]'s index_type is ",
"schema validate failed: scalar field[", name_,
"] does not support vector index params, but got index_type ",
IndexTypeCodeBook::AsString(index_params_->type()));
}
if (index_params_->type() == IndexType::FTS &&
data_type_ != DataType::STRING) {
return Status::InvalidArgument(
"schema validate failed: FTS index only supports STRING data type, "
"but field[",
name_, "]'s data_type is ", DataTypeCodeBook::AsString(data_type_));
}
}
}
return Status::OK();
@ -631,4 +635,4 @@ bool CollectionSchema::has_index(const std::string &column) const {
return false;
}
} // namespace zvec
} // namespace zvec

View File

@ -503,6 +503,9 @@ struct BlockTypeCodeBook {
case proto::BlockType::BT_VECTOR_INDEX_QUANTIZE:
block_types = BlockType::VECTOR_INDEX_QUANTIZE;
break;
case proto::BlockType::BT_FTS_INDEX:
block_types = BlockType::FTS_INDEX;
break;
default:
break;
}
@ -524,6 +527,9 @@ struct BlockTypeCodeBook {
case BlockType::VECTOR_INDEX_QUANTIZE:
block_types = proto::BlockType::BT_VECTOR_INDEX_QUANTIZE;
break;
case BlockType::FTS_INDEX:
block_types = proto::BlockType::BT_FTS_INDEX;
break;
default:
break;
}
@ -541,6 +547,8 @@ struct BlockTypeCodeBook {
return "VECTOR_INDEX";
case BlockType::VECTOR_INDEX_QUANTIZE:
return "VECTOR_INDEX_QUANTIZE";
case BlockType::FTS_INDEX:
return "FTS_INDEX";
default:
return "UNDEFINED";
}

View File

@ -46,8 +46,7 @@
#include "db/common/global_resource.h"
#include "db/common/typedef.h"
#include "db/index/column/fts_column/fts_column_indexer.h"
#include "db/index/column/fts_column/fts_rocksdb_merge.h"
#include "db/index/column/fts_column/fts_types.h"
#include "db/index/column/fts_column/fts_indexer.h"
#include "db/index/column/inverted_column/inverted_indexer.h"
#include "db/index/column/vector_column/vector_column_indexer.h"
#include "db/index/column/vector_column/vector_column_params.h"
@ -213,6 +212,19 @@ class SegmentImpl : public Segment,
const CollectionSchema &schema, const SegmentMeta::Ptr &segment_meta,
const InvertedIndexer::Ptr &scalar_indexer) override;
Status create_fts_index(const std::string &column,
const IndexParams::Ptr &index_params,
SegmentMeta::Ptr *new_segment_meta,
FtsIndexer::Ptr *output_fts_indexer) override;
Status drop_fts_index(const std::string &column,
SegmentMeta::Ptr *new_segment_meta,
FtsIndexer::Ptr *output_fts_indexer) override;
Status reload_fts_index(const CollectionSchema &schema,
const SegmentMeta::Ptr &segment_meta,
const FtsIndexer::Ptr &new_fts_indexer) override;
Status dump() override;
Status flush() override;
@ -336,8 +348,7 @@ class SegmentImpl : public Segment,
InvertedIndexer::Ptr invert_indexers_;
// FTS index (uses segment-local doc ID)
std::shared_ptr<RocksdbContext> fts_ctx_;
std::unordered_map<std::string, fts::FtsColumnIndexerPtr> fts_indexers_;
FtsIndexer::Ptr fts_indexer_;
bool has_fts_{false};
// vector index (uses block-local doc ID, each indexer starts from 0)
@ -1958,10 +1969,17 @@ Status SegmentImpl::create_scalar_index(const std::vector<std::string> &columns,
return Status::InvalidArgument("Invalid column name");
}
if (field->index_params() != nullptr &&
*field->index_params() == *index_params) {
// if already indexed, just skip it
continue;
if (field->index_params() != nullptr) {
if (*field->index_params() == *index_params) {
// if already indexed with same params, just skip it
continue;
}
if (field->index_params()->type() != index_params->type()) {
return Status::InvalidArgument(
"create_scalar_index: field[", column, "] already has index type ",
IndexTypeCodeBook::AsString(field->index_params()->type()));
}
// same type but different params — will rebuild below
}
auto new_field = std::make_shared<FieldSchema>(*field);
@ -4467,7 +4485,7 @@ Result<Segment::Ptr> Segment::Open(const std::string &path,
}
////////////////////////////////////////////////////////////////////////////////////
// FTS integration
// FTS integration (delegated to FtsIndexer)
////////////////////////////////////////////////////////////////////////////////////
Status SegmentImpl::open_fts_indexers(bool create) {
@ -4475,115 +4493,47 @@ Status SegmentImpl::open_fts_indexers(bool create) {
return Status::OK();
}
auto fts_fields = collection_schema_->fts_fields();
has_fts_ = true;
auto fts_path = FileHelper::MakeFtsIndexPath(seg_path_);
// Collect CF names and per-CF merge operators
std::vector<std::string> cf_names;
std::unordered_map<std::string, std::shared_ptr<rocksdb::MergeOperator>>
per_cf_merge_ops;
for (const auto &field : fts_fields) {
const auto &name = field->name();
cf_names.push_back(name); // postings
cf_names.push_back(name + kFtsPositionsSuffix); // positions
per_cf_merge_ops[name] = std::make_shared<fts::FtsPostingsMerge>();
per_cf_merge_ops[name + kFtsMaxTfSuffix] =
std::make_shared<fts::FtsMaxTfMerge>();
// Side CFs (_tf / _max_tf / _doc_len) are present in mutable segments
// that have not yet been dumped. After dump,
// convert_postings_to_bitpacked() inlines their payloads into BitPacked
// postings and the CFs are dropped. When opening (!create), we pass
// empty column_names so RocksdbContext::open auto-discovers existing CFs
// via ListColumnFamilies — side CFs are included only when present.
if (create) {
cf_names.push_back(name + kFtsTfSuffix);
cf_names.push_back(name + kFtsMaxTfSuffix);
cf_names.push_back(name + kFtsDocLenSuffix);
}
}
cf_names.push_back(kFtsStatCfName);
fts_ctx_ = std::make_shared<RocksdbContext>();
Status s;
bool enable_hash_skiplist = true;
if (create) {
s = fts_ctx_->create(RocksdbContext::Args{
fts_path, cf_names, nullptr, per_cf_merge_ops, enable_hash_skiplist});
} else {
// Auto-discover existing CFs via ListColumnFamilies (empty column_names).
// per_cf_merge_ops covers both base and side CFs; entries for CFs that
// were dropped after dump are harmlessly ignored.
s = fts_ctx_->open(
RocksdbContext::Args{
fts_path, {}, nullptr, per_cf_merge_ops, enable_hash_skiplist},
options_.read_only_);
}
if (!s.ok()) {
LOG_ERROR("open_fts_indexers: failed to %s FTS RocksDB at [%s]: %s",
create ? "create" : "open", fts_path.c_str(),
s.message().c_str());
return s;
}
auto *stat_cf = fts_ctx_->get_cf(kFtsStatCfName);
for (const auto &field : fts_fields) {
const auto &name = field->name();
auto *postings_cf = fts_ctx_->get_cf(name);
auto *positions_cf = fts_ctx_->get_cf(name + kFtsPositionsSuffix);
// Side CF handles are non-null when the segment has not been dumped
// (side CFs still exist). For dumped immutable segments get_cf returns
// nullptr and FtsColumnIndexer falls back to BitPacked inline payloads
// or tf=1/doc_len=1 defaults.
auto *term_freq_cf = fts_ctx_->get_cf(name + kFtsTfSuffix);
auto *max_tf_cf = fts_ctx_->get_cf(name + kFtsMaxTfSuffix);
auto *doc_len_cf = fts_ctx_->get_cf(name + kFtsDocLenSuffix);
auto indexer = std::make_shared<fts::FtsColumnIndexer>();
auto ret = indexer->open(field, fts_ctx_.get(), postings_cf, positions_cf,
term_freq_cf, max_tf_cf, doc_len_cf, stat_cf);
if (!ret.has_value()) {
LOG_ERROR(
"open_fts_indexers: FtsColumnIndexer::open failed for field[%s] "
"err[%s] postings_cf[%p] positions_cf[%p] stat_cf[%p]",
name.c_str(), ret.error().message().c_str(), (void *)postings_cf,
(void *)positions_cf, (void *)stat_cf);
return Status::InternalError("Failed to open FTS indexer: ", name, " ",
ret.error().message());
auto block_id = allocate_block_id();
auto fts_path = FileHelper::MakeFtsIndexPath(seg_path_, block_id);
fts_indexer_ = FtsIndexer::CreateAndOpen(
fts_path, collection_schema_->fts_fields(), true);
if (!fts_indexer_) {
return Status::InternalError("open_fts_indexers: create failed at [",
fts_path, "]");
}
segment_meta_->add_persisted_block(
BlockMeta{block_id, BlockType::FTS_INDEX, 0, 0, 0, {}});
} else {
for (const auto &block : segment_meta_->persisted_blocks()) {
if (block.type() == BlockType::FTS_INDEX) {
auto fts_path = FileHelper::MakeFtsIndexPath(seg_path_, block.id());
fts_indexer_ = FtsIndexer::CreateAndOpen(
fts_path, collection_schema_->fts_fields(), false,
options_.read_only_);
if (!fts_indexer_) {
return Status::InternalError("open_fts_indexers: open failed at [",
fts_path, "]");
}
break;
}
}
fts_indexers_[name] = indexer;
}
has_fts_ = (fts_indexer_ != nullptr);
return Status::OK();
}
Status SegmentImpl::flush_fts_indexers() {
for (const auto &[name, indexer] : fts_indexers_) {
auto ret = indexer->flush();
if (!ret.has_value()) {
return Status::InternalError("FTS flush failed: ", name, " ",
ret.error().message());
}
if (!fts_indexer_) {
return Status::OK();
}
auto s = fts_ctx_->flush();
CHECK_RETURN_STATUS(s);
return Status::OK();
return fts_indexer_->flush();
}
Status SegmentImpl::close_fts_indexers() {
fts_indexers_.clear();
if (fts_ctx_) {
auto s = fts_ctx_->close();
fts_ctx_.reset();
return s;
if (fts_indexer_) {
fts_indexer_.reset();
}
return Status::OK();
}
@ -4593,17 +4543,13 @@ Status SegmentImpl::insert_fts_indexer(Doc &doc) {
return Status::OK();
}
for (const auto &field : collection_schema_->fts_fields()) {
auto it = fts_indexers_.find(field->name());
if (it == fts_indexers_.end()) {
return Status::InternalError("FTS indexer not found: ", field->name());
}
auto value = doc.get<std::string>(field->name());
if (value.has_value()) {
auto segment_doc_id = doc_ids_.size();
auto ret = it->second->insert(segment_doc_id, value.value());
if (!ret.has_value()) {
return Status::InternalError("FTS insert failed: ", field->name(), " ",
ret.error().message());
auto s =
fts_indexer_->insert(field->name(), segment_doc_id, value.value());
if (!s.ok()) {
return s;
}
}
}
@ -4611,49 +4557,18 @@ Status SegmentImpl::insert_fts_indexer(Doc &doc) {
}
Status SegmentImpl::dump_fts_indexers() {
if (!has_fts_) {
if (!has_fts_ || !fts_indexer_) {
return Status::OK();
}
// flush all indexers
for (const auto &[name, indexer] : fts_indexers_) {
auto ret = indexer->flush();
if (!ret.has_value()) {
return Status::InternalError("FTS flush failed during dump: ", name, " ",
ret.error().message());
}
}
// convert postings to bitpacked format
for (const auto &[name, indexer] : fts_indexers_) {
auto ret = indexer->convert_postings_to_bitpacked();
if (!ret.has_value()) {
return Status::InternalError("FTS convert_postings_to_bitpacked failed: ",
name, " ", ret.error().message());
}
}
// reset side CFs and drop $TF/$MAX_TF/$DOC_LEN CFs
for (const auto &[name, indexer] : fts_indexers_) {
indexer->reset_side_cfs();
}
for (const auto &field : collection_schema_->fts_fields()) {
const auto &name = field->name();
fts_ctx_->drop_cf(name + kFtsTfSuffix);
fts_ctx_->drop_cf(name + kFtsMaxTfSuffix);
fts_ctx_->drop_cf(name + kFtsDocLenSuffix);
}
return Status::OK();
return fts_indexer_->seal_all();
}
fts::FtsColumnIndexerPtr SegmentImpl::get_fts_indexer(
const std::string &field_name) const {
auto it = fts_indexers_.find(field_name);
if (it != fts_indexers_.end()) {
return it->second;
if (!fts_indexer_) {
return nullptr;
}
return nullptr;
return fts_indexer_->get(field_name);
}
Result<std::vector<fts::FtsResult>> SegmentImpl::fts_search(
@ -4674,4 +4589,222 @@ Result<std::vector<fts::FtsResult>> SegmentImpl::fts_search(
return std::move(ret.value());
}
Status SegmentImpl::create_fts_index(const std::string &column,
const IndexParams::Ptr &index_params,
SegmentMeta::Ptr *out_segment_meta,
FtsIndexer::Ptr *out_fts_indexer) {
auto fts_params = std::dynamic_pointer_cast<FtsIndexParams>(index_params);
if (!fts_params) {
return Status::InvalidArgument("create_fts_index: not FtsIndexParams");
}
auto field = collection_schema_->get_field(column);
if (!field) {
return Status::NotFound("create_fts_index: field not found: ", column);
}
if (field->index_params() != nullptr) {
if (*field->index_params() == *index_params) {
// Already indexed with same params, nothing to do.
*out_segment_meta = std::make_shared<SegmentMeta>(*segment_meta_);
*out_fts_indexer = fts_indexer_;
return Status::OK();
}
if (field->index_params()->type() != index_params->type()) {
return Status::InvalidArgument(
"create_fts_index: field[", column, "] already has index type ",
IndexTypeCodeBook::AsString(field->index_params()->type()));
}
// Same type but different params — will rebuild below.
}
auto new_segment_meta = std::make_shared<SegmentMeta>(*segment_meta_);
new_segment_meta->remove_fts_index_block();
auto block_id = allocate_block_id();
auto new_fts_path = FileHelper::MakeFtsIndexPath(seg_path_, block_id);
// Build a new schema that includes the new FTS field for the snapshot open.
auto new_schema = std::make_shared<CollectionSchema>(*collection_schema_);
new_schema->get_field(column)->set_index_params(index_params);
FtsIndexer::Ptr new_fts_indexer;
if (fts_indexer_) {
// Snapshot existing fts DB, then open the copy with current schema.
auto s = fts_indexer_->create_snapshot(new_fts_path);
CHECK_RETURN_STATUS(s);
new_fts_indexer = FtsIndexer::CreateAndOpen(
new_fts_path, collection_schema_->fts_fields(), false);
} else {
// No existing fts DB — create a fresh one.
new_fts_indexer =
FtsIndexer::CreateAndOpen(new_fts_path, new_schema->fts_fields(), true);
}
if (!new_fts_indexer) {
FileHelper::RemoveDirectory(new_fts_path);
return Status::InternalError("create_fts_index: failed to open snapshot");
}
// If the field already exists in the snapshot (params change), remove it
// first so we can recreate with the new params.
if (new_fts_indexer->has_field(column)) {
auto s = new_fts_indexer->remove_field_indexer(column);
if (!s.ok()) {
FileHelper::RemoveDirectory(new_fts_path);
return s;
}
}
{
auto new_field_schema = std::make_shared<FieldSchema>(
column, field->data_type(), field->nullable(), index_params);
auto s = new_fts_indexer->create_field_indexer(*new_field_schema);
if (!s.ok()) {
FileHelper::RemoveDirectory(new_fts_path);
return s;
}
}
// Scan forward store and replay all existing documents.
auto reader = scan({column});
if (reader) {
uint32_t seg_doc_id = 0;
while (true) {
auto batch = reader->Next();
if (!batch.ok()) {
FileHelper::RemoveDirectory(new_fts_path);
return Status::InternalError("create_fts_index: scan failed: ",
batch.status().message());
}
auto batch_value = batch.ValueOrDie();
if (!batch_value) {
break;
}
auto col_idx = batch_value->schema()->GetFieldIndex(column);
if (col_idx < 0) {
seg_doc_id += batch_value->num_rows();
continue;
}
auto string_array = std::static_pointer_cast<arrow::StringArray>(
batch_value->column(col_idx));
for (int64_t i = 0; i < string_array->length(); ++i) {
if (!string_array->IsNull(i)) {
auto s = new_fts_indexer->insert(column, seg_doc_id,
string_array->GetString(i));
if (!s.ok()) {
FileHelper::RemoveDirectory(new_fts_path);
return s;
}
}
seg_doc_id++;
}
}
}
// Seal the new field (flush + convert to BitPacked + drop side CFs).
auto s = new_fts_indexer->seal(column);
if (!s.ok()) {
FileHelper::RemoveDirectory(new_fts_path);
return s;
}
s = new_fts_indexer->flush();
if (!s.ok()) {
FileHelper::RemoveDirectory(new_fts_path);
return s;
}
// Register the new block in segment meta.
BlockMeta block;
block.set_id(block_id);
block.set_type(BlockType::FTS_INDEX);
new_segment_meta->add_persisted_block(block);
*out_segment_meta = new_segment_meta;
*out_fts_indexer = new_fts_indexer;
return Status::OK();
}
Status SegmentImpl::drop_fts_index(const std::string &column,
SegmentMeta::Ptr *out_segment_meta,
FtsIndexer::Ptr *out_fts_indexer) {
auto field = collection_schema_->get_field(column);
if (!field) {
return Status::NotFound("drop_fts_index: field not found: ", column);
}
auto new_segment_meta = std::make_shared<SegmentMeta>(*segment_meta_);
new_segment_meta->remove_fts_index_block();
// Build a new schema without the FTS index on this column.
auto new_schema = std::make_shared<CollectionSchema>(*collection_schema_);
new_schema->get_field(column)->set_index_params(nullptr);
if (!fts_indexer_) {
*out_segment_meta = new_segment_meta;
*out_fts_indexer = nullptr;
return Status::OK();
}
// Check if other FTS fields remain after removal.
bool has_other_fts = new_schema->has_fts_field();
if (has_other_fts) {
auto block_id = allocate_block_id();
auto new_fts_path = FileHelper::MakeFtsIndexPath(seg_path_, block_id);
// Snapshot and reopen without this field.
auto s = fts_indexer_->create_snapshot(new_fts_path);
CHECK_RETURN_STATUS(s);
auto new_fts_indexer = FtsIndexer::CreateAndOpen(
new_fts_path, collection_schema_->fts_fields(), false);
if (!new_fts_indexer) {
FileHelper::RemoveDirectory(new_fts_path);
return Status::InternalError("drop_fts_index: failed to open snapshot");
}
s = new_fts_indexer->remove_field_indexer(column);
CHECK_RETURN_STATUS(s);
s = new_fts_indexer->flush();
CHECK_RETURN_STATUS(s);
BlockMeta block;
block.set_id(block_id);
block.set_type(BlockType::FTS_INDEX);
new_segment_meta->add_persisted_block(block);
*out_fts_indexer = new_fts_indexer;
} else {
// Last FTS field removed — no new indexer.
*out_fts_indexer = nullptr;
}
*out_segment_meta = new_segment_meta;
return Status::OK();
}
Status SegmentImpl::reload_fts_index(const CollectionSchema &schema,
const SegmentMeta::Ptr &segment_meta,
const FtsIndexer::Ptr &new_fts_indexer) {
collection_schema_ = std::make_shared<CollectionSchema>(schema);
segment_meta_ = segment_meta;
if (fts_indexer_) {
auto old_dir = fts_indexer_->working_dir();
fts_indexer_ = new_fts_indexer;
FileHelper::RemoveDirectory(old_dir);
} else {
fts_indexer_ = new_fts_indexer;
}
has_fts_ = (fts_indexer_ != nullptr);
fresh_persist_block_offset();
return Status::OK();
}
} // namespace zvec

View File

@ -26,6 +26,7 @@
#include <zvec/db/schema.h>
#include <zvec/db/status.h>
#include "db/index/column/fts_column/fts_column_indexer.h"
#include "db/index/column/fts_column/fts_indexer.h"
#include "db/index/column/inverted_column/inverted_column_indexer.h"
#include "db/index/column/inverted_column/inverted_indexer.h"
#include "db/index/column/vector_column/combined_vector_column_indexer.h"
@ -125,6 +126,19 @@ class Segment {
const CollectionSchema &schema, const SegmentMeta::Ptr &segment_meta,
const InvertedIndexer::Ptr &scalar_indexer) = 0;
virtual Status create_fts_index(const std::string &column,
const IndexParams::Ptr &index_params,
SegmentMeta::Ptr *new_segment_meta,
FtsIndexer::Ptr *output_fts_indexer) = 0;
virtual Status drop_fts_index(const std::string &column,
SegmentMeta::Ptr *new_segment_meta,
FtsIndexer::Ptr *output_fts_indexer) = 0;
virtual Status reload_fts_index(const CollectionSchema &schema,
const SegmentMeta::Ptr &segment_meta,
const FtsIndexer::Ptr &new_fts_indexer) = 0;
// ---- Data operations ----------------------------------------------------
virtual Status Insert(Doc &doc) = 0;
@ -168,6 +182,7 @@ class Segment {
virtual InvertedColumnIndexer::Ptr get_scalar_indexer(
const std::string &field_name) const = 0;
// caller hold segment shared_ptr for segment handle the indexer's lifetime
virtual fts::FtsColumnIndexerPtr get_fts_indexer(
const std::string &field_name) const = 0;

View File

@ -66,6 +66,16 @@ Status SegmentHelper::Execute(SegmentTask::Ptr &task) {
} else if (std::holds_alternative<DropScalarIndexTask>(task_info)) {
auto &drop_index_task = std::get<DropScalarIndexTask>(task_info);
s = ExecuteDropScalarIndexTask(drop_index_task);
} else if (std::holds_alternative<CreateFtsIndexTask>(task_info)) {
auto &fts_task = std::get<CreateFtsIndexTask>(task_info);
s = fts_task.input_segment_->create_fts_index(
fts_task.column_, fts_task.index_params_,
&fts_task.output_segment_meta_, &fts_task.output_fts_indexer_);
} else if (std::holds_alternative<DropFtsIndexTask>(task_info)) {
auto &fts_task = std::get<DropFtsIndexTask>(task_info);
s = fts_task.input_segment_->drop_fts_index(fts_task.column_,
&fts_task.output_segment_meta_,
&fts_task.output_fts_indexer_);
} else {
return Status::InvalidArgument("Unknown task type");
}
@ -146,7 +156,7 @@ Status SegmentHelper::ExecuteCompactTask(CompactTask &task) {
LOG_INFO("Compacted vector index");
s = ReduceFts(schema, input_segments, output_segment_path,
delete_row_id_bitmap);
delete_row_id_bitmap, block_id_generator, &block_metas);
CHECK_RETURN_STATUS(s);
LOG_INFO("Compacted fts index");
@ -973,7 +983,9 @@ arrow::Status SegmentHelper::FilterRecordBatch(
Status SegmentHelper::ReduceFts(const CollectionSchema::Ptr &schema,
const std::vector<Segment::Ptr> &input_segments,
const std::string &output_segment_path,
const roaring::Roaring &delete_row_id_bitmap) {
const roaring::Roaring &delete_row_id_bitmap,
std::function<BlockID()> &block_id_generator,
std::vector<BlockMeta> *output_block_metas) {
if (!schema->has_fts_field()) {
return Status::OK();
}
@ -983,12 +995,15 @@ Status SegmentHelper::ReduceFts(const CollectionSchema::Ptr &schema,
auto fts_fields = schema->fts_fields();
auto fts_block_id = block_id_generator();
// Build the destination FTS RocksDB with the post-dump CF layout:
// postings + positions per field, plus the shared stat CF. Side CFs
// ($TF/$MAX_TF/$DOC_LEN) are skipped — the reducer writes BitPacked
// directly, matching the immutable-segment shape after
// convert_postings_to_bitpacked().
auto dst_fts_path = FileHelper::MakeFtsIndexPath(output_segment_path);
auto dst_fts_path =
FileHelper::MakeFtsIndexPath(output_segment_path, fts_block_id);
std::vector<std::string> cf_names;
std::unordered_map<std::string, std::shared_ptr<rocksdb::MergeOperator>>
per_cf_merge_ops;
@ -1078,6 +1093,12 @@ Status SegmentHelper::ReduceFts(const CollectionSchema::Ptr &schema,
s.message().c_str());
return s;
}
BlockMeta fts_block;
fts_block.set_id(fts_block_id);
fts_block.set_type(BlockType::FTS_INDEX);
output_block_metas->push_back(fts_block);
return Status::OK();
}

View File

@ -130,13 +130,42 @@ struct DropScalarIndexTask {
InvertedIndexer::Ptr output_scalar_indexer_; // nullptr means no scalar index
};
struct CreateFtsIndexTask {
CreateFtsIndexTask(Segment::Ptr input_segment, std::string column,
IndexParams::Ptr index_params)
: input_segment_(input_segment),
column_(std::move(column)),
index_params_(std::move(index_params)) {}
Segment::Ptr input_segment_;
std::string column_;
IndexParams::Ptr index_params_;
// output
SegmentMeta::Ptr output_segment_meta_;
FtsIndexer::Ptr output_fts_indexer_;
};
struct DropFtsIndexTask {
DropFtsIndexTask(Segment::Ptr input_segment, std::string column)
: input_segment_(input_segment), column_(std::move(column)) {}
Segment::Ptr input_segment_;
std::string column_;
// output
SegmentMeta::Ptr output_segment_meta_;
FtsIndexer::Ptr output_fts_indexer_;
};
class SegmentTask {
public:
using Ptr = std::shared_ptr<SegmentTask>;
using TaskInfo =
std::variant<CompactTask, CreateVectorIndexTask, DropVectorIndexTask,
CreateScalarIndexTask, DropScalarIndexTask>;
CreateScalarIndexTask, DropScalarIndexTask,
CreateFtsIndexTask, DropFtsIndexTask>;
static Ptr CreateCompactTask(const CompactTask &task) {
return std::make_shared<SegmentTask>(task);
@ -158,6 +187,14 @@ class SegmentTask {
return std::make_shared<SegmentTask>(task);
}
static Ptr CreateCreateFtsIndexTask(const CreateFtsIndexTask &task) {
return std::make_shared<SegmentTask>(task);
}
static Ptr CreateDropFtsIndexTask(const DropFtsIndexTask &task) {
return std::make_shared<SegmentTask>(task);
}
public:
SegmentTask(const CompactTask &task) : task_info_(task) {}
@ -169,6 +206,10 @@ class SegmentTask {
SegmentTask(const DropScalarIndexTask &task) : task_info_(task) {}
SegmentTask(const CreateFtsIndexTask &task) : task_info_(task) {}
SegmentTask(const DropFtsIndexTask &task) : task_info_(task) {}
TaskInfo &task_info() {
return task_info_;
}
@ -251,7 +292,9 @@ class SegmentHelper {
static Status ReduceFts(const CollectionSchema::Ptr &schema,
const std::vector<Segment::Ptr> &input_segments,
const std::string &output_segment_path,
const roaring::Roaring &delete_row_id_bitmap);
const roaring::Roaring &delete_row_id_bitmap,
std::function<BlockID()> &block_id_generator,
std::vector<BlockMeta> *output_block_metas);
static arrow::Status FilterRecordBatch(
const std::shared_ptr<arrow::RecordBatch> &batch,

View File

@ -171,6 +171,7 @@ enum BlockType {
BT_SCALAR_INDEX = 2;
BT_VECTOR_INDEX = 3;
BT_VECTOR_INDEX_QUANTIZE = 4;
BT_FTS_INDEX = 5;
};
message BlockMeta {

View File

@ -125,6 +125,7 @@ enum BlockType : uint32_t {
SCALAR_INDEX = 2,
VECTOR_INDEX = 3,
VECTOR_INDEX_QUANTIZE = 4,
FTS_INDEX = 5,
};

View File

@ -5625,7 +5625,7 @@ TEST_F(CollectionTest, Feature_NoVectorCollection_FtsLifecycle) {
auto create_res = Collection::CreateAndOpen(col_path, *schema,
CollectionOptions{false, true});
ASSERT_TRUE(create_res.has_value()) << create_res.error().message();
auto col = create_res.value();
auto col = std::move(create_res.value());
// Insert a corpus where 4 of 5 docs contain "hello". Doc 4 is the only
// doc without "hello"; we'll delete it later to verify Optimize correctly
@ -5680,16 +5680,41 @@ TEST_F(CollectionTest, Feature_NoVectorCollection_FtsLifecycle) {
ASSERT_EQ(fts_search("hello").size(), 3u);
ASSERT_EQ(fts_search("nothing").size(), 0u);
// Close and reopen in read-only mode (same as bench query mode).
col.reset();
CollectionOptions ro_options;
ro_options.read_only_ = true;
auto reopen_res = Collection::Open(col_path, ro_options);
ASSERT_TRUE(reopen_res.has_value()) << reopen_res.error().message();
col = std::move(reopen_res.value());
auto fts_search_ro = [&](const std::string &term) {
SearchQuery vq;
vq.target_.field_name_ = "content";
vq.topk_ = 10;
FtsClause fts_q;
fts_q.query_string_ = term;
vq.target_.clause_ = fts_q;
auto r = col->Query(vq);
EXPECT_TRUE(r.has_value()) << r.error().message();
return r.has_value() ? r.value() : DocPtrList{};
};
ASSERT_EQ(fts_search_ro("hello").size(), 3u);
ASSERT_EQ(fts_search_ro("nothing").size(), 0u);
col.reset();
FileHelper::RemoveDirectory(col_path);
}
// CreateIndex/DropIndex must explicitly reject index types they don't
// support (today: anything other than vector index types or INVERT). This
// keeps a hypothetically supported-looking call like CreateIndex(field,
// FtsClause) from silently routing through the scalar/invert path and
// corrupting state.
TEST_F(CollectionTest, CornerCase_CreateOrDropIndex_UnsupportedTypes) {
// Dynamic CreateIndex/DropIndex for FTS: create an FTS index on a STRING column
// that already has data, verify queries hit, then drop the index and verify FTS
// is no longer available. Also covers reopen persistence.
TEST_F(CollectionTest, Feature_CreateOrDropFtsIndex) {
#ifdef __ANDROID__
GTEST_SKIP() << "Skipped on Android: emulator filesystem lacks hardlink "
"support (needed by RocksDB checkpoint)";
#endif
auto build_schema = [](bool with_fts) {
auto schema = std::make_shared<CollectionSchema>("fts_dyn");
schema->add_field(std::make_shared<FieldSchema>("title", DataType::STRING));
@ -5720,16 +5745,32 @@ TEST_F(CollectionTest, CornerCase_CreateOrDropIndex_UnsupportedTypes) {
return col->Query(vq);
};
// Case 1: CreateIndex(FtsIndexParams) and CreateIndex(nullptr) on a column
// declared without an FTS index — both should be rejected up front and
// leave the schema unchanged.
// CreateIndex(nullptr) should fail with INVALID_ARGUMENT.
{
FileHelper::RemoveDirectory(col_path);
auto schema = build_schema(/*with_fts=*/false);
auto create_res = Collection::CreateAndOpen(col_path, *schema,
CollectionOptions{false, true});
ASSERT_TRUE(create_res.has_value()) << create_res.error().message();
auto col = create_res.value();
auto schema = build_schema(false);
auto col_res = Collection::CreateAndOpen(col_path, *schema,
CollectionOptions{false, true});
ASSERT_TRUE(col_res.has_value()) << col_res.error().message();
auto col = std::move(col_res.value());
auto s_null = col->CreateIndex("content", nullptr);
ASSERT_FALSE(s_null.ok());
ASSERT_EQ(s_null.code(), StatusCode::INVALID_ARGUMENT);
col.reset();
FileHelper::RemoveDirectory(col_path);
}
// Case 1: CreateIndex(FtsIndexParams) on a STRING column without FTS.
// Insert data first, then create index, verify queries hit, verify reopen.
{
FileHelper::RemoveDirectory(col_path);
auto schema = build_schema(false);
CollectionOptions options{false, true};
auto col_res = Collection::CreateAndOpen(col_path, *schema, options);
ASSERT_TRUE(col_res.has_value()) << col_res.error().message();
auto col = std::move(col_res.value());
std::vector<Doc> docs;
docs.push_back(make_doc(0, "intro", "hello world"));
@ -5738,56 +5779,191 @@ TEST_F(CollectionTest, CornerCase_CreateOrDropIndex_UnsupportedTypes) {
ASSERT_TRUE(col->Insert(docs).has_value());
ASSERT_TRUE(col->Flush().ok());
auto s_fts =
col->CreateIndex("content", std::make_shared<FtsIndexParams>());
ASSERT_FALSE(s_fts.ok());
ASSERT_EQ(s_fts.code(), StatusCode::NOT_SUPPORTED);
// FTS query before index creation should fail.
auto q_before = fts_search(col, "hello");
ASSERT_FALSE(q_before.has_value());
auto s_null = col->CreateIndex("content", nullptr);
ASSERT_FALSE(s_null.ok());
ASSERT_EQ(s_null.code(), StatusCode::INVALID_ARGUMENT);
// Create FTS index.
auto s = col->CreateIndex("content", std::make_shared<FtsIndexParams>());
ASSERT_TRUE(s.ok()) << s.message();
// Schema must not be mutated by the rejected calls.
ASSERT_EQ(col->Schema().value(), *schema);
// FTS query should now succeed.
auto q_after = fts_search(col, "hello");
ASSERT_TRUE(q_after.has_value()) << q_after.error().message();
ASSERT_EQ(q_after.value().size(), 2u);
// Subsequent FTS query still fails because the column was never indexed,
// but it's a query-side validation error rather than a corruption symptom.
auto q = fts_search(col, "hello");
ASSERT_FALSE(q.has_value());
// "nothing" appears in doc 2 only.
auto q_nothing = fts_search(col, "nothing");
ASSERT_TRUE(q_nothing.has_value()) << q_nothing.error().message();
ASSERT_EQ(q_nothing.value().size(), 1u);
// Reopen and verify persistence.
col.reset();
auto reopen_res = Collection::Open(col_path, options);
ASSERT_TRUE(reopen_res.has_value()) << reopen_res.error().message();
col = reopen_res.value();
auto q_reopen = fts_search(col, "hello");
ASSERT_TRUE(q_reopen.has_value()) << q_reopen.error().message();
ASSERT_EQ(q_reopen.value().size(), 2u);
col.reset();
FileHelper::RemoveDirectory(col_path);
}
// Case 2: DropIndex on an FTS column is rejected (we don't tear down FTS
// physical state through DropIndex today), and the FTS index remains usable.
// Case 2: DropIndex on an FTS column removes the FTS index.
{
FileHelper::RemoveDirectory(col_path);
auto schema = build_schema(/*with_fts=*/true);
auto create_res = Collection::CreateAndOpen(col_path, *schema,
CollectionOptions{false, true});
ASSERT_TRUE(create_res.has_value()) << create_res.error().message();
auto col = create_res.value();
auto schema = build_schema(true);
CollectionOptions options{false, true};
auto col_res = Collection::CreateAndOpen(col_path, *schema, options);
ASSERT_TRUE(col_res.has_value()) << col_res.error().message();
auto col = std::move(col_res.value());
std::vector<Doc> docs;
docs.push_back(make_doc(0, "intro", "hello world"));
docs.push_back(make_doc(1, "guide", "hello foo"));
ASSERT_TRUE(col->Insert(docs).has_value());
ASSERT_TRUE(col->Flush().ok());
// Baseline: FTS query works.
auto baseline = fts_search(col, "hello");
ASSERT_TRUE(baseline.has_value());
ASSERT_EQ(baseline.value().size(), 2u);
// Drop FTS index.
auto s = col->DropIndex("content");
ASSERT_FALSE(s.ok());
ASSERT_EQ(s.code(), StatusCode::NOT_SUPPORTED);
ASSERT_TRUE(s.ok()) << s.message();
// Schema and FTS index untouched.
ASSERT_EQ(col->Schema().value(), *schema);
// FTS query should now fail (field no longer FTS-indexed).
auto q_after = fts_search(col, "hello");
ASSERT_FALSE(q_after.has_value());
// Reopen and verify FTS is still gone.
col.reset();
auto reopen_res = Collection::Open(col_path, options);
ASSERT_TRUE(reopen_res.has_value()) << reopen_res.error().message();
col = reopen_res.value();
auto q_reopen = fts_search(col, "hello");
ASSERT_FALSE(q_reopen.has_value());
col.reset();
FileHelper::RemoveDirectory(col_path);
}
// Case 3: Create → Drop → Create → Drop cycle on the same column.
{
FileHelper::RemoveDirectory(col_path);
auto schema = build_schema(false);
CollectionOptions options{false, true};
auto col_res = Collection::CreateAndOpen(col_path, *schema, options);
ASSERT_TRUE(col_res.has_value()) << col_res.error().message();
auto col = std::move(col_res.value());
std::vector<Doc> docs;
docs.push_back(make_doc(0, "intro", "hello world"));
docs.push_back(make_doc(1, "guide", "hello foo"));
docs.push_back(make_doc(2, "more", "nothing here"));
ASSERT_TRUE(col->Insert(docs).has_value());
ASSERT_TRUE(col->Flush().ok());
// Round 1: Create FTS index.
auto s = col->CreateIndex("content", std::make_shared<FtsIndexParams>());
ASSERT_TRUE(s.ok()) << s.message();
auto q = fts_search(col, "hello");
ASSERT_TRUE(q.has_value());
ASSERT_TRUE(q.has_value()) << q.error().message();
ASSERT_EQ(q.value().size(), 2u);
// Round 1: Drop FTS index.
s = col->DropIndex("content");
ASSERT_TRUE(s.ok()) << s.message();
q = fts_search(col, "hello");
ASSERT_FALSE(q.has_value());
// Round 2: Re-create FTS index.
s = col->CreateIndex("content", std::make_shared<FtsIndexParams>());
ASSERT_TRUE(s.ok()) << s.message();
q = fts_search(col, "hello");
ASSERT_TRUE(q.has_value()) << q.error().message();
ASSERT_EQ(q.value().size(), 2u);
// Round 2: Re-drop FTS index.
s = col->DropIndex("content");
ASSERT_TRUE(s.ok()) << s.message();
q = fts_search(col, "hello");
ASSERT_FALSE(q.has_value());
// Reopen and verify final state (no FTS).
col.reset();
auto reopen_res = Collection::Open(col_path, options);
ASSERT_TRUE(reopen_res.has_value()) << reopen_res.error().message();
col = reopen_res.value();
q = fts_search(col, "hello");
ASSERT_FALSE(q.has_value());
col.reset();
FileHelper::RemoveDirectory(col_path);
}
// Case 4: CreateIndex with different FtsIndexParams on a column that already
// has an FTS index — should remove the old index and rebuild with new params.
{
FileHelper::RemoveDirectory(col_path);
auto schema = build_schema(false);
CollectionOptions options{false, true};
auto col_res = Collection::CreateAndOpen(col_path, *schema, options);
ASSERT_TRUE(col_res.has_value()) << col_res.error().message();
auto col = std::move(col_res.value());
std::vector<Doc> docs;
docs.push_back(make_doc(0, "intro", "hello world"));
docs.push_back(make_doc(1, "guide", "hello foo"));
docs.push_back(make_doc(2, "more", "nothing here"));
ASSERT_TRUE(col->Insert(docs).has_value());
ASSERT_TRUE(col->Flush().ok());
// Create FTS index with default params (tokenizer="standard").
auto params_v1 = std::make_shared<FtsIndexParams>("standard");
auto s = col->CreateIndex("content", params_v1);
ASSERT_TRUE(s.ok()) << s.message();
auto q = fts_search(col, "hello");
ASSERT_TRUE(q.has_value()) << q.error().message();
ASSERT_EQ(q.value().size(), 2u);
// Re-create with different params: no lowercase filter, so indexing
// preserves original case and case-mismatched queries should miss.
auto params_v2 = std::make_shared<FtsIndexParams>(
"standard", std::vector<std::string>{});
ASSERT_NE(*params_v1, *params_v2);
s = col->CreateIndex("content", params_v2);
ASSERT_TRUE(s.ok()) << s.message();
// Lowercase query should still hit (source text is lowercase).
q = fts_search(col, "hello");
ASSERT_TRUE(q.has_value()) << q.error().message();
ASSERT_EQ(q.value().size(), 2u);
// Uppercase query should miss — no lowercase filter means case-sensitive.
q = fts_search(col, "HELLO");
ASSERT_TRUE(q.has_value());
ASSERT_EQ(q.value().size(), 0u);
// Reopen and verify persistence.
col.reset();
auto reopen_res = Collection::Open(col_path, options);
ASSERT_TRUE(reopen_res.has_value()) << reopen_res.error().message();
col = reopen_res.value();
q = fts_search(col, "hello");
ASSERT_TRUE(q.has_value()) << q.error().message();
ASSERT_EQ(q.value().size(), 2u);
q = fts_search(col, "HELLO");
ASSERT_TRUE(q.has_value());
ASSERT_EQ(q.value().size(), 0u);
col.reset();
FileHelper::RemoveDirectory(col_path);
}

View File

@ -462,6 +462,25 @@ class MockSegment : public Segment {
return Status::OK();
}
Status reload_fts_index(const CollectionSchema &schema,
const SegmentMeta::Ptr &segment_meta,
const FtsIndexer::Ptr &new_fts_indexer) override {
return Status::OK();
}
Status create_fts_index(const std::string &column,
const IndexParams::Ptr &index_params,
SegmentMeta::Ptr *new_segment_meta,
FtsIndexer::Ptr *output_fts_indexer) override {
return Status::OK();
}
Status drop_fts_index(const std::string &column,
SegmentMeta::Ptr *new_segment_meta,
FtsIndexer::Ptr *output_fts_indexer) override {
return Status::OK();
}
Status Insert(Doc &doc) override {
return Status::OK();
}