feat: add fts support (#408)

Add BM25-based full-text search with CJK (jieba) tokenization, supporting
  query_string and match_string syntax, phrase queries, boolean operators
  (AND/OR/NOT/MUST), and hybrid retrieval with existing vector search.

  ## Core
  - BitPacked posting format with block-max WAND pruning
  - Tokenizer pipeline: jieba (cut/cut_for_search/hmm/full), whitespace,
    lowercase, with extensible pipeline composition
  - Query parser: boolean operators, phrase queries, field scoping,
    boost, MUST(+) modifier inside OR (ES query_string semantics)
  - AST rewriter: dedup repeated terms with linear boost aggregation,
    flatten same-type composites, canonicalize OR-with-must_not into AND
    wrapper, empty-node propagation, contradiction detection
  - FTS reduce/merge integrated into Optimize compaction
  - Multi-segment score-descending sort
  - Auto-register bundled jieba dict on SDK import

  ## Performance
  - Block-max WAND with cached block_max_info_for (single binary search)
  - AVX2/SSE bitpacked encoding with cross-arch scalar fallback
  - MultiGet for batch posting retrieval and phrase position verification
  - HashSkipList memtable for posting writes
  - PinnableSlice zero-copy reads
  - Filter pushdown into composite iterators (Disjunction/Conjunction/Phrase)
  - Candidate-driven (brute-force) evaluation for selective invert filters
  - Precomputed BM25 IDF weights, cached SIMD dispatch pointers
  - Shortest-list anchor for phrase position matching
  - Single-open per-term posting iterator

  ## Query
  - Tokenize query terms through the same pipeline as indexing
  - EmptyNode for zero-token queries (all stop-words / punctuation)
  - Backslash unescape after lexing in query parser
  - Schema allows collections without vector fields (FTS-only use case)
  - Create/Drop Index validates supported index types
  - FTS fields disallowed in SQL filter expressions

  ## Bindings
  - C API: fts query params, brute-force ratio config
  - Python SDK: FTS search, jieba dict auto-registration

  ## Internals
  - Bypass cppjieba::Jieba to drop KeywordExtractor (~12MB fewer required files)
  - Hide tokenizer pipeline from public header (Pimpl-style FtsState)
  - ListColumnFamilies to avoid double-open on segment load
  - Reorganized fts_column into tokenizer/, posting/, iterator/ subdirs
This commit is contained in:
egolearner 2026-06-01 15:02:54 +08:00 committed by GitHub
parent 74beb2a828
commit 02bfb31cf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
154 changed files with 22646 additions and 223 deletions

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
# Auto-generated files — collapsed in GitHub PR diffs
src/db/index/column/fts_column/gen/** linguist-generated=true
src/db/sqlengine/antlr/gen/** linguist-generated=true

9
.gitmodules vendored
View File

@ -40,3 +40,12 @@
[submodule "thirdparty/RaBitQ-Library/RaBitQ-Library-0.1"]
path = thirdparty/RaBitQ-Library/RaBitQ-Library-0.1
url = https://github.com/VectorDB-NTU/RaBitQ-Library.git
[submodule "thirdparty/cppjieba/cppjieba-5.6.7"]
path = thirdparty/cppjieba/cppjieba-5.6.7
url = https://github.com/yanyiwu/cppjieba.git
[submodule "thirdparty/FastPFOR/FastPFOR-0.4.0"]
path = thirdparty/FastPFOR/FastPFOR-0.4.0
url = https://github.com/fast-pack/FastPFOR.git
[submodule "thirdparty/limonp/limonp-v1.0.2"]
path = thirdparty/limonp/limonp-v1.0.2
url = https://github.com/yanyiwu/limonp.git

View File

@ -145,4 +145,14 @@ if(BUILD_PYTHON_BINDINGS)
message(STATUS "Zvec install path: ${ZVEC_PY_INSTALL_DIR}")
install(TARGETS _zvec LIBRARY DESTINATION ${ZVEC_PY_INSTALL_DIR})
# Bundle cppjieba's dictionary files so the `jieba` FTS tokenizer works
# out of the box. python/zvec/__init__.py resolves this directory via
# importlib.resources and registers it with set_default_jieba_dict_dir().
set(ZVEC_JIEBA_DICT_SRC
"${PROJECT_SOURCE_DIR}/thirdparty/cppjieba/cppjieba-5.6.7/dict")
install(FILES
"${ZVEC_JIEBA_DICT_SRC}/jieba.dict.utf8"
"${ZVEC_JIEBA_DICT_SRC}/hmm_model.utf8"
DESTINATION ${ZVEC_PY_INSTALL_DIR}/zvec/data/jieba_dict)
endif()

View File

@ -0,0 +1,188 @@
# 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.
"""End-to-end tests for FTS-only collections (no vector field).
The schema validation rule "must have at least one vector field" has been
lifted; these tests pin the new behavior so insert / query / delete /
optimize all work on a vector-less collection.
"""
from __future__ import annotations
import pytest
import zvec
from zvec import (
Collection,
CollectionOption,
DataType,
Doc,
FieldSchema,
FtsIndexParam,
OptimizeOption,
)
from zvec.model.param.query import Fts, Query
# ==================== Fixtures ====================
@pytest.fixture(scope="function")
def fts_collection(tmp_path_factory) -> Collection:
"""FTS-only collection: a STRING field for forward + an FTS-indexed STRING."""
temp_dir = tmp_path_factory.mktemp("zvec_fts_only")
collection_path = temp_dir / "fts_collection"
schema = zvec.CollectionSchema(
name="fts_only",
fields=[
FieldSchema("title", DataType.STRING, nullable=False),
FieldSchema(
"content",
DataType.STRING,
nullable=False,
index_param=FtsIndexParam(
tokenizer_name="standard",
filters=["lowercase"],
),
),
],
# vectors omitted on purpose — schema validation must accept this.
)
coll = zvec.create_and_open(
path=str(collection_path),
schema=schema,
option=CollectionOption(read_only=False, enable_mmap=True),
)
assert coll is not None
try:
yield coll
finally:
try:
coll.destroy()
except Exception as e:
print(f"Warning: failed to destroy collection: {e}")
def _make_docs() -> list[Doc]:
"""5-doc corpus where 4 contain 'hello' and doc 4 is the only outlier."""
return [
Doc(id="pk_0", fields={"title": "intro", "content": "hello world"}),
Doc(id="pk_1", fields={"title": "guide", "content": "hello foo bar"}),
Doc(id="pk_2", fields={"title": "tips", "content": "hello baz"}),
Doc(id="pk_3", fields={"title": "more", "content": "hello hello"}),
Doc(id="pk_4", fields={"title": "other", "content": "nothing relevant"}),
]
def _fts_query(coll: Collection, term: str) -> list[Doc]:
"""Run a single-term FTS match query against the `content` field."""
return coll.query(
queries=Query(field_name="content", fts=Fts(match_string=term)),
topk=10,
)
# ==================== Tests ====================
class TestFtsOnlyCollectionSchema:
def test_create_and_open_without_vectors(self, fts_collection: Collection):
"""Schema with zero vector fields must be accepted by validate()."""
assert fts_collection.schema.name == "fts_only"
assert {f.name for f in fts_collection.schema.fields} == {"title", "content"}
# Empty vectors is the whole point of the test.
assert list(fts_collection.schema.vectors) == []
assert fts_collection.stats.doc_count == 0
def test_create_schema_omitting_vectors_kwarg(self):
"""Constructing CollectionSchema without `vectors=` argument is valid."""
schema = zvec.CollectionSchema(
name="bare_fts",
fields=[
FieldSchema(
"content",
DataType.STRING,
nullable=False,
index_param=FtsIndexParam(),
),
],
)
assert list(schema.vectors) == []
assert {f.name for f in schema.fields} == {"content"}
class TestFtsOnlyCollectionLifecycle:
def test_insert_and_fts_query(self, fts_collection: Collection):
"""FTS-only collection supports insert + FTS query end-to-end."""
results = fts_collection.insert(_make_docs())
assert all(r.ok() for r in results)
assert fts_collection.stats.doc_count == 5
hits = _fts_query(fts_collection, "hello")
assert len(hits) == 4
assert {doc.id for doc in hits} == {"pk_0", "pk_1", "pk_2", "pk_3"}
# Term that nothing in the surviving corpus contains.
assert _fts_query(fts_collection, "missing_term_xyz") == []
def test_delete_then_query(self, fts_collection: Collection):
"""Tombstone filter must drop deleted docs from FTS results."""
fts_collection.insert(_make_docs())
statuses = fts_collection.delete(["pk_0", "pk_4"])
assert all(s.ok() for s in statuses)
assert fts_collection.stats.doc_count == 3
hits = _fts_query(fts_collection, "hello")
assert len(hits) == 3
assert {doc.id for doc in hits} == {"pk_1", "pk_2", "pk_3"}
# pk_4's unique term is filtered out post-delete.
assert _fts_query(fts_collection, "nothing") == []
def test_optimize_rebuilds_fts(self, fts_collection: Collection):
"""Optimize with >30% deletes triggers ReduceFts; recall unchanged."""
fts_collection.insert(_make_docs())
# 40% delete ratio — above COMPACT_DELETE_RATIO_THRESHOLD=0.3, so
# build_compact_task picks the rebuild path and ReduceFts runs.
fts_collection.delete(["pk_0", "pk_4"])
before = {doc.id for doc in _fts_query(fts_collection, "hello")}
assert before == {"pk_1", "pk_2", "pk_3"}
fts_collection.optimize(option=OptimizeOption())
assert fts_collection.stats.doc_count == 3
after = {doc.id for doc in _fts_query(fts_collection, "hello")}
assert after == before
assert _fts_query(fts_collection, "nothing") == []
class TestFtsOnlyCollectionQueryValidation:
def test_vector_query_rejected(self, fts_collection: Collection):
"""Vector query on a no-vector collection must raise."""
with pytest.raises(ValueError, match="vector or id"):
fts_collection.query(
queries=Query(field_name="content", vector=[0.1, 0.2, 0.3]),
topk=5,
)
def test_id_query_rejected(self, fts_collection: Collection):
"""ID-based query on a no-vector collection must raise."""
fts_collection.insert(_make_docs()[:1])
with pytest.raises(ValueError, match="vector or id"):
fts_collection.query(
queries=Query(field_name="content", id="pk_0"),
topk=5,
)

View File

@ -0,0 +1,158 @@
# 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.
"""Tests for FTS (Full-Text Search) query support in the Python SDK."""
import pickle
import pytest
from zvec.model.param.query import Fts, Query
class TestFtsQueryValidation:
"""Test FTS parameter validation in Query dataclass."""
def test_fts_query_string_only(self):
"""Query with only query_string in Fts should be valid."""
q = Query(
field_name="content", fts=Fts(query_string='+hello -world "exact phrase"')
)
q._validate()
assert q.fts.query_string == '+hello -world "exact phrase"'
assert q.fts.match_string is None
assert q.has_fts() is True
def test_fts_match_string_only(self):
"""Query with only match_string in Fts should be valid."""
q = Query(field_name="content", fts=Fts(match_string="machine learning"))
q._validate()
assert q.fts.match_string == "machine learning"
assert q.fts.query_string is None
assert q.has_fts() is True
def test_fts_query_string_and_match_string_mutually_exclusive(self):
"""Cannot provide both query_string and match_string in Fts."""
q = Query(
field_name="content",
fts=Fts(query_string="+hello", match_string="hello world"),
)
with pytest.raises(ValueError, match="mutually exclusive"):
q._validate()
def test_no_fts(self):
"""Query without FTS fields should have has_fts() == False."""
q = Query(field_name="embedding", vector=[0.1, 0.2, 0.3])
assert q.has_fts() is False
def test_vector_and_fts_mutually_exclusive(self):
"""Cannot combine vector search with FTS in a single Query."""
q = Query(
field_name="embedding",
vector=[0.1, 0.2, 0.3],
fts=Fts(match_string="deep learning"),
)
with pytest.raises(ValueError, match="Cannot combine fts with vector search"):
q._validate()
def test_fts_without_vector_or_id(self):
"""Query with only FTS (no vector, no id) should be valid."""
q = Query(field_name="content", fts=Fts(query_string="hello"))
q._validate()
assert q.has_vector() is False
assert q.has_id() is False
assert q.has_fts() is True
class TestFtsQueryBinding:
"""Test FTS binding layer (_Fts)."""
def test_import_fts_query(self):
"""_Fts should be importable from _zvec.param."""
from _zvec.param import _Fts
fts = _Fts()
assert fts.query_string == ""
assert fts.match_string == ""
def test_fts_query_set_fields(self):
"""Setting fields on _Fts should work."""
from _zvec.param import _Fts
fts = _Fts()
fts.query_string = "+hello -world"
assert fts.query_string == "+hello -world"
fts2 = _Fts()
fts2.match_string = "machine learning"
assert fts2.match_string == "machine learning"
def test_fts_query_pickle(self):
"""_Fts should support pickling."""
from _zvec.param import _Fts
fts = _Fts()
fts.query_string = "+vector search"
fts.match_string = ""
data = pickle.dumps(fts)
restored = pickle.loads(data)
assert restored.query_string == "+vector search"
assert restored.match_string == ""
def test_vector_query_fts_field(self):
"""_VectorQuery should have fts field."""
from _zvec.param import _Fts, _VectorQuery
vq = _VectorQuery()
# fts should be None by default (optional)
assert vq.fts is None
# set fts
fts = _Fts()
fts.query_string = "hello"
vq.fts = fts
assert vq.fts is not None
assert vq.fts.query_string == "hello"
def test_vector_query_pickle_with_fts(self):
"""_VectorQuery with fts should survive pickling."""
from _zvec.param import _Fts, _VectorQuery
vq = _VectorQuery()
vq.topk = 10
vq.field_name = "embedding"
fts = _Fts()
fts.match_string = "test query"
vq.fts = fts
data = pickle.dumps(vq)
restored = pickle.loads(data)
assert restored.topk == 10
assert restored.field_name == "embedding"
assert restored.fts is not None
assert restored.fts.match_string == "test query"
def test_vector_query_pickle_without_fts(self):
"""_VectorQuery without fts should survive pickling."""
from _zvec.param import _VectorQuery
vq = _VectorQuery()
vq.topk = 5
vq.field_name = "vec"
data = pickle.dumps(vq)
restored = pickle.loads(data)
assert restored.topk == 5
assert restored.field_name == "vec"
assert restored.fts is None

View File

@ -0,0 +1,201 @@
# 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.
"""End-to-end: jieba FTS works without any user configuration.
`import zvec` is supposed to register the wheel-bundled jieba dict
directory via `set_default_jieba_dict_dir`. With that in place a user can
declare an FTS field with `tokenizer_name="jieba"`, leave `extra_params`
empty, and Chinese full-text search just works.
Falls back to GTEST_SKIP-equivalent when running against a build that did
not bundle the dict (e.g., source-tree dev install without the install
step). In that case CI will rely on the C++ unit tests instead.
"""
from __future__ import annotations
import os
import sys
import pytest
import zvec
from zvec import (
Collection,
CollectionOption,
DataType,
Doc,
FieldSchema,
FtsIndexParam,
)
from zvec.model.param.query import Fts, Query
def _bundled_dict_dir() -> str:
"""Path zvec.__init__ would have registered; empty when not bundled."""
return zvec.get_default_jieba_dict_dir()
def _bundled_dict_files_exist() -> bool:
"""Whether the registered default actually contains the dict files.
`importlib.resources` happily returns a path even when the data dir was
not installed (e.g. source-tree dev runs); only an installed wheel has
the files on disk.
"""
import os
base = _bundled_dict_dir()
if not base:
return False
return os.path.isfile(os.path.join(base, "jieba.dict.utf8")) and os.path.isfile(
os.path.join(base, "hmm_model.utf8")
)
@pytest.fixture(scope="module", autouse=True)
def _require_bundled_dict():
if not _bundled_dict_files_exist():
pytest.skip(
"Bundled jieba dict not found at zvec/data/jieba_dict/ — "
"this test requires an installed wheel (not a source-tree dev "
"build without the install step).",
)
@pytest.fixture(scope="function")
def jieba_collection(tmp_path_factory) -> Collection:
"""FTS-only collection using jieba tokenizer and no explicit dict path."""
# env-var shadows GlobalConfig in the priority chain.
if os.environ.get("ZVEC_JIEBA_DICT_DIR"):
pytest.skip("ZVEC_JIEBA_DICT_DIR shadows the bundled default")
temp_dir = tmp_path_factory.mktemp("zvec_jieba_default")
collection_path = temp_dir / "fts_jieba"
schema = zvec.CollectionSchema(
name="fts_jieba_default",
fields=[
FieldSchema("title", DataType.STRING, nullable=False),
FieldSchema(
"content",
DataType.STRING,
nullable=False,
# Deliberately omit extra_params — the bundled default must
# be picked up via GlobalConfig.jieba_dict_dir.
index_param=FtsIndexParam(
tokenizer_name="jieba",
filters=["lowercase"],
),
),
],
)
coll = zvec.create_and_open(
path=str(collection_path),
schema=schema,
option=CollectionOption(read_only=False, enable_mmap=True),
)
assert coll is not None
try:
yield coll
finally:
try:
coll.destroy()
except Exception as e:
print(f"Warning: failed to destroy collection: {e}")
def test_jieba_works_without_explicit_dict_path(jieba_collection: Collection):
"""User opens collection, inserts CJK doc, searches — no init() / no
extra_params / no env var / no manual setter call. Just `import zvec`."""
docs = [
Doc(id="pk_1", fields={"title": "t1", "content": "中华人民共和国成立"}),
Doc(id="pk_2", fields={"title": "t2", "content": "无关文档"}),
]
insert_results = jieba_collection.insert(docs)
assert all(r.ok() for r in insert_results)
hits = jieba_collection.query(
queries=Query(field_name="content", fts=Fts(match_string="中华")),
topk=10,
)
ids = {doc.id for doc in hits}
assert "pk_1" in ids
assert "pk_2" not in ids
def test_default_dict_dir_is_registered_on_import():
"""Sanity check: zvec.__init__ registered a non-empty default."""
assert _bundled_dict_dir() != ""
def test_user_can_override_default_at_runtime():
"""zvec.set_default_jieba_dict_dir can be called any time to override."""
saved = zvec.get_default_jieba_dict_dir()
try:
zvec.set_default_jieba_dict_dir("/tmp/zvec/jieba-override")
assert zvec.get_default_jieba_dict_dir() == "/tmp/zvec/jieba-override"
finally:
zvec.set_default_jieba_dict_dir(saved)
@pytest.mark.skipif(
sys.platform == "win32",
reason="os.environ writes may not propagate across CRT to zvec.pyd",
)
def test_env_var_overrides_global_config(monkeypatch, tmp_path_factory):
"""ZVEC_JIEBA_DICT_DIR beats GlobalConfig in jieba's resolution chain."""
bundled = _bundled_dict_dir()
monkeypatch.setenv("ZVEC_JIEBA_DICT_DIR", bundled)
saved_global = zvec.get_default_jieba_dict_dir()
try:
zvec.set_default_jieba_dict_dir("/zvec/intentionally/missing/global")
temp_dir = tmp_path_factory.mktemp("zvec_jieba_env")
schema = zvec.CollectionSchema(
name="fts_jieba_env",
fields=[
FieldSchema("title", DataType.STRING, nullable=False),
FieldSchema(
"content",
DataType.STRING,
nullable=False,
index_param=FtsIndexParam(
tokenizer_name="jieba",
filters=["lowercase"],
),
),
],
)
coll = zvec.create_and_open(
path=str(temp_dir / "fts_jieba_env"),
schema=schema,
option=CollectionOption(read_only=False, enable_mmap=True),
)
assert coll is not None
try:
results = coll.insert(
[
Doc(id="pk_1", fields={"title": "t", "content": "搜索引擎技术"}),
]
)
assert all(r.ok() for r in results)
hits = coll.query(
queries=Query(field_name="content", fts=Fts(match_string="搜索")),
topk=10,
)
assert {d.id for d in hits} == {"pk_1"}
finally:
coll.destroy()
finally:
zvec.set_default_jieba_dict_dir(saved_global)

View File

@ -259,7 +259,9 @@ class TestNoVectorQueryExecutor:
def test_do_validate_with_queries(self):
schema = MockCollectionSchema()
executor = NoVectorQueryExecutor(schema)
ctx = QueryContext(topk=10, queries=[Query(field_name="test")])
ctx = QueryContext(
topk=10, queries=[Query(field_name="test", vector=[0.1, 0.2, 0.3])]
)
with pytest.raises(
ValueError, match="Collection does not support query with vector or id"

View File

@ -21,6 +21,24 @@ if TYPE_CHECKING:
from importlib.metadata import PackageNotFoundError
# Register the wheel-bundled jieba dict dir so `import zvec` alone makes
# the jieba FTS tokenizer usable. Users can still override via
# zvec.init(jieba_dict_dir=...), zvec.set_default_jieba_dict_dir(...),
# ZVEC_JIEBA_DICT_DIR, or per-field FtsIndexParam.extra_params.
try:
from importlib.resources import files as _resource_files
from _zvec import (
get_default_jieba_dict_dir,
set_default_jieba_dict_dir,
)
set_default_jieba_dict_dir(str(_resource_files("zvec").joinpath("data/jieba_dict")))
except Exception:
# Custom builds without bundled dict; users must configure explicitly.
pass
# ==============================
# Public API — grouped by category
# ==============================
@ -56,11 +74,14 @@ from .model.collection import Collection
from .model.doc import Doc
# —— Query & index parameters ——
# —— FTS params (C++ binding) ——
from .model.param import (
AddColumnOption,
AlterColumnOption,
CollectionOption,
FlatIndexParam,
FtsIndexParam,
FtsQueryParam,
HnswIndexParam,
HnswQueryParam,
HnswRabitqIndexParam,
@ -73,7 +94,7 @@ from .model.param import (
VamanaIndexParam,
VamanaQueryParam,
)
from .model.param.query import Query, VectorQuery
from .model.param.query import Fts, Query, VectorQuery
# —— Schema & field definitions ——
from .model.schema import CollectionSchema, CollectionStats, FieldSchema, VectorSchema
@ -101,6 +122,8 @@ __all__ = [
"create_and_open",
"init",
"open",
"set_default_jieba_dict_dir",
"get_default_jieba_dict_dir",
# Core classes
"Collection",
"Doc",
@ -112,6 +135,9 @@ __all__ = [
# Parameters
"Query",
"VectorQuery",
"Fts",
"FtsIndexParam",
"FtsQueryParam",
"InvertIndexParam",
"HnswIndexParam",
"HnswRabitqIndexParam",

View File

@ -20,7 +20,7 @@ from typing import Optional, Union, final
import numpy as np
from _zvec import _Collection, _MultiQuery
from _zvec.param import _SubQuery, _VectorQuery
from _zvec.param import _Fts, _SubQuery, _VectorQuery
from ..extension import ReRanker, RrfReRanker, WeightedReRanker
from ..model.convert import convert_to_py_doc
@ -141,6 +141,14 @@ class QueryExecutor(ABC):
core_vector.output_fields = ctx.output_fields
return core_vector
def _do_build_fts_query(self, query: Query, core_vector: _VectorQuery) -> None:
"""Set FTS query on core_vector if the query has FTS parameters."""
if query.has_fts():
fts = _Fts()
fts.query_string = query.fts.query_string or ""
fts.match_string = query.fts.match_string or ""
core_vector.fts = fts
def _do_build_query_with_vector(
self, ctx: QueryContext, query: Query, collection: _Collection
) -> _VectorQuery:
@ -149,25 +157,34 @@ class QueryExecutor(ABC):
if query.param:
core_vector.query_params = query.param
vector_schema = (
self._schema.vector(query.field_name) if query else self._schema.vectors[0]
)
if vector_schema is None:
raise ValueError("No vector field found")
# set FTS query if provided
self._do_build_fts_query(query, core_vector)
# set output_fields
core_vector.output_fields = ctx.output_fields
vector_schema = None
if query.has_vector() or query.has_id():
vector_schema = (
self._schema.vector(query.field_name)
if query
else self._schema.vectors[0]
)
if vector_schema is None:
raise ValueError("No vector field found")
# set vector
if query.has_vector():
vec_data = query.vector
else:
elif query.has_id():
fetched = collection.Fetch([query.id])
doc = next(iter(fetched.values()))
if not doc:
return core_vector
vec_data = doc.get_any(vector_schema.name, vector_schema.data_type)
else:
return core_vector
target_dtype = DTYPE_MAP.get(vector_schema.data_type.value)
core_vector.set_vector(
@ -243,13 +260,21 @@ class NoVectorQueryExecutor(QueryExecutor):
super().__init__(schema)
def _do_validate(self, ctx: QueryContext) -> None:
if len(ctx.queries) > 0:
raise ValueError("Collection does not support query with vector or id")
for query in ctx.queries:
if query.has_vector() or query.has_id():
raise ValueError("Collection does not support query with vector or id")
query._validate()
def _do_build(
self, ctx: QueryContext, _collection: _Collection
self, ctx: QueryContext, collection: _Collection
) -> list[_VectorQuery]:
return [self._do_build_query_wo_vector(ctx)]
if len(ctx.queries) == 0:
return [self._do_build_query_wo_vector(ctx)]
# FTS-only branch in _do_build_query_with_vector skips vector resolution.
return [
self._do_build_query_with_vector(ctx, query, collection)
for query in ctx.queries
]
class SingleVectorQueryExecutor(NoVectorQueryExecutor):

View File

@ -15,7 +15,7 @@ from __future__ import annotations
from .collection import Collection
from .doc import Doc
from .param.query import Query, VectorQuery
from .param.query import Fts, Query, VectorQuery
from .schema.collection_schema import CollectionSchema
from .schema.field_schema import FieldSchema
@ -24,6 +24,7 @@ __all__ = [
"CollectionSchema",
"Doc",
"FieldSchema",
"Fts",
"Query",
"VectorQuery",
]

View File

@ -18,6 +18,8 @@ from _zvec.param import (
AlterColumnOption,
CollectionOption,
FlatIndexParam,
FtsIndexParam,
FtsQueryParam,
HnswIndexParam,
HnswQueryParam,
HnswRabitqIndexParam,
@ -36,6 +38,8 @@ __all__ = [
"AlterColumnOption",
"CollectionOption",
"FlatIndexParam",
"FtsIndexParam",
"FtsQueryParam",
"HnswIndexParam",
"HnswQueryParam",
"HnswRabitqIndexParam",

View File

@ -20,26 +20,42 @@ from typing import Optional, Union
from ...common import VectorType
from . import HnswQueryParam, HnswRabitqQueryParam, IVFQueryParam
__all__ = ["Query", "VectorQuery"]
__all__ = ["Fts", "Query", "VectorQuery"]
@dataclass(frozen=True)
class Fts:
"""Full-text search query parameters.
Attributes:
query_string (Optional[str]): FTS query expression
(e.g. '+vector -slow "exact phrase"'). Mutually exclusive with match_string.
match_string (Optional[str]): Natural language match string,
tokenized and combined using the default operator.
Mutually exclusive with query_string.
"""
query_string: Optional[str] = None
match_string: Optional[str] = None
@dataclass(frozen=True)
class Query:
"""Represents a search query for a specific field in a collection.
A `Query` can be constructed using either a document ID (to look up
its vector) or an explicit vector. It may optionally include index-specific
query parameters to control search behavior (e.g., `ef` for HNSW, `nprobe` for IVF).
A `Query` can be constructed for either vector search or full-text search,
but not both simultaneously.
Exactly one of `id` or `vector` should be provided. If both are given,
behavior is implementation-defined (typically `id` takes precedence).
For vector search, provide `id` or `vector` (and optionally `param`).
For FTS, provide `fts`.
Attributes:
field_name (str): Name of the field to query.
id (Optional[str], optional): Document ID to fetch vector from. Default is None.
vector (VectorType, optional): Explicit query vector. Default is None.
param (Optional[Union[HnswQueryParam, IVFQueryParam]], optional):
Index-specific query parameters. Default is None.
Index-specific query parameters for vector search. Default is None.
fts (Optional[Fts], optional): Full-text search parameters. Default is None.
Examples:
>>> import zvec
@ -51,12 +67,18 @@ class Query:
... vector=[0.1, 0.2, 0.3],
... param=HnswQueryParam(ef=300)
... )
>>> # FTS query
>>> q3 = zvec.Query(
... field_name="content",
... fts=Fts(match_string="machine learning")
... )
"""
field_name: str
id: Optional[str] = None
vector: VectorType = None
param: Optional[Union[HnswQueryParam, HnswRabitqQueryParam, IVFQueryParam]] = None
fts: Optional[Fts] = None
def has_id(self) -> bool:
"""Check if the query is based on a document ID.
@ -74,11 +96,32 @@ class Query:
"""
return self.vector is not None and len(self.vector) > 0
def has_fts(self) -> bool:
"""Check if the query contains an FTS (full-text search) condition.
Returns:
bool: True if `fts` is set with a query_string or match_string.
"""
if self.fts is not None:
return bool(self.fts.query_string) or bool(self.fts.match_string)
return False
def _validate(self) -> None:
if self.field_name is None:
raise ValueError("Field name cannot be empty")
if self.id and self.vector:
raise ValueError("Cannot provide both id and vector")
if self.has_fts() and (
self.has_vector() or self.has_id() or self.param is not None
):
raise ValueError(
"Cannot combine fts with vector search fields (id/vector/param) in a single Query"
)
if self.fts is not None and self.fts.query_string and self.fts.match_string:
raise ValueError(
"Cannot provide both query_string and match_string in Fts; "
"they are mutually exclusive"
)
class VectorQuery(Query):

View File

@ -38,7 +38,9 @@ def init(
optimize_threads: Optional[int] = None,
invert_to_forward_scan_ratio: Optional[float] = None,
brute_force_by_keys_ratio: Optional[float] = None,
fts_brute_force_by_keys_ratio: Optional[float] = None,
memory_limit_mb: Optional[int] = None,
jieba_dict_dir: Optional[str] = None,
) -> None:
"""Initialize Zvec with configuration options.
@ -88,11 +90,25 @@ def init(
Threshold to use brute-force key lookup over index.
Lower prefer index; higher prefer brute-force.
Range: [0.0, 1.0]. Default: ``0.1``.
fts_brute_force_by_keys_ratio (Optional[float], optional):
Threshold to switch FTS scan from posting-driven to
candidate-driven (brute-force) when the invert filter is
highly selective. Independent from ``brute_force_by_keys_ratio``
because per-candidate FTS cost is higher.
Range: [0.0, 1.0]. Default: ``0.05``.
memory_limit_mb (Optional[int], optional):
Soft memory cap in MB. Zvec may throttle or fail operations
approaching this limit.
If ``None``, inferred from cgroup memory limit * 0.8 (e.g., in Docker).
Must be > 0 if provided.
jieba_dict_dir (Optional[str], optional):
Override the default directory containing ``jieba.dict.utf8`` and
``hmm_model.utf8`` for the jieba FTS tokenizer. When ``None``, the
value previously registered by ``zvec.set_default_jieba_dict_dir``
(called automatically on ``import zvec`` to point at the wheel's
bundled dict) is preserved. JiebaTokenizer also honors the
``ZVEC_JIEBA_DICT_DIR`` environment variable and per-field
``FtsIndexParam.extra_params.jieba_dict_dir`` ahead of this value.
Raises:
RuntimeError: If Zvec is already initialized.
@ -157,8 +173,12 @@ def init(
config_dict["invert_to_forward_scan_ratio"] = invert_to_forward_scan_ratio
if brute_force_by_keys_ratio is not None:
config_dict["brute_force_by_keys_ratio"] = brute_force_by_keys_ratio
if fts_brute_force_by_keys_ratio is not None:
config_dict["fts_brute_force_by_keys_ratio"] = fts_brute_force_by_keys_ratio
if memory_limit_mb is not None:
config_dict["memory_limit_mb"] = memory_limit_mb
if jieba_dict_dir is not None:
config_dict["jieba_dict_dir"] = jieba_dict_dir
Initialize(config_dict)

View File

@ -138,10 +138,10 @@ target_include_directories(zvec_shared
# Strip symbols in release builds to reduce library size
if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug")
if(UNIX AND NOT APPLE)
add_custom_command(TARGET zvec_shared POST_BUILD
COMMAND ${CMAKE_STRIP} $<TARGET_FILE:zvec_shared>
COMMENT "Stripping symbols from libzvec.so"
)
# add_custom_command(TARGET zvec_shared POST_BUILD
# COMMAND ${CMAKE_STRIP} $<TARGET_FILE:zvec_shared>
# COMMENT "Stripping symbols from libzvec.so"
# )
elseif(APPLE)
add_custom_command(TARGET zvec_shared POST_BUILD
COMMAND /usr/bin/strip -x $<TARGET_FILE:zvec_shared>

View File

@ -629,6 +629,27 @@ float zvec_config_data_get_brute_force_by_keys_ratio(
return cpp_config->brute_force_by_keys_ratio;
}
zvec_error_code_t zvec_config_data_set_fts_brute_force_by_keys_ratio(
zvec_config_data_t *config, float ratio) {
if (!config) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT, "Config pointer is null");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *cpp_config = reinterpret_cast<zvec::GlobalConfig::ConfigData *>(config);
cpp_config->fts_brute_force_by_keys_ratio = ratio;
return ZVEC_OK;
}
float zvec_config_data_get_fts_brute_force_by_keys_ratio(
const zvec_config_data_t *config) {
if (!config) {
return 0.0f;
}
auto *cpp_config =
reinterpret_cast<const zvec::GlobalConfig::ConfigData *>(config);
return cpp_config->fts_brute_force_by_keys_ratio;
}
zvec_error_code_t zvec_config_data_set_optimize_thread_count(
zvec_config_data_t *config, uint32_t thread_count) {
if (!config) {
@ -650,6 +671,27 @@ uint32_t zvec_config_data_get_optimize_thread_count(
return cpp_config->optimize_thread_count;
}
zvec_error_code_t zvec_config_data_set_jieba_dict_dir(
zvec_config_data_t *config, const char *dir) {
if (!config) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT, "Config pointer is null");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *cpp_config = reinterpret_cast<zvec::GlobalConfig::ConfigData *>(config);
cpp_config->jieba_dict_dir = (dir != nullptr) ? std::string(dir) : "";
return ZVEC_OK;
}
const char *zvec_config_data_get_jieba_dict_dir(
const zvec_config_data_t *config) {
if (!config) {
return "";
}
auto *cpp_config =
reinterpret_cast<const zvec::GlobalConfig::ConfigData *>(config);
return cpp_config->jieba_dict_dir.c_str();
}
// =============================================================================
// Initialization and cleanup interface implementation
@ -705,6 +747,18 @@ bool zvec_is_initialized(void) {
return g_initialized.load();
}
void zvec_set_default_jieba_dict_dir(const char *dir) {
zvec::GlobalConfig::Instance().set_default_jieba_dict_dir(
(dir != nullptr) ? std::string(dir) : std::string());
}
const char *zvec_get_default_jieba_dict_dir(void) {
// Thread-local buffer keeps c_str() valid until the next call on this thread.
thread_local std::string cached;
cached = zvec::GlobalConfig::Instance().jieba_dict_dir();
return cached.c_str();
}
// =============================================================================
// Error handling interface implementation
// =============================================================================
@ -881,6 +935,16 @@ static std::shared_ptr<zvec::IndexParams> convert_c_index_params_to_cpp(
? std::make_shared<zvec::InvertIndexParams>(*invert_params)
: nullptr;
}
case zvec::IndexType::FTS: {
auto *fts_params =
dynamic_cast<const zvec::FtsIndexParams *>(cpp_params);
// FtsIndexParams is not copy-constructible; rebuild from accessors.
return fts_params ? std::make_shared<zvec::FtsIndexParams>(
fts_params->tokenizer_name(),
fts_params->filters(),
fts_params->extra_params())
: nullptr;
}
default:
return nullptr;
}
@ -1302,6 +1366,11 @@ zvec_index_params_t *zvec_index_params_create(zvec_index_type_t index_type) {
new zvec::InvertIndexParams(true, // enable_range_optimization
false); // enable_extended_wildcard
break;
case ZVEC_INDEX_TYPE_FTS:
// Defaults align with FtsIndexParams default ctor:
// tokenizer="standard", filters=["lowercase"], extra="".
cpp_params = new zvec::FtsIndexParams();
break;
case ZVEC_INDEX_TYPE_HNSW:
cpp_params =
new zvec::HnswIndexParams(
@ -1637,6 +1706,77 @@ zvec_error_code_t zvec_index_params_get_invert_params(const zvec_index_params_t
return ZVEC_OK;
}
zvec_error_code_t zvec_index_params_set_fts_params(
zvec_index_params_t *params, const char *tokenizer_name,
const zvec_string_array_t *filters, const char *extra_params) {
if (!params) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT,
"Invalid params or not FTS index type");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *cpp_params = reinterpret_cast<zvec::IndexParams *>(params);
auto *fts_params = dynamic_cast<zvec::FtsIndexParams *>(cpp_params);
if (!fts_params) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT,
"Invalid params or not FTS index type");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
if (tokenizer_name) {
fts_params->set_tokenizer_name(std::string(tokenizer_name));
}
if (filters) {
std::vector<std::string> filter_vec;
filter_vec.reserve(filters->count);
for (size_t i = 0; i < filters->count; ++i) {
const auto &item = filters->strings[i];
filter_vec.emplace_back(item.data ? item.data : "",
item.data ? item.length : 0);
}
fts_params->set_filters(std::move(filter_vec));
}
if (extra_params) {
fts_params->set_extra_params(std::string(extra_params));
}
return ZVEC_OK;
}
zvec_error_code_t zvec_index_params_get_fts_params(
const zvec_index_params_t *params, const char **out_tokenizer_name,
zvec_string_array_t **out_filters, const char **out_extra_params) {
if (!params) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT,
"Invalid params or not FTS index type");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *cpp_params = reinterpret_cast<const zvec::IndexParams *>(params);
auto *fts_params = dynamic_cast<const zvec::FtsIndexParams *>(cpp_params);
if (!fts_params) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT,
"Invalid params or not FTS index type");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
if (out_tokenizer_name) {
*out_tokenizer_name = fts_params->tokenizer_name().c_str();
}
if (out_extra_params) {
*out_extra_params = fts_params->extra_params().c_str();
}
if (out_filters) {
const auto &filters = fts_params->filters();
zvec_string_array_t *arr = zvec_string_array_create(filters.size());
if (!arr) {
SET_LAST_ERROR(ZVEC_ERROR_RESOURCE_EXHAUSTED,
"Failed to allocate filters string array");
return ZVEC_ERROR_RESOURCE_EXHAUSTED;
}
for (size_t i = 0; i < filters.size(); ++i) {
zvec_string_array_add(arr, i, filters[i].c_str());
}
*out_filters = arr;
}
return ZVEC_OK;
}
// =============================================================================
// FieldSchema management interface implementation
// =============================================================================
@ -2484,6 +2624,8 @@ const char *zvec_index_type_to_string(zvec_index_type_t index_type) {
return "FLAT";
case ZVEC_INDEX_TYPE_INVERT:
return "INVERT";
case ZVEC_INDEX_TYPE_FTS:
return "FTS";
default:
return "UNKNOWN_INDEX_TYPE";
}
@ -4839,6 +4981,47 @@ bool zvec_query_params_flat_get_is_using_refiner(
return ptr->is_using_refiner();
}
// =============================================================================
// FtsQueryParams implementation - wrapper around zvec::FtsQueryParams
// =============================================================================
zvec_fts_query_params_t *zvec_query_params_fts_create(
const char *default_operator) {
ZVEC_TRY_RETURN_NULL(
"Failed to create FtsQueryParams",
auto *params = new zvec::FtsQueryParams();
if (default_operator && *default_operator) {
params->set_default_operator(std::string(default_operator));
} return reinterpret_cast<zvec_fts_query_params_t *>(params);)
return nullptr;
}
void zvec_query_params_fts_destroy(zvec_fts_query_params_t *params) {
if (params) {
delete reinterpret_cast<zvec::FtsQueryParams *>(params);
}
}
zvec_error_code_t zvec_query_params_fts_set_default_operator(
zvec_fts_query_params_t *params, const char *default_operator) {
if (!params) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT,
"FTS query params pointer is null");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *ptr = reinterpret_cast<zvec::FtsQueryParams *>(params);
ptr->set_default_operator(std::string(default_operator ? default_operator
: ""));
return ZVEC_OK;
}
const char *zvec_query_params_fts_get_default_operator(
const zvec_fts_query_params_t *params) {
if (!params) return nullptr;
auto *ptr = reinterpret_cast<const zvec::FtsQueryParams *>(params);
return ptr->default_operator().c_str();
}
// =============================================================================
// Query implementation - owns zvec::SearchQuery via raw pointer
// (external C symbol naming kept for ABI compatibility)
@ -5082,6 +5265,97 @@ zvec_error_code_t zvec_vector_query_set_flat_params(
return ZVEC_OK;
}
zvec_error_code_t zvec_vector_query_set_fts_params(
zvec_vector_query_t *query, zvec_fts_query_params_t *fts_params) {
if (!query || !fts_params) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT,
"Query or FTS params pointer is null");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *query_ptr = reinterpret_cast<zvec::SearchQuery *>(query);
auto *params_ptr = reinterpret_cast<zvec::FtsQueryParams *>(fts_params);
query_ptr->target_.query_params_.reset(params_ptr);
return ZVEC_OK;
}
// =============================================================================
// Fts payload implementation - wrapper around zvec::FtsClause (value type)
// =============================================================================
zvec_fts_t *zvec_fts_create(void) {
ZVEC_TRY_RETURN_NULL("Failed to create Fts payload",
auto *fts = new zvec::FtsClause();
return reinterpret_cast<zvec_fts_t *>(fts);)
return nullptr;
}
void zvec_fts_destroy(zvec_fts_t *fts) {
if (fts) {
delete reinterpret_cast<zvec::FtsClause *>(fts);
}
}
zvec_error_code_t zvec_fts_set_query_string(zvec_fts_t *fts,
const char *query_string) {
if (!fts) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT, "Fts pointer is null");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *ptr = reinterpret_cast<zvec::FtsClause *>(fts);
ptr->query_string_ = query_string ? query_string : "";
return ZVEC_OK;
}
zvec_error_code_t zvec_fts_set_match_string(zvec_fts_t *fts,
const char *match_string) {
if (!fts) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT, "Fts pointer is null");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *ptr = reinterpret_cast<zvec::FtsClause *>(fts);
ptr->match_string_ = match_string ? match_string : "";
return ZVEC_OK;
}
const char *zvec_fts_get_query_string(const zvec_fts_t *fts) {
if (!fts) return nullptr;
auto *ptr = reinterpret_cast<const zvec::FtsClause *>(fts);
return ptr->query_string_.c_str();
}
const char *zvec_fts_get_match_string(const zvec_fts_t *fts) {
if (!fts) return nullptr;
auto *ptr = reinterpret_cast<const zvec::FtsClause *>(fts);
return ptr->match_string_.c_str();
}
zvec_error_code_t zvec_vector_query_set_fts(zvec_vector_query_t *query,
const zvec_fts_t *fts) {
if (!query) {
SET_LAST_ERROR(ZVEC_ERROR_INVALID_ARGUMENT, "Vector query pointer is null");
return ZVEC_ERROR_INVALID_ARGUMENT;
}
auto *query_ptr = reinterpret_cast<zvec::SearchQuery *>(query);
if (!fts) {
// Clearing FTS resets the target to an empty vector clause.
query_ptr->target_.clause_ = zvec::VectorClause{};
} else {
query_ptr->target_.clause_ = *reinterpret_cast<const zvec::FtsClause *>(fts);
}
return ZVEC_OK;
}
const zvec_fts_t *zvec_vector_query_get_fts(const zvec_vector_query_t *query) {
if (!query) return nullptr;
auto *query_ptr = reinterpret_cast<const zvec::SearchQuery *>(query);
const auto *fc = std::get_if<zvec::FtsClause>(&query_ptr->target_.clause_);
if (!fc) return nullptr;
return reinterpret_cast<const zvec_fts_t *>(fc);
}
// =============================================================================
// GroupByVectorQuery implementation - owns zvec::GroupByVectorQuery via raw
// pointer

View File

@ -177,6 +177,24 @@ void ZVecPyConfig::Initialize(pybind11::module_ &m) {
data.brute_force_by_keys_ratio = static_cast<float>(v);
}
// set fts_brute_force_by_keys_ratio
if (has_key(config_dict, "fts_brute_force_by_keys_ratio")) {
auto v =
get_if<double>(config_dict, "fts_brute_force_by_keys_ratio").value();
if (v < 0.0 || v > 1.0) {
throw py::value_error(
"fts_brute_force_by_keys_ratio must be in [0.0, 1.0]");
}
data.fts_brute_force_by_keys_ratio = static_cast<float>(v);
}
// jieba_dict_dir: optional override of the SDK-registered default.
// Empty value is a no-op (Initialize preserves the SDK default).
if (has_key(config_dict, "jieba_dict_dir")) {
data.jieba_dict_dir =
get_if<std::string>(config_dict, "jieba_dict_dir").value();
}
// initialize (contains validate)
Status status = GlobalConfig::Instance().Initialize(data);
if (!status.ok()) {
@ -184,6 +202,21 @@ void ZVecPyConfig::Initialize(pybind11::module_ &m) {
}
return py::none();
});
// Process-wide setter, independent of Initialize(); called by __init__.py
// on import to register the wheel-bundled dict path.
m.def(
"set_default_jieba_dict_dir",
[](const std::string &dir) {
GlobalConfig::Instance().set_default_jieba_dict_dir(dir);
},
pybind11::arg("dir"),
"Register the process-wide default jieba dict directory.");
m.def(
"get_default_jieba_dict_dir",
[]() -> std::string { return GlobalConfig::Instance().jieba_dict_dir(); },
"Read the currently registered default jieba dict directory.");
}

View File

@ -36,6 +36,8 @@ static std::string index_type_to_string(const IndexType type) {
return "HNSW_RABITQ";
case IndexType::VAMANA:
return "VAMANA";
case IndexType::FTS:
return "FTS";
default:
return "UNDEFINED";
}
@ -252,6 +254,88 @@ Note: Prefix search is always enabled regardless of this setting.
t[1].cast<bool>());
}));
// binding fts index params
py::class_<FtsIndexParams, IndexParams, std::shared_ptr<FtsIndexParams>>
fts_index_params(m, "FtsIndexParam", R"pbdoc(
Parameters for configuring a full-text search (FTS) index.
Controls the tokenizer pipeline used during indexing and querying.
Attributes:
type (IndexType): Always ``IndexType.FTS``.
tokenizer_name (str): Name of the tokenizer (e.g., "standard", "jieba").
Default is "standard".
filters (list[str]): List of token filter names applied after tokenization.
Default is ["lowercase"].
extra_params (str): Additional parameters passed to the tokenizer.
Default is "".
Examples:
>>> params = FtsIndexParam(tokenizer_name="jieba", filters=["lowercase"])
>>> print(params.tokenizer_name)
jieba
)pbdoc");
fts_index_params
.def(py::init<std::string, std::vector<std::string>, std::string>(),
py::arg("tokenizer_name") = "standard",
py::arg("filters") = std::vector<std::string>{"lowercase"},
py::arg("extra_params") = "",
R"pbdoc(
Constructs an FtsIndexParam instance.
Args:
tokenizer_name (str, optional): Tokenizer name. Defaults to "standard".
filters (list[str], optional): Token filter names. Defaults to ["lowercase"].
extra_params (str, optional): Extra tokenizer parameters. Defaults to "".
)pbdoc")
.def_property_readonly("tokenizer_name", &FtsIndexParams::tokenizer_name,
"str: Name of the tokenizer.")
.def_property_readonly("filters", &FtsIndexParams::filters,
"list[str]: Token filter names.")
.def_property_readonly("extra_params", &FtsIndexParams::extra_params,
"str: Additional tokenizer parameters.")
.def(
"to_dict",
[](const FtsIndexParams &self) -> py::dict {
py::dict dict;
dict["type"] = index_type_to_string(self.type());
dict["tokenizer_name"] = self.tokenizer_name();
dict["filters"] = self.filters();
dict["extra_params"] = self.extra_params();
return dict;
},
"Convert to dictionary with all fields")
.def("__repr__",
[](const FtsIndexParams &self) -> std::string {
std::string filters_str = "[";
for (size_t i = 0; i < self.filters().size(); ++i) {
if (i > 0) {
filters_str += ",";
}
filters_str += "\"" + self.filters()[i] + "\"";
}
filters_str += "]";
return "{"
"\"type\":\"" +
index_type_to_string(self.type()) +
"\", \"tokenizer_name\":\"" + self.tokenizer_name() +
"\", \"filters\":" + filters_str + ", \"extra_params\":\"" +
self.extra_params() + "\"}";
})
.def(py::pickle(
[](const FtsIndexParams &self) {
return py::make_tuple(self.tokenizer_name(), self.filters(),
self.extra_params());
},
[](py::tuple t) {
if (t.size() != 3) {
throw std::runtime_error("Invalid state for FtsIndexParams");
}
return std::make_shared<FtsIndexParams>(
t[0].cast<std::string>(), t[1].cast<std::vector<std::string>>(),
t[2].cast<std::string>());
}));
// binding base vector index params
py::class_<VectorIndexParams, IndexParams, std::shared_ptr<VectorIndexParams>>
vector_params(m, "VectorIndexParam", R"pbdoc(
@ -1103,6 +1187,64 @@ Args:
obj->set_is_using_refiner(t[3].cast<bool>());
return obj;
}));
// binding fts query params
py::class_<FtsQueryParams, QueryParams, std::shared_ptr<FtsQueryParams>>
fts_query_params(m, "FtsQueryParam", R"pbdoc(
Query parameters for full-text search (FTS) index.
Controls the default boolean operator used to combine adjacent bare terms
in a query string.
Attributes:
type (IndexType): Always ``IndexType.FTS``.
default_operator (str): Default boolean operator for adjacent bare terms.
Supported values (case-insensitive): "OR" (default), "AND".
Examples:
>>> params = FtsQueryParam(default_operator="AND")
>>> print(params.default_operator)
AND
)pbdoc");
fts_query_params
.def(py::init([](const std::string &default_operator) {
auto params = std::make_shared<FtsQueryParams>();
if (!default_operator.empty()) {
params->set_default_operator(default_operator);
}
return params;
}),
py::arg("default_operator") = "",
R"pbdoc(
Constructs an FtsQueryParam instance.
Args:
default_operator (str, optional): Default boolean operator for adjacent
bare terms. Supported: "OR", "AND". Defaults to "" (uses engine default).
)pbdoc")
.def_property_readonly("default_operator",
&FtsQueryParams::default_operator,
"str: Default boolean operator for bare terms.")
.def("__repr__",
[](const FtsQueryParams &self) -> std::string {
return "{"
"\"type\":\"" +
index_type_to_string(self.type()) +
"\", \"default_operator\":\"" + self.default_operator() +
"\"}";
})
.def(py::pickle(
[](const FtsQueryParams &self) {
return py::make_tuple(self.default_operator());
},
[](py::tuple t) {
if (t.size() != 1) {
throw std::runtime_error("Invalid state for FtsQueryParams");
}
auto obj = std::make_shared<FtsQueryParams>();
obj->set_default_operator(t[0].cast<std::string>());
return obj;
}));
}
void ZVecPyParams::bind_options(py::module_ &m) { // binding collection options
@ -1373,6 +1515,24 @@ Args:
}
void ZVecPyParams::bind_vector_query(py::module_ &m) {
// bind Fts
py::class_<FtsClause>(m, "_Fts")
.def(py::init<>())
.def_readwrite("query_string", &FtsClause::query_string_)
.def_readwrite("match_string", &FtsClause::match_string_)
.def(py::pickle(
[](const FtsClause &self) {
return py::make_tuple(self.query_string_, self.match_string_);
},
[](py::tuple t) {
if (t.size() != 2)
throw std::runtime_error("Invalid pickle data for Fts");
FtsClause obj{};
obj.query_string_ = t[0].cast<std::string>();
obj.match_string_ = t[1].cast<std::string>();
return obj;
}));
// Bind SubQuery (used by MultiQuery)
py::class_<SubQuery>(m, "_SubQuery")
.def(py::init<>())
@ -1409,6 +1569,23 @@ void ZVecPyParams::bind_vector_query(py::module_ &m) {
s.target_.query_params_ = std::move(p);
})
.def_readwrite("output_fields", &SearchQuery::output_fields_)
.def_property(
"fts",
[](const SearchQuery &self) -> py::object {
const auto *fc = self.target_.get_fts_clause();
if (fc != nullptr) {
return py::cast(*fc);
}
return py::none();
},
[](SearchQuery &self, const py::object &obj) {
if (obj.is_none()) {
// Clearing FTS resets the target to an empty vector clause.
self.target_.clause_ = VectorClause{};
} else {
self.target_.clause_ = obj.cast<FtsClause>();
}
})
// vector
.def("set_vector",
[](SearchQuery &self, const FieldSchema &field_schema,
@ -1614,6 +1791,7 @@ void ZVecPyParams::bind_vector_query(py::module_ &m) {
.def(py::pickle(
[](const SearchQuery &self) {
const VectorClause *vc = self.target_.get_vector_clause();
const auto *fc = self.target_.get_fts_clause();
return py::make_tuple(self.topk_, self.target_.field_name_,
vc ? vc->query_vector_ : std::string(),
vc ? vc->sparse_indices_ : std::string(),
@ -1622,22 +1800,31 @@ void ZVecPyParams::bind_vector_query(py::module_ &m) {
self.output_fields_,
self.target_.query_params_
? py::cast(self.target_.query_params_)
: py::none());
: py::none(),
fc ? py::cast(*fc) : py::none());
},
[](py::tuple t) {
if (t.size() != 9)
if (t.size() != 10)
throw std::runtime_error("Invalid pickle data for _VectorQuery");
SearchQuery obj{};
obj.topk_ = t[0].cast<int>();
obj.target_.field_name_ = t[1].cast<std::string>();
obj.target_.clause_ =
VectorClause{t[2].cast<std::string>(), t[3].cast<std::string>(),
t[4].cast<std::string>()};
// A vector clause and an FTS clause are mutually exclusive in the
// variant target; restore whichever the pickle carried.
if (!t[9].is_none()) {
obj.target_.clause_ = t[9].cast<FtsClause>();
} else {
obj.target_.clause_ = VectorClause{t[2].cast<std::string>(),
t[3].cast<std::string>(),
t[4].cast<std::string>()};
}
obj.filter_ = t[5].cast<std::string>();
obj.include_vector_ = t[6].cast<bool>();
obj.output_fields_ = t[7].cast<std::vector<std::string>>();
if (!t[7].is_none()) {
obj.output_fields_ = t[7].cast<std::vector<std::string>>();
}
if (!t[8].is_none()) {
obj.target_.query_params_ = t[8].cast<QueryParams::Ptr>();
}

View File

@ -13,6 +13,23 @@ cc_directory(sqlengine)
file(GLOB_RECURSE ALL_DB_SRCS *.cc *.c *.h)
# Ensure bitpacked_simd_sse41.cc is compiled with SSE4.1 flag and
# bitpacked_simd_avx2.cc with AVX2 flag in the packed zvec_db target as well
# (they are also compiled separately in zvec_index).
if(NOT ANDROID AND AUTO_DETECT_ARCH)
if(HOST_ARCH MATCHES "^(x86|x64)$")
setup_compiler_march_for_x86(_DB_MARCH_SSE _DB_MARCH_AVX2 _DB_MARCH_AVX512 _DB_MARCH_AVX512FP16)
set_source_files_properties(
${CMAKE_CURRENT_SOURCE_DIR}/index/column/fts_column/posting/bitpacked_simd_sse41.cc
PROPERTIES COMPILE_FLAGS "${_DB_MARCH_SSE}"
)
set_source_files_properties(
${CMAKE_CURRENT_SOURCE_DIR}/index/column/fts_column/posting/bitpacked_simd_avx2.cc
PROPERTIES COMPILE_FLAGS "${_DB_MARCH_AVX2}"
)
endif()
endif()
cc_library(
NAME zvec_db STATIC STRICT SRCS_NO_GLOB PACKED
SRCS ${ALL_DB_SRCS} ${CMAKE_CURRENT_BINARY_DIR}/proto/zvec.pb.cc
@ -26,6 +43,8 @@ cc_library(
rocksdb
antlr4
libprotobuf
FastPFOR
cppjieba
Arrow::arrow_static
Arrow::arrow_compute
Arrow::arrow_dataset

View File

@ -41,6 +41,7 @@
#include "db/index/common/delete_store.h"
#include "db/index/common/id_map.h"
#include "db/index/common/index_filter.h"
#include "db/index/common/type_helper.h"
#include "db/index/common/version_manager.h"
#include "db/index/segment/segment.h"
#include "db/index/segment/segment_helper.h"
@ -57,6 +58,7 @@ enum class WriteMode : uint8_t {
UPSERT,
};
Collection::~Collection() = default;
class CollectionImpl : public Collection {
@ -453,6 +455,10 @@ Status CollectionImpl::CreateIndex(const std::string &column_name,
CHECK_DESTROY_RETURN_STATUS(destroyed_, false);
if (index_params == nullptr) {
return Status::InvalidArgument("CreateIndex: index_params is null");
}
auto new_schema = std::make_shared<CollectionSchema>(*schema_);
auto s = new_schema->add_index(column_name, index_params);
CHECK_RETURN_STATUS(s);
@ -525,10 +531,14 @@ Status CollectionImpl::CreateIndex(const std::string &column_name,
if (is_vector_field) {
tasks = build_create_vector_index_task(persist_segments, column_name,
index_params, options.concurrency_);
} else {
} else if (index_params->type() == IndexType::INVERT) {
tasks = build_create_scalar_index_task(persist_segments, column_name,
index_params, options.concurrency_);
} else {
return Status::NotSupported(
"CreateIndex: index type [",
IndexTypeCodeBook::AsString(index_params->type()),
"] is not supported");
}
if (tasks.empty()) {
@ -660,8 +670,6 @@ Status CollectionImpl::DropIndex(const std::string &column_name) {
Version new_version = version_manager_->get_current_version();
bool is_vector_field = field->is_vector_field();
if (writing_segment_->doc_count() > 0) {
s = writing_segment_->dump();
CHECK_RETURN_STATUS(s);
@ -709,11 +717,18 @@ Status CollectionImpl::DropIndex(const std::string &column_name) {
auto persist_segments = get_all_persist_segments();
bool is_vector_field = field->is_vector_field();
std::vector<SegmentTask::Ptr> tasks;
if (is_vector_field) {
tasks = build_drop_vector_index_task(persist_segments, column_name);
} else {
} else if (field->index_params()->type() == IndexType::INVERT) {
tasks = build_drop_scalar_index_task(persist_segments, column_name);
} else {
return Status::NotSupported(
"DropIndex: index type [",
IndexTypeCodeBook::AsString(field->index_params()->type()),
"] on column[", column_name, "] is not supported");
}
if (tasks.empty()) {
@ -1590,8 +1605,13 @@ Result<DocPtrList> CollectionImpl::Query(const SearchQuery &query) const {
CHECK_DESTROY_RETURN_STATUS_EXPECTED(destroyed_, false);
SearchQuery sanitized = query;
auto s = sanitized.validate_and_sanitize(
schema_->get_vector_field(sanitized.target_.field_name_));
// When field_name_ is set, use get_field to retrieve the schema uniformly.
// validate_and_sanitize checks that the field type matches the query type
// (FTS query requires an FTS field, vector query requires a vector field).
const auto &field_name = sanitized.target_.field_name_;
const FieldSchema *field_schema =
field_name.empty() ? nullptr : schema_->get_field(field_name);
auto s = sanitized.validate_and_sanitize(field_schema);
CHECK_RETURN_STATUS_EXPECTED(s);
auto segments = get_all_segments();

View File

@ -37,7 +37,9 @@ GlobalConfig::ConfigData::ConfigData()
query_thread_count(CgroupUtil::getCpuLimit()),
invert_to_forward_scan_ratio(0.9),
brute_force_by_keys_ratio(0.1),
optimize_thread_count(CgroupUtil::getCpuLimit()) {}
fts_brute_force_by_keys_ratio(0.05),
optimize_thread_count(CgroupUtil::getCpuLimit()),
jieba_dict_dir() {}
Status GlobalConfig::Validate(const ConfigData &config) const {
if (config.memory_limit_bytes < MIN_MEMORY_LIMIT_BYTES) {
@ -69,6 +71,13 @@ Status GlobalConfig::Validate(const ConfigData &config) const {
"brute_force_by_keys_ratio must be between 0 and 1");
}
// Validate fts_brute_force_by_keys_ratio (should be between 0 and 1)
if (config.fts_brute_force_by_keys_ratio < 0.0f ||
config.fts_brute_force_by_keys_ratio > 1.0f) {
return Status::InvalidArgument(
"fts_brute_force_by_keys_ratio must be between 0 and 1");
}
// Validate optimize thread count
if (config.optimize_thread_count == 0) {
return Status::InvalidArgument(
@ -116,7 +125,16 @@ Status GlobalConfig::Initialize(const ConfigData &config) {
auto s = Validate(config);
CHECK_RETURN_STATUS(s);
config_ = config;
// Preserve the SDK-set jieba_dict_dir when caller didn't specify one.
// Lock spans the bulk assign so readers never see a half-written string.
{
std::lock_guard<std::mutex> lk(mutex_);
std::string final_jieba = config.jieba_dict_dir.empty()
? config_.jieba_dict_dir
: config.jieba_dict_dir;
config_ = config;
config_.jieba_dict_dir = std::move(final_jieba);
}
s = LogUtil::Init(log_dir(), log_file_basename(), int(log_level()),
log_type(), log_file_size(), log_overdue_days());
@ -131,6 +149,16 @@ Status GlobalConfig::Initialize(const ConfigData &config) {
return Status::OK();
}
void GlobalConfig::set_default_jieba_dict_dir(const std::string &dir) {
std::lock_guard<std::mutex> lk(mutex_);
config_.jieba_dict_dir = dir;
}
std::string GlobalConfig::jieba_dict_dir() const {
std::lock_guard<std::mutex> lk(mutex_);
return config_.jieba_dict_dir;
}
uint64_t GlobalConfig::memory_limit_bytes() const noexcept {
return config_.memory_limit_bytes;
}

View File

@ -80,5 +80,11 @@ const std::string INVERT_KEY_SEALED{"$ZVEC$SEALED"};
const uint32_t INVERT_ID_LIST_SIZE_THRESHOLD = 3;
// FTS (Full-Text Search) column family name suffixes and shared CF name
constexpr const char *kFtsPositionsSuffix = "$POSITIONS";
constexpr const char *kFtsTfSuffix = "$TF";
constexpr const char *kFtsMaxTfSuffix = "$MAX_TF";
constexpr const char *kFtsDocLenSuffix = "$DOC_LEN";
constexpr const char *kFtsStatCfName = "$FTS_STAT";
} // namespace zvec

View File

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

View File

@ -15,6 +15,8 @@
#include "rocksdb_context.h"
#include <rocksdb/filter_policy.h>
#include <rocksdb/memtablerep.h>
#include <rocksdb/slice_transform.h>
#include <rocksdb/statistics.h>
#include <rocksdb/table.h>
#include <rocksdb/utilities/checkpoint.h>
@ -27,39 +29,14 @@ namespace zvec {
Status RocksdbContext::create(
const std::string &db_path,
std::shared_ptr<rocksdb::MergeOperator> merge_op) {
std::lock_guard<std::mutex> lock(mutex_);
if (db_) {
LOG_ERROR("RocksDB[%s] is already opened", db_path_.c_str());
return Status::PermissionDenied();
}
if (auto s = validate_and_set_db_path(db_path, false); !s.ok()) {
return s;
}
create_opts_.create_if_missing = true;
prepare_options(merge_op);
// Open RocksDB
rocksdb::DB *db;
if (auto s = rocksdb::DB::Open(create_opts_, db_path, &db); !s.ok()) {
LOG_ERROR("Failed to create RocksDB[%s], code[%d], reason[%s]",
db_path.c_str(), s.code(), s.ToString().c_str());
return Status::InternalError();
}
db_.reset(db);
read_only_ = false;
write_opts_.disableWAL = true;
LOG_DEBUG("Created RocksDB[%s]", db_path.c_str());
return Status::OK();
return create(Args{db_path, {}, std::move(merge_op), {}});
}
Status RocksdbContext::create(
const std::string &db_path, const std::vector<std::string> &column_names,
std::shared_ptr<rocksdb::MergeOperator> merge_op) {
Status RocksdbContext::create(Args args) {
per_cf_merge_ops_ = std::move(args.per_cf_merge_ops);
enable_hash_skiplist_ = args.enable_hash_skiplist;
std::lock_guard<std::mutex> lock(mutex_);
if (db_) {
@ -67,26 +44,24 @@ Status RocksdbContext::create(
return Status::PermissionDenied();
}
if (auto s = validate_and_set_db_path(db_path, false); !s.ok()) {
if (auto s = validate_and_set_db_path(args.db_path, false); !s.ok()) {
return s;
}
create_opts_.create_if_missing = true;
prepare_options(merge_op);
prepare_options(std::move(args.merge_op));
// Open RocksDB
rocksdb::DB *db;
rocksdb::Status s = rocksdb::DB::Open(create_opts_, db_path, &db);
rocksdb::Status s = rocksdb::DB::Open(create_opts_, args.db_path, &db);
if (!s.ok()) {
LOG_ERROR("Failed to create RocksDB[%s], code[%d], reason[%s]",
db_path.c_str(), s.code(), s.ToString().c_str());
args.db_path.c_str(), s.code(), s.ToString().c_str());
return Status::InternalError();
}
db_.reset(db);
// Create column families
bool has_default = false;
for (auto const &column_name : column_names) {
for (const auto &column_name : args.column_names) {
if (column_name == rocksdb::kDefaultColumnFamilyName) {
cf_handles_.push_back(db->DefaultColumnFamily());
has_default = true;
@ -94,10 +69,14 @@ Status RocksdbContext::create(
}
rocksdb::ColumnFamilyHandle *cf_handle{nullptr};
rocksdb::ColumnFamilyOptions cf_options(create_opts_);
auto it = per_cf_merge_ops_.find(column_name);
if (it != per_cf_merge_ops_.end() && it->second) {
cf_options.merge_operator = it->second;
}
s = db->CreateColumnFamily(cf_options, column_name, &cf_handle);
if (!s.ok()) {
LOG_ERROR("Failed to create cf[%s] in RocksDB[%s], code[%d], reason[%s]",
column_name.c_str(), db_path.c_str(), s.code(),
column_name.c_str(), args.db_path.c_str(), s.code(),
s.ToString().c_str());
delete_cf_handles();
db->Close();
@ -112,13 +91,28 @@ Status RocksdbContext::create(
read_only_ = false;
write_opts_.disableWAL = true;
LOG_DEBUG("Created RocksDB[%s]", db_path.c_str());
LOG_DEBUG("Created RocksDB[%s] with Args", args.db_path.c_str());
return Status::OK();
}
Status RocksdbContext::create(
const std::string &db_path, const std::vector<std::string> &column_names,
std::shared_ptr<rocksdb::MergeOperator> merge_op) {
return create(Args{db_path, column_names, std::move(merge_op), {}});
}
Status RocksdbContext::open(const std::string &db_path, bool read_only,
std::shared_ptr<rocksdb::MergeOperator> merge_op) {
return open(Args{db_path, {}, std::move(merge_op), {}}, read_only);
}
Status RocksdbContext::open(Args args, bool read_only) {
per_cf_merge_ops_ = std::move(args.per_cf_merge_ops);
enable_hash_skiplist_ = args.enable_hash_skiplist;
std::lock_guard<std::mutex> lock(mutex_);
if (db_) {
@ -126,31 +120,84 @@ Status RocksdbContext::open(const std::string &db_path, bool read_only,
return Status::PermissionDenied();
}
if (auto s = validate_and_set_db_path(db_path, true); !s.ok()) {
if (auto s = validate_and_set_db_path(args.db_path, true); !s.ok()) {
return s;
}
create_opts_.create_if_missing = false;
prepare_options(merge_op);
prepare_options(std::move(args.merge_op));
// Open RocksDB
rocksdb::DB *db;
rocksdb::Status s;
if (read_only) {
s = rocksdb::DB::OpenForReadOnly(create_opts_, db_path, &db);
std::vector<std::string> existing_cf_names{};
std::vector<rocksdb::ColumnFamilyDescriptor> cf_descriptors{};
s = rocksdb::DB::ListColumnFamilies(create_opts_, args.db_path,
&existing_cf_names);
if (!s.ok()) {
LOG_ERROR("Failed to list cf in RocksDB[%s], code[%d], reason[%s]",
args.db_path.c_str(), s.code(), s.ToString().c_str());
return Status::InternalError();
}
auto make_cf_options = [&](const std::string &cf_name) {
rocksdb::ColumnFamilyOptions cf_options(create_opts_);
auto it = per_cf_merge_ops_.find(cf_name);
if (it != per_cf_merge_ops_.end() && it->second) {
cf_options.merge_operator = it->second;
}
return cf_options;
};
if (args.column_names.empty()) {
for (const auto &column_name : existing_cf_names) {
cf_descriptors.emplace_back(column_name, make_cf_options(column_name));
}
} else {
s = rocksdb::DB::Open(create_opts_, db_path, &db);
bool has_default = false;
for (const auto &column_name : args.column_names) {
if (std::find(existing_cf_names.begin(), existing_cf_names.end(),
column_name) == existing_cf_names.end()) {
LOG_ERROR("Column family[%s] does not exist in RocksDB[%s]",
column_name.c_str(), args.db_path.c_str());
return Status::InvalidArgument();
}
if (column_name == rocksdb::kDefaultColumnFamilyName) {
has_default = true;
}
}
if (read_only) {
for (const auto &column_name : args.column_names) {
cf_descriptors.emplace_back(column_name, make_cf_options(column_name));
}
if (!has_default) {
cf_descriptors.emplace_back(
rocksdb::kDefaultColumnFamilyName,
make_cf_options(rocksdb::kDefaultColumnFamilyName));
}
} else {
for (const auto &column_name : existing_cf_names) {
cf_descriptors.emplace_back(column_name, make_cf_options(column_name));
}
}
}
rocksdb::DB *db;
if (read_only) {
s = rocksdb::DB::OpenForReadOnly(create_opts_, args.db_path, cf_descriptors,
&cf_handles_, &db);
} else {
s = rocksdb::DB::Open(create_opts_, args.db_path, cf_descriptors,
&cf_handles_, &db);
}
if (!s.ok()) {
LOG_ERROR("Failed to open RocksDB[%s], code[%d], reason[%s]",
db_path.c_str(), s.code(), s.ToString().c_str());
args.db_path.c_str(), s.code(), s.ToString().c_str());
return Status::InternalError();
}
db_.reset(db);
read_only_ = read_only;
write_opts_.disableWAL = true;
LOG_DEBUG("Opened RocksDB[%s]", db_path.c_str());
LOG_DEBUG("Opened RocksDB[%s] with Args", args.db_path.c_str());
return Status::OK();
}
@ -159,84 +206,7 @@ Status RocksdbContext::open(const std::string &db_path,
const std::vector<std::string> &column_names,
bool read_only,
std::shared_ptr<rocksdb::MergeOperator> merge_op) {
std::lock_guard<std::mutex> lock(mutex_);
if (db_) {
LOG_ERROR("RocksDB[%s] is already opened", db_path_.c_str());
return Status::PermissionDenied();
}
if (auto s = validate_and_set_db_path(db_path, true); !s.ok()) {
return s;
}
create_opts_.create_if_missing = false;
prepare_options(merge_op);
// Set up column families
rocksdb::Status s;
std::vector<std::string> existing_cf_names{};
std::vector<rocksdb::ColumnFamilyDescriptor> cf_descriptors{};
s = rocksdb::DB::ListColumnFamilies(create_opts_, db_path,
&existing_cf_names);
if (!s.ok()) {
LOG_ERROR("Failed to list cf in RocksDB[%s], code[%d], reason[%s]",
db_path.c_str(), s.code(), s.ToString().c_str());
return Status::InternalError();
}
rocksdb::ColumnFamilyOptions cf_options(create_opts_);
if (column_names.empty()) { // Get all column families from DB
for (auto const &column_name : existing_cf_names) {
cf_descriptors.emplace_back(column_name, cf_options);
}
} else {
bool has_default = false;
for (const auto &column_name : column_names) {
if (std::find(existing_cf_names.begin(), existing_cf_names.end(),
column_name) == existing_cf_names.end()) {
LOG_ERROR("Column family[%s] does not exist in RocksDB[%s]",
column_name.c_str(), db_path.c_str());
return Status::InvalidArgument();
}
if (column_name == rocksdb::kDefaultColumnFamilyName) {
has_default = true;
}
}
if (read_only) {
for (const auto &column_name : column_names) {
cf_descriptors.emplace_back(column_name, cf_options);
}
if (!has_default) {
cf_descriptors.emplace_back(rocksdb::kDefaultColumnFamilyName,
cf_options);
}
} else { // Rocksdb must be opened with all column families in write mode
for (auto const &column_name : existing_cf_names) {
cf_descriptors.emplace_back(column_name, cf_options);
}
}
}
// Open RocksDB
rocksdb::DB *db;
if (read_only) {
s = rocksdb::DB::OpenForReadOnly(create_opts_, db_path, cf_descriptors,
&cf_handles_, &db);
} else {
s = rocksdb::DB::Open(create_opts_, db_path, cf_descriptors, &cf_handles_,
&db);
}
if (!s.ok()) {
LOG_ERROR("Failed to open RocksDB[%s], code[%d], reason[%s]",
db_path.c_str(), s.code(), s.ToString().c_str());
return Status::InternalError();
}
db_.reset(db);
read_only_ = read_only;
write_opts_.disableWAL = true;
LOG_DEBUG("Opened RocksDB[%s]", db_path.c_str());
return Status::OK();
return open(Args{db_path, column_names, std::move(merge_op), {}}, read_only);
}
@ -321,6 +291,18 @@ void RocksdbContext::prepare_options(
// Disable direct reads (use buffered I/O instead)
create_opts_.use_direct_reads = false;
// Hash skip list memtable for prefix-based lookups
if (enable_hash_skiplist_) {
create_opts_.prefix_extractor.reset(rocksdb::NewCappedPrefixTransform(8));
create_opts_.memtable_factory.reset(rocksdb::NewHashSkipListRepFactory(
1000000, // bucket_count
4, // skiplist_height
4 // skiplist_branching_factor
));
create_opts_.allow_concurrent_memtable_write = false;
read_opts_.total_order_seek = true;
}
}
@ -443,8 +425,13 @@ Status RocksdbContext::create_cf(const std::string &cf_name) {
}
rocksdb::ColumnFamilyHandle *cf_handle{nullptr};
auto s = db_->CreateColumnFamily(rocksdb::ColumnFamilyOptions(create_opts_),
cf_name, &cf_handle);
rocksdb::ColumnFamilyOptions cf_options(create_opts_);
// Apply per-CF merge operator if one was registered for this CF name
auto it = per_cf_merge_ops_.find(cf_name);
if (it != per_cf_merge_ops_.end() && it->second) {
cf_options.merge_operator = it->second;
}
auto s = db_->CreateColumnFamily(cf_options, cf_name, &cf_handle);
if (s.ok()) {
cf_handles_.push_back(cf_handle);
LOG_DEBUG("Created cf[%s] in RocksDB[%s]", cf_name.c_str(),
@ -590,6 +577,4 @@ size_t RocksdbContext::count() {
return 0;
}
}
} // namespace zvec

View File

@ -16,7 +16,12 @@
#pragma once
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
#include <rocksdb/db.h>
#include <rocksdb/write_batch.h>
#include <zvec/ailego/io/file.h>
#include <zvec/db/status.h>
@ -27,9 +32,18 @@ namespace zvec {
// A very thin wrapper around RocksDB
struct RocksdbContext {
public:
struct Args {
std::string db_path;
std::vector<std::string> column_names;
std::shared_ptr<rocksdb::MergeOperator> merge_op;
std::unordered_map<std::string, std::shared_ptr<rocksdb::MergeOperator>>
per_cf_merge_ops;
bool enable_hash_skiplist = false;
};
std::unique_ptr<rocksdb::DB> db_{nullptr};
std::string db_path_;
bool read_only_;
bool enable_hash_skiplist_{false};
std::vector<rocksdb::ColumnFamilyHandle *> cf_handles_;
rocksdb::Options create_opts_;
rocksdb::WriteOptions write_opts_;
@ -37,6 +51,9 @@ struct RocksdbContext {
rocksdb::FlushOptions flush_opts_;
rocksdb::CompactRangeOptions compact_range_opts_;
std::mutex mutex_;
// Per-CF merge operators (keyed by CF name)
std::unordered_map<std::string, std::shared_ptr<rocksdb::MergeOperator>>
per_cf_merge_ops_;
public:
@ -79,7 +96,7 @@ struct RocksdbContext {
rocksdb::ColumnFamilyHandle *get_cf(const std::string &cf_name);
// Create a column family
// Create a column family (uses per_cf_merge_ops_ if set for cf_name)
Status create_cf(const std::string &cf_name);
@ -103,6 +120,13 @@ struct RocksdbContext {
size_t count();
// Create a Rocksdb instance from Args
Status create(Args args);
// Open an existing Rocksdb instance from Args
Status open(Args args, bool read_only);
private:
using FILE = ailego::File;

View File

@ -1,9 +1,25 @@
include(${PROJECT_ROOT_DIR}/cmake/bazel.cmake)
include(${PROJECT_ROOT_DIR}/cmake/option.cmake)
if(NOT ANDROID AND AUTO_DETECT_ARCH)
if (HOST_ARCH MATCHES "^(x86|x64)$")
setup_compiler_march_for_x86(INDEX_MARCH_FLAG_SSE INDEX_MARCH_FLAG_AVX2 INDEX_MARCH_FLAG_AVX512 INDEX_MARCH_FLAG_AVX512FP16)
set_source_files_properties(
${CMAKE_CURRENT_SOURCE_DIR}/column/fts_column/posting/bitpacked_simd_sse41.cc
PROPERTIES
COMPILE_FLAGS "${INDEX_MARCH_FLAG_SSE}"
)
set_source_files_properties(
${CMAKE_CURRENT_SOURCE_DIR}/column/fts_column/posting/bitpacked_simd_avx2.cc
PROPERTIES
COMPILE_FLAGS "${INDEX_MARCH_FLAG_AVX2}"
)
endif()
endif()
cc_library(
NAME zvec_index STATIC STRICT
SRCS *.cc segment/*.cc column/vector_column/*.cc column/inverted_column/*.cc storage/*.cc storage/wal/*.cc common/*.cc
SRCS *.cc segment/*.cc column/vector_column/*.cc column/inverted_column/*.cc column/fts_column/*.cc column/fts_column/tokenizer/*.cc column/fts_column/posting/*.cc column/fts_column/iterator/*.cc storage/*.cc storage/wal/*.cc common/*.cc
LIBS zvec_common
zvec_proto
rocksdb
@ -11,6 +27,8 @@ cc_library(
Arrow::arrow_static
Arrow::arrow_compute
Arrow::arrow_dataset
cppjieba
FastPFOR
INCS . ${PROJECT_ROOT_DIR}/src
VERSION "${PROXIMA_ZVEC_VERSION}"
)

View File

@ -0,0 +1,59 @@
// 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.
lexer grammar FtsLexer;
// ── Boolean operators ────────────────────────────────────────────────────────
OR : [Oo][Rr];
AND : [Aa][Nn][Dd];
NOT : [Nn][Oo][Tt];
// ── Modifier prefixes ────────────────────────────────────────────────────────
PLUS_SIGN: '+';
MINUS_SIGN: '-';
COLON: ':';
CARET: '^';
// ── Grouping ─────────────────────────────────────────────────────────────────
LP: '(';
RP: ')';
// ── Quoted strings (phrase queries) ──────────────────────────────────────────
DQUOTA_STRING
: '"' (~["\\\r\n] | '\\' .)* '"'
;
fragment ASCII_ALNUM : [A-Za-z0-9_];
fragment ESCAPED_CHAR
: '\\' [-+=&|!(){}[\]^"~*?:\\/]
;
fragment UNI_CHAR : [\u0080-\uFFFF];
fragment TERM_START : ASCII_ALNUM | UNI_CHAR;
fragment TERM_BODY : ASCII_ALNUM | UNI_CHAR | [._#/%\-'@] | ESCAPED_CHAR;
// Matches sequences of letters, digits, underscores and hyphens that start
// with a letter or underscore (same as the original SQLLexer REGULAR_ID).
REGULAR_ID: [A-Za-z_] [A-Za-z0-9_\-]*;
NUMBER: [0-9]+ ('.' [0-9]+)?;
// Generic term
TERM: TERM_START TERM_BODY*;
// ── Whitespace (skip) ─────────────────────────────────────────────────────────
SPACES: [ \t\r\n]+ -> skip;
DEFAULT: . ;

View File

@ -0,0 +1,92 @@
// 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.
parser grammar FtsParser;
options { tokenVocab = FtsLexer; }
// ── Entry point ───────────────────────────────────────────────────────────────
fts_query_unit
: fts_or_expr EOF
;
// ── OR (lowest precedence) ────────────────────────────────────────────────────
fts_or_expr
: fts_and_expr (OR fts_and_expr)*
;
// ── AND / NOT (same precedence) ──────────────────────────────────────────────
// `a NOT b` is the binary `a AND NOT b` operator: documents matching `a`
// excluding those matching `b`. The explicit form `a AND NOT b` is also
// accepted for readability; semantically it is identical to `a NOT b`.
fts_and_expr
: fts_seq_expr ((AND NOT? | NOT) fts_seq_expr)*
;
// ── Implicit adjacency ────────────────────────────────────────────────────────
// Adjacent atoms without an explicit operator are grouped together; the
// builder treats them as an implicit OR (same behaviour as the original SQL
// parser).
fts_seq_expr
: fts_unary+
;
// ── Unary modifier ────────────────────────────────────────────────────────────
// NOT is *not* a unary modifier here — it is consumed by fts_and_expr above
// as a binary operator. Unary modifiers are limited to `+` (must) and `-`
// (must_not).
fts_unary
: PLUS_SIGN fts_atom # must_atom
| MINUS_SIGN fts_atom # must_not_atom
| fts_atom # plain_atom
;
// ── Atom: optional field prefix + primary + optional boost ───────────────────
fts_atom
: fts_field_prefix? fts_primary fts_boost?
;
// ── Field prefix: REGULAR_ID ':' ─────────────────────────────────────────────
fts_field_prefix
: REGULAR_ID COLON
;
// ── Primary: term | phrase | parenthesised sub-expression ────────────────────
fts_primary
: fts_term
| fts_phrase
| LP fts_or_expr RP
;
// ── Boost: '^' NUMBER ────────────────────────────────────────────────────────
fts_boost
: CARET NUMBER
;
fts_natural_term
: DEFAULT+ // One or more default characters forming a natural language term
;
// ── Term: identifier, number, or generic token ───────────────────────────────
fts_term
: TERM
| REGULAR_ID
| NUMBER
| fts_natural_term
;
// ── Phrase: double-quoted string ─────────────────────────────────────────────
fts_phrase
: DQUOTA_STRING
;

View File

@ -0,0 +1,196 @@
// 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 "bm25_scorer.h"
#include <cmath>
#include <cstring>
#include <zvec/ailego/logger/logger.h>
#include "fts_utils.h"
namespace zvec::fts {
// ============================================================
// BM25Scorer implementation
// ============================================================
int BM25Scorer::load_segment_stats(const std::string &field_name,
RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *stat_cf) {
if (!ctx || !stat_cf) {
LOG_WARN("BM25Scorer::load_segment_stats: null ctx/stat_cf for field[%s]",
field_name.c_str());
return -1;
}
// Read total_docs
std::string total_docs_value;
auto ret = ctx->db_->Get(ctx->read_opts_, stat_cf,
make_total_docs_key(field_name), &total_docs_value);
if (!ret.ok()) {
LOG_ERROR(
"BM25Scorer::load_segment_stats: failed to read total_docs. "
"field[%s]",
field_name.c_str());
return -1;
}
if (total_docs_value.size() < sizeof(uint64_t)) {
LOG_ERROR(
"BM25Scorer::load_segment_stats: total_docs value too small. "
"field[%s] value_size[%zu]",
field_name.c_str(), total_docs_value.size());
return -1;
}
uint64_t total_docs = decode_uint64_value(total_docs_value.data());
stats_.total_docs.store(total_docs, std::memory_order_release);
// Read total_tokens
std::string total_tokens_value;
auto status =
ctx->db_->Get(ctx->read_opts_, stat_cf, make_total_tokens_key(field_name),
&total_tokens_value);
if (!status.ok()) {
LOG_ERROR(
"BM25Scorer::load_segment_stats: failed to read total_tokens. "
"field[%s]",
field_name.c_str());
return -1;
}
if (total_tokens_value.size() < sizeof(uint64_t)) {
LOG_ERROR(
"BM25Scorer::load_segment_stats: total_tokens value too small. "
"field[%s] value_size[%zu]",
field_name.c_str(), total_tokens_value.size());
return -1;
}
uint64_t total_tokens = decode_uint64_value(total_tokens_value.data());
stats_.total_tokens.store(total_tokens, std::memory_order_release);
return 0;
}
float BM25Scorer::idf(uint64_t term_doc_freq) const {
const auto snap = stats_.snapshot();
if (snap.total_docs == 0) {
return 0.0f;
}
// Robertson-Sparck Jones IDF formula (with smoothing):
// IDF(t) = ln((N - df + 0.5) / (df + 0.5) + 1)
const float total_docs = static_cast<float>(snap.total_docs);
const float df = static_cast<float>(term_doc_freq);
return std::log((total_docs - df + 0.5f) / (df + 0.5f) + 1.0f);
}
float BM25Scorer::max_score_bound(uint64_t term_doc_freq) const {
const float idf_value = idf(term_doc_freq);
if (idf_value <= 0.0f) {
return 0.0f;
}
// tf→infinity limit: tf_norm → (k1 + 1), so idf*(k1+1) upper-bounds the
// score for any (tf, doc_len).
return idf_value * (params_.k1 + 1.0f);
}
float BM25Scorer::score(uint64_t term_doc_freq, uint32_t term_freq,
uint32_t doc_len) const {
// Take a single snapshot so that IDF and TF normalization use the same
// consistent values of total_docs / total_tokens.
const auto snap = stats_.snapshot();
if (snap.total_docs == 0) {
return 0.0f;
}
// IDF
const float total_docs = static_cast<float>(snap.total_docs);
const float df = static_cast<float>(term_doc_freq);
const float idf_value =
std::log((total_docs - df + 0.5f) / (df + 0.5f) + 1.0f);
if (idf_value <= 0.0f) {
return 0.0f;
}
// TF normalization
const float tf = static_cast<float>(term_freq);
const float doc_length = static_cast<float>(doc_len);
const float avg_dl = snap.avg_doc_len();
// BM25 TF normalization formula:
// tf_norm = tf * (k1 + 1) / (tf + k1 * (1 - b + b * |d| / avgdl))
const float tf_norm =
tf * (params_.k1 + 1.0f) /
(tf + params_.k1 * (1.0f - params_.b + params_.b * doc_length / avg_dl));
return idf_value * tf_norm;
}
float BM25Scorer::score_with_idf(float idf_value, uint32_t term_freq,
uint32_t doc_len) const {
return score_with_idf(idf_value, term_freq, doc_len, 1.0f);
}
float BM25Scorer::score_with_idf(float idf_value, uint32_t term_freq,
uint32_t doc_len, float boost) const {
if (idf_value <= 0.0f) {
return 0.0f;
}
const auto snap = stats_.snapshot();
if (snap.total_docs == 0) {
return 0.0f;
}
const float tf = static_cast<float>(term_freq);
const float doc_length = static_cast<float>(doc_len);
const float avg_dl = snap.avg_doc_len();
const float tf_norm =
tf * (params_.k1 + 1.0f) /
(tf + params_.k1 * (1.0f - params_.b + params_.b * doc_length / avg_dl));
return boost * idf_value * tf_norm;
}
// ============================================================
// WandOptimizer implementation
// ============================================================
int WandOptimizer::open(BM25ScorerPtr scorer, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *max_tf_cf, uint32_t topk) {
if (!scorer || !ctx || !max_tf_cf) {
LOG_ERROR(
"WandOptimizer open failed: null arguments scorer[%p] ctx[%p] "
"max_tf_cf[%p]",
(void *)scorer.get(), (void *)ctx, (void *)max_tf_cf);
return -1;
}
scorer_ = std::move(scorer);
ctx_ = ctx;
max_tf_cf_ = max_tf_cf;
topk_ = topk;
return 0;
}
uint32_t WandOptimizer::read_max_tf(const std::string &term) const {
if (!max_tf_cf_) {
return 1;
}
std::string max_tf_value;
if (!ctx_->db_->Get(ctx_->read_opts_, max_tf_cf_, term, &max_tf_value).ok() ||
max_tf_value.size() < sizeof(uint32_t)) {
return 1; // Default max term frequency is 1
}
uint32_t max_tf = 0;
std::memcpy(&max_tf, max_tf_value.data(), sizeof(uint32_t));
return max_tf;
}
} // namespace zvec::fts

View File

@ -0,0 +1,217 @@
// 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 <atomic>
#include <cstdint>
#include <memory>
#include <string>
#include "db/common/rocksdb_context.h"
namespace zvec::fts {
/*! BM25 scoring parameters
*/
struct BM25Params {
// Term frequency saturation parameter, typical value 1.2
float k1{1.2f};
// Document length normalization parameter, typical value 0.75
float b{0.75f};
};
/*! Plain snapshot of per-segment BM25 statistics (non-atomic, for callers)
*/
struct SegmentStatsSnapshot {
uint64_t total_docs{0};
uint64_t total_tokens{0};
float avg_doc_len() const {
if (total_docs == 0) {
return 1.0f;
}
return static_cast<float>(total_tokens) / static_cast<float>(total_docs);
}
};
/*! Per-segment BM25 statistics (thread-safe)
* Fields are std::atomic so that concurrent insert (writer) and search
* (reader) threads do not race on the raw values.
*/
struct SegmentStats {
// Total number of documents in segment
std::atomic<uint64_t> total_docs{0};
// Total number of tokens in all documents in segment (used to calculate
// average document length)
std::atomic<uint64_t> total_tokens{0};
SegmentStats() = default;
// std::atomic is neither copyable nor movable; provide manual move
// semantics so that BM25Scorer (which embeds SegmentStats) stays movable.
// These are only used during single-threaded construction / NRVO and are
// therefore safe with relaxed ordering.
SegmentStats(SegmentStats &&other) noexcept
: total_docs(other.total_docs.load(std::memory_order_relaxed)),
total_tokens(other.total_tokens.load(std::memory_order_relaxed)) {}
SegmentStats &operator=(SegmentStats &&other) noexcept {
total_docs.store(other.total_docs.load(std::memory_order_relaxed),
std::memory_order_relaxed);
total_tokens.store(other.total_tokens.load(std::memory_order_relaxed),
std::memory_order_relaxed);
return *this;
}
SegmentStats(const SegmentStats &) = delete;
SegmentStats &operator=(const SegmentStats &) = delete;
// Take a consistent snapshot: load total_tokens first (the value that
// grows together with total_docs) so the pair is *at least* as fresh as
// the docs count, avoiding avg_doc_len() returning an inflated value.
SegmentStatsSnapshot snapshot() const {
const uint64_t tokens = total_tokens.load(std::memory_order_acquire);
const uint64_t docs = total_docs.load(std::memory_order_acquire);
return {docs, tokens};
}
// Average document length (total_tokens / total_docs)
float avg_doc_len() const {
return snapshot().avg_doc_len();
}
};
/*! BM25 scorer
* Encapsulates standard BM25 formula, supports per-segment statistics loading
* and WAND optimization
*
* BM25 formula:
* score(q, d) = Σ IDF(t) * (tf(t,d) * (k1+1)) / (tf(t,d) +
* k1*(1-b+b*|d|/avgdl)) IDF(t) = ln((N - df(t) + 0.5) / (df(t) + 0.5) + 1)
*/
class BM25Scorer {
public:
explicit BM25Scorer(BM25Params params = BM25Params{}) : params_(params) {}
/*! Load per-segment statistics from $SEGMENT_STAT CF
* \param field_name Field name
* \param stat_cf $SEGMENT_STAT CF
* \return 0 for success, non-0 for failure
*/
int load_segment_stats(const std::string &field_name, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *stat_cf);
/*! Calculate BM25 contribution score of a single term for a single document
* \param term_doc_freq Document frequency of this term in segment (df)
* \param term_freq Term frequency of this term in current document
* (tf) \param doc_len Length of current document (number of tokens)
* \return BM25 score contribution
*/
float score(uint64_t term_doc_freq, uint32_t term_freq,
uint32_t doc_len) const;
/*! Calculate IDF value of a term
* \param term_doc_freq Document frequency of this term in segment (df)
* \return IDF value
*/
float idf(uint64_t term_doc_freq) const;
/*! Compute a tight WAND upper-bound score for a term without knowing
* per-document tf / doc_len. Uses the identity lim_{tf} tf_norm = k1+1
* so the bound is idf(df) * (k1 + 1).
* \param term_doc_freq Document frequency of this term in segment (df)
* \return upper-bound score (0 when IDF 0)
*/
float max_score_bound(uint64_t term_doc_freq) const;
/*! Calculate BM25 score using a pre-computed IDF value.
* Avoids recomputing log() on every call IDF is constant per term.
* \param idf_value Pre-computed IDF value (from idf())
* \param term_freq Term frequency in current document
* \param doc_len Document length (number of tokens)
* \return BM25 score contribution
*/
float score_with_idf(float idf_value, uint32_t term_freq,
uint32_t doc_len) const;
/*! Calculate BM25 score with a per-term boost multiplier.
* Boost > 1 represents a term that appears multiple times in the original
* query (collapsed by the AST rewriter) or carries an explicit user weight.
* The multiplier is linear so that the post-rewrite score exactly matches
* the pre-rewrite "sum of N independent scorers" value.
* \param idf_value Pre-computed IDF value (from idf())
* \param term_freq Term frequency in current document
* \param doc_len Document length (number of tokens)
* \param boost Per-term boost (1.0 = no boost)
* \return BM25 score contribution scaled by boost
*/
float score_with_idf(float idf_value, uint32_t term_freq, uint32_t doc_len,
float boost) const;
/*! Update in-memory segment statistics (called by FtsColumnIndexer after
* each insert so that search() uses up-to-date stats for BM25 scoring)
* \param total_docs Current total number of documents
* \param total_tokens Current total number of tokens
*/
void update_stats(uint64_t total_docs, uint64_t total_tokens) {
// Store total_docs first so that a concurrent reader calling snapshot()
// (which loads total_tokens before total_docs) never sees a new docs
// count paired with a stale tokens count, which would deflate avg_doc_len.
stats_.total_docs.store(total_docs, std::memory_order_release);
stats_.total_tokens.store(total_tokens, std::memory_order_release);
}
SegmentStatsSnapshot stats() const {
return stats_.snapshot();
}
const BM25Params &params() const {
return params_;
}
private:
BM25Params params_;
SegmentStats stats_;
};
using BM25ScorerPtr = std::shared_ptr<BM25Scorer>;
/*! WAND optimizer
* Uses $MAX_TF as upper bound for TopK pruning, reduces unnecessary document
* scoring
*/
class WandOptimizer {
public:
/*! Initialize WAND optimizer
* \param scorer BM25 scorer (with segment statistics loaded)
* \param max_tf_cf $MAX_TF CF (stores maximum term frequency for each
* term) \param topk Number of TopK results to return
*/
int open(BM25ScorerPtr scorer, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *max_tf_cf, uint32_t topk);
/*! Read the maximum term frequency for a term from $MAX_TF CF.
* Used by TermDocIterator to precompute WAND upper bound score.
* \param term The term to look up
* \return Maximum term frequency, or 1 if not found
*/
uint32_t read_max_tf(const std::string &term) const;
private:
BM25ScorerPtr scorer_;
RocksdbContext *ctx_{nullptr};
rocksdb::ColumnFamilyHandle *max_tf_cf_{nullptr};
uint32_t topk_{10};
};
} // namespace zvec::fts

View File

@ -0,0 +1,410 @@
// 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_ast_rewriter.h"
#include <algorithm>
#include <utility>
#include <vector>
namespace zvec::fts {
namespace {
// Two AST nodes are dedup-equivalent when they are the same leaf kind and
// carry identical modifiers and identical scoring key (term string for
// TermNode, terms vector for PhraseNode). Boost is intentionally NOT part of
// the key — it is what we accumulate during dedup.
bool same_dedup_key(const FtsAstNode &a, const FtsAstNode &b) {
if (a.type() != b.type()) {
return false;
}
if (a.must != b.must || a.must_not != b.must_not) {
return false;
}
if (a.type() == FtsNodeType::TERM) {
return static_cast<const TermNode &>(a).term ==
static_cast<const TermNode &>(b).term;
}
if (a.type() == FtsNodeType::PHRASE) {
return static_cast<const PhraseNode &>(a).terms ==
static_cast<const PhraseNode &>(b).terms;
}
return false;
}
// Same scoring key as same_dedup_key but ignores modifiers — used to detect
// `+apple -apple` style conflicts inside an AND node.
bool same_term_or_phrase_text(const FtsAstNode &a, const FtsAstNode &b) {
if (a.type() != b.type()) {
return false;
}
if (a.type() == FtsNodeType::TERM) {
return static_cast<const TermNode &>(a).term ==
static_cast<const TermNode &>(b).term;
}
if (a.type() == FtsNodeType::PHRASE) {
return static_cast<const PhraseNode &>(a).terms ==
static_cast<const PhraseNode &>(b).terms;
}
return false;
}
// Collapse adjacent duplicates (TermNode/PhraseNode siblings sharing the same
// dedup key) into a single node whose boost is the linear sum. O(K^2) — K is
// the sibling count, typically small enough that a hash map would cost more in
// allocations than it would save in comparisons.
void merge_duplicate_siblings(std::vector<FtsAstNodePtr> &children) {
for (size_t i = 0; i < children.size(); ++i) {
auto &a = children[i];
if (!a) {
continue;
}
if (a->type() != FtsNodeType::TERM && a->type() != FtsNodeType::PHRASE) {
continue;
}
for (size_t j = i + 1; j < children.size();) {
auto &b = children[j];
if (b && same_dedup_key(*a, *b)) {
a->boost += b->boost;
children.erase(children.begin() + j);
} else {
++j;
}
}
}
}
// Flatten guard: an inner OrNode can be inlined into a parent OR only when it
// is a pure disjunction — itself unmodified and containing no must/must_not
// children. Otherwise inlining would change semantics (a must_not child would
// silently widen its exclusion scope from the inner OR to the outer OR).
bool can_inline_into_or(const FtsAstNode &child) {
if (child.type() != FtsNodeType::OR) {
return false;
}
if (child.must || child.must_not) {
return false;
}
const auto &inner = static_cast<const OrNode &>(child);
for (const auto &c : inner.children) {
if (c && (c->must || c->must_not)) {
return false;
}
}
return true;
}
// Flatten guard: an inner AndNode can be inlined into a parent AND only when
// itself unmodified and containing no must_not children. must children inside
// an AND are equivalent to plain children (build_and_iterator treats both as
// MUST), so they are safe to inline. must_not children are NOT safe to lift
// across a must_not parent boundary.
bool can_inline_into_and(const FtsAstNode &child) {
if (child.type() != FtsNodeType::AND) {
return false;
}
if (child.must || child.must_not) {
return false;
}
const auto &inner = static_cast<const AndNode &>(child);
for (const auto &c : inner.children) {
if (c && c->must_not) {
return false;
}
}
return true;
}
// Splice inlinable OR children's grandchildren in place of the child. Reuses
// each grandchild's unique_ptr — no AST node allocations.
void flatten_or_children(std::vector<FtsAstNodePtr> &children) {
std::vector<FtsAstNodePtr> out;
out.reserve(children.size());
for (auto &child : children) {
if (child && can_inline_into_or(*child)) {
auto &inner = static_cast<OrNode &>(*child);
for (auto &grandchild : inner.children) {
if (grandchild) {
out.push_back(std::move(grandchild));
}
}
} else {
out.push_back(std::move(child));
}
}
children = std::move(out);
}
void flatten_and_children(std::vector<FtsAstNodePtr> &children) {
std::vector<FtsAstNodePtr> out;
out.reserve(children.size());
for (auto &child : children) {
if (child && can_inline_into_and(*child)) {
auto &inner = static_cast<AndNode &>(*child);
for (auto &grandchild : inner.children) {
if (grandchild) {
out.push_back(std::move(grandchild));
}
}
} else {
out.push_back(std::move(child));
}
}
children = std::move(out);
}
// Drop null children left behind by recursive simplify() reporting "this
// subtree contributed nothing" via a moved-out pointer.
void drop_nulls(std::vector<FtsAstNodePtr> &children) {
children.erase(std::remove_if(children.begin(), children.end(),
[](const FtsAstNodePtr &p) { return !p; }),
children.end());
}
// Make an EmptyNode carrying the modifier of the node being replaced. This
// preserves +/- semantics so parent nodes interpret the replacement the same
// way they would the original.
FtsAstNodePtr make_empty_like(const FtsAstNode &original) {
auto e = std::make_unique<EmptyNode>();
e->must = original.must;
e->must_not = original.must_not;
// Boost is meaningless on EmptyNode — it matches nothing — but keep the
// value for round-trippable debug output.
e->boost = original.boost;
return e;
}
// If the AND contains a positive child and a must_not child with the same
// term/phrase key, the conjunction matches nothing.
bool and_has_mustnot_conflict(const AndNode &n) {
for (size_t i = 0; i < n.children.size(); ++i) {
const auto &pi = n.children[i];
if (!pi || pi->must_not) {
continue;
}
if (pi->type() != FtsNodeType::TERM && pi->type() != FtsNodeType::PHRASE) {
continue;
}
for (size_t j = 0; j < n.children.size(); ++j) {
if (i == j) {
continue;
}
const auto &pj = n.children[j];
if (!pj || !pj->must_not) {
continue;
}
if (same_term_or_phrase_text(*pi, *pj)) {
return true;
}
}
}
return false;
}
void simplify_and(FtsAstNodePtr &node);
void simplify_or(FtsAstNodePtr &node);
void simplify_and(FtsAstNodePtr &node) {
auto &n = static_cast<AndNode &>(*node);
// 1. Recurse first so children are already in normal form.
for (auto &child : n.children) {
simplify(child);
}
drop_nulls(n.children);
// 2. EmptyNode propagation: a positive EMPTY makes the whole AND empty;
// a must_not EMPTY (i.e. "exclude nothing") is a no-op and is dropped.
for (auto it = n.children.begin(); it != n.children.end();) {
if ((*it)->type() == FtsNodeType::EMPTY) {
if ((*it)->must_not) {
it = n.children.erase(it);
} else {
node = make_empty_like(n);
return;
}
} else {
++it;
}
}
// 3. Flatten nested AND, then dedup siblings (linear-boost sum).
flatten_and_children(n.children);
merge_duplicate_siblings(n.children);
// 4. `+apple -apple` style conflict → empty doc set.
if (and_has_mustnot_conflict(n)) {
node = make_empty_like(n);
return;
}
// 5. AND containing only must_not children has no positive base set to
// subtract from — by convention this matches nothing.
bool any_positive = false;
for (const auto &c : n.children) {
if (!c->must_not) {
any_positive = true;
break;
}
}
if (!any_positive) {
node = make_empty_like(n);
return;
}
// 6. Single-child fold. Combine the outer AND's modifier with the surviving
// child; if the combination yields must && must_not, replace with EMPTY
// (a self-contradictory clause matches nothing).
if (n.children.size() == 1) {
FtsAstNodePtr child = std::move(n.children[0]);
child->must = child->must || n.must;
child->must_not = child->must_not || n.must_not;
if (child->must && child->must_not) {
auto e = std::make_unique<EmptyNode>();
e->must = n.must;
e->must_not = n.must_not;
node = std::move(e);
return;
}
node = std::move(child);
}
}
void simplify_or(FtsAstNodePtr &node) {
auto &n = static_cast<OrNode &>(*node);
for (auto &child : n.children) {
simplify(child);
}
drop_nulls(n.children);
// EmptyNode in OR: a positive EMPTY contributes no documents → drop it.
// A must_not EMPTY excludes nothing → also drop. Either way, simply remove.
n.children.erase(std::remove_if(n.children.begin(), n.children.end(),
[](const FtsAstNodePtr &p) {
return p && p->type() == FtsNodeType::EMPTY;
}),
n.children.end());
flatten_or_children(n.children);
merge_duplicate_siblings(n.children);
// Classify children into must (+), must_not (-), and plain buckets.
size_t mustnot_count = 0;
size_t must_count = 0;
for (const auto &c : n.children) {
if (c->must_not) {
++mustnot_count;
} else if (c->must) {
++must_count;
}
}
// OR with only must_not children has no positive base → matches nothing.
if (mustnot_count == n.children.size()) {
node = make_empty_like(n);
return;
}
// Canonicalize OR-with-modifiers into AND:
// - must_not children → AND exclusions
// - must children → AND required clauses (must flag cleared)
// - plain children → positive base (if no must) or SHOULD scoring
// Conflict cases like `+apple -apple` end up inside the new AND where
// and_has_mustnot_conflict catches them and collapses to EmptyNode.
if (mustnot_count > 0 || must_count > 0) {
std::vector<FtsAstNodePtr> must_children;
std::vector<FtsAstNodePtr> mustnot_children;
std::vector<FtsAstNodePtr> plain_children;
for (auto &c : n.children) {
if (c->must_not) {
mustnot_children.push_back(std::move(c));
} else if (c->must) {
c->must = false;
must_children.push_back(std::move(c));
} else {
plain_children.push_back(std::move(c));
}
}
auto wrap = std::make_unique<AndNode>();
wrap->children = std::move(must_children);
if (!plain_children.empty()) {
FtsAstNodePtr plain_part;
if (plain_children.size() == 1) {
plain_part = std::move(plain_children[0]);
} else {
auto inner_or = std::make_unique<OrNode>();
inner_or->children = std::move(plain_children);
plain_part = std::move(inner_or);
}
// When must children exist, plain terms become SHOULD (scoring only);
// otherwise they are the positive base of the AND.
if (must_count > 0) {
plain_part->should = true;
}
wrap->children.push_back(std::move(plain_part));
}
for (auto &mn : mustnot_children) {
wrap->children.push_back(std::move(mn));
}
wrap->must = n.must;
wrap->must_not = n.must_not;
wrap->boost = n.boost;
FtsAstNodePtr replacement = std::move(wrap);
simplify_and(replacement);
node = std::move(replacement);
return;
}
if (n.children.size() == 1) {
FtsAstNodePtr child = std::move(n.children[0]);
child->must = child->must || n.must;
child->must_not = child->must_not || n.must_not;
if (child->must && child->must_not) {
auto e = std::make_unique<EmptyNode>();
e->must = n.must;
e->must_not = n.must_not;
node = std::move(e);
return;
}
node = std::move(child);
}
}
} // namespace
void simplify(FtsAstNodePtr &node) {
if (!node) {
return;
}
switch (node->type()) {
case FtsNodeType::TERM:
case FtsNodeType::PHRASE:
case FtsNodeType::EMPTY:
return;
case FtsNodeType::AND:
simplify_and(node);
return;
case FtsNodeType::OR:
simplify_or(node);
return;
}
}
} // namespace zvec::fts

View File

@ -0,0 +1,43 @@
// 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 "fts_query_ast.h"
namespace zvec::fts {
/*! Structural simplification of an FTS AST.
*
* Performs a single post-order pass that:
* - flattens nested AND-of-AND / OR-of-OR (with Lucene-style guards that
* preserve the must/must_not semantics of the inner node)
* - dedups sibling TermNode / PhraseNode duplicates by summing boosts
* linearly, so the resulting score equals the pre-rewrite "sum of N
* independent scorers" output exactly
* - propagates EmptyNode (AND short-circuits, OR drops empties)
* - folds single-child AND/OR into the child
* - detects must vs must_not contradictions inside an AND
* (e.g. `+apple -apple`) and rewrites the AND to EmptyNode
*
* Idempotent: simplify(simplify(x)) == simplify(x). The transformation
* preserves the document-set semantics of the original AST and, under the
* linear-boost rule, also preserves the per-document BM25 score.
*
* Mutates the node in place via the unique_ptr (may replace it with a
* different node, e.g. EmptyNode or a folded child).
*/
void simplify(FtsAstNodePtr &node);
} // namespace zvec::fts

View File

@ -0,0 +1,902 @@
// 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_column_indexer.h"
#include <chrono>
#include <cstring>
#include <queue>
#include <thread>
#include <unordered_map>
#include <roaring/roaring.h>
#include <rocksdb/write_batch.h>
#include <zvec/ailego/logger/logger.h>
#include <zvec/db/status.h>
#include "db/common/typedef.h"
#include "iterator/fts_candidate_iterator.h"
#include "iterator/fts_conjunction_iterator.h"
#include "iterator/fts_disjunction_iterator.h"
#include "iterator/fts_phrase_iterator.h"
#include "iterator/fts_term_iterator.h"
#include "posting/bitpacked_posting_list.h"
#include "fts_pipeline.h"
#include "fts_utils.h"
namespace zvec::fts {
// ============================================================
// Lifecycle
// ============================================================
FtsColumnIndexer::~FtsColumnIndexer() {
// Pipeline release is handled by FtsIndexParams destructor via fts_params_.
if (opened_.load()) {
(void)close();
}
}
// ============================================================
// Initialization — shared reader core
// ============================================================
Result<void> FtsColumnIndexer::open_reader(
const std::string &field_name, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *postings_cf,
rocksdb::ColumnFamilyHandle *positions_cf,
rocksdb::ColumnFamilyHandle *term_freq_cf,
rocksdb::ColumnFamilyHandle *max_tf_cf,
rocksdb::ColumnFamilyHandle *doc_len_cf,
rocksdb::ColumnFamilyHandle *stat_cf, BM25Params bm25_params) {
if (opened_.load()) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer already opened. field=", field_name));
}
field_name_ = field_name;
ctx_ = ctx;
postings_cf_ = postings_cf;
positions_cf_ = positions_cf;
term_freq_cf_ = term_freq_cf;
max_tf_cf_ = max_tf_cf;
doc_len_cf_ = doc_len_cf;
stat_cf_ = stat_cf;
scorer_ = std::make_shared<BM25Scorer>(bm25_params);
// doc_len_cf == nullptr → immutable path, load persisted stats.
// doc_len_cf != nullptr → mutable path, stats maintained in-memory.
if (doc_len_cf == nullptr) {
int ret = scorer_->load_segment_stats(field_name, ctx, stat_cf);
if (ret != 0) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer failed to load segment stats. field=", field_name));
}
}
opened_.store(true);
return {};
}
// ============================================================
// Initialization — read+write (mutable)
// ============================================================
Result<void> FtsColumnIndexer::open(FieldSchema::Ptr field_meta,
RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *postings_cf,
rocksdb::ColumnFamilyHandle *positions_cf,
rocksdb::ColumnFamilyHandle *term_freq_cf,
rocksdb::ColumnFamilyHandle *max_tf_cf,
rocksdb::ColumnFamilyHandle *doc_len_cf,
rocksdb::ColumnFamilyHandle *stat_cf) {
if (!field_meta || !ctx) {
return tl::make_unexpected(
Status::InvalidArgument("FtsColumnIndexer: null field_meta or ctx"));
}
// Obtain FtsIndexParams from field_meta's index_params.
auto index_params = field_meta->index_params();
auto fts_param =
std::dynamic_pointer_cast<zvec::FtsIndexParams>(index_params);
if (!fts_param) {
return tl::make_unexpected(Status::InvalidArgument(
"FtsColumnIndexer: field has no FtsIndexParams. field=",
field_meta->name()));
}
auto pipeline_result = zvec::detail::AcquireFtsPipeline(*fts_param);
if (!pipeline_result.has_value()) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer: failed to create tokenizer pipeline. field=",
field_meta->name(), " err=", pipeline_result.error().message()));
}
field_meta_ = std::move(field_meta);
tokenizer_pipeline_ = std::move(pipeline_result.value());
fts_params_ = fts_param;
return open_reader(field_meta_->name(), ctx, postings_cf, positions_cf,
term_freq_cf, max_tf_cf, doc_len_cf, stat_cf);
}
// ============================================================
// Initialization — read-only (immutable / standalone)
// ============================================================
// ============================================================
// Close
// ============================================================
Result<void> FtsColumnIndexer::close() {
if (!opened_.load()) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::close: not opened. field=", field_name_));
}
ctx_ = nullptr;
tokenizer_pipeline_.reset();
postings_cf_ = nullptr;
positions_cf_ = nullptr;
term_freq_cf_.store(nullptr, std::memory_order_release);
max_tf_cf_.store(nullptr, std::memory_order_release);
doc_len_cf_.store(nullptr, std::memory_order_release);
stat_cf_ = nullptr;
scorer_.reset();
opened_.store(false);
return {};
}
// ============================================================
// Query entry point
// ============================================================
Result<std::vector<FtsResult>> FtsColumnIndexer::search(
const FtsAstNode &ast, const FtsQueryParams &query_params) const {
if (!scorer_) {
LOG_ERROR("FtsColumnIndexer::search: not opened. field[%s]",
field_name_.c_str());
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::search: not opened. field=", field_name_));
}
if (query_params.topk == 0) {
return std::vector<FtsResult>{};
}
if (ast.must_not) {
LOG_WARN(
"FtsColumnIndexer::search: must_not on root is not allowed. field[%s]",
field_name_.c_str());
return tl::make_unexpected(Status::InvalidArgument(
"FtsColumnIndexer::search: must_not on root is not allowed. field=",
field_name_));
}
auto iter_result = build_iterator(ast);
if (!iter_result.has_value()) {
LOG_ERROR("FtsColumnIndexer::search: build_iterator failed. field[%s] %s",
field_name_.c_str(), iter_result.error().message().c_str());
return tl::make_unexpected(iter_result.error());
}
DocIteratorPtr root_iter = std::move(iter_result.value());
if (!root_iter) {
// No matching terms found — valid empty result, not an error.
return std::vector<FtsResult>{};
}
// Candidate-driven mode: AND a CandidateDocIterator into the root so the
// small candidate set leads (Conjunction sorts by cost asc), turning the
// posting walk into per-candidate advance()+matches()+score().
if (!query_params.candidate_ids.empty()) {
std::vector<DocIteratorPtr> musts;
musts.reserve(2);
musts.push_back(
std::make_unique<CandidateDocIterator>(query_params.candidate_ids));
musts.push_back(std::move(root_iter));
root_iter = std::make_unique<ConjunctionIterator>(
std::move(musts), std::vector<DocIteratorPtr>{});
}
const uint32_t topk = query_params.topk;
const zvec::IndexFilter *filter_ptr = query_params.filter.get();
using MinHeap = std::priority_queue<FtsResult, std::vector<FtsResult>,
std::greater<FtsResult>>;
MinHeap min_heap;
// Filter pushdown: when a filter is present, use the filter-aware next_doc
// overload so composite iterators skip filtered docs before paying for
// block-max binary search, do_next alignment, or phase-2 position checks.
uint32_t doc_id =
filter_ptr ? root_iter->next_doc(filter_ptr) : root_iter->next_doc();
while (doc_id != DocIterator::NO_MORE_DOCS) {
const uint64_t global_doc_id = static_cast<uint64_t>(doc_id);
if (root_iter->matches()) {
float s = root_iter->score();
if (s > 0.0f) {
if (min_heap.size() < topk) {
min_heap.push({global_doc_id, s});
if (min_heap.size() == topk) {
root_iter->set_min_competitive_score(min_heap.top().score);
}
} else if (s > min_heap.top().score) {
min_heap.pop();
min_heap.push({global_doc_id, s});
root_iter->set_min_competitive_score(min_heap.top().score);
}
}
}
doc_id =
filter_ptr ? root_iter->next_doc(filter_ptr) : root_iter->next_doc();
}
std::vector<FtsResult> results(min_heap.size());
for (auto it = results.rbegin(); it != results.rend(); ++it) {
*it = min_heap.top();
min_heap.pop();
}
return results;
}
// ============================================================
// Side CF reset (dump path)
// ============================================================
void FtsColumnIndexer::reset_side_cfs() {
cf_dropped_.store(true);
while (cf_counter_.load() > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
term_freq_cf_.store(nullptr, std::memory_order_release);
max_tf_cf_.store(nullptr, std::memory_order_release);
doc_len_cf_.store(nullptr, std::memory_order_release);
}
// ============================================================
// Iterator tree construction
// ============================================================
Result<DocIteratorPtr> FtsColumnIndexer::build_iterator(
const FtsAstNode &node) const {
switch (node.type()) {
case FtsNodeType::TERM:
return build_term_iterator(static_cast<const TermNode &>(node));
case FtsNodeType::PHRASE:
return build_phrase_iterator(static_cast<const PhraseNode &>(node));
case FtsNodeType::AND:
return build_and_iterator(static_cast<const AndNode &>(node));
case FtsNodeType::OR:
return build_or_iterator(static_cast<const OrNode &>(node));
case FtsNodeType::EMPTY:
// Null iterator reuses the existing AND/OR/search() null-handling path.
return DocIteratorPtr{nullptr};
default:
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::build_iterator: unknown node type. field=",
field_name_));
}
}
Result<DocIteratorPtr> FtsColumnIndexer::create_term_iterator_from_raw(
const std::string &term, rocksdb::PinnableSlice raw_data,
float boost) const {
if (BitPackedPostingList::is_bitpacked_format(raw_data.data(),
raw_data.size())) {
auto iter = std::make_unique<TermDocIterator>(term, std::move(raw_data),
scorer_, boost);
if (iter->cost() == 0) {
return DocIteratorPtr{nullptr};
}
return iter;
}
roaring_bitmap_t *bitmap = roaring_bitmap_portable_deserialize_safe(
raw_data.data(), raw_data.size());
if (!bitmap) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer: failed to deserialize roaring bitmap. field=",
field_name_, " term=", term));
}
const uint64_t df = roaring_bitmap_get_cardinality(bitmap);
if (df == 0) {
roaring_bitmap_free(bitmap);
return nullptr;
}
++cf_counter_;
auto *term_freq_cf = term_freq_cf_.load(std::memory_order_acquire);
auto *doc_len_cf = doc_len_cf_.load(std::memory_order_acquire);
auto *max_tf_cf = max_tf_cf_.load(std::memory_order_acquire);
auto *cf_counter = &cf_counter_;
if (cf_dropped_) {
term_freq_cf = nullptr;
doc_len_cf = nullptr;
cf_counter = nullptr;
max_tf_cf = nullptr;
--cf_counter_;
}
// WAND upper bound. When max_tf_cf is available we compute the tight
// score(df, max_tf, min_dl). Otherwise fall back to the formula-derived
// bound idf*(k1+1), which is still a valid upper bound yet much tighter
// than +inf, so WAND pruning remains effective.
float max_score_val = scorer_->max_score_bound(df);
if (max_tf_cf) {
WandOptimizer wand;
if (wand.open(scorer_, ctx_, max_tf_cf, 0) == 0) {
uint32_t max_tf = wand.read_max_tf(term);
uint32_t min_dl = min_doc_len_.load(std::memory_order_relaxed);
if (min_dl == std::numeric_limits<uint32_t>::max()) {
min_dl = 1;
}
max_score_val = scorer_->score(df, max_tf, min_dl);
}
}
return std::make_unique<TermDocIterator>(term, bitmap, df, scorer_,
max_score_val, ctx_, term_freq_cf,
doc_len_cf, cf_counter, boost);
}
Result<DocIteratorPtr> FtsColumnIndexer::build_term_iterator(
const TermNode &term_node) const {
const std::string &term = term_node.term;
rocksdb::PinnableSlice raw_data;
auto s = ctx_->db_->Get(ctx_->read_opts_, postings_cf_, term, &raw_data);
if (!s.ok() || raw_data.empty()) {
return DocIteratorPtr{nullptr};
}
return create_term_iterator_from_raw(term, std::move(raw_data),
term_node.boost);
}
std::vector<rocksdb::PinnableSlice> FtsColumnIndexer::batch_get_postings(
const std::vector<rocksdb::Slice> &terms) const {
std::vector<rocksdb::PinnableSlice> raw_postings(terms.size());
if (terms.empty()) {
return raw_postings;
}
std::vector<rocksdb::ColumnFamilyHandle *> cfs(terms.size(), postings_cf_);
std::vector<rocksdb::Status> statuses(terms.size());
ctx_->db_->MultiGet(ctx_->read_opts_, terms.size(), cfs.data(), terms.data(),
raw_postings.data(), statuses.data());
// Ignore failed lookups as callers can check via empty()
return raw_postings;
}
Result<DocIteratorPtr> FtsColumnIndexer::build_phrase_iterator(
const PhraseNode &phrase_node) const {
if (phrase_node.terms.empty()) {
return DocIteratorPtr{nullptr};
}
const std::vector<std::string> &terms = phrase_node.terms;
std::vector<rocksdb::Slice> term_slices;
term_slices.reserve(terms.size());
for (const auto &t : terms) {
term_slices.emplace_back(t);
}
auto raw_postings = batch_get_postings(term_slices);
std::vector<DocIteratorPtr> term_iterators;
term_iterators.reserve(terms.size());
// Phrase-level boost is distributed across the internal term iterators.
// PhraseDocIterator.score() delegates to conjunction.score() which sums the
// internal contributions, so multiplying each contribution by boost yields
// boost * (sum) = boost-applied-once at the phrase level.
for (size_t i = 0; i < terms.size(); ++i) {
if (raw_postings[i].empty()) {
return DocIteratorPtr{nullptr};
}
auto iter_result = create_term_iterator_from_raw(
terms[i], std::move(raw_postings[i]), phrase_node.boost);
if (!iter_result.has_value()) {
return iter_result;
}
if (!iter_result.value()) {
return DocIteratorPtr{nullptr};
}
term_iterators.push_back(std::move(iter_result.value()));
}
if (term_iterators.empty()) {
return DocIteratorPtr{nullptr};
}
auto conjunction = std::make_unique<ConjunctionIterator>(
std::move(term_iterators), std::vector<DocIteratorPtr>{});
return std::make_unique<PhraseDocIterator>(std::move(conjunction), terms,
ctx_, positions_cf_);
}
Result<DocIteratorPtr> FtsColumnIndexer::build_and_iterator(
const AndNode &and_node) const {
if (and_node.children.empty()) {
return DocIteratorPtr{nullptr};
}
std::vector<rocksdb::Slice> term_key_slices;
std::vector<size_t> term_child_indices;
term_key_slices.reserve(and_node.children.size());
term_child_indices.reserve(and_node.children.size());
for (size_t i = 0; i < and_node.children.size(); ++i) {
const auto &child = and_node.children[i];
if (child && child->type() == FtsNodeType::TERM) {
term_key_slices.emplace_back(static_cast<const TermNode &>(*child).term);
term_child_indices.push_back(i);
}
}
auto term_raw_postings = batch_get_postings(term_key_slices);
std::vector<DocIteratorPtr> must_iterators;
std::vector<DocIteratorPtr> must_not_iterators;
std::vector<DocIteratorPtr> should_iterators;
size_t batched_cursor = 0;
for (size_t i = 0; i < and_node.children.size(); ++i) {
const auto &child = and_node.children[i];
const bool is_must_not = child->must_not;
const bool is_should = child->should;
DocIteratorPtr iter;
if (batched_cursor < term_child_indices.size() &&
term_child_indices[batched_cursor] == i) {
rocksdb::PinnableSlice &raw = term_raw_postings[batched_cursor];
const auto &term_node = static_cast<const TermNode &>(*child);
if (!raw.empty()) {
auto iter_result = create_term_iterator_from_raw(
term_node.term, std::move(raw), term_node.boost);
if (!iter_result.has_value()) {
return iter_result;
}
iter = std::move(iter_result.value());
}
++batched_cursor;
} else {
auto iter_result = build_iterator(*child);
if (!iter_result.has_value()) {
return iter_result;
}
iter = std::move(iter_result.value());
}
if (!iter) {
if (!is_must_not && !is_should) {
return DocIteratorPtr{nullptr};
}
continue;
}
if (is_must_not) {
must_not_iterators.push_back(std::move(iter));
} else if (is_should) {
should_iterators.push_back(std::move(iter));
} else {
must_iterators.push_back(std::move(iter));
}
}
if (must_iterators.empty()) {
return DocIteratorPtr{nullptr};
}
if (must_iterators.size() == 1 && must_not_iterators.empty() &&
should_iterators.empty()) {
return std::move(must_iterators[0]);
}
return std::make_unique<ConjunctionIterator>(std::move(must_iterators),
std::move(must_not_iterators),
std::move(should_iterators));
}
Result<DocIteratorPtr> FtsColumnIndexer::build_or_iterator(
const OrNode &or_node) const {
if (or_node.children.empty()) {
return DocIteratorPtr{nullptr};
}
std::vector<rocksdb::Slice> term_key_slices;
std::vector<size_t> term_child_indices;
term_key_slices.reserve(or_node.children.size());
term_child_indices.reserve(or_node.children.size());
for (size_t i = 0; i < or_node.children.size(); ++i) {
const auto &child = or_node.children[i];
if (child && child->type() == FtsNodeType::TERM) {
term_key_slices.emplace_back(static_cast<const TermNode &>(*child).term);
term_child_indices.push_back(i);
}
}
auto term_raw_postings = batch_get_postings(term_key_slices);
// Invariant: the AST rewriter (fts::simplify) lifts both must_not and must
// children out of OrNode into a wrapping AndNode before we get here, so the
// loop below only ever sees plain positives. A must_not or must child
// reaching this point indicates a caller that bypassed simplify — bail out
// loudly rather than silently produce wrong results.
std::vector<DocIteratorPtr> positive_iterators;
size_t batched_cursor = 0;
for (size_t i = 0; i < or_node.children.size(); ++i) {
const auto &child = or_node.children[i];
if (child->must_not || child->must) {
LOG_ERROR(
"build_or_iterator: must/must_not child reached OR "
"(rewriter bypassed)");
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::build_or_iterator: OR contains must/must_not "
"child"));
}
DocIteratorPtr iter;
if (batched_cursor < term_child_indices.size() &&
term_child_indices[batched_cursor] == i) {
rocksdb::PinnableSlice &raw = term_raw_postings[batched_cursor];
const auto &term_node = static_cast<const TermNode &>(*child);
if (!raw.empty()) {
auto iter_result = create_term_iterator_from_raw(
term_node.term, std::move(raw), term_node.boost);
if (!iter_result.has_value()) {
return iter_result;
}
iter = std::move(iter_result.value());
}
++batched_cursor;
} else {
auto iter_result = build_iterator(*child);
if (!iter_result.has_value()) {
return iter_result;
}
iter = std::move(iter_result.value());
}
if (iter) {
positive_iterators.push_back(std::move(iter));
}
}
if (positive_iterators.empty()) {
return DocIteratorPtr{nullptr};
}
if (positive_iterators.size() == 1) {
return std::move(positive_iterators[0]);
}
return std::make_unique<DisjunctionIterator>(std::move(positive_iterators));
}
// ============================================================
// Write operations
// ============================================================
Result<void> FtsColumnIndexer::insert(uint64_t seg_doc_id,
const std::string &text) {
// safe access check
if (!tokenizer_pipeline_ || !ctx_) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::insert: not opened. field=", field_name_));
}
// Tokenize
std::vector<Token> tokens = tokenizer_pipeline_->process(text);
const uint32_t doc_len = static_cast<uint32_t>(tokens.size());
// Aggregate position lists by term
std::unordered_map<std::string, std::vector<uint32_t>> term_positions;
for (const auto &token : tokens) {
term_positions[token.text].push_back(token.position);
}
// Store seg_doc_id in RocksDB directly, similar to invert indexer
const uint32_t doc_id_32 = static_cast<uint32_t>(seg_doc_id);
// Pre-serialize a single-element Roaring Bitmap for this doc_id once,
// reused across all terms to avoid repeated create/serialize/free overhead.
roaring_bitmap_t *single_bitmap = roaring_bitmap_create_with_capacity(1);
roaring_bitmap_add(single_bitmap, doc_id_32);
size_t bitmap_size = roaring_bitmap_portable_size_in_bytes(single_bitmap);
std::string bitmap_data(bitmap_size, '\0');
roaring_bitmap_portable_serialize(single_bitmap, bitmap_data.data());
roaring_bitmap_free(single_bitmap);
// Batch all writes for this document into a single cross-CF WriteBatch,
// reducing 4N+1 individual RocksDB Write() calls to one atomic write.
rocksdb::WriteBatch batch;
for (const auto &[term, positions] : term_positions) {
const uint32_t tf = static_cast<uint32_t>(positions.size());
// 1. Postings CF: merge doc_id bitmap
batch.Merge(postings_cf_, term, bitmap_data);
// 2. Positions CF: term\0doc_id -> delta-varint positions
const std::string doc_term_key = make_doc_term_key(term, doc_id_32);
batch.Put(positions_cf_, doc_term_key, encode_positions(positions));
// 3. Term-freq CF: term\0doc_id -> uint32_t tf
std::string tf_value(sizeof(uint32_t), '\0');
std::memcpy(tf_value.data(), &tf, sizeof(uint32_t));
batch.Put(term_freq_cf_.load(), doc_term_key, tf_value);
// 4. Max-TF CF: term -> max(tf) via merge
batch.Merge(max_tf_cf_.load(), term, tf_value);
}
// 5. Doc-len CF: doc_id -> uint32_t doc_len
std::string doc_id_key(sizeof(uint32_t), '\0');
std::memcpy(doc_id_key.data(), &doc_id_32, sizeof(uint32_t));
std::string doc_len_value(sizeof(uint32_t), '\0');
std::memcpy(doc_len_value.data(), &doc_len, sizeof(uint32_t));
batch.Put(doc_len_cf_.load(), doc_id_key, doc_len_value);
if (auto s = ctx_->db_->Write(ctx_->write_opts_, &batch); !s.ok()) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::insert: write batch failed. field=", field_name_,
" status=", s.ToString()));
}
// 6. Update in-memory statistics atomically so concurrent search() calls
// see up-to-date values for BM25 scoring.
const uint64_t new_total_docs =
total_docs_.fetch_add(1, std::memory_order_relaxed) + 1;
const uint64_t new_total_tokens =
total_tokens_.fetch_add(doc_len, std::memory_order_relaxed) + doc_len;
// Propagate updated stats to the scorer so that search() uses current avgdl.
if (scorer_) {
scorer_->update_stats(new_total_docs, new_total_tokens);
}
// CAS-update min_doc_len_ only when this document has tokens (doc_len > 0).
if (doc_len > 0) {
uint32_t cur = min_doc_len_.load(std::memory_order_relaxed);
while (doc_len < cur && !min_doc_len_.compare_exchange_weak(
cur, doc_len, std::memory_order_relaxed)) {
}
}
return {};
}
Result<void> FtsColumnIndexer::flush() {
// safe access check
if (!stat_cf_) {
return {};
}
// Write total_docs and total_tokens to $SEGMENT_STAT CF.
// Use acquire ordering so we see all inserts that happened before flush().
const uint64_t snapshot_total_docs =
total_docs_.load(std::memory_order_acquire);
const uint64_t snapshot_total_tokens =
total_tokens_.load(std::memory_order_acquire);
ctx_->db_->Put(ctx_->write_opts_, stat_cf_, make_total_docs_key(field_name_),
encode_uint64_value(snapshot_total_docs));
ctx_->db_->Put(ctx_->write_opts_, stat_cf_,
make_total_tokens_key(field_name_),
encode_uint64_value(snapshot_total_tokens));
return {};
}
// ============================================================
// BitPacked conversion (called by MutableSegment::dump_fts_column_indexers)
// ============================================================
Result<void> FtsColumnIndexer::convert_postings_to_bitpacked() {
// safe access check
if (!postings_cf_ || !term_freq_cf_ || !doc_len_cf_ || !scorer_) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::convert_postings_to_bitpacked: not opened. field=",
field_name_));
}
// ---------------------------------------------------------------
// 1) Load doc_len_cf into an in-memory vector indexed by local doc_id.
// Single segment is at most a few MB even for 1M docs (4B per doc),
// so a flat vector is by far the cheapest lookup structure.
// ---------------------------------------------------------------
std::vector<uint32_t> doc_lens;
{
std::unique_ptr<rocksdb::Iterator> iter(
ctx_->db_->NewIterator(ctx_->read_opts_, doc_len_cf_.load()));
iter->SeekToFirst();
while (iter->Valid()) {
const std::string key = iter->key().ToString();
const std::string value = iter->value().ToString();
if (key.size() != sizeof(uint32_t) || value.size() != sizeof(uint32_t)) {
LOG_WARN(
"FtsColumnIndexer::convert_postings_to_bitpacked: malformed "
"doc_len entry. field[%s] key_size[%zu] value_size[%zu]",
field_name_.c_str(), key.size(), value.size());
iter->Next();
continue;
}
uint32_t local_doc_id = 0;
uint32_t doc_len = 0;
std::memcpy(&local_doc_id, key.data(), sizeof(uint32_t));
std::memcpy(&doc_len, value.data(), sizeof(uint32_t));
if (local_doc_id >= doc_lens.size()) {
// Resize with default 1 to avoid divide-by-zero / log(0) downstream
// if a stray doc_id ever shows up without a doc_len entry.
doc_lens.resize(local_doc_id + 1, 1);
}
doc_lens[local_doc_id] = doc_len;
iter->Next();
}
}
// ---------------------------------------------------------------
// 2) Streaming scan of term_freq_cf, grouped by term.
// RocksDB BytewiseComparator + big-endian doc_id encoding guarantees
// that within a term, doc_ids appear in ascending order — exactly what
// BitPackedPostingList::encode() requires.
// ---------------------------------------------------------------
std::string current_term;
std::vector<uint32_t> doc_ids;
std::vector<uint32_t> tfs;
std::vector<uint32_t> term_doc_lens; // reused buffer
auto flush_current_term = [&]() -> Result<void> {
if (current_term.empty() || doc_ids.empty()) {
return {};
}
// Idempotency: skip if this term's postings are already BitPacked.
// Important for crash-recovery — a re-run of dump after a partial
// conversion must not double-encode.
std::string existing;
auto get_ret =
ctx_->db_->Get(ctx_->read_opts_, postings_cf_, current_term, &existing);
if (get_ret.ok() && !existing.empty() &&
BitPackedPostingList::is_bitpacked_format(existing.data(),
existing.size())) {
return {};
}
term_doc_lens.assign(doc_ids.size(), 1);
for (size_t i = 0; i < doc_ids.size(); ++i) {
const uint32_t did = doc_ids[i];
if (did < doc_lens.size() && doc_lens[did] > 0) {
term_doc_lens[i] = doc_lens[did];
}
}
std::string packed = BitPackedPostingList::encode(
doc_ids.data(), tfs.data(), term_doc_lens.data(), doc_ids.size(),
/*df=*/doc_ids.size(), *scorer_);
if (!ctx_->db_->Put(ctx_->write_opts_, postings_cf_, current_term, packed)
.ok()) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::convert_postings_to_bitpacked: put failed. field=",
field_name_, " term=", current_term));
}
return {};
};
{
std::unique_ptr<rocksdb::Iterator> iter(
ctx_->db_->NewIterator(ctx_->read_opts_, term_freq_cf_.load()));
iter->SeekToFirst();
while (iter->Valid()) {
const std::string key = iter->key().ToString();
const std::string value = iter->value().ToString();
std::string term;
uint32_t local_doc_id = 0;
if (!parse_doc_term_key(key, &term, &local_doc_id) ||
value.size() != sizeof(uint32_t)) {
LOG_WARN(
"FtsColumnIndexer::convert_postings_to_bitpacked: malformed "
"term_freq entry. field[%s] key_size[%zu] value_size[%zu]",
field_name_.c_str(), key.size(), value.size());
iter->Next();
continue;
}
uint32_t tf = 0;
std::memcpy(&tf, value.data(), sizeof(uint32_t));
if (term != current_term) {
auto ret = flush_current_term();
if (!ret) {
return ret;
}
current_term = std::move(term);
doc_ids.clear();
tfs.clear();
}
doc_ids.push_back(local_doc_id);
tfs.push_back(tf);
iter->Next();
}
}
// Flush the last term.
auto ret = flush_current_term();
if (!ret) {
return ret;
}
// ---------------------------------------------------------------
// 3) Clear $TF / $DOC_LEN / $MAX_TF CFs via DeleteRange.
//
// All payloads (tf, doc_len, max_score) have been inlined into the
// BitPacked postings in step 2. Wiping them here ensures the SST files
// are cleaned up during the dump-side compaction, so the dumped immutable
// segment is significantly smaller. MutableSegment then drops the CFs
// entirely after all indexers finish conversion.
//
// DeleteRange uses [begin, end) semantics; an empty begin and a 256-byte
// 0xFF end together cover every possible key in these CFs.
// ---------------------------------------------------------------
static const std::string kClearBegin{};
static const std::string kClearEnd(256, '\xFF');
const std::pair<const char *, rocksdb::ColumnFamilyHandle *> cfs_to_clear[] =
{
{"$TF", term_freq_cf_.load()},
{"$DOC_LEN", doc_len_cf_.load()},
{"$MAX_TF", max_tf_cf_.load()},
};
for (const auto &[cf_name, cf] : cfs_to_clear) {
if (cf == nullptr) {
continue;
}
if (!ctx_->db_->DeleteRange(ctx_->write_opts_, cf, kClearBegin, kClearEnd)
.ok()) {
return tl::make_unexpected(Status::InternalError(
"FtsColumnIndexer::convert_postings_to_bitpacked: failed to clear ",
cf_name, " CF. field=", field_name_));
}
}
return {};
}
// ============================================================
// Private helper methods
// ============================================================
void FtsColumnIndexer::encode_varint(uint32_t value, std::string *output) {
while (value >= 0x80) {
output->push_back(static_cast<char>((value & 0x7F) | 0x80));
value >>= 7;
}
output->push_back(static_cast<char>(value));
}
std::string FtsColumnIndexer::encode_positions(
const std::vector<uint32_t> &positions) {
std::string result;
uint32_t prev_position = 0;
for (uint32_t position : positions) {
// Delta encoding: store the difference between adjacent positions
encode_varint(position - prev_position, &result);
prev_position = position;
}
return result;
}
} // namespace zvec::fts

View File

@ -0,0 +1,238 @@
// 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 <atomic>
#include <cstdint>
#include <limits>
#include <memory>
#include <string>
#include <vector>
#include <zvec/db/schema.h>
#include <zvec/db/status.h>
#include "db/common/rocksdb_context.h"
#include "db/index/column/fts_column/fts_types.h"
#include "iterator/fts_doc_iterator.h"
#include "tokenizer/tokenizer_factory.h"
#include "bm25_scorer.h"
#include "fts_query_ast.h"
namespace zvec::fts {
/*! Single document in FTS query results.
*
* Note: `doc_id` here is the GLOBAL doc_id */
struct FtsResult {
uint64_t doc_id{0};
float score{0.0f};
bool operator>(const FtsResult &other) const {
return score > other.score;
}
};
/*! FTS column indexer
* Handles both read (search with BM25 + WAND) and write (insert / flush)
* operations on a single FTS column backed by RocksDB.
* Uses cross-CF WriteBatch to batch all per-document writes into a single
* atomic RocksDB Write() call for optimal write throughput.
*/
class FtsColumnIndexer {
public:
FtsColumnIndexer() = default;
~FtsColumnIndexer();
// -----------------------------------------------------------------
// Initialization
// -----------------------------------------------------------------
/*! Initialize for read+write (mutable path).
* \param field_meta Field meta describing this FTS field; provides both
* the field name and the tokenizer extra params used
* to acquire/release the shared pipeline.
* \param ctx RocksdbContext pointer
* \param postings_cf postings CF (main CF)
* \param positions_cf $POS CF
* \param term_freq_cf $TF CF
* \param max_tf_cf $MAX_TF CF
* \param doc_len_cf $DOC_LEN CF
* \param stat_cf $SEGMENT_STAT CF
* \return Result<void> on success, or Status on failure
*/
Result<void> open(FieldSchema::Ptr field_meta, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *postings_cf,
rocksdb::ColumnFamilyHandle *positions_cf,
rocksdb::ColumnFamilyHandle *term_freq_cf,
rocksdb::ColumnFamilyHandle *max_tf_cf,
rocksdb::ColumnFamilyHandle *doc_len_cf,
rocksdb::ColumnFamilyHandle *stat_cf);
/*! Initialize for read-only (immutable / standalone reader path).
* No tokenizer is acquired; insert() will fail if called.
* \param field_name Field name
* \param ctx RocksdbContext pointer
* \param postings_cf postings CF
* \param positions_cf $POS CF
* \param term_freq_cf $TF CF (may be nullptr for immutable)
* \param max_tf_cf $MAX_TF CF (may be nullptr)
* \param doc_len_cf $DOC_LEN CF (may be nullptr)
* \param stat_cf $SEGMENT_STAT CF
* \param bm25_params BM25 parameters (k1, b)
* \return Result<void> on success, or Status on failure
*/
Result<void> open_reader(const std::string &field_name, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *postings_cf,
rocksdb::ColumnFamilyHandle *positions_cf,
rocksdb::ColumnFamilyHandle *term_freq_cf,
rocksdb::ColumnFamilyHandle *max_tf_cf,
rocksdb::ColumnFamilyHandle *doc_len_cf,
rocksdb::ColumnFamilyHandle *stat_cf,
BM25Params bm25_params = BM25Params{});
/*! Release all CF pointers and reset internal state.
* Must be called before the underlying RocksdbStore is closed.
* The caller is responsible for ensuring no concurrent search() or
* reset_side_cfs() call is in flight this method does NOT drain
* or wait for them.
* \return Result<void> on success, or Status on failure (e.g. already
* closed).
*/
Result<void> close();
// -----------------------------------------------------------------
// Query
// -----------------------------------------------------------------
/*! Execute FTS query and return result list with BM25 scores
* \param ast Pre-parsed FTS AST (caller owns the parse step)
* \param query_params Query parameters (topk, filter, etc.)
* \return Result containing sorted results (descending score), or Status
*/
Result<std::vector<FtsResult>> search(
const FtsAstNode &ast, const FtsQueryParams &query_params) const;
/*! Atomically reset $TF/$MAX_TF/$DOC_LEN CF pointers to nullptr.
* Called before dropping these CFs so that concurrent search() calls
* on the Roaring path gracefully degrade (return default tf=1/doc_len=1).
*/
void reset_side_cfs();
// -----------------------------------------------------------------
// Write
// -----------------------------------------------------------------
/*! Insert FTS field content for a document
* \param seg_doc_id Segment-local document ID
* \param text UTF-8 encoded text content
* \return Result<void> on success, or Status on failure
*/
Result<void> insert(uint64_t seg_doc_id, const std::string &text);
/*! Flush in-memory statistics to RocksDB (called before segment dump)
* \return Result<void> on success, or Status on failure
*/
Result<void> flush();
/*! Convert all Roaring-format postings in postings_cf to BitPacked format
* with inline tf/doc_len/max_score payloads, then DeleteRange-clear the
* $TF, $DOC_LEN, and $MAX_TF CFs.
*
* Called by MutableSegment::dump_fts_column_indexers() right before the
* SST dump. After all indexers finish conversion, MutableSegment drops
* the $TF/$MAX_TF/$DOC_LEN CFs entirely (via reset_side_cfs() +
* RocksdbStore::drop_column_family()), so the dumped immutable segment
* no longer contains these CFs at all.
*
* Idempotent: terms whose postings are already in BitPacked format are
* skipped, so re-running after a partial-failure dump is safe.
*
* Must be called after flush() so that the BM25 scorer used by encode()
* sees the up-to-date segment statistics.
*
* \return Result<void> on success, or Status on failure
*/
Result<void> convert_postings_to_bitpacked();
uint64_t total_docs() const {
return total_docs_.load(std::memory_order_relaxed);
}
uint64_t total_tokens() const {
return total_tokens_.load(std::memory_order_relaxed);
}
// Accessors used by the compaction-time FTS reducer to feed source segments
// (postings + positions) without going through the higher-level search path.
RocksdbContext *ctx() const {
return ctx_;
}
rocksdb::ColumnFamilyHandle *postings_cf() const {
return postings_cf_;
}
rocksdb::ColumnFamilyHandle *positions_cf() const {
return positions_cf_;
}
private:
// --- Iterator tree construction (search internals) ---
Result<DocIteratorPtr> build_iterator(const FtsAstNode &node) const;
Result<DocIteratorPtr> build_term_iterator(const TermNode &term_node) const;
Result<DocIteratorPtr> build_phrase_iterator(
const PhraseNode &phrase_node) const;
Result<DocIteratorPtr> build_and_iterator(const AndNode &and_node) const;
Result<DocIteratorPtr> build_or_iterator(const OrNode &or_node) const;
Result<DocIteratorPtr> create_term_iterator_from_raw(
const std::string &term, rocksdb::PinnableSlice raw_data,
float boost = 1.0f) const;
std::vector<rocksdb::PinnableSlice> batch_get_postings(
const std::vector<rocksdb::Slice> &terms) const;
// --- Write helpers ---
static void encode_varint(uint32_t value, std::string *output);
static std::string encode_positions(const std::vector<uint32_t> &positions);
// --- Tokenizer (write path only) ---
FieldSchema::Ptr field_meta_{};
TokenizerPipelinePtr tokenizer_pipeline_{nullptr};
std::shared_ptr<zvec::FtsIndexParams> fts_params_;
// --- Reader state ---
std::string field_name_;
RocksdbContext *ctx_{nullptr};
BM25ScorerPtr scorer_;
rocksdb::ColumnFamilyHandle *postings_cf_{nullptr};
rocksdb::ColumnFamilyHandle *positions_cf_{nullptr};
std::atomic<rocksdb::ColumnFamilyHandle *> term_freq_cf_{nullptr};
std::atomic<rocksdb::ColumnFamilyHandle *> max_tf_cf_{nullptr};
std::atomic<rocksdb::ColumnFamilyHandle *> doc_len_cf_{nullptr};
mutable std::atomic<int> cf_counter_{0};
std::atomic<bool> cf_dropped_{false};
rocksdb::ColumnFamilyHandle *stat_cf_{nullptr};
// Minimum doc length observed so far. Used as a (loose) lower bound on
// doc_len when computing the WAND max_score for Roaring-format postings.
std::atomic<uint32_t> min_doc_len_{std::numeric_limits<uint32_t>::max()};
std::atomic<bool> opened_{false};
// --- Write-path statistics ---
std::atomic<uint64_t> total_docs_{0};
std::atomic<uint64_t> total_tokens_{0};
};
using FtsColumnIndexerPtr = std::shared_ptr<FtsColumnIndexer>;
} // namespace zvec::fts

View File

@ -0,0 +1,85 @@
// 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 <vector>
#include "db/common/constants.h"
#include "db/index/column/common/index_results.h"
#include "db/index/column/fts_column/fts_column_indexer.h"
namespace zvec {
// IndexResults adapter for FTS search results (doc_id + BM25 score pairs).
// Results are ordered by descending score from FtsColumnIndexer::search().
class FtsIndexResults : public IndexResults,
public std::enable_shared_from_this<FtsIndexResults> {
public:
using Ptr = std::shared_ptr<FtsIndexResults>;
explicit FtsIndexResults(std::vector<fts::FtsResult> results)
: results_(std::move(results)) {}
size_t count() const override {
return results_.size();
}
const std::vector<fts::FtsResult> &results() const {
return results_;
}
class FtsIterator : public Iterator {
public:
explicit FtsIterator(std::shared_ptr<const FtsIndexResults> owner)
: owner_(std::move(owner)), pos_(0) {}
idx_t doc_id() const override {
if (pos_ < owner_->results_.size()) {
return static_cast<idx_t>(owner_->results_[pos_].doc_id);
}
return INVALID_DOC_ID;
}
float score() const override {
if (pos_ < owner_->results_.size()) {
return owner_->results_[pos_].score;
}
return 0.0f;
}
void next() override {
if (pos_ < owner_->results_.size()) {
++pos_;
}
}
bool valid() const override {
return pos_ < owner_->results_.size();
}
private:
std::shared_ptr<const FtsIndexResults> owner_;
size_t pos_;
};
IteratorUPtr create_iterator() override {
return std::make_unique<FtsIterator>(shared_from_this());
}
private:
std::vector<fts::FtsResult> results_;
};
} // namespace zvec

View File

@ -0,0 +1,37 @@
// 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 <zvec/db/index_params.h>
#include <zvec/db/status.h>
namespace zvec {
namespace fts {
class TokenizerPipeline;
} // namespace fts
namespace detail {
// Internal entry to lazily acquire (and cache, per FtsIndexParams instance)
// the tokenizer pipeline. Thread-safe; same params instance returns the
// same shared_ptr on subsequent calls; the manager-side reference is
// released when the params instance is destroyed.
Result<std::shared_ptr<fts::TokenizerPipeline>> AcquireFtsPipeline(
FtsIndexParams &params);
} // namespace detail
} // namespace zvec

View File

@ -0,0 +1,191 @@
// 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 <cmath>
#include <memory>
#include <string>
#include <vector>
namespace zvec::fts {
/*! AST node type enumeration
*/
enum class FtsNodeType {
TERM, // Term node, e.g., "vector"
PHRASE, // Phrase node, e.g., "\"exact phrase\""
AND, // AND combination node (intersection)
OR, // OR combination node (union)
EMPTY, // Matches zero documents (analogous to Lucene MatchNoDocsQuery).
};
/*! AST node base class
* All FTS AST nodes carry must/must_not modifiers so that the +/- prefix
* (and AND NOT semantics) can be applied uniformly to terms, phrases and
* composite (AND/OR) sub-expressions.
*/
struct FtsAstNode {
bool must{false}; // Prefix + means must
bool must_not{false}; // Prefix - / right-hand side of AND NOT means must_not
bool should{
false}; // SHOULD semantics: does not affect matching, only scoring
// Per-node scoring weight. Currently meaningful only on TermNode / PhraseNode
// (composite nodes inherit boost from their scored leaves). Repeated terms in
// a sibling list are collapsed by the AST rewriter into a single node whose
// boost is the linear sum of duplicates, so that the post-rewrite score
// matches the pre-rewrite "sum of independent scorers" semantics exactly.
float boost{1.0f};
virtual ~FtsAstNode() = default;
virtual FtsNodeType type() const = 0;
// Return a human-readable text representation for debugging / logging
virtual std::string text() const = 0;
protected:
// Helper: prepend +/-/? modifier prefix
std::string modifier_prefix() const {
if (must) {
return "+";
}
if (must_not) {
return "-";
}
if (should) {
return "?";
}
return "";
}
// Helper: append ^X boost suffix when boost differs from default 1.0
std::string boost_suffix() const {
if (std::fabs(boost - 1.0f) < 1e-6f) {
return "";
}
return "^" + std::to_string(boost);
}
};
using FtsAstNodePtr = std::unique_ptr<FtsAstNode>;
/*! Term node
* Represents a single query term, can have must (+) or must_not (-) modifiers
* inherited from FtsAstNode.
*/
struct TermNode : public FtsAstNode {
std::string term;
explicit TermNode(std::string term_text, bool is_must = false,
bool is_must_not = false)
: term(std::move(term_text)) {
must = is_must;
must_not = is_must_not;
}
FtsNodeType type() const override {
return FtsNodeType::TERM;
}
std::string text() const override {
return modifier_prefix() + term + boost_suffix();
}
};
/*! Phrase node
* Represents an exact phrase query, e.g., "exact phrase"
* Requires exact match of word order and adjacent positions
*/
struct PhraseNode : public FtsAstNode {
std::vector<std::string> terms; // Individual words in the phrase
FtsNodeType type() const override {
return FtsNodeType::PHRASE;
}
std::string text() const override {
std::string result = modifier_prefix() + "\"";
for (size_t i = 0; i < terms.size(); ++i) {
if (i > 0) {
result += " ";
}
result += terms[i];
}
result += "\"";
result += boost_suffix();
return result;
}
};
/*! Match-nothing node — used when the analyzer drops every term (e.g.
* pure punctuation or all stop-words). Composes naturally with AND/OR so
* callers don't have to special-case nullptr.
*/
struct EmptyNode : public FtsAstNode {
FtsNodeType type() const override {
return FtsNodeType::EMPTY;
}
std::string text() const override {
return modifier_prefix() + "<empty>";
}
};
/*! AND combination node
* All child nodes must match (intersection semantics)
*/
struct AndNode : public FtsAstNode {
std::vector<FtsAstNodePtr> children;
FtsNodeType type() const override {
return FtsNodeType::AND;
}
std::string text() const override {
std::string result = modifier_prefix() + "AND(";
for (size_t i = 0; i < children.size(); ++i) {
if (i > 0) {
result += " ";
}
result += children[i]->text();
}
result += ")";
return result;
}
};
/*! OR combination node
* Any child node matches (union semantics)
*/
struct OrNode : public FtsAstNode {
std::vector<FtsAstNodePtr> children;
FtsNodeType type() const override {
return FtsNodeType::OR;
}
std::string text() const override {
std::string result = modifier_prefix() + "OR(";
for (size_t i = 0; i < children.size(); ++i) {
if (i > 0) {
result += " ";
}
result += children[i]->text();
}
result += ")";
return result;
}
};
} // namespace zvec::fts

View File

@ -0,0 +1,181 @@
// 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_rocksdb_merge.h"
#include <cstring>
#include <roaring/roaring.h>
#include <zvec/ailego/logger/logger.h>
#include "db/index/column/fts_column/posting/bitpacked_posting_list.h"
namespace zvec::fts {
// ============================================================
// Helper: deserialize a posting value (Roaring Bitmap or BitPacked) into a
// Roaring Bitmap. Caller owns the returned bitmap and must free it.
// Returns nullptr on failure.
// ============================================================
static roaring_bitmap_t *deserialize_posting_to_roaring(const char *data,
size_t size) {
if (BitPackedPostingList::is_bitpacked_format(data, size)) {
// Decode BitPacked format into a new Roaring Bitmap
BitPackedPostingIterator bp_iter;
if (bp_iter.open(data, size) != 0) {
LOG_ERROR(
"FtsPostingsMerge: failed to open bitpacked posting during merge, "
"size[%zu]",
size);
return nullptr;
}
roaring_bitmap_t *bitmap = roaring_bitmap_create();
uint32_t doc_id = bp_iter.next_doc();
while (doc_id != BitPackedPostingIterator::NO_MORE_DOCS) {
roaring_bitmap_add(bitmap, doc_id);
doc_id = bp_iter.next_doc();
}
return bitmap;
}
// Roaring Bitmap format
return roaring_bitmap_portable_deserialize_safe(data, size);
}
// ============================================================
// FtsPostingsMerge: Roaring Bitmap OR merge (supports BitPacked input)
// ============================================================
bool FtsPostingsMerge::FullMergeV2(const MergeOperationInput &merge_in,
MergeOperationOutput *merge_out) const {
// If there is only one operand and no existing_value, return directly
if (merge_in.existing_value == nullptr && merge_in.operand_list.size() == 1) {
merge_out->new_value = std::string(merge_in.operand_list[0].data(),
merge_in.operand_list[0].size());
return true;
}
// Deserialize bitmap from existing_value
roaring_bitmap_t *result_bitmap = roaring_bitmap_create();
if (merge_in.existing_value != nullptr) {
roaring_bitmap_t *existing_bitmap = deserialize_posting_to_roaring(
merge_in.existing_value->data(), merge_in.existing_value->size());
if (existing_bitmap != nullptr) {
roaring_bitmap_or_inplace(result_bitmap, existing_bitmap);
roaring_bitmap_free(existing_bitmap);
}
}
// Merge all operands
for (const auto &operand : merge_in.operand_list) {
roaring_bitmap_t *operand_bitmap =
deserialize_posting_to_roaring(operand.data(), operand.size());
if (operand_bitmap != nullptr) {
roaring_bitmap_or_inplace(result_bitmap, operand_bitmap);
roaring_bitmap_free(operand_bitmap);
}
}
// Serialize result as Roaring Bitmap
roaring_bitmap_run_optimize(result_bitmap);
size_t serialized_size = roaring_bitmap_portable_size_in_bytes(result_bitmap);
merge_out->new_value.resize(serialized_size);
roaring_bitmap_portable_serialize(result_bitmap, merge_out->new_value.data());
roaring_bitmap_free(result_bitmap);
return true;
}
bool FtsPostingsMerge::PartialMerge(const rocksdb::Slice & /*key*/,
const rocksdb::Slice &left_operand,
const rocksdb::Slice &right_operand,
std::string *new_value,
rocksdb::Logger * /*logger*/) const {
roaring_bitmap_t *left_bitmap =
deserialize_posting_to_roaring(left_operand.data(), left_operand.size());
roaring_bitmap_t *right_bitmap = deserialize_posting_to_roaring(
right_operand.data(), right_operand.size());
if (left_bitmap == nullptr || right_bitmap == nullptr) {
LOG_ERROR(
"FtsPostingsMerge::PartialMerge: failed to deserialize operand. "
"left_size[%zu] right_size[%zu]",
left_operand.size(), right_operand.size());
if (left_bitmap != nullptr) roaring_bitmap_free(left_bitmap);
if (right_bitmap != nullptr) roaring_bitmap_free(right_bitmap);
return false;
}
roaring_bitmap_or_inplace(left_bitmap, right_bitmap);
roaring_bitmap_free(right_bitmap);
size_t serialized_size = roaring_bitmap_portable_size_in_bytes(left_bitmap);
new_value->resize(serialized_size);
roaring_bitmap_portable_serialize(left_bitmap, new_value->data());
roaring_bitmap_free(left_bitmap);
return true;
}
// ============================================================
// FtsMaxTfMerge: uint32_t max merge
// ============================================================
bool FtsMaxTfMerge::FullMergeV2(const MergeOperationInput &merge_in,
MergeOperationOutput *merge_out) const {
uint32_t max_tf = 0;
if (merge_in.existing_value != nullptr &&
merge_in.existing_value->size() >= sizeof(uint32_t)) {
std::memcpy(&max_tf, merge_in.existing_value->data(), sizeof(uint32_t));
}
for (const auto &operand : merge_in.operand_list) {
if (operand.size() >= sizeof(uint32_t)) {
uint32_t operand_tf = 0;
std::memcpy(&operand_tf, operand.data(), sizeof(uint32_t));
if (operand_tf > max_tf) {
max_tf = operand_tf;
}
}
}
merge_out->new_value.resize(sizeof(uint32_t));
std::memcpy(merge_out->new_value.data(), &max_tf, sizeof(uint32_t));
return true;
}
bool FtsMaxTfMerge::PartialMerge(const rocksdb::Slice & /*key*/,
const rocksdb::Slice &left_operand,
const rocksdb::Slice &right_operand,
std::string *new_value,
rocksdb::Logger * /*logger*/) const {
if (left_operand.size() < sizeof(uint32_t) ||
right_operand.size() < sizeof(uint32_t)) {
LOG_ERROR(
"FtsMaxTfMerge::PartialMerge: operand too small. "
"left_size[%zu] right_size[%zu] expected[%zu]",
left_operand.size(), right_operand.size(), sizeof(uint32_t));
return false;
}
uint32_t left_tf = 0;
uint32_t right_tf = 0;
std::memcpy(&left_tf, left_operand.data(), sizeof(uint32_t));
std::memcpy(&right_tf, right_operand.data(), sizeof(uint32_t));
uint32_t max_tf = (left_tf > right_tf) ? left_tf : right_tf;
new_value->resize(sizeof(uint32_t));
std::memcpy(new_value->data(), &max_tf, sizeof(uint32_t));
return true;
}
} // namespace zvec::fts

View File

@ -0,0 +1,59 @@
// 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 <rocksdb/merge_operator.h>
namespace zvec::fts {
/*! FTS postings CF-specific Merge Operator
* Performs OR merge on Roaring Bitmap serialized values, used for
* incrementally updating term document lists
*/
class FtsPostingsMerge : public ROCKSDB_NAMESPACE::MergeOperator {
public:
bool FullMergeV2(const MergeOperationInput &merge_in,
MergeOperationOutput *merge_out) const override;
bool PartialMerge(const rocksdb::Slice &key,
const rocksdb::Slice &left_operand,
const rocksdb::Slice &right_operand, std::string *new_value,
rocksdb::Logger *logger) const override;
const char *Name() const override {
return "FtsPostingsMerge";
}
};
/*! FTS $MAX_TF CF-specific Merge Operator
* Performs max merge on uint32_t values, used for maintaining the maximum term
* frequency for each term (WAND upper bound)
*/
class FtsMaxTfMerge : public ROCKSDB_NAMESPACE::MergeOperator {
public:
bool FullMergeV2(const MergeOperationInput &merge_in,
MergeOperationOutput *merge_out) const override;
bool PartialMerge(const rocksdb::Slice &key,
const rocksdb::Slice &left_operand,
const rocksdb::Slice &right_operand, std::string *new_value,
rocksdb::Logger *logger) const override;
const char *Name() const override {
return "FtsMaxTfMerge";
}
};
} // namespace zvec::fts

View File

@ -0,0 +1,492 @@
// 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_rocksdb_reducer.h"
#include <cstring>
#include <vector>
#include <zvec/ailego/logger/logger.h>
#include <zvec/db/status.h>
#include "db/index/column/fts_column/fts_utils.h"
#include "db/index/column/fts_column/posting/bitpacked_posting_list.h"
namespace zvec::fts {
namespace {
// Dense survivor index in [0, effective_total_docs), or kFilteredRank if
// scan_pos is in the delete bitmap. Roaring rank(x) counts elements ≤ x;
// for an alive scan_pos that's exactly the number of deletes strictly
// before it, so `scan_pos - rank(scan_pos)` is its survivor rank.
constexpr uint32_t kFilteredRank = std::numeric_limits<uint32_t>::max();
inline uint32_t dense_rank(uint64_t scan_pos, const roaring::Roaring &bitmap) {
const uint32_t pos32 = static_cast<uint32_t>(scan_pos);
if (bitmap.contains(pos32)) {
return kFilteredRank;
}
return static_cast<uint32_t>(scan_pos - bitmap.rank(pos32));
}
} // namespace
// ============================================================
// Design notes
// ============================================================
//
// Immutable FTS segment CFs:
// - postings_cf : term -> BitPacked posting (inline tf/doc_len/max_score)
// - positions_cf : term\0doc_id -> varint delta positions (phrase queries)
// - stat_cf : field_total_docs / field_total_tokens
//
// Multi-way merge N source segments into one destination, in two passes.
// All input postings must be BitPacked; output is BitPacked too — no
// Roaring intermediate, no side CFs ($TF/$MAX_TF/$DOC_LEN) read or written.
//
// Doc id spaces:
// SRC LOCAL ∈ [0, stats.doc_count): value stored in src postings.
// SCAN POS ∈ [0, Σ stats.doc_count): feed-order concatenated position;
// same id space as SegmentHelper::delete_row_id_bitmap.
// scan_pos = scan_offset_per_seg_[seg] + local
// DST LOCAL ∈ [0, effective_total_docs_): dense survivor rank.
// Equals the row index ReduceScalar writes into the new
// segment's densified forward storage, so post-merge fetch()
// needs no translation.
// dst_local = scan_pos - bitmap.rank(scan_pos)
//
// Pass 1 (collect_effective_stats): no per-doc materialization.
// - effective_total_docs_ = Σ stats.doc_count - bitmap.cardinality()
// - effective_total_tokens_ = sum of survivors' inline doc_len
// (per-segment dedup uses vector<bool>, ~125 KB / 1M docs)
//
// Pass 2 (merge_and_flush_postings): N RocksDB iterators, term-by-term
// multi-way merge in lex order; per-term entries are encoded + put
// immediately so peak memory is one term's entries. dst_local resolved
// on the fly via dense_rank(scan_pos), sharing the bitmap with the
// vector reducer.
// ============================================================
// Public interface
// ============================================================
Result<void> FtsRocksdbReducer::init(
const std::string &field_name, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *dst_postings_cf,
rocksdb::ColumnFamilyHandle *dst_positions_cf,
rocksdb::ColumnFamilyHandle *dst_stat_cf) {
if (!dst_postings_cf || !dst_positions_cf || !dst_stat_cf) {
return tl::make_unexpected(Status::InvalidArgument(
"FtsRocksdbReducer: null destination CF. field=", field_name));
}
field_name_ = field_name;
ctx_ = ctx;
dst_postings_cf_ = dst_postings_cf;
dst_positions_cf_ = dst_positions_cf;
dst_stat_cf_ = dst_stat_cf;
state_ = STATE_INITED;
return {};
}
Result<void> FtsRocksdbReducer::cleanup() {
segment_stats_.clear();
src_ctxs_.clear();
src_postings_cfs_.clear();
src_positions_cfs_.clear();
scan_offset_per_seg_.clear();
num_segments_ = 0;
state_ = STATE_UNINITED;
return {};
}
Result<void> FtsRocksdbReducer::feed(
FtsSegmentStats segment_stats, RocksdbContext *src_ctx,
rocksdb::ColumnFamilyHandle *src_postings_cf,
rocksdb::ColumnFamilyHandle *src_positions_cf) {
if (state_ != STATE_INITED && state_ != STATE_FEED) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: call init() before feed(). field=", field_name_));
}
if (!src_postings_cf || !src_positions_cf) {
return tl::make_unexpected(Status::InvalidArgument(
"FtsRocksdbReducer: null source CF. field=", field_name_));
}
// doc_count == 0 segments contribute nothing; mark state and skip so the
// contiguity check and scan_offset cumsum only see non-empty inputs (the
// matching FilterRecordBatch / RowIdFilter id space behaves the same way).
if (segment_stats.doc_count == 0) {
state_ = STATE_FEED;
return {};
}
// Require consecutive global doc_id ranges between non-empty segments so
// the shared delete_row_id_bitmap stays aligned with input scan order.
if (!segment_stats_.empty() &&
segment_stats.min_doc_id != segment_stats_.back().max_doc_id + 1) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: segments not in consecutive doc_id order. field=",
field_name_));
}
segment_stats_.emplace_back(std::move(segment_stats));
src_ctxs_.emplace_back(src_ctx);
src_postings_cfs_.emplace_back(src_postings_cf);
src_positions_cfs_.emplace_back(src_positions_cf);
++num_segments_;
state_ = STATE_FEED;
return {};
}
Result<void> FtsRocksdbReducer::reduce(
const roaring::Roaring &delete_row_id_bitmap) {
if (state_ != STATE_FEED || num_segments_ == 0) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: call feed() before reduce(). field=", field_name_));
}
effective_total_docs_ = 0;
effective_total_tokens_ = 0;
// Precompute scan_offset = cumulative doc_count. Combined with the
// bitmap this lets dense_rank() resolve any (seg, local) in
// O(roaring::rank) without a per-doc table.
scan_offset_per_seg_.assign(num_segments_, 0);
uint64_t cumulative = 0;
for (uint32_t seg = 0; seg < num_segments_; ++seg) {
scan_offset_per_seg_[seg] = cumulative;
cumulative += segment_stats_[seg].doc_count;
}
// Phase 1: streaming per-term BitPacked merge into dst_postings_cf;
// accumulates effective_total_docs_ / effective_total_tokens_.
auto ret = reduce_postings(delete_row_id_bitmap);
if (!ret) {
LOG_ERROR("FtsRocksdbReducer: reduce_postings failed. field[%s]",
field_name_.c_str());
return ret;
}
// Phase 2: per-segment positions CF remap (phrase queries).
for (uint32_t segment_index = 0; segment_index < num_segments_;
++segment_index) {
ret = reduce_positions(segment_index, delete_row_id_bitmap);
if (!ret) {
LOG_ERROR(
"FtsRocksdbReducer: reduce_positions failed. segment[%u] field[%s]",
segment_index, field_name_.c_str());
return ret;
}
}
// Phase 3: persist effective stats — same source of truth used by Phase 1
// when encoding block_max_score, so search-time IDF/avgdl stays consistent.
ret = flush_stat(effective_total_docs_, effective_total_tokens_);
if (!ret) {
LOG_ERROR("FtsRocksdbReducer: flush_stat failed. field[%s]",
field_name_.c_str());
return ret;
}
state_ = STATE_REDUCE;
LOG_INFO(
"FtsRocksdbReducer: reduce done. field[%s] segments[%u] "
"effective_docs[%zu] effective_tokens[%zu]",
field_name_.c_str(), num_segments_, (size_t)effective_total_docs_,
(size_t)effective_total_tokens_);
return {};
}
// ============================================================
// Private
// ============================================================
Result<void> FtsRocksdbReducer::reduce_postings(
const roaring::Roaring &delete_row_id_bitmap) {
auto ret = collect_effective_stats(delete_row_id_bitmap);
if (!ret) {
return ret;
}
// Scorer seeded with final effective stats; used by Pass 2 to compute
// block_max_score consistent with the values flushed to stat_cf.
scorer_ = std::make_shared<BM25Scorer>();
scorer_->update_stats(effective_total_docs_, effective_total_tokens_);
return merge_and_flush_postings(delete_row_id_bitmap);
}
Result<void> FtsRocksdbReducer::collect_effective_stats(
const roaring::Roaring &delete_row_id_bitmap) {
effective_total_docs_ = 0;
effective_total_tokens_ = 0;
// effective_total_docs = Σ doc_count - |deletes|. Bitmap covers scan
// positions [0, Σ doc_count), so cardinality() is the exact filtered
// count. Includes empty docs, matching mutable indexer semantics.
uint64_t total_input_docs = 0;
for (const auto &s : segment_stats_) {
total_input_docs += s.doc_count;
}
const uint64_t total_deletes = delete_row_id_bitmap.cardinality();
if (total_deletes > total_input_docs) {
return tl::make_unexpected(
Status::InternalError("FtsRocksdbReducer: delete bitmap cardinality[",
total_deletes, "] exceeds total input docs[",
total_input_docs, "]. field=", field_name_));
}
effective_total_docs_ = total_input_docs - total_deletes;
// effective_total_tokens_: walk every posting, sum doc_len once per
// surviving local_doc_id. Per-segment vector<bool> dedup (~125 KB / 1M
// docs) is required because immutable segments have no per-doc doc_len
// column to read from directly.
for (uint32_t seg = 0; seg < num_segments_; ++seg) {
const uint64_t seg_doc_count = segment_stats_[seg].doc_count;
const uint64_t scan_offset = scan_offset_per_seg_[seg];
std::vector<bool> seen_docs(seg_doc_count, false);
auto *src_cf = src_postings_cfs_[seg];
auto iter = std::unique_ptr<rocksdb::Iterator>(
src_ctxs_[seg]->db_->NewIterator(src_ctxs_[seg]->read_opts_, src_cf));
iter->SeekToFirst();
while (iter->Valid()) {
const std::string posting_data = iter->value().ToString();
if (!BitPackedPostingList::is_bitpacked_format(posting_data.data(),
posting_data.size())) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: source postings is not BitPacked. field=",
field_name_));
}
BitPackedPostingIterator bp_iter;
if (bp_iter.open(posting_data.data(), posting_data.size()) != 0) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: failed to open bitpacked postings. field=",
field_name_));
}
uint32_t local_doc_id = bp_iter.next_doc();
while (local_doc_id != BitPackedPostingIterator::NO_MORE_DOCS) {
if (local_doc_id < seg_doc_count && !seen_docs[local_doc_id]) {
const uint64_t scan_pos = scan_offset + local_doc_id;
if (!delete_row_id_bitmap.contains(static_cast<uint32_t>(scan_pos))) {
seen_docs[local_doc_id] = true;
effective_total_tokens_ += bp_iter.doc_len();
}
}
local_doc_id = bp_iter.next_doc();
}
iter->Next();
}
}
LOG_INFO(
"FtsRocksdbReducer: collect_effective_stats done. field[%s] "
"effective_docs[%zu] effective_tokens[%zu]",
field_name_.c_str(), (size_t)effective_total_docs_,
(size_t)effective_total_tokens_);
return {};
}
Result<void> FtsRocksdbReducer::merge_and_flush_postings(
const roaring::Roaring &delete_row_id_bitmap) {
struct PostingEntry {
uint32_t doc_id;
uint32_t tf;
uint32_t doc_len;
};
// Open N iterators, one per source segment.
struct SegmentCursor {
uint32_t segment_index;
std::unique_ptr<rocksdb::Iterator> iter;
const FtsSegmentStats *stats;
};
std::vector<SegmentCursor> cursors;
cursors.reserve(num_segments_);
for (uint32_t i = 0; i < num_segments_; ++i) {
auto it = std::unique_ptr<rocksdb::Iterator>(src_ctxs_[i]->db_->NewIterator(
src_ctxs_[i]->read_opts_, src_postings_cfs_[i]));
it->SeekToFirst();
cursors.push_back(SegmentCursor{i, std::move(it), &segment_stats_[i]});
}
// Reusable buffers.
std::vector<PostingEntry> term_entries;
std::vector<uint32_t> doc_ids_buf, tfs_buf, doc_lens_buf;
while (true) {
// Pick the lex-smallest current term across cursors.
std::string min_term;
bool found = false;
for (auto &c : cursors) {
if (!c.iter->Valid()) {
continue;
}
const std::string t = c.iter->key().ToString();
if (!found || t < min_term) {
min_term = t;
found = true;
}
}
if (!found) {
break;
}
// Cursors visited in segment order ⇒ dense ranks emerge ascending.
term_entries.clear();
for (auto &c : cursors) {
if (!c.iter->Valid()) {
continue;
}
if (c.iter->key().ToString() != min_term) {
continue;
}
const std::string posting_data = c.iter->value().ToString();
if (!BitPackedPostingList::is_bitpacked_format(posting_data.data(),
posting_data.size())) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: source postings is not BitPacked. field=",
field_name_, " term=", min_term));
}
BitPackedPostingIterator bp_iter;
if (bp_iter.open(posting_data.data(), posting_data.size()) != 0) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: failed to open bitpacked postings. field=",
field_name_, " term=", min_term));
}
term_entries.reserve(term_entries.size() + bp_iter.cost());
const uint64_t scan_offset = scan_offset_per_seg_[c.segment_index];
const uint64_t seg_doc_count = c.stats->doc_count;
uint32_t local_doc_id = bp_iter.next_doc();
while (local_doc_id != BitPackedPostingIterator::NO_MORE_DOCS) {
if (local_doc_id < seg_doc_count) {
const uint32_t new_doc_id =
dense_rank(scan_offset + local_doc_id, delete_row_id_bitmap);
if (new_doc_id != kFilteredRank) {
term_entries.push_back(
{new_doc_id, bp_iter.term_freq(), bp_iter.doc_len()});
}
}
local_doc_id = bp_iter.next_doc();
}
c.iter->Next();
}
if (term_entries.empty()) {
continue;
}
// Encode + put per term ⇒ peak memory is one term's entries.
doc_ids_buf.clear();
tfs_buf.clear();
doc_lens_buf.clear();
doc_ids_buf.reserve(term_entries.size());
tfs_buf.reserve(term_entries.size());
doc_lens_buf.reserve(term_entries.size());
for (const auto &e : term_entries) {
doc_ids_buf.push_back(e.doc_id);
tfs_buf.push_back(e.tf);
doc_lens_buf.push_back(e.doc_len);
}
std::string packed = BitPackedPostingList::encode(
doc_ids_buf.data(), tfs_buf.data(), doc_lens_buf.data(),
doc_ids_buf.size(), doc_ids_buf.size(), *scorer_);
if (!ctx_->db_->Put(ctx_->write_opts_, dst_postings_cf_, min_term, packed)
.ok()) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: failed to put bitpacked postings. field=",
field_name_));
}
}
return {};
}
Result<void> FtsRocksdbReducer::reduce_positions(
uint32_t segment_index, const roaring::Roaring &delete_row_id_bitmap) {
auto *src_positions_cf = src_positions_cfs_[segment_index];
const uint64_t scan_offset = scan_offset_per_seg_[segment_index];
const uint64_t seg_doc_count = segment_stats_[segment_index].doc_count;
auto iter = std::unique_ptr<rocksdb::Iterator>(
src_ctxs_[segment_index]->db_->NewIterator(
src_ctxs_[segment_index]->read_opts_, src_positions_cf));
iter->SeekToFirst();
for (; iter->Valid(); iter->Next()) {
const std::string key = iter->key().ToString();
std::string term;
uint32_t local_doc_id = 0;
if (!parse_doc_term_key(key, &term, &local_doc_id)) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: malformed positions key. field=", field_name_));
}
if (local_doc_id >= seg_doc_count) {
continue;
}
const uint32_t new_doc_id =
dense_rank(scan_offset + local_doc_id, delete_row_id_bitmap);
if (new_doc_id == kFilteredRank) {
continue;
}
const std::string new_key = make_doc_term_key(term, new_doc_id);
if (!ctx_->db_
->Put(ctx_->write_opts_, dst_positions_cf_, new_key,
iter->value().ToString())
.ok()) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: failed to write positions. field=", field_name_));
}
}
return {};
}
Result<void> FtsRocksdbReducer::flush_stat(uint64_t total_docs,
uint64_t total_tokens) {
if (!ctx_->db_
->Put(ctx_->write_opts_, dst_stat_cf_,
make_total_docs_key(field_name_),
encode_uint64_value(total_docs))
.ok()) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: failed to write total_docs. field=", field_name_));
}
if (!ctx_->db_
->Put(ctx_->write_opts_, dst_stat_cf_,
make_total_tokens_key(field_name_),
encode_uint64_value(total_tokens))
.ok()) {
return tl::make_unexpected(Status::InternalError(
"FtsRocksdbReducer: failed to write total_tokens. field=",
field_name_));
}
return {};
}
} // namespace zvec::fts

View File

@ -0,0 +1,155 @@
// 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 <vector>
#include <roaring.hh>
#include <zvec/db/status.h>
#include "db/common/rocksdb_context.h"
#include "db/index/column/fts_column/bm25_scorer.h"
#include "db/index/column/fts_column/fts_types.h"
namespace zvec::fts {
class FtsRocksdbReducer;
using FtsRocksdbReducerPtr = std::shared_ptr<FtsRocksdbReducer>;
/*! FTS RocksDB segment reducer
* Merges FTS index data from multiple source segments into one destination
* segment, remapping doc_ids and filtering deleted documents. Reads only
* postings_cf (BitPacked) and positions_cf from each source segment; writes
* only postings_cf, positions_cf, and stat_cf on the destination side.
*/
class FtsRocksdbReducer {
public:
/*! Initialize the reducer with destination column families.
* \param field_name FTS field name (used for stat_cf keys)
* \param dst_postings_cf Destination postings CF (BitPacked output)
* \param dst_positions_cf Destination positions CF (phrase support)
* \param dst_stat_cf Destination segment-stat CF
* \return Result<void> on success, or Status on failure
*/
Result<void> init(const std::string &field_name, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *dst_postings_cf,
rocksdb::ColumnFamilyHandle *dst_positions_cf,
rocksdb::ColumnFamilyHandle *dst_stat_cf);
/*! Clean up internal state. */
Result<void> cleanup();
/*! Feed a source segment to be merged.
* Segments must be fed in consecutive doc_id order.
* \param segment_stats Stats of the source segment (min/max doc_id)
* \param src_ctx RocksdbContext owning the source CFs
* \param src_postings_cf Source postings CF (must be BitPacked)
* \param src_positions_cf Source positions CF
* \return Result<void> on success, or Status on failure
*/
Result<void> feed(FtsSegmentStats segment_stats, RocksdbContext *src_ctx,
rocksdb::ColumnFamilyHandle *src_postings_cf,
rocksdb::ColumnFamilyHandle *src_positions_cf);
/*! Merge fed segments into the destination: per-term BitPacked postings
* to dst_postings_cf, doc_ids remapped to the new segment's dense space,
* effective total_docs / total_tokens to dst_stat_cf for BM25.
*
* \param delete_row_id_bitmap Deleted positions in input scan order,
* id space [0, Σ stats.doc_count). For segment i with
* scan_offset = Σ_{j<i} stats_j.doc_count, source (i, local) is
* filtered iff the bitmap contains (scan_offset + local).
* Same bitmap built by SegmentHelper::FilterRecordBatch sharing
* it avoids materializing a per-doc dense rank table.
*/
Result<void> reduce(const roaring::Roaring &delete_row_id_bitmap);
/*! No-op: FTS data is written directly during reduce(). */
Result<void> dump() {
return {};
}
private:
// Two-pass streaming merge. Pass 1: collect effective stats. Pass 2:
// multi-way merge by term, encode + put one BitPacked posting per term
// (peak memory bounded by one term's entries). Both passes take the
// shared delete bitmap by reference rather than storing it on the
// reducer so its lifetime stays scoped to reduce().
Result<void> reduce_postings(const roaring::Roaring &delete_row_id_bitmap);
// Pass 1: effective_total_docs_ = Σ stats.doc_count - bitmap.cardinality
// (counts empty docs too, like the mutable indexer); effective_total_tokens_
// is summed from inline doc_len payloads of surviving docs.
Result<void> collect_effective_stats(
const roaring::Roaring &delete_row_id_bitmap);
// Pass 2: see reduce_postings. Dense rank looked up on the fly via
// the file-local dense_rank helper in the .cc.
Result<void> merge_and_flush_postings(
const roaring::Roaring &delete_row_id_bitmap);
// Per-segment positions CF remap (phrase query support).
Result<void> reduce_positions(uint32_t segment_index,
const roaring::Roaring &delete_row_id_bitmap);
// Write accumulated stats to destination stat CF.
Result<void> flush_stat(uint64_t total_docs, uint64_t total_tokens);
private:
enum State {
STATE_UNINITED = 0,
STATE_INITED = 1,
STATE_FEED = 2,
STATE_REDUCE = 3,
};
std::string field_name_{};
// RocksdbContext for CF-level operations (get/put/create_iter)
RocksdbContext *ctx_{nullptr};
// Destination column families (only the 3 active ones are tracked here;
// $TF/$MAX_TF/$DOC_LEN dst CFs exist in the RocksDB schema but the reducer
// never writes them — they will be empty in the output SST).
rocksdb::ColumnFamilyHandle *dst_postings_cf_{nullptr};
rocksdb::ColumnFamilyHandle *dst_positions_cf_{nullptr};
rocksdb::ColumnFamilyHandle *dst_stat_cf_{nullptr};
// Per-segment source RocksdbContexts, column families and stats (only
// postings + positions are needed; the empty $TF/$MAX_TF/$DOC_LEN side CFs
// are not opened here).
std::vector<FtsSegmentStats> segment_stats_{};
std::vector<RocksdbContext *> src_ctxs_{};
std::vector<rocksdb::ColumnFamilyHandle *> src_postings_cfs_{};
std::vector<rocksdb::ColumnFamilyHandle *> src_positions_cfs_{};
uint32_t num_segments_{0};
// Survivor-only stats; fed into scorer_ for block_max_score and written
// to dst stat_cf.
uint64_t effective_total_docs_{0};
uint64_t effective_total_tokens_{0};
// Precomputed cumsum: scan_offset_per_seg_[i] = Σ_{j<i} stats_j.doc_count.
std::vector<uint64_t> scan_offset_per_seg_{};
// BM25 scorer for computing block_max_score during BitPacked encoding.
// Initialized inside reduce() once effective stats are known.
BM25ScorerPtr scorer_;
State state_{STATE_UNINITED};
};
} // namespace zvec::fts

View File

@ -0,0 +1,58 @@
// 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 <cstdint>
#include <string>
#include <vector>
#include "db/index/common/index_filter.h"
namespace zvec::fts {
/*! FTS query parameters passed to FtsColumnIndexer::search(). */
struct FtsQueryParams {
uint32_t topk{10};
// Optional filter: returns true if a doc should be EXCLUDED.
// Wraps zvec::IndexFilter for push-down filtering inside the search loop.
IndexFilter::Ptr filter{nullptr};
// Candidate-driven (brute-force) mode: ascending segment-local doc_ids;
// when non-empty, FtsColumnIndexer restricts evaluation to this set by
// AND-ing it with the root iterator. Filled by the planner via
// DocFilter::get_bf_by_keys_and_update when an invert result is highly
// selective.
std::vector<uint64_t> candidate_ids;
};
/*! Per-segment statistics needed by the FTS reducer for doc_id remapping.
* - min_doc_id / max_doc_id: GLOBAL doc_id range used by the delete filter
* (filter.is_filtered() takes a global doc_id).
* - doc_count: number of FTS LOCAL doc_ids in the source segment; the posting
* list domain is [0, doc_count). For fresh (non-merged) segments this
* equals max_doc_id - min_doc_id + 1, and the local-to-global mapping is
* `global = min_doc_id + local`.
*/
struct FtsSegmentStats {
uint64_t min_doc_id{0};
uint64_t max_doc_id{0};
uint64_t doc_count{0};
};
struct FtsIndexParams {
std::string tokenizer_name{"standard"};
std::vector<std::string> filters{"lowercase"};
std::string extra_params;
};
} // namespace zvec::fts

View File

@ -0,0 +1,38 @@
// 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_utils.h"
#include <zvec/ailego/logger/logger.h>
namespace zvec::fts {
bool parse_doc_term_key(const std::string &key, std::string *term_out,
uint32_t *doc_id_out) {
// Key format: term + '\0' + doc_id(4B big-endian)
// Minimum length: 1 byte term + 1 byte '\0' + 4 bytes doc_id = 6 bytes.
if (key.size() < 6) {
LOG_WARN("parse_doc_term_key: key too short. size[%zu]", key.size());
return false;
}
const size_t separator_pos = key.size() - sizeof(uint32_t) - 1;
if (key[separator_pos] != '\0') {
LOG_WARN("parse_doc_term_key: missing separator. size[%zu]", key.size());
return false;
}
*term_out = key.substr(0, separator_pos);
*doc_id_out = decode_uint32_big_endian(key.data() + separator_pos + 1);
return true;
}
} // namespace zvec::fts

View File

@ -0,0 +1,99 @@
// 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 <cstdint>
#include <cstring>
#include <string>
namespace zvec::fts {
// Big-endian uint32 encoding/decoding.
inline uint32_t decode_uint32_big_endian(const char *data) {
return (static_cast<uint32_t>(static_cast<uint8_t>(data[0])) << 24) |
(static_cast<uint32_t>(static_cast<uint8_t>(data[1])) << 16) |
(static_cast<uint32_t>(static_cast<uint8_t>(data[2])) << 8) |
static_cast<uint32_t>(static_cast<uint8_t>(data[3]));
}
inline void encode_uint32_big_endian(uint32_t value, std::string *output) {
output->push_back(static_cast<char>((value >> 24) & 0xFF));
output->push_back(static_cast<char>((value >> 16) & 0xFF));
output->push_back(static_cast<char>((value >> 8) & 0xFF));
output->push_back(static_cast<char>(value & 0xFF));
}
// Doc-term key: term + '\0' + doc_id (4-byte big-endian).
// Used by postings ($TF/$POS) column families.
inline std::string make_doc_term_key(const std::string &term, uint32_t doc_id) {
std::string key;
key.reserve(term.size() + 1 + sizeof(uint32_t));
key.append(term);
key.push_back('\0');
encode_uint32_big_endian(doc_id, &key);
return key;
}
// In-place variant of make_doc_term_key: appends the key to an existing buffer.
// Callers that build many keys in a row can reserve once and reuse the buffer,
// avoiding per-key allocation. Returns the number of bytes appended so the
// caller can build Slices into the buffer.
inline size_t append_doc_term_key(const std::string &term, uint32_t doc_id,
std::string *buf) {
const size_t bytes = term.size() + 1 + sizeof(uint32_t);
buf->append(term);
buf->push_back('\0');
encode_uint32_big_endian(doc_id, buf);
return bytes;
}
bool parse_doc_term_key(const std::string &key, std::string *term_out,
uint32_t *doc_id_out);
// Per-field segment-stat keys (stat_cf) for BM25 scoring.
inline std::string make_total_docs_key(const std::string &field_name) {
return field_name + "_total_docs";
}
inline std::string make_total_tokens_key(const std::string &field_name) {
return field_name + "_total_tokens";
}
// uint64 big-endian encoding for stat values.
inline std::string encode_uint64_value(uint64_t value) {
std::string out(sizeof(uint64_t), '\0');
out[0] = static_cast<char>((value >> 56) & 0xFF);
out[1] = static_cast<char>((value >> 48) & 0xFF);
out[2] = static_cast<char>((value >> 40) & 0xFF);
out[3] = static_cast<char>((value >> 32) & 0xFF);
out[4] = static_cast<char>((value >> 24) & 0xFF);
out[5] = static_cast<char>((value >> 16) & 0xFF);
out[6] = static_cast<char>((value >> 8) & 0xFF);
out[7] = static_cast<char>(value & 0xFF);
return out;
}
inline uint64_t decode_uint64_value(const char *data) {
return (static_cast<uint64_t>(static_cast<uint8_t>(data[0])) << 56) |
(static_cast<uint64_t>(static_cast<uint8_t>(data[1])) << 48) |
(static_cast<uint64_t>(static_cast<uint8_t>(data[2])) << 40) |
(static_cast<uint64_t>(static_cast<uint8_t>(data[3])) << 32) |
(static_cast<uint64_t>(static_cast<uint8_t>(data[4])) << 24) |
(static_cast<uint64_t>(static_cast<uint8_t>(data[5])) << 16) |
(static_cast<uint64_t>(static_cast<uint8_t>(data[6])) << 8) |
static_cast<uint64_t>(static_cast<uint8_t>(data[7]));
}
} // namespace zvec::fts

View File

@ -0,0 +1,257 @@
// Generated from FtsLexer.g4 by ANTLR 4.8
#include "FtsLexer.h"
using namespace antlr4;
using namespace antlr4;
FtsLexer::FtsLexer(CharStream *input) : Lexer(input) {
_interpreter = new atn::LexerATNSimulator(this, _atn, _decisionToDFA,
_sharedContextCache);
}
FtsLexer::~FtsLexer() {
delete _interpreter;
}
std::string FtsLexer::getGrammarFileName() const {
return "FtsLexer.g4";
}
const std::vector<std::string> &FtsLexer::getRuleNames() const {
return _ruleNames;
}
const std::vector<std::string> &FtsLexer::getChannelNames() const {
return _channelNames;
}
const std::vector<std::string> &FtsLexer::getModeNames() const {
return _modeNames;
}
const std::vector<std::string> &FtsLexer::getTokenNames() const {
return _tokenNames;
}
dfa::Vocabulary &FtsLexer::getVocabulary() const {
return _vocabulary;
}
const std::vector<uint16_t> FtsLexer::getSerializedATN() const {
return _serializedATN;
}
const atn::ATN &FtsLexer::getATN() const {
return _atn;
}
// Static vars and initialization.
std::vector<dfa::DFA> FtsLexer::_decisionToDFA;
atn::PredictionContextCache FtsLexer::_sharedContextCache;
// We own the ATN which in turn owns the ATN states.
atn::ATN FtsLexer::_atn;
std::vector<uint16_t> FtsLexer::_serializedATN;
std::vector<std::string> FtsLexer::_ruleNames = {
"OR", "AND", "NOT", "PLUS_SIGN", "MINUS_SIGN",
"COLON", "CARET", "LP", "RP", "DQUOTA_STRING",
"ASCII_ALNUM", "ESCAPED_CHAR", "UNI_CHAR", "TERM_START", "TERM_BODY",
"REGULAR_ID", "NUMBER", "TERM", "SPACES", "DEFAULT"};
std::vector<std::string> FtsLexer::_channelNames = {"DEFAULT_TOKEN_CHANNEL",
"HIDDEN"};
std::vector<std::string> FtsLexer::_modeNames = {"DEFAULT_MODE"};
std::vector<std::string> FtsLexer::_literalNames = {
"", "", "", "", "'+'", "'-'", "':'", "'^'", "'('", "')'"};
std::vector<std::string> FtsLexer::_symbolicNames = {
"", "OR", "AND", "NOT", "PLUS_SIGN", "MINUS_SIGN",
"COLON", "CARET", "LP", "RP", "DQUOTA_STRING", "REGULAR_ID",
"NUMBER", "TERM", "SPACES", "DEFAULT"};
dfa::Vocabulary FtsLexer::_vocabulary(_literalNames, _symbolicNames);
std::vector<std::string> FtsLexer::_tokenNames;
FtsLexer::Initializer::Initializer() {
// This code could be in a static initializer lambda, but VS doesn't allow
// access to private class members from there.
for (size_t i = 0; i < _symbolicNames.size(); ++i) {
std::string name = _vocabulary.getLiteralName(i);
if (name.empty()) {
name = _vocabulary.getSymbolicName(i);
}
if (name.empty()) {
_tokenNames.push_back("<INVALID>");
} else {
_tokenNames.push_back(name);
}
}
_serializedATN = {
0x3, 0x608b, 0xa72a, 0x8133, 0xb9ed, 0x417c, 0x3be7, 0x7786, 0x5964,
0x2, 0x11, 0x82, 0x8, 0x1, 0x4, 0x2, 0x9, 0x2,
0x4, 0x3, 0x9, 0x3, 0x4, 0x4, 0x9, 0x4, 0x4,
0x5, 0x9, 0x5, 0x4, 0x6, 0x9, 0x6, 0x4, 0x7,
0x9, 0x7, 0x4, 0x8, 0x9, 0x8, 0x4, 0x9, 0x9,
0x9, 0x4, 0xa, 0x9, 0xa, 0x4, 0xb, 0x9, 0xb,
0x4, 0xc, 0x9, 0xc, 0x4, 0xd, 0x9, 0xd, 0x4,
0xe, 0x9, 0xe, 0x4, 0xf, 0x9, 0xf, 0x4, 0x10,
0x9, 0x10, 0x4, 0x11, 0x9, 0x11, 0x4, 0x12, 0x9,
0x12, 0x4, 0x13, 0x9, 0x13, 0x4, 0x14, 0x9, 0x14,
0x4, 0x15, 0x9, 0x15, 0x3, 0x2, 0x3, 0x2, 0x3,
0x2, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3,
0x3, 0x4, 0x3, 0x4, 0x3, 0x4, 0x3, 0x4, 0x3,
0x5, 0x3, 0x5, 0x3, 0x6, 0x3, 0x6, 0x3, 0x7,
0x3, 0x7, 0x3, 0x8, 0x3, 0x8, 0x3, 0x9, 0x3,
0x9, 0x3, 0xa, 0x3, 0xa, 0x3, 0xb, 0x3, 0xb,
0x3, 0xb, 0x3, 0xb, 0x7, 0xb, 0x47, 0xa, 0xb,
0xc, 0xb, 0xe, 0xb, 0x4a, 0xb, 0xb, 0x3, 0xb,
0x3, 0xb, 0x3, 0xc, 0x3, 0xc, 0x3, 0xd, 0x3,
0xd, 0x3, 0xd, 0x3, 0xe, 0x3, 0xe, 0x3, 0xf,
0x3, 0xf, 0x5, 0xf, 0x57, 0xa, 0xf, 0x3, 0x10,
0x3, 0x10, 0x3, 0x10, 0x3, 0x10, 0x5, 0x10, 0x5d,
0xa, 0x10, 0x3, 0x11, 0x3, 0x11, 0x7, 0x11, 0x61,
0xa, 0x11, 0xc, 0x11, 0xe, 0x11, 0x64, 0xb, 0x11,
0x3, 0x12, 0x6, 0x12, 0x67, 0xa, 0x12, 0xd, 0x12,
0xe, 0x12, 0x68, 0x3, 0x12, 0x3, 0x12, 0x6, 0x12,
0x6d, 0xa, 0x12, 0xd, 0x12, 0xe, 0x12, 0x6e, 0x5,
0x12, 0x71, 0xa, 0x12, 0x3, 0x13, 0x3, 0x13, 0x7,
0x13, 0x75, 0xa, 0x13, 0xc, 0x13, 0xe, 0x13, 0x78,
0xb, 0x13, 0x3, 0x14, 0x6, 0x14, 0x7b, 0xa, 0x14,
0xd, 0x14, 0xe, 0x14, 0x7c, 0x3, 0x14, 0x3, 0x14,
0x3, 0x15, 0x3, 0x15, 0x2, 0x2, 0x16, 0x3, 0x3,
0x5, 0x4, 0x7, 0x5, 0x9, 0x6, 0xb, 0x7, 0xd,
0x8, 0xf, 0x9, 0x11, 0xa, 0x13, 0xb, 0x15, 0xc,
0x17, 0x2, 0x19, 0x2, 0x1b, 0x2, 0x1d, 0x2, 0x1f,
0x2, 0x21, 0xd, 0x23, 0xe, 0x25, 0xf, 0x27, 0x10,
0x29, 0x11, 0x3, 0x2, 0x11, 0x4, 0x2, 0x51, 0x51,
0x71, 0x71, 0x4, 0x2, 0x54, 0x54, 0x74, 0x74, 0x4,
0x2, 0x43, 0x43, 0x63, 0x63, 0x4, 0x2, 0x50, 0x50,
0x70, 0x70, 0x4, 0x2, 0x46, 0x46, 0x66, 0x66, 0x4,
0x2, 0x56, 0x56, 0x76, 0x76, 0x6, 0x2, 0xc, 0xc,
0xf, 0xf, 0x24, 0x24, 0x5e, 0x5e, 0x6, 0x2, 0x32,
0x3b, 0x43, 0x5c, 0x61, 0x61, 0x63, 0x7c, 0xc, 0x2,
0x23, 0x24, 0x28, 0x28, 0x2a, 0x2d, 0x2f, 0x2f, 0x31,
0x31, 0x3c, 0x3c, 0x3f, 0x3f, 0x41, 0x41, 0x5d, 0x60,
0x7d, 0x80, 0x3, 0x2, 0x82, 0x1, 0x8, 0x2, 0x25,
0x25, 0x27, 0x27, 0x29, 0x29, 0x2f, 0x31, 0x42, 0x42,
0x61, 0x61, 0x5, 0x2, 0x43, 0x5c, 0x61, 0x61, 0x63,
0x7c, 0x7, 0x2, 0x2f, 0x2f, 0x32, 0x3b, 0x43, 0x5c,
0x61, 0x61, 0x63, 0x7c, 0x3, 0x2, 0x32, 0x3b, 0x5,
0x2, 0xb, 0xc, 0xf, 0xf, 0x22, 0x22, 0x2, 0x88,
0x2, 0x3, 0x3, 0x2, 0x2, 0x2, 0x2, 0x5, 0x3,
0x2, 0x2, 0x2, 0x2, 0x7, 0x3, 0x2, 0x2, 0x2,
0x2, 0x9, 0x3, 0x2, 0x2, 0x2, 0x2, 0xb, 0x3,
0x2, 0x2, 0x2, 0x2, 0xd, 0x3, 0x2, 0x2, 0x2,
0x2, 0xf, 0x3, 0x2, 0x2, 0x2, 0x2, 0x11, 0x3,
0x2, 0x2, 0x2, 0x2, 0x13, 0x3, 0x2, 0x2, 0x2,
0x2, 0x15, 0x3, 0x2, 0x2, 0x2, 0x2, 0x21, 0x3,
0x2, 0x2, 0x2, 0x2, 0x23, 0x3, 0x2, 0x2, 0x2,
0x2, 0x25, 0x3, 0x2, 0x2, 0x2, 0x2, 0x27, 0x3,
0x2, 0x2, 0x2, 0x2, 0x29, 0x3, 0x2, 0x2, 0x2,
0x3, 0x2b, 0x3, 0x2, 0x2, 0x2, 0x5, 0x2e, 0x3,
0x2, 0x2, 0x2, 0x7, 0x32, 0x3, 0x2, 0x2, 0x2,
0x9, 0x36, 0x3, 0x2, 0x2, 0x2, 0xb, 0x38, 0x3,
0x2, 0x2, 0x2, 0xd, 0x3a, 0x3, 0x2, 0x2, 0x2,
0xf, 0x3c, 0x3, 0x2, 0x2, 0x2, 0x11, 0x3e, 0x3,
0x2, 0x2, 0x2, 0x13, 0x40, 0x3, 0x2, 0x2, 0x2,
0x15, 0x42, 0x3, 0x2, 0x2, 0x2, 0x17, 0x4d, 0x3,
0x2, 0x2, 0x2, 0x19, 0x4f, 0x3, 0x2, 0x2, 0x2,
0x1b, 0x52, 0x3, 0x2, 0x2, 0x2, 0x1d, 0x56, 0x3,
0x2, 0x2, 0x2, 0x1f, 0x5c, 0x3, 0x2, 0x2, 0x2,
0x21, 0x5e, 0x3, 0x2, 0x2, 0x2, 0x23, 0x66, 0x3,
0x2, 0x2, 0x2, 0x25, 0x72, 0x3, 0x2, 0x2, 0x2,
0x27, 0x7a, 0x3, 0x2, 0x2, 0x2, 0x29, 0x80, 0x3,
0x2, 0x2, 0x2, 0x2b, 0x2c, 0x9, 0x2, 0x2, 0x2,
0x2c, 0x2d, 0x9, 0x3, 0x2, 0x2, 0x2d, 0x4, 0x3,
0x2, 0x2, 0x2, 0x2e, 0x2f, 0x9, 0x4, 0x2, 0x2,
0x2f, 0x30, 0x9, 0x5, 0x2, 0x2, 0x30, 0x31, 0x9,
0x6, 0x2, 0x2, 0x31, 0x6, 0x3, 0x2, 0x2, 0x2,
0x32, 0x33, 0x9, 0x5, 0x2, 0x2, 0x33, 0x34, 0x9,
0x2, 0x2, 0x2, 0x34, 0x35, 0x9, 0x7, 0x2, 0x2,
0x35, 0x8, 0x3, 0x2, 0x2, 0x2, 0x36, 0x37, 0x7,
0x2d, 0x2, 0x2, 0x37, 0xa, 0x3, 0x2, 0x2, 0x2,
0x38, 0x39, 0x7, 0x2f, 0x2, 0x2, 0x39, 0xc, 0x3,
0x2, 0x2, 0x2, 0x3a, 0x3b, 0x7, 0x3c, 0x2, 0x2,
0x3b, 0xe, 0x3, 0x2, 0x2, 0x2, 0x3c, 0x3d, 0x7,
0x60, 0x2, 0x2, 0x3d, 0x10, 0x3, 0x2, 0x2, 0x2,
0x3e, 0x3f, 0x7, 0x2a, 0x2, 0x2, 0x3f, 0x12, 0x3,
0x2, 0x2, 0x2, 0x40, 0x41, 0x7, 0x2b, 0x2, 0x2,
0x41, 0x14, 0x3, 0x2, 0x2, 0x2, 0x42, 0x48, 0x7,
0x24, 0x2, 0x2, 0x43, 0x47, 0xa, 0x8, 0x2, 0x2,
0x44, 0x45, 0x7, 0x5e, 0x2, 0x2, 0x45, 0x47, 0xb,
0x2, 0x2, 0x2, 0x46, 0x43, 0x3, 0x2, 0x2, 0x2,
0x46, 0x44, 0x3, 0x2, 0x2, 0x2, 0x47, 0x4a, 0x3,
0x2, 0x2, 0x2, 0x48, 0x46, 0x3, 0x2, 0x2, 0x2,
0x48, 0x49, 0x3, 0x2, 0x2, 0x2, 0x49, 0x4b, 0x3,
0x2, 0x2, 0x2, 0x4a, 0x48, 0x3, 0x2, 0x2, 0x2,
0x4b, 0x4c, 0x7, 0x24, 0x2, 0x2, 0x4c, 0x16, 0x3,
0x2, 0x2, 0x2, 0x4d, 0x4e, 0x9, 0x9, 0x2, 0x2,
0x4e, 0x18, 0x3, 0x2, 0x2, 0x2, 0x4f, 0x50, 0x7,
0x5e, 0x2, 0x2, 0x50, 0x51, 0x9, 0xa, 0x2, 0x2,
0x51, 0x1a, 0x3, 0x2, 0x2, 0x2, 0x52, 0x53, 0x9,
0xb, 0x2, 0x2, 0x53, 0x1c, 0x3, 0x2, 0x2, 0x2,
0x54, 0x57, 0x5, 0x17, 0xc, 0x2, 0x55, 0x57, 0x5,
0x1b, 0xe, 0x2, 0x56, 0x54, 0x3, 0x2, 0x2, 0x2,
0x56, 0x55, 0x3, 0x2, 0x2, 0x2, 0x57, 0x1e, 0x3,
0x2, 0x2, 0x2, 0x58, 0x5d, 0x5, 0x17, 0xc, 0x2,
0x59, 0x5d, 0x5, 0x1b, 0xe, 0x2, 0x5a, 0x5d, 0x9,
0xc, 0x2, 0x2, 0x5b, 0x5d, 0x5, 0x19, 0xd, 0x2,
0x5c, 0x58, 0x3, 0x2, 0x2, 0x2, 0x5c, 0x59, 0x3,
0x2, 0x2, 0x2, 0x5c, 0x5a, 0x3, 0x2, 0x2, 0x2,
0x5c, 0x5b, 0x3, 0x2, 0x2, 0x2, 0x5d, 0x20, 0x3,
0x2, 0x2, 0x2, 0x5e, 0x62, 0x9, 0xd, 0x2, 0x2,
0x5f, 0x61, 0x9, 0xe, 0x2, 0x2, 0x60, 0x5f, 0x3,
0x2, 0x2, 0x2, 0x61, 0x64, 0x3, 0x2, 0x2, 0x2,
0x62, 0x60, 0x3, 0x2, 0x2, 0x2, 0x62, 0x63, 0x3,
0x2, 0x2, 0x2, 0x63, 0x22, 0x3, 0x2, 0x2, 0x2,
0x64, 0x62, 0x3, 0x2, 0x2, 0x2, 0x65, 0x67, 0x9,
0xf, 0x2, 0x2, 0x66, 0x65, 0x3, 0x2, 0x2, 0x2,
0x67, 0x68, 0x3, 0x2, 0x2, 0x2, 0x68, 0x66, 0x3,
0x2, 0x2, 0x2, 0x68, 0x69, 0x3, 0x2, 0x2, 0x2,
0x69, 0x70, 0x3, 0x2, 0x2, 0x2, 0x6a, 0x6c, 0x7,
0x30, 0x2, 0x2, 0x6b, 0x6d, 0x9, 0xf, 0x2, 0x2,
0x6c, 0x6b, 0x3, 0x2, 0x2, 0x2, 0x6d, 0x6e, 0x3,
0x2, 0x2, 0x2, 0x6e, 0x6c, 0x3, 0x2, 0x2, 0x2,
0x6e, 0x6f, 0x3, 0x2, 0x2, 0x2, 0x6f, 0x71, 0x3,
0x2, 0x2, 0x2, 0x70, 0x6a, 0x3, 0x2, 0x2, 0x2,
0x70, 0x71, 0x3, 0x2, 0x2, 0x2, 0x71, 0x24, 0x3,
0x2, 0x2, 0x2, 0x72, 0x76, 0x5, 0x1d, 0xf, 0x2,
0x73, 0x75, 0x5, 0x1f, 0x10, 0x2, 0x74, 0x73, 0x3,
0x2, 0x2, 0x2, 0x75, 0x78, 0x3, 0x2, 0x2, 0x2,
0x76, 0x74, 0x3, 0x2, 0x2, 0x2, 0x76, 0x77, 0x3,
0x2, 0x2, 0x2, 0x77, 0x26, 0x3, 0x2, 0x2, 0x2,
0x78, 0x76, 0x3, 0x2, 0x2, 0x2, 0x79, 0x7b, 0x9,
0x10, 0x2, 0x2, 0x7a, 0x79, 0x3, 0x2, 0x2, 0x2,
0x7b, 0x7c, 0x3, 0x2, 0x2, 0x2, 0x7c, 0x7a, 0x3,
0x2, 0x2, 0x2, 0x7c, 0x7d, 0x3, 0x2, 0x2, 0x2,
0x7d, 0x7e, 0x3, 0x2, 0x2, 0x2, 0x7e, 0x7f, 0x8,
0x14, 0x2, 0x2, 0x7f, 0x28, 0x3, 0x2, 0x2, 0x2,
0x80, 0x81, 0xb, 0x2, 0x2, 0x2, 0x81, 0x2a, 0x3,
0x2, 0x2, 0x2, 0xd, 0x2, 0x46, 0x48, 0x56, 0x5c,
0x62, 0x68, 0x6e, 0x70, 0x76, 0x7c, 0x3, 0x8, 0x2,
0x2,
};
atn::ATNDeserializer deserializer;
_atn = deserializer.deserialize(_serializedATN);
size_t count = _atn.getNumberOfDecisions();
_decisionToDFA.reserve(count);
for (size_t i = 0; i < count; i++) {
_decisionToDFA.emplace_back(_atn.getDecisionState(i), i);
}
}
FtsLexer::Initializer FtsLexer::_init;

View File

@ -0,0 +1,73 @@
// Generated from FtsLexer.g4 by ANTLR 4.8
#pragma once
#include "antlr4-runtime.h"
namespace antlr4 {
class FtsLexer : public antlr4::Lexer {
public:
enum {
OR = 1,
AND = 2,
NOT = 3,
PLUS_SIGN = 4,
MINUS_SIGN = 5,
COLON = 6,
CARET = 7,
LP = 8,
RP = 9,
DQUOTA_STRING = 10,
REGULAR_ID = 11,
NUMBER = 12,
TERM = 13,
SPACES = 14,
DEFAULT = 15
};
FtsLexer(antlr4::CharStream *input);
~FtsLexer();
virtual std::string getGrammarFileName() const override;
virtual const std::vector<std::string> &getRuleNames() const override;
virtual const std::vector<std::string> &getChannelNames() const override;
virtual const std::vector<std::string> &getModeNames() const override;
virtual const std::vector<std::string> &getTokenNames()
const override; // deprecated, use vocabulary instead
virtual antlr4::dfa::Vocabulary &getVocabulary() const override;
virtual const std::vector<uint16_t> getSerializedATN() const override;
virtual const antlr4::atn::ATN &getATN() const override;
private:
static std::vector<antlr4::dfa::DFA> _decisionToDFA;
static antlr4::atn::PredictionContextCache _sharedContextCache;
static std::vector<std::string> _ruleNames;
static std::vector<std::string> _tokenNames;
static std::vector<std::string> _channelNames;
static std::vector<std::string> _modeNames;
static std::vector<std::string> _literalNames;
static std::vector<std::string> _symbolicNames;
static antlr4::dfa::Vocabulary _vocabulary;
static antlr4::atn::ATN _atn;
static std::vector<uint16_t> _serializedATN;
// Individual action functions triggered by action() above.
// Individual semantic predicate functions triggered by sempred() above.
struct Initializer {
Initializer();
};
static Initializer _init;
};
} // namespace antlr4

View File

@ -0,0 +1,67 @@
token literal names:
null
null
null
null
'+'
'-'
':'
'^'
'('
')'
null
null
null
null
null
null
token symbolic names:
null
OR
AND
NOT
PLUS_SIGN
MINUS_SIGN
COLON
CARET
LP
RP
DQUOTA_STRING
REGULAR_ID
NUMBER
TERM
SPACES
DEFAULT
rule names:
OR
AND
NOT
PLUS_SIGN
MINUS_SIGN
COLON
CARET
LP
RP
DQUOTA_STRING
ASCII_ALNUM
ESCAPED_CHAR
UNI_CHAR
TERM_START
TERM_BODY
REGULAR_ID
NUMBER
TERM
SPACES
DEFAULT
channel names:
DEFAULT_TOKEN_CHANNEL
HIDDEN
mode names:
DEFAULT_MODE
atn:
[3, 24715, 42794, 33075, 47597, 16764, 15335, 30598, 22884, 2, 17, 130, 8, 1, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 4, 14, 9, 14, 4, 15, 9, 15, 4, 16, 9, 16, 4, 17, 9, 17, 4, 18, 9, 18, 4, 19, 9, 19, 4, 20, 9, 20, 4, 21, 9, 21, 3, 2, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 3, 4, 3, 4, 3, 4, 3, 5, 3, 5, 3, 6, 3, 6, 3, 7, 3, 7, 3, 8, 3, 8, 3, 9, 3, 9, 3, 10, 3, 10, 3, 11, 3, 11, 3, 11, 3, 11, 7, 11, 71, 10, 11, 12, 11, 14, 11, 74, 11, 11, 3, 11, 3, 11, 3, 12, 3, 12, 3, 13, 3, 13, 3, 13, 3, 14, 3, 14, 3, 15, 3, 15, 5, 15, 87, 10, 15, 3, 16, 3, 16, 3, 16, 3, 16, 5, 16, 93, 10, 16, 3, 17, 3, 17, 7, 17, 97, 10, 17, 12, 17, 14, 17, 100, 11, 17, 3, 18, 6, 18, 103, 10, 18, 13, 18, 14, 18, 104, 3, 18, 3, 18, 6, 18, 109, 10, 18, 13, 18, 14, 18, 110, 5, 18, 113, 10, 18, 3, 19, 3, 19, 7, 19, 117, 10, 19, 12, 19, 14, 19, 120, 11, 19, 3, 20, 6, 20, 123, 10, 20, 13, 20, 14, 20, 124, 3, 20, 3, 20, 3, 21, 3, 21, 2, 2, 22, 3, 3, 5, 4, 7, 5, 9, 6, 11, 7, 13, 8, 15, 9, 17, 10, 19, 11, 21, 12, 23, 2, 25, 2, 27, 2, 29, 2, 31, 2, 33, 13, 35, 14, 37, 15, 39, 16, 41, 17, 3, 2, 17, 4, 2, 81, 81, 113, 113, 4, 2, 84, 84, 116, 116, 4, 2, 67, 67, 99, 99, 4, 2, 80, 80, 112, 112, 4, 2, 70, 70, 102, 102, 4, 2, 86, 86, 118, 118, 6, 2, 12, 12, 15, 15, 36, 36, 94, 94, 6, 2, 50, 59, 67, 92, 97, 97, 99, 124, 12, 2, 35, 36, 40, 40, 42, 45, 47, 47, 49, 49, 60, 60, 63, 63, 65, 65, 93, 96, 125, 128, 3, 2, 130, 1, 8, 2, 37, 37, 39, 39, 41, 41, 47, 49, 66, 66, 97, 97, 5, 2, 67, 92, 97, 97, 99, 124, 7, 2, 47, 47, 50, 59, 67, 92, 97, 97, 99, 124, 3, 2, 50, 59, 5, 2, 11, 12, 15, 15, 34, 34, 2, 136, 2, 3, 3, 2, 2, 2, 2, 5, 3, 2, 2, 2, 2, 7, 3, 2, 2, 2, 2, 9, 3, 2, 2, 2, 2, 11, 3, 2, 2, 2, 2, 13, 3, 2, 2, 2, 2, 15, 3, 2, 2, 2, 2, 17, 3, 2, 2, 2, 2, 19, 3, 2, 2, 2, 2, 21, 3, 2, 2, 2, 2, 33, 3, 2, 2, 2, 2, 35, 3, 2, 2, 2, 2, 37, 3, 2, 2, 2, 2, 39, 3, 2, 2, 2, 2, 41, 3, 2, 2, 2, 3, 43, 3, 2, 2, 2, 5, 46, 3, 2, 2, 2, 7, 50, 3, 2, 2, 2, 9, 54, 3, 2, 2, 2, 11, 56, 3, 2, 2, 2, 13, 58, 3, 2, 2, 2, 15, 60, 3, 2, 2, 2, 17, 62, 3, 2, 2, 2, 19, 64, 3, 2, 2, 2, 21, 66, 3, 2, 2, 2, 23, 77, 3, 2, 2, 2, 25, 79, 3, 2, 2, 2, 27, 82, 3, 2, 2, 2, 29, 86, 3, 2, 2, 2, 31, 92, 3, 2, 2, 2, 33, 94, 3, 2, 2, 2, 35, 102, 3, 2, 2, 2, 37, 114, 3, 2, 2, 2, 39, 122, 3, 2, 2, 2, 41, 128, 3, 2, 2, 2, 43, 44, 9, 2, 2, 2, 44, 45, 9, 3, 2, 2, 45, 4, 3, 2, 2, 2, 46, 47, 9, 4, 2, 2, 47, 48, 9, 5, 2, 2, 48, 49, 9, 6, 2, 2, 49, 6, 3, 2, 2, 2, 50, 51, 9, 5, 2, 2, 51, 52, 9, 2, 2, 2, 52, 53, 9, 7, 2, 2, 53, 8, 3, 2, 2, 2, 54, 55, 7, 45, 2, 2, 55, 10, 3, 2, 2, 2, 56, 57, 7, 47, 2, 2, 57, 12, 3, 2, 2, 2, 58, 59, 7, 60, 2, 2, 59, 14, 3, 2, 2, 2, 60, 61, 7, 96, 2, 2, 61, 16, 3, 2, 2, 2, 62, 63, 7, 42, 2, 2, 63, 18, 3, 2, 2, 2, 64, 65, 7, 43, 2, 2, 65, 20, 3, 2, 2, 2, 66, 72, 7, 36, 2, 2, 67, 71, 10, 8, 2, 2, 68, 69, 7, 94, 2, 2, 69, 71, 11, 2, 2, 2, 70, 67, 3, 2, 2, 2, 70, 68, 3, 2, 2, 2, 71, 74, 3, 2, 2, 2, 72, 70, 3, 2, 2, 2, 72, 73, 3, 2, 2, 2, 73, 75, 3, 2, 2, 2, 74, 72, 3, 2, 2, 2, 75, 76, 7, 36, 2, 2, 76, 22, 3, 2, 2, 2, 77, 78, 9, 9, 2, 2, 78, 24, 3, 2, 2, 2, 79, 80, 7, 94, 2, 2, 80, 81, 9, 10, 2, 2, 81, 26, 3, 2, 2, 2, 82, 83, 9, 11, 2, 2, 83, 28, 3, 2, 2, 2, 84, 87, 5, 23, 12, 2, 85, 87, 5, 27, 14, 2, 86, 84, 3, 2, 2, 2, 86, 85, 3, 2, 2, 2, 87, 30, 3, 2, 2, 2, 88, 93, 5, 23, 12, 2, 89, 93, 5, 27, 14, 2, 90, 93, 9, 12, 2, 2, 91, 93, 5, 25, 13, 2, 92, 88, 3, 2, 2, 2, 92, 89, 3, 2, 2, 2, 92, 90, 3, 2, 2, 2, 92, 91, 3, 2, 2, 2, 93, 32, 3, 2, 2, 2, 94, 98, 9, 13, 2, 2, 95, 97, 9, 14, 2, 2, 96, 95, 3, 2, 2, 2, 97, 100, 3, 2, 2, 2, 98, 96, 3, 2, 2, 2, 98, 99, 3, 2, 2, 2, 99, 34, 3, 2, 2, 2, 100, 98, 3, 2, 2, 2, 101, 103, 9, 15, 2, 2, 102, 101, 3, 2, 2, 2, 103, 104, 3, 2, 2, 2, 104, 102, 3, 2, 2, 2, 104, 105, 3, 2, 2, 2, 105, 112, 3, 2, 2, 2, 106, 108, 7, 48, 2, 2, 107, 109, 9, 15, 2, 2, 108, 107, 3, 2, 2, 2, 109, 110, 3, 2, 2, 2, 110, 108, 3, 2, 2, 2, 110, 111, 3, 2, 2, 2, 111, 113, 3, 2, 2, 2, 112, 106, 3, 2, 2, 2, 112, 113, 3, 2, 2, 2, 113, 36, 3, 2, 2, 2, 114, 118, 5, 29, 15, 2, 115, 117, 5, 31, 16, 2, 116, 115, 3, 2, 2, 2, 117, 120, 3, 2, 2, 2, 118, 116, 3, 2, 2, 2, 118, 119, 3, 2, 2, 2, 119, 38, 3, 2, 2, 2, 120, 118, 3, 2, 2, 2, 121, 123, 9, 16, 2, 2, 122, 121, 3, 2, 2, 2, 123, 124, 3, 2, 2, 2, 124, 122, 3, 2, 2, 2, 124, 125, 3, 2, 2, 2, 125, 126, 3, 2, 2, 2, 126, 127, 8, 20, 2, 2, 127, 40, 3, 2, 2, 2, 128, 129, 11, 2, 2, 2, 129, 42, 3, 2, 2, 2, 13, 2, 70, 72, 86, 92, 98, 104, 110, 112, 118, 124, 3, 8, 2, 2]

View File

@ -0,0 +1,21 @@
OR=1
AND=2
NOT=3
PLUS_SIGN=4
MINUS_SIGN=5
COLON=6
CARET=7
LP=8
RP=9
DQUOTA_STRING=10
REGULAR_ID=11
NUMBER=12
TERM=13
SPACES=14
DEFAULT=15
'+'=4
'-'=5
':'=6
'^'=7
'('=8
')'=9

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,303 @@
// Generated from FtsParser.g4 by ANTLR 4.8
#pragma once
#include "antlr4-runtime.h"
namespace antlr4 {
class FtsParser : public antlr4::Parser {
public:
enum {
OR = 1,
AND = 2,
NOT = 3,
PLUS_SIGN = 4,
MINUS_SIGN = 5,
COLON = 6,
CARET = 7,
LP = 8,
RP = 9,
DQUOTA_STRING = 10,
REGULAR_ID = 11,
NUMBER = 12,
TERM = 13,
SPACES = 14,
DEFAULT = 15
};
enum {
RuleFts_query_unit = 0,
RuleFts_or_expr = 1,
RuleFts_and_expr = 2,
RuleFts_seq_expr = 3,
RuleFts_unary = 4,
RuleFts_atom = 5,
RuleFts_field_prefix = 6,
RuleFts_primary = 7,
RuleFts_boost = 8,
RuleFts_natural_term = 9,
RuleFts_term = 10,
RuleFts_phrase = 11
};
FtsParser(antlr4::TokenStream *input);
~FtsParser();
virtual std::string getGrammarFileName() const override;
virtual const antlr4::atn::ATN &getATN() const override {
return _atn;
};
virtual const std::vector<std::string> &getTokenNames() const override {
return _tokenNames;
}; // deprecated: use vocabulary instead.
virtual const std::vector<std::string> &getRuleNames() const override;
virtual antlr4::dfa::Vocabulary &getVocabulary() const override;
class Fts_query_unitContext;
class Fts_or_exprContext;
class Fts_and_exprContext;
class Fts_seq_exprContext;
class Fts_unaryContext;
class Fts_atomContext;
class Fts_field_prefixContext;
class Fts_primaryContext;
class Fts_boostContext;
class Fts_natural_termContext;
class Fts_termContext;
class Fts_phraseContext;
class Fts_query_unitContext : public antlr4::ParserRuleContext {
public:
Fts_query_unitContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
Fts_or_exprContext *fts_or_expr();
antlr4::tree::TerminalNode *EOF();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_query_unitContext *fts_query_unit();
class Fts_or_exprContext : public antlr4::ParserRuleContext {
public:
Fts_or_exprContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
std::vector<Fts_and_exprContext *> fts_and_expr();
Fts_and_exprContext *fts_and_expr(size_t i);
std::vector<antlr4::tree::TerminalNode *> OR();
antlr4::tree::TerminalNode *OR(size_t i);
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_or_exprContext *fts_or_expr();
class Fts_and_exprContext : public antlr4::ParserRuleContext {
public:
Fts_and_exprContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
std::vector<Fts_seq_exprContext *> fts_seq_expr();
Fts_seq_exprContext *fts_seq_expr(size_t i);
std::vector<antlr4::tree::TerminalNode *> AND();
antlr4::tree::TerminalNode *AND(size_t i);
std::vector<antlr4::tree::TerminalNode *> NOT();
antlr4::tree::TerminalNode *NOT(size_t i);
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_and_exprContext *fts_and_expr();
class Fts_seq_exprContext : public antlr4::ParserRuleContext {
public:
Fts_seq_exprContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
std::vector<Fts_unaryContext *> fts_unary();
Fts_unaryContext *fts_unary(size_t i);
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_seq_exprContext *fts_seq_expr();
class Fts_unaryContext : public antlr4::ParserRuleContext {
public:
Fts_unaryContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
Fts_unaryContext() = default;
void copyFrom(Fts_unaryContext *context);
using antlr4::ParserRuleContext::copyFrom;
virtual size_t getRuleIndex() const override;
};
class Must_not_atomContext : public Fts_unaryContext {
public:
Must_not_atomContext(Fts_unaryContext *ctx);
antlr4::tree::TerminalNode *MINUS_SIGN();
Fts_atomContext *fts_atom();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
class Must_atomContext : public Fts_unaryContext {
public:
Must_atomContext(Fts_unaryContext *ctx);
antlr4::tree::TerminalNode *PLUS_SIGN();
Fts_atomContext *fts_atom();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
class Plain_atomContext : public Fts_unaryContext {
public:
Plain_atomContext(Fts_unaryContext *ctx);
Fts_atomContext *fts_atom();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_unaryContext *fts_unary();
class Fts_atomContext : public antlr4::ParserRuleContext {
public:
Fts_atomContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
Fts_primaryContext *fts_primary();
Fts_field_prefixContext *fts_field_prefix();
Fts_boostContext *fts_boost();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_atomContext *fts_atom();
class Fts_field_prefixContext : public antlr4::ParserRuleContext {
public:
Fts_field_prefixContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
antlr4::tree::TerminalNode *REGULAR_ID();
antlr4::tree::TerminalNode *COLON();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_field_prefixContext *fts_field_prefix();
class Fts_primaryContext : public antlr4::ParserRuleContext {
public:
Fts_primaryContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
Fts_termContext *fts_term();
Fts_phraseContext *fts_phrase();
antlr4::tree::TerminalNode *LP();
Fts_or_exprContext *fts_or_expr();
antlr4::tree::TerminalNode *RP();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_primaryContext *fts_primary();
class Fts_boostContext : public antlr4::ParserRuleContext {
public:
Fts_boostContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
antlr4::tree::TerminalNode *CARET();
antlr4::tree::TerminalNode *NUMBER();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_boostContext *fts_boost();
class Fts_natural_termContext : public antlr4::ParserRuleContext {
public:
Fts_natural_termContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
std::vector<antlr4::tree::TerminalNode *> DEFAULT();
antlr4::tree::TerminalNode *DEFAULT(size_t i);
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_natural_termContext *fts_natural_term();
class Fts_termContext : public antlr4::ParserRuleContext {
public:
Fts_termContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
antlr4::tree::TerminalNode *TERM();
antlr4::tree::TerminalNode *REGULAR_ID();
antlr4::tree::TerminalNode *NUMBER();
Fts_natural_termContext *fts_natural_term();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_termContext *fts_term();
class Fts_phraseContext : public antlr4::ParserRuleContext {
public:
Fts_phraseContext(antlr4::ParserRuleContext *parent_ctx,
size_t invoking_state);
virtual size_t getRuleIndex() const override;
antlr4::tree::TerminalNode *DQUOTA_STRING();
virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;
};
Fts_phraseContext *fts_phrase();
private:
static std::vector<antlr4::dfa::DFA> _decisionToDFA;
static antlr4::atn::PredictionContextCache _sharedContextCache;
static std::vector<std::string> _ruleNames;
static std::vector<std::string> _tokenNames;
static std::vector<std::string> _literalNames;
static std::vector<std::string> _symbolicNames;
static antlr4::dfa::Vocabulary _vocabulary;
static antlr4::atn::ATN _atn;
static std::vector<uint16_t> _serializedATN;
struct Initializer {
Initializer();
};
static Initializer _init;
};
} // namespace antlr4

View File

@ -0,0 +1,53 @@
token literal names:
null
null
null
null
'+'
'-'
':'
'^'
'('
')'
null
null
null
null
null
null
token symbolic names:
null
OR
AND
NOT
PLUS_SIGN
MINUS_SIGN
COLON
CARET
LP
RP
DQUOTA_STRING
REGULAR_ID
NUMBER
TERM
SPACES
DEFAULT
rule names:
fts_query_unit
fts_or_expr
fts_and_expr
fts_seq_expr
fts_unary
fts_atom
fts_field_prefix
fts_primary
fts_boost
fts_natural_term
fts_term
fts_phrase
atn:
[3, 24715, 42794, 33075, 47597, 16764, 15335, 30598, 22884, 3, 17, 98, 4, 2, 9, 2, 4, 3, 9, 3, 4, 4, 9, 4, 4, 5, 9, 5, 4, 6, 9, 6, 4, 7, 9, 7, 4, 8, 9, 8, 4, 9, 9, 9, 4, 10, 9, 10, 4, 11, 9, 11, 4, 12, 9, 12, 4, 13, 9, 13, 3, 2, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 7, 3, 33, 10, 3, 12, 3, 14, 3, 36, 11, 3, 3, 4, 3, 4, 3, 4, 5, 4, 41, 10, 4, 3, 4, 5, 4, 44, 10, 4, 3, 4, 7, 4, 47, 10, 4, 12, 4, 14, 4, 50, 11, 4, 3, 5, 6, 5, 53, 10, 5, 13, 5, 14, 5, 54, 3, 6, 3, 6, 3, 6, 3, 6, 3, 6, 5, 6, 62, 10, 6, 3, 7, 5, 7, 65, 10, 7, 3, 7, 3, 7, 5, 7, 69, 10, 7, 3, 8, 3, 8, 3, 8, 3, 9, 3, 9, 3, 9, 3, 9, 3, 9, 3, 9, 5, 9, 80, 10, 9, 3, 10, 3, 10, 3, 10, 3, 11, 6, 11, 86, 10, 11, 13, 11, 14, 11, 87, 3, 12, 3, 12, 3, 12, 3, 12, 5, 12, 94, 10, 12, 3, 13, 3, 13, 3, 13, 2, 2, 14, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 2, 2, 2, 100, 2, 26, 3, 2, 2, 2, 4, 29, 3, 2, 2, 2, 6, 37, 3, 2, 2, 2, 8, 52, 3, 2, 2, 2, 10, 61, 3, 2, 2, 2, 12, 64, 3, 2, 2, 2, 14, 70, 3, 2, 2, 2, 16, 79, 3, 2, 2, 2, 18, 81, 3, 2, 2, 2, 20, 85, 3, 2, 2, 2, 22, 93, 3, 2, 2, 2, 24, 95, 3, 2, 2, 2, 26, 27, 5, 4, 3, 2, 27, 28, 7, 2, 2, 3, 28, 3, 3, 2, 2, 2, 29, 34, 5, 6, 4, 2, 30, 31, 7, 3, 2, 2, 31, 33, 5, 6, 4, 2, 32, 30, 3, 2, 2, 2, 33, 36, 3, 2, 2, 2, 34, 32, 3, 2, 2, 2, 34, 35, 3, 2, 2, 2, 35, 5, 3, 2, 2, 2, 36, 34, 3, 2, 2, 2, 37, 48, 5, 8, 5, 2, 38, 40, 7, 4, 2, 2, 39, 41, 7, 5, 2, 2, 40, 39, 3, 2, 2, 2, 40, 41, 3, 2, 2, 2, 41, 44, 3, 2, 2, 2, 42, 44, 7, 5, 2, 2, 43, 38, 3, 2, 2, 2, 43, 42, 3, 2, 2, 2, 44, 45, 3, 2, 2, 2, 45, 47, 5, 8, 5, 2, 46, 43, 3, 2, 2, 2, 47, 50, 3, 2, 2, 2, 48, 46, 3, 2, 2, 2, 48, 49, 3, 2, 2, 2, 49, 7, 3, 2, 2, 2, 50, 48, 3, 2, 2, 2, 51, 53, 5, 10, 6, 2, 52, 51, 3, 2, 2, 2, 53, 54, 3, 2, 2, 2, 54, 52, 3, 2, 2, 2, 54, 55, 3, 2, 2, 2, 55, 9, 3, 2, 2, 2, 56, 57, 7, 6, 2, 2, 57, 62, 5, 12, 7, 2, 58, 59, 7, 7, 2, 2, 59, 62, 5, 12, 7, 2, 60, 62, 5, 12, 7, 2, 61, 56, 3, 2, 2, 2, 61, 58, 3, 2, 2, 2, 61, 60, 3, 2, 2, 2, 62, 11, 3, 2, 2, 2, 63, 65, 5, 14, 8, 2, 64, 63, 3, 2, 2, 2, 64, 65, 3, 2, 2, 2, 65, 66, 3, 2, 2, 2, 66, 68, 5, 16, 9, 2, 67, 69, 5, 18, 10, 2, 68, 67, 3, 2, 2, 2, 68, 69, 3, 2, 2, 2, 69, 13, 3, 2, 2, 2, 70, 71, 7, 13, 2, 2, 71, 72, 7, 8, 2, 2, 72, 15, 3, 2, 2, 2, 73, 80, 5, 22, 12, 2, 74, 80, 5, 24, 13, 2, 75, 76, 7, 10, 2, 2, 76, 77, 5, 4, 3, 2, 77, 78, 7, 11, 2, 2, 78, 80, 3, 2, 2, 2, 79, 73, 3, 2, 2, 2, 79, 74, 3, 2, 2, 2, 79, 75, 3, 2, 2, 2, 80, 17, 3, 2, 2, 2, 81, 82, 7, 9, 2, 2, 82, 83, 7, 14, 2, 2, 83, 19, 3, 2, 2, 2, 84, 86, 7, 17, 2, 2, 85, 84, 3, 2, 2, 2, 86, 87, 3, 2, 2, 2, 87, 85, 3, 2, 2, 2, 87, 88, 3, 2, 2, 2, 88, 21, 3, 2, 2, 2, 89, 94, 7, 15, 2, 2, 90, 94, 7, 13, 2, 2, 91, 94, 7, 14, 2, 2, 92, 94, 5, 20, 11, 2, 93, 89, 3, 2, 2, 2, 93, 90, 3, 2, 2, 2, 93, 91, 3, 2, 2, 2, 93, 92, 3, 2, 2, 2, 94, 23, 3, 2, 2, 2, 95, 96, 7, 12, 2, 2, 96, 25, 3, 2, 2, 2, 13, 34, 40, 43, 48, 54, 61, 64, 68, 79, 87, 93]

View File

@ -0,0 +1,21 @@
OR=1
AND=2
NOT=3
PLUS_SIGN=4
MINUS_SIGN=5
COLON=6
CARET=7
LP=8
RP=9
DQUOTA_STRING=10
REGULAR_ID=11
NUMBER=12
TERM=13
SPACES=14
DEFAULT=15
'+'=4
'-'=5
':'=6
'^'=7
'('=8
')'=9

View File

@ -0,0 +1,8 @@
// Generated from FtsParser.g4 by ANTLR 4.8
#include "FtsParserBaseListener.h"
using namespace antlr4;

View File

@ -0,0 +1,89 @@
// Generated from FtsParser.g4 by ANTLR 4.8
#pragma once
#include "FtsParserListener.h"
#include "antlr4-runtime.h"
namespace antlr4 {
/**
* This class provides an empty implementation of FtsParserListener,
* which can be extended to create a listener which only needs to handle a
* subset of the available methods.
*/
class FtsParserBaseListener : public FtsParserListener {
public:
virtual void enterFts_query_unit(
FtsParser::Fts_query_unitContext * /*ctx*/) override {}
virtual void exitFts_query_unit(
FtsParser::Fts_query_unitContext * /*ctx*/) override {}
virtual void enterFts_or_expr(
FtsParser::Fts_or_exprContext * /*ctx*/) override {}
virtual void exitFts_or_expr(
FtsParser::Fts_or_exprContext * /*ctx*/) override {}
virtual void enterFts_and_expr(
FtsParser::Fts_and_exprContext * /*ctx*/) override {}
virtual void exitFts_and_expr(
FtsParser::Fts_and_exprContext * /*ctx*/) override {}
virtual void enterFts_seq_expr(
FtsParser::Fts_seq_exprContext * /*ctx*/) override {}
virtual void exitFts_seq_expr(
FtsParser::Fts_seq_exprContext * /*ctx*/) override {}
virtual void enterMust_atom(FtsParser::Must_atomContext * /*ctx*/) override {}
virtual void exitMust_atom(FtsParser::Must_atomContext * /*ctx*/) override {}
virtual void enterMust_not_atom(
FtsParser::Must_not_atomContext * /*ctx*/) override {}
virtual void exitMust_not_atom(
FtsParser::Must_not_atomContext * /*ctx*/) override {}
virtual void enterPlain_atom(
FtsParser::Plain_atomContext * /*ctx*/) override {}
virtual void exitPlain_atom(FtsParser::Plain_atomContext * /*ctx*/) override {
}
virtual void enterFts_atom(FtsParser::Fts_atomContext * /*ctx*/) override {}
virtual void exitFts_atom(FtsParser::Fts_atomContext * /*ctx*/) override {}
virtual void enterFts_field_prefix(
FtsParser::Fts_field_prefixContext * /*ctx*/) override {}
virtual void exitFts_field_prefix(
FtsParser::Fts_field_prefixContext * /*ctx*/) override {}
virtual void enterFts_primary(
FtsParser::Fts_primaryContext * /*ctx*/) override {}
virtual void exitFts_primary(
FtsParser::Fts_primaryContext * /*ctx*/) override {}
virtual void enterFts_boost(FtsParser::Fts_boostContext * /*ctx*/) override {}
virtual void exitFts_boost(FtsParser::Fts_boostContext * /*ctx*/) override {}
virtual void enterFts_natural_term(
FtsParser::Fts_natural_termContext * /*ctx*/) override {}
virtual void exitFts_natural_term(
FtsParser::Fts_natural_termContext * /*ctx*/) override {}
virtual void enterFts_term(FtsParser::Fts_termContext * /*ctx*/) override {}
virtual void exitFts_term(FtsParser::Fts_termContext * /*ctx*/) override {}
virtual void enterFts_phrase(
FtsParser::Fts_phraseContext * /*ctx*/) override {}
virtual void exitFts_phrase(FtsParser::Fts_phraseContext * /*ctx*/) override {
}
virtual void enterEveryRule(antlr4::ParserRuleContext * /*ctx*/) override {}
virtual void exitEveryRule(antlr4::ParserRuleContext * /*ctx*/) override {}
virtual void visitTerminal(antlr4::tree::TerminalNode * /*node*/) override {}
virtual void visitErrorNode(antlr4::tree::ErrorNode * /*node*/) override {}
};
} // namespace antlr4

View File

@ -0,0 +1,8 @@
// Generated from FtsParser.g4 by ANTLR 4.8
#include "FtsParserListener.h"
using namespace antlr4;

View File

@ -0,0 +1,66 @@
// Generated from FtsParser.g4 by ANTLR 4.8
#pragma once
#include "FtsParser.h"
#include "antlr4-runtime.h"
namespace antlr4 {
/**
* This interface defines an abstract listener for a parse tree produced by
* FtsParser.
*/
class FtsParserListener : public antlr4::tree::ParseTreeListener {
public:
virtual void enterFts_query_unit(FtsParser::Fts_query_unitContext *ctx) = 0;
virtual void exitFts_query_unit(FtsParser::Fts_query_unitContext *ctx) = 0;
virtual void enterFts_or_expr(FtsParser::Fts_or_exprContext *ctx) = 0;
virtual void exitFts_or_expr(FtsParser::Fts_or_exprContext *ctx) = 0;
virtual void enterFts_and_expr(FtsParser::Fts_and_exprContext *ctx) = 0;
virtual void exitFts_and_expr(FtsParser::Fts_and_exprContext *ctx) = 0;
virtual void enterFts_seq_expr(FtsParser::Fts_seq_exprContext *ctx) = 0;
virtual void exitFts_seq_expr(FtsParser::Fts_seq_exprContext *ctx) = 0;
virtual void enterMust_atom(FtsParser::Must_atomContext *ctx) = 0;
virtual void exitMust_atom(FtsParser::Must_atomContext *ctx) = 0;
virtual void enterMust_not_atom(FtsParser::Must_not_atomContext *ctx) = 0;
virtual void exitMust_not_atom(FtsParser::Must_not_atomContext *ctx) = 0;
virtual void enterPlain_atom(FtsParser::Plain_atomContext *ctx) = 0;
virtual void exitPlain_atom(FtsParser::Plain_atomContext *ctx) = 0;
virtual void enterFts_atom(FtsParser::Fts_atomContext *ctx) = 0;
virtual void exitFts_atom(FtsParser::Fts_atomContext *ctx) = 0;
virtual void enterFts_field_prefix(
FtsParser::Fts_field_prefixContext *ctx) = 0;
virtual void exitFts_field_prefix(
FtsParser::Fts_field_prefixContext *ctx) = 0;
virtual void enterFts_primary(FtsParser::Fts_primaryContext *ctx) = 0;
virtual void exitFts_primary(FtsParser::Fts_primaryContext *ctx) = 0;
virtual void enterFts_boost(FtsParser::Fts_boostContext *ctx) = 0;
virtual void exitFts_boost(FtsParser::Fts_boostContext *ctx) = 0;
virtual void enterFts_natural_term(
FtsParser::Fts_natural_termContext *ctx) = 0;
virtual void exitFts_natural_term(
FtsParser::Fts_natural_termContext *ctx) = 0;
virtual void enterFts_term(FtsParser::Fts_termContext *ctx) = 0;
virtual void exitFts_term(FtsParser::Fts_termContext *ctx) = 0;
virtual void enterFts_phrase(FtsParser::Fts_phraseContext *ctx) = 0;
virtual void exitFts_phrase(FtsParser::Fts_phraseContext *ctx) = 0;
};
} // namespace antlr4

View File

@ -0,0 +1,9 @@
#!/bin/sh
#****************************************************************#
# ScriptName: gen_parser.sh
# Author: fancy.lf
# Function: command to generate antlr sql parser code in se directory
#***************************************************************#
java -jar ../../../../deps/thirdparty/antlr/antlr-4.8-complete.jar -Dlanguage=Cpp -package antlr4 FtsLexer.g4 FtsParser.g4 -o gen
sed -i 's/\bu8"/"/g' gen/*.cc

View File

@ -0,0 +1,53 @@
// 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_candidate_iterator.h"
#include <algorithm>
namespace zvec::fts {
CandidateDocIterator::CandidateDocIterator(
const std::vector<uint64_t> &sorted_local_ids) {
ids_.reserve(sorted_local_ids.size());
for (uint64_t id : sorted_local_ids) {
ids_.push_back(static_cast<uint32_t>(id));
}
cached_max_score_ = 0.0f;
}
uint32_t CandidateDocIterator::next_doc() {
if (pos_ >= ids_.size()) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
cached_doc_id_ = ids_[pos_++];
return cached_doc_id_;
}
uint32_t CandidateDocIterator::advance(uint32_t target) {
// Start from pos_: everything before it is already consumed.
auto begin = ids_.begin() + pos_;
auto it = std::lower_bound(begin, ids_.end(), target);
if (it == ids_.end()) {
pos_ = ids_.size();
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
pos_ = static_cast<size_t>(it - ids_.begin()) + 1;
cached_doc_id_ = *it;
return cached_doc_id_;
}
} // namespace zvec::fts

View File

@ -0,0 +1,55 @@
// 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 <cstdint>
#include <vector>
#include "fts_doc_iterator.h"
namespace zvec::fts {
/*! Candidate-driven document iterator.
*
* AND-ed with an FTS iterator tree under ConjunctionIterator: since cost()
* returns the (small) candidate count, this iterator becomes the lead and
* the FTS tree is only asked to advance() to each candidate reusing the
* existing BM25 / matches / filter-pushdown machinery.
*
* Input MUST be ascending segment-local doc_ids (the space TermDocIterator
* uses; no GLOBALLOCAL translation needed in zvec).
*/
class CandidateDocIterator : public DocIterator {
public:
explicit CandidateDocIterator(const std::vector<uint64_t> &sorted_local_ids);
uint32_t next_doc() override;
uint32_t advance(uint32_t target) override;
float score() override {
return 0.0f;
}
uint64_t cost() const override {
return ids_.size();
}
float max_score() const override {
return 0.0f;
}
private:
std::vector<uint32_t> ids_; // ascending segment-local doc_ids
size_t pos_{0}; // index of next element to return
};
} // namespace zvec::fts

View File

@ -0,0 +1,201 @@
// 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_conjunction_iterator.h"
#include <algorithm>
namespace zvec::fts {
ConjunctionIterator::ConjunctionIterator(
std::vector<DocIteratorPtr> must_iterators,
std::vector<DocIteratorPtr> must_not_iterators,
std::vector<DocIteratorPtr> should_iterators)
: must_iterators_(std::move(must_iterators)),
must_not_iterators_(std::move(must_not_iterators)),
should_iterators_(std::move(should_iterators)) {
// Sort must iterators by cost (ascending) so the cheapest leads
std::sort(must_iterators_.begin(), must_iterators_.end(),
[](const DocIteratorPtr &a, const DocIteratorPtr &b) {
return a->cost() < b->cost();
});
// Compute and cache max_score in base class field
float total = 0.0f;
for (auto &iter : must_iterators_) {
total += iter->cached_max_score_;
}
for (auto &iter : should_iterators_) {
total += iter->cached_max_score_;
}
cached_max_score_ = total;
}
uint32_t ConjunctionIterator::next_doc() {
if (must_iterators_.empty()) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// MaxScore pruning: If the maximum possible score of this AND node
// cannot beat the threshold, terminate iteration early.
if (min_competitive_score_ > 0.0f && max_score() < min_competitive_score_) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// Advance the lead iterator and try to find agreement
uint32_t candidate = must_iterators_[0]->next_doc();
cached_doc_id_ = do_next(candidate);
return cached_doc_id_;
}
uint32_t ConjunctionIterator::next_doc(const zvec::IndexFilter *filter) {
if (must_iterators_.empty()) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// MaxScore pruning
if (min_competitive_score_ > 0.0f && max_score() < min_competitive_score_) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// Lead iterator advances with filter-awareness so filtered docs never
// reach do_next() alignment.
uint32_t candidate = must_iterators_[0]->next_doc(filter);
while (candidate != NO_MORE_DOCS) {
candidate = do_next(candidate);
if (candidate == NO_MORE_DOCS || !filter->is_filtered(candidate)) {
break;
}
// do_next may have re-anchored the lead onto a filtered doc; advance
// the lead past it (still filter-aware) and try again.
candidate = must_iterators_[0]->next_doc(filter);
}
cached_doc_id_ = candidate;
return candidate;
}
uint32_t ConjunctionIterator::advance(uint32_t target) {
if (must_iterators_.empty()) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// MaxScore pruning
if (min_competitive_score_ > 0.0f && max_score() < min_competitive_score_) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
uint32_t candidate = must_iterators_[0]->advance(target);
cached_doc_id_ = do_next(candidate);
return cached_doc_id_;
}
uint32_t ConjunctionIterator::do_next(uint32_t candidate) {
if (candidate == NO_MORE_DOCS) {
return NO_MORE_DOCS;
}
while (true) {
// Try to advance all other must iterators to the candidate
bool all_match = true;
for (size_t i = 1; i < must_iterators_.size(); ++i) {
uint32_t other_doc = must_iterators_[i]->advance(candidate);
if (other_doc == NO_MORE_DOCS) {
return NO_MORE_DOCS;
}
if (other_doc != candidate) {
// Mismatch: use the higher doc_id as the new candidate
// and re-advance the lead iterator
candidate = must_iterators_[0]->advance(other_doc);
if (candidate == NO_MORE_DOCS) {
return NO_MORE_DOCS;
}
all_match = false;
break;
}
}
if (all_match) {
// All must iterators agree on this candidate
// Check must_not exclusion
if (!is_excluded(candidate)) {
return candidate;
}
// Excluded by must_not, advance lead to next doc
candidate = must_iterators_[0]->next_doc();
if (candidate == NO_MORE_DOCS) {
return NO_MORE_DOCS;
}
}
}
}
bool ConjunctionIterator::is_excluded(uint32_t candidate) {
for (auto &not_iter : must_not_iterators_) {
uint32_t not_doc = not_iter->advance(candidate);
if (not_doc == candidate) {
// This document is excluded by a must_not clause
return true;
}
}
return false;
}
bool ConjunctionIterator::matches() {
// Phase-2 verification: all must sub-iterators must pass matches()
for (auto &iter : must_iterators_) {
if (!iter->matches()) {
return false;
}
}
return true;
}
float ConjunctionIterator::score() {
float total = 0.0f;
for (auto &iter : must_iterators_) {
total += iter->score();
}
for (auto &iter : should_iterators_) {
uint32_t doc = iter->advance(cached_doc_id_);
if (doc == cached_doc_id_ && iter->matches()) {
total += iter->score();
}
}
return total;
}
uint64_t ConjunctionIterator::cost() const {
if (must_iterators_.empty()) {
return 0;
}
// Cost is determined by the shortest (lead) iterator
return must_iterators_[0]->cost();
}
float ConjunctionIterator::max_score() const {
float total = 0.0f;
for (auto &iter : must_iterators_) {
total += iter->max_score();
}
for (auto &iter : should_iterators_) {
total += iter->max_score();
}
return total;
}
} // namespace zvec::fts

View File

@ -0,0 +1,73 @@
// 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 <cstdint>
#include <memory>
#include <vector>
#include "fts_doc_iterator.h"
namespace zvec::fts {
/*! Conjunction (AND) document iterator
*
* Implements multi-way intersection of must sub-iterators with must_not
* exclusion filtering. The lead iterator (lowest cost) drives the iteration;
* other iterators are advanced to match the lead's current doc_id.
*/
class ConjunctionIterator : public DocIterator {
public:
/*! Construct a conjunction iterator.
* \param must_iterators Sub-iterators that must all match (AND)
* \param must_not_iterators Sub-iterators whose matches are excluded (NOT)
* \param should_iterators Sub-iterators that contribute to scoring but
* do not affect matching (optional boost)
*/
ConjunctionIterator(std::vector<DocIteratorPtr> must_iterators,
std::vector<DocIteratorPtr> must_not_iterators,
std::vector<DocIteratorPtr> should_iterators = {});
uint32_t next_doc() override;
//! Internal-driven filter skip: pushes filter into the lead iterator so
//! filtered candidates never trigger the do_next alignment cascade.
uint32_t next_doc(const zvec::IndexFilter *filter) override;
uint32_t advance(uint32_t target) override;
bool matches() override;
float score() override;
uint64_t cost() const override;
float max_score() const override;
void set_min_competitive_score(float min_score) override {
min_competitive_score_ = min_score;
}
private:
// Try to find the next doc_id where all must iterators agree,
// starting from the lead iterator's current position.
// Returns NO_MORE_DOCS if no such document exists.
uint32_t do_next(uint32_t candidate);
// Check if candidate doc_id is excluded by any must_not iterator
bool is_excluded(uint32_t candidate);
private:
// must_iterators_[0] is the lead (lowest cost)
std::vector<DocIteratorPtr> must_iterators_;
std::vector<DocIteratorPtr> must_not_iterators_;
std::vector<DocIteratorPtr> should_iterators_;
float min_competitive_score_{0.0f};
};
} // namespace zvec::fts

View File

@ -0,0 +1,258 @@
// 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_disjunction_iterator.h"
#include <algorithm>
namespace zvec::fts {
namespace {
// Move element at `idx` forward (toward higher indices) to restore sorted
// order. Only the element at `idx` may be out of place; all other elements
// must already be sorted.
inline void sift_forward(std::vector<DocIterator *> &vec, size_t idx) {
DocIterator *elem = vec[idx];
uint32_t elem_doc = elem->cached_doc_id_;
size_t pos = idx;
size_t end = vec.size();
while (pos + 1 < end && vec[pos + 1]->cached_doc_id_ < elem_doc) {
vec[pos] = vec[pos + 1];
++pos;
}
vec[pos] = elem;
}
} // namespace
DisjunctionIterator::DisjunctionIterator(
std::vector<DocIteratorPtr> sub_iterators)
: sub_iterators_(std::move(sub_iterators)) {
// Initialize each sub-iterator to its first doc and prepare postings array
total_cost_ = 0;
total_max_score_ = 0.0f;
for (auto &iter : sub_iterators_) {
total_cost_ += iter->cost();
total_max_score_ += iter->cached_max_score_;
iter->next_doc();
postings_.push_back(iter.get());
}
// Initial sort to establish sorted order
resort_postings();
cached_max_score_ = total_max_score_;
}
void DisjunctionIterator::set_min_competitive_score(float min_score) {
min_competitive_score_ = min_score;
}
// Re-establish sorted order of postings_ by cached_doc_id_ ascending.
// Called when multiple iterators may have changed position.
void DisjunctionIterator::resort_postings() {
std::sort(postings_.begin(), postings_.end(),
[](const DocIterator *a, const DocIterator *b) {
return a->cached_doc_id_ < b->cached_doc_id_;
});
}
uint32_t DisjunctionIterator::next_doc() {
return next_doc_impl(nullptr);
}
uint32_t DisjunctionIterator::next_doc(const zvec::IndexFilter *filter) {
return next_doc_impl(filter);
}
uint32_t DisjunctionIterator::next_doc_impl(const zvec::IndexFilter *filter) {
// Advance matched from the previous document
for (auto *iter : matching_iterators_) {
iter->next_doc();
}
matching_iterators_.clear();
// Restore sorted order — multiple iterators may have changed
resort_postings();
while (true) {
// 1. postings_ is maintained in sorted order
if (postings_.empty() || postings_[0]->cached_doc_id_ == NO_MORE_DOCS) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// 2. Find Pivot: accumulate max_score until it reaches the threshold
float partial_max_score = 0.0f;
size_t pivot_idx = 0;
bool found_pivot = false;
for (; pivot_idx < postings_.size(); ++pivot_idx) {
if (postings_[pivot_idx]->cached_doc_id_ == NO_MORE_DOCS) {
break;
}
partial_max_score += postings_[pivot_idx]->cached_max_score_;
if (partial_max_score >= min_competitive_score_) {
found_pivot = true;
break;
}
}
if (!found_pivot) {
// If all remaining iterators' max_score sum is less than threshold,
// no more competitive documents can be produced.
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
uint32_t pivot_doc = postings_[pivot_idx]->cached_doc_id_;
// 3. Check alignment
if (postings_[0]->cached_doc_id_ == pivot_doc) {
// 3.1 Filter pushdown: if pivot_doc is filtered, skip it before paying
// for block-max accumulation, matches(), or score(). Advance every
// posting currently sitting at pivot_doc past it, then resort.
if (filter && filter->is_filtered(pivot_doc)) {
for (size_t i = 0; i < postings_.size(); ++i) {
if (postings_[i]->cached_doc_id_ == pivot_doc) {
postings_[i]->next_doc();
} else {
break; // postings_ is sorted; rest are > pivot_doc
}
}
resort_postings();
continue;
}
// 3.5 Block-Max WAND pruning (Ding & Suel 2011).
// First accumulate block_max_scores from [0..pivot_idx].
// If already >= threshold, skip the pruning check (fast path).
// Otherwise, lazily include iterators beyond pivot_idx whose
// posting lists may also contain pivot_doc — their block_max_score
// contributions must be counted to avoid underestimating the
// potential score and incorrectly skipping TopK documents.
if (min_competitive_score_ > 0.0f) {
float block_score_sum = 0.0f;
uint32_t min_block_end = NO_MORE_DOCS;
bool can_skip = true;
// Phase 1: accumulate [0..pivot_idx] (always needed)
for (size_t i = 0; i <= pivot_idx; ++i) {
auto info = postings_[i]->block_max_info_for(pivot_doc);
block_score_sum += info.block_max_score;
if (info.block_last_doc < min_block_end) {
min_block_end = info.block_last_doc;
}
}
// Phase 2: if [0..pivot_idx] sum is already sufficient, no pruning
if (block_score_sum >= min_competitive_score_) {
can_skip = false;
} else {
// Lazily accumulate remaining iterators beyond pivot_idx.
// They may also contribute scores for pivot_doc.
for (size_t i = pivot_idx + 1; i < postings_.size(); ++i) {
if (postings_[i]->cached_doc_id_ == NO_MORE_DOCS) {
break;
}
auto info = postings_[i]->block_max_info_for(pivot_doc);
block_score_sum += info.block_max_score;
if (info.block_last_doc < min_block_end) {
min_block_end = info.block_last_doc;
}
if (block_score_sum >= min_competitive_score_) {
can_skip = false;
break;
}
}
}
if (can_skip && block_score_sum < min_competitive_score_ &&
min_block_end != NO_MORE_DOCS) {
// All iterators' blocks containing pivot_doc cannot produce a
// competitive score. Advance ALL iterators in [0..pivot_idx] past
// the smallest block boundary to maximize the jump distance.
uint32_t skip_target = min_block_end + 1;
for (size_t i = 0; i <= pivot_idx; ++i) {
if (postings_[i]->cached_doc_id_ < skip_target) {
postings_[i]->advance(skip_target);
}
}
// Multiple iterators changed — full resort
resort_postings();
continue;
}
}
// Candidate doc passed block-level check. Collect all matching iterators.
for (size_t i = 0; i < postings_.size(); ++i) {
if (postings_[i]->cached_doc_id_ == pivot_doc) {
matching_iterators_.push_back(postings_[i]);
} else {
break; // because postings_ is sorted by cached_doc_id_
}
}
cached_doc_id_ = pivot_doc;
return pivot_doc;
} else {
// 4. Iterator Jumping: advance the iterator with the smallest doc_id
// to at least the pivot's doc_id. This bypasses scoring and checking
// for all documents smaller than pivot_doc!
// Only postings_[0] changed — use sift_forward instead of full sort.
postings_[0]->advance(pivot_doc);
sift_forward(postings_, 0);
}
}
}
uint32_t DisjunctionIterator::advance(uint32_t target) {
// Clear pending matches as they will be re-advanced below
matching_iterators_.clear();
for (auto *iter : postings_) {
if (iter->cached_doc_id_ < target) {
iter->advance(target);
}
}
return next_doc();
}
bool DisjunctionIterator::matches() {
// At least one matching sub-iterator must pass phase-2 verification
for (DocIterator *iter : matching_iterators_) {
if (iter->matches()) {
return true;
}
}
return false;
}
float DisjunctionIterator::score() {
// Sum scores of all matching sub-iterators that pass phase-2 verification
float total = 0.0f;
for (DocIterator *iter : matching_iterators_) {
if (iter->matches()) {
total += iter->score();
}
}
return total;
}
uint64_t DisjunctionIterator::cost() const {
return total_cost_;
}
float DisjunctionIterator::max_score() const {
return total_max_score_;
}
} // namespace zvec::fts

View File

@ -0,0 +1,63 @@
// 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 <cstdint>
#include <memory>
#include <vector>
#include "fts_doc_iterator.h"
namespace zvec::fts {
/*! Disjunction (OR) document iterator with WAND pruning
*/
class DisjunctionIterator : public DocIterator {
public:
/*! Construct a disjunction iterator.
* \param sub_iterators Sub-iterators to merge (OR semantics)
*/
explicit DisjunctionIterator(std::vector<DocIteratorPtr> sub_iterators);
uint32_t next_doc() override;
//! Internal-driven filter skip: checks filter inside the WAND loop after
//! pivot alignment, before block-max accumulation and resort overhead.
uint32_t next_doc(const zvec::IndexFilter *filter) override;
uint32_t advance(uint32_t target) override;
bool matches() override;
float score() override;
uint64_t cost() const override;
float max_score() const override;
//! Update the minimum competitive score threshold for WAND pruning.
//! Documents whose total max_score sum falls below this threshold
//! are skipped without exact scoring.
void set_min_competitive_score(float min_score) override;
private:
void resort_postings();
//! Unified WAND loop body. \p filter may be null (no-filter fast path).
uint32_t next_doc_impl(const zvec::IndexFilter *filter);
private:
std::vector<DocIteratorPtr> sub_iterators_; // Owns the sub-iterators
std::vector<DocIterator *> postings_; // Pointers for fast sorting (WAND)
std::vector<DocIterator *> matching_iterators_; // Current doc matches
float min_competitive_score_{0.0f};
uint64_t total_cost_{0};
float total_max_score_{0.0f};
};
} // namespace zvec::fts

View File

@ -0,0 +1,123 @@
// 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 <cstdint>
#include <limits>
#include <memory>
#include "db/index/common/index_filter.h"
namespace zvec::fts {
/*! Abstract base class for FTS document iterators.
*
* All query nodes (Term, Phrase, AND, OR) implement this interface to form
* a composable iterator tree. The iterator produces matching documents in
* ascending doc_id order.
*
* Two-phase iteration:
* Phase 1: next_doc() / advance() locate candidate documents using only
* doc_id information (cheap).
* Phase 2: matches() performs exact verification (e.g. position check for
* phrase queries). Only called after Phase 1 succeeds.
*/
class DocIterator {
public:
virtual ~DocIterator() = default;
//! Sentinel value indicating no more matching documents
static constexpr uint32_t NO_MORE_DOCS = UINT32_MAX;
//! Cached doc_id for hot-path access without virtual dispatch.
//! Sub-classes MUST update this in next_doc() / advance() before returning.
uint32_t cached_doc_id_{NO_MORE_DOCS};
//! Cached max_score for hot-path access without virtual dispatch.
//! Sub-classes MUST set this in constructors (and update if max_score
//! changes, which is rare for most iterators).
float cached_max_score_{0.0f};
//! Advance to the next matching document.
//! \return doc_id of the next match, or NO_MORE_DOCS if exhausted.
virtual uint32_t next_doc() = 0;
//! Filter-aware next_doc. Composite iterators (Disjunction/Conjunction/
//! Phrase) override to check the filter at the optimal point inside their
//! loops — before block-max binary search, do_next alignment, or phase-2
//! position verification — so filtered docs do not pay that cost.
//! Default implementation just loops over next_doc() and skips filtered
//! docs (functionally equivalent to a caller-side post-filter check).
//! \param filter Must be non-null; true means SKIP the doc.
virtual uint32_t next_doc(const zvec::IndexFilter *filter) {
uint32_t doc = next_doc();
while (doc != NO_MORE_DOCS && filter->is_filtered(doc)) {
doc = next_doc();
}
return doc;
}
//! Advance to the first matching document with doc_id >= target.
//! \param target Minimum doc_id to seek to.
//! \return doc_id of the match (>= target), or NO_MORE_DOCS if exhausted.
virtual uint32_t advance(uint32_t target) = 0;
//! Return the current document ID.
//! Undefined before the first call to next_doc() or advance().
uint32_t doc_id() const {
return cached_doc_id_;
}
//! Phase-2 exact verification for the current document.
//! For most iterators this is a no-op (returns true).
//! PhraseDocIterator overrides this to check position adjacency.
//! \return true if the current document truly matches.
virtual bool matches() {
return true;
}
//! Compute the BM25 score of the current document.
//! Must only be called after matches() returns true.
virtual float score() = 0;
//! Estimated cost of this iterator (e.g. posting list length).
//! Used to order sub-iterators in ConjunctionIterator (shortest first).
virtual uint64_t cost() const = 0;
//! Upper bound on the score this iterator can produce for any document.
//! Used by WAND pruning in DisjunctionIterator.
virtual float max_score() const {
return std::numeric_limits<float>::max();
}
//! Update the minimum competitive score threshold for WAND pruning.
//! Only DisjunctionIterator implements meaningful behavior; other iterators
//! ignore this call.
//! \param min_score Current minimum score needed to enter the TopK heap.
virtual void set_min_competitive_score(float /*min_score*/) {}
//! Block-Max WAND support: return both block_max_score and max_doc_id
//! for the block containing \p target in a single skip list binary search.
struct BlockMaxInfo {
float block_max_score{0.0f};
uint32_t block_last_doc{NO_MORE_DOCS};
};
virtual BlockMaxInfo block_max_info_for(uint32_t /*target*/) const {
return {max_score(), NO_MORE_DOCS};
}
};
using DocIteratorPtr = std::unique_ptr<DocIterator>;
} // namespace zvec::fts

View File

@ -0,0 +1,210 @@
// 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_phrase_iterator.h"
#include <algorithm>
#include <cstring>
#include <unordered_map>
#include "../fts_utils.h"
namespace zvec::fts {
PhraseDocIterator::PhraseDocIterator(DocIteratorPtr conjunction,
std::vector<std::string> terms,
RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *positions_cf)
: conjunction_(std::move(conjunction)),
terms_(std::move(terms)),
ctx_(ctx),
positions_cf_(positions_cf) {
cached_max_score_ = conjunction_->cached_max_score_;
}
uint32_t PhraseDocIterator::next_doc() {
cached_doc_id_ = conjunction_->next_doc();
return cached_doc_id_;
}
uint32_t PhraseDocIterator::next_doc(const zvec::IndexFilter *filter) {
cached_doc_id_ = conjunction_->next_doc(filter);
return cached_doc_id_;
}
uint32_t PhraseDocIterator::advance(uint32_t target) {
cached_doc_id_ = conjunction_->advance(target);
return cached_doc_id_;
}
bool PhraseDocIterator::matches() {
if (cached_doc_id_ == NO_MORE_DOCS) {
return false;
}
if (cached_doc_id_ == cached_matches_doc_id_) {
return cached_matches_result_;
}
// Phase 2: verify position adjacency (deferred IO)
cached_matches_doc_id_ = cached_doc_id_;
cached_matches_result_ = verify_phrase_positions(cached_doc_id_);
return cached_matches_result_;
}
float PhraseDocIterator::score() {
return conjunction_->score();
}
uint64_t PhraseDocIterator::cost() const {
return conjunction_->cost();
}
float PhraseDocIterator::max_score() const {
return conjunction_->max_score();
}
bool PhraseDocIterator::verify_phrase_positions(uint32_t doc_id) const {
const size_t n = terms_.size();
if (n == 0) {
return false;
}
// Deduplicate terms within the phrase. Repeated terms (e.g., "to be or not
// to be") collapse into one $POS lookup; term_to_unique_idx maps each phrase
// position back to its slot in the unique list.
std::vector<size_t> term_to_unique_idx(n);
std::vector<size_t> unique_to_first_term_idx;
unique_to_first_term_idx.reserve(n);
std::unordered_map<std::string, size_t> seen;
seen.reserve(n);
for (size_t i = 0; i < n; ++i) {
const size_t next_idx = unique_to_first_term_idx.size();
auto [it, inserted] = seen.try_emplace(terms_[i], next_idx);
if (inserted) {
unique_to_first_term_idx.push_back(i);
}
term_to_unique_idx[i] = it->second;
}
const size_t unique_size = unique_to_first_term_idx.size();
// Build unique (term, doc_id) keys into a single reusable buffer; reserve
// up-front so the buffer never reallocates and the Slice pointers below stay
// valid until the MultiGet returns.
size_t total_key_bytes = 0;
for (size_t u = 0; u < unique_size; ++u) {
total_key_bytes +=
terms_[unique_to_first_term_idx[u]].size() + 1 + sizeof(uint32_t);
}
std::string key_buffer;
key_buffer.reserve(total_key_bytes);
std::vector<rocksdb::Slice> key_slices;
key_slices.reserve(unique_size);
for (size_t u = 0; u < unique_size; ++u) {
const std::string &term = terms_[unique_to_first_term_idx[u]];
const size_t offset = key_buffer.size();
const size_t bytes = fts::append_doc_term_key(term, doc_id, &key_buffer);
key_slices.emplace_back(key_buffer.data() + offset, bytes);
}
// Batched read across unique (term, doc_id) keys — single MultiGet instead
// of per-anchor-position Gets.
std::vector<rocksdb::ColumnFamilyHandle *> cfs(unique_size, positions_cf_);
std::vector<rocksdb::PinnableSlice> values(unique_size);
std::vector<rocksdb::Status> statuses(unique_size);
ctx_->db_->MultiGet(ctx_->read_opts_, unique_size, cfs.data(),
key_slices.data(), values.data(), statuses.data());
// Decode every position list once. A missing entry means this doc cannot
// be a phrase match — this happens for docs filtered through the conjunction
// without a position-CF entry, so we do NOT log here.
std::vector<std::vector<uint32_t>> positions_cache(unique_size);
for (size_t u = 0; u < unique_size; ++u) {
if (!statuses[u].ok() || values[u].size() == 0) {
return false;
}
positions_cache[u] = decode_positions(values[u]);
if (positions_cache[u].empty()) {
return false;
}
}
// Pick the term with the shortest position list as anchor so the outer
// loop iterates as few candidates as possible. anchor_term_idx stays in
// original phrase order — the phrase start equals anchor_pos -
// anchor_term_idx.
size_t anchor_term_idx = 0;
size_t min_size = positions_cache[term_to_unique_idx[0]].size();
for (size_t i = 1; i < n; ++i) {
const size_t sz = positions_cache[term_to_unique_idx[i]].size();
if (sz < min_size) {
min_size = sz;
anchor_term_idx = i;
}
}
const auto &anchor_positions =
positions_cache[term_to_unique_idx[anchor_term_idx]];
const uint32_t anchor_offset = static_cast<uint32_t>(anchor_term_idx);
for (uint32_t anchor_pos : anchor_positions) {
if (anchor_pos < anchor_offset) {
// phrase start would be negative — impossible
continue;
}
const uint32_t start = anchor_pos - anchor_offset;
bool phrase_matched = true;
for (size_t i = 0; i < n; ++i) {
if (i == anchor_term_idx) {
continue;
}
const uint32_t expected = start + static_cast<uint32_t>(i);
const auto &positions = positions_cache[term_to_unique_idx[i]];
if (!std::binary_search(positions.begin(), positions.end(), expected)) {
phrase_matched = false;
break;
}
}
if (phrase_matched) {
return true;
}
}
return false;
}
std::vector<uint32_t> PhraseDocIterator::decode_positions(
const rocksdb::Slice &data) {
std::vector<uint32_t> positions;
size_t index = 0;
uint32_t current_position = 0;
const char *bytes = data.data();
const size_t size = data.size();
while (index < size) {
// Decode varint
uint32_t delta = 0;
uint32_t shift = 0;
while (index < size) {
const uint8_t byte = static_cast<uint8_t>(bytes[index++]);
delta |= static_cast<uint32_t>(byte & 0x7F) << shift;
shift += 7;
if ((byte & 0x80) == 0) {
break;
}
}
current_position += delta;
positions.push_back(current_position);
}
return positions;
}
} // namespace zvec::fts

View File

@ -0,0 +1,79 @@
// 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 <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "db/common/rocksdb_context.h"
#include "fts_conjunction_iterator.h"
#include "fts_doc_iterator.h"
#include "../bm25_scorer.h"
namespace zvec::fts {
/*! Phrase document iterator (two-phase)
*
* Internally wraps a ConjunctionIterator for phase-1 doc_id intersection.
* Phase-2 matches() reads position payloads and checks adjacency.
*/
class PhraseDocIterator : public DocIterator {
public:
/*! Construct a phrase iterator.
* \param conjunction ConjunctionIterator over all terms in the phrase
* \param terms Processed (tokenized) term strings in phrase order
* \param positions_cf $POS column family for reading position lists
*/
PhraseDocIterator(DocIteratorPtr conjunction, std::vector<std::string> terms,
RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *positions_cf);
uint32_t next_doc() override;
//! Internal-driven filter skip: delegates to the inner conjunction so the
//! expensive phase-2 verify_phrase_positions() ($POS CF reads) is never
//! run on filtered docs.
uint32_t next_doc(const zvec::IndexFilter *filter) override;
uint32_t advance(uint32_t target) override;
//! Phase-2: verify position adjacency for the current document.
//! Reads position lists from $POS CF (deferred IO).
bool matches() override;
float score() override;
uint64_t cost() const override;
float max_score() const override;
private:
// Verify that terms appear at consecutive positions in the document.
// Issues a single MultiGet across the unique terms in the phrase, decodes
// every position list once, then validates adjacency entirely in memory.
bool verify_phrase_positions(uint32_t doc_id) const;
// Decode varint delta-encoded position list out of a RocksDB value slice.
static std::vector<uint32_t> decode_positions(const rocksdb::Slice &data);
private:
DocIteratorPtr conjunction_;
std::vector<std::string> terms_;
RocksdbContext *ctx_;
rocksdb::ColumnFamilyHandle *positions_cf_;
// Cache matches() result per doc_id to avoid redundant $POS MultiGet when
// DisjunctionIterator calls matches() from both matches() and score().
uint32_t cached_matches_doc_id_{NO_MORE_DOCS};
bool cached_matches_result_{false};
};
} // namespace zvec::fts

View File

@ -0,0 +1,206 @@
// 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_term_iterator.h"
#include <cstring>
#include <roaring/roaring.h>
#include <zvec/ailego/logger/logger.h>
#include "../fts_utils.h"
namespace zvec::fts {
// ============================================================
// Constructors
// ============================================================
// Roaring Bitmap mode — takes ownership of bitmap, iterates lazily.
TermDocIterator::TermDocIterator(std::string term, roaring_bitmap_t *bitmap,
uint64_t df, BM25ScorerPtr scorer,
float max_score_val, RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *term_freq_cf,
rocksdb::ColumnFamilyHandle *doc_len_cf,
std::atomic<int> *cf_counter, float boost)
: mode_(Mode::ROARING),
term_(std::move(term)),
df_(df),
scorer_(std::move(scorer)),
max_score_val_(max_score_val * boost),
boost_(boost),
bitmap_(bitmap),
ctx_(ctx),
term_freq_cf_(term_freq_cf),
doc_len_cf_(doc_len_cf),
cf_counter_(cf_counter) {
roaring_init_iterator(bitmap_, &roaring_iter_);
cached_max_score_ = max_score_val_;
idf_weight_ = scorer_->idf(df_);
}
TermDocIterator::~TermDocIterator() {
if (bitmap_) {
roaring_bitmap_free(bitmap_);
bitmap_ = nullptr;
}
if (cf_counter_) {
--*cf_counter_;
}
}
// BitPacked mode
TermDocIterator::TermDocIterator(std::string term,
rocksdb::PinnableSlice packed_data,
BM25ScorerPtr scorer, float boost)
: mode_(Mode::BITPACKED),
term_(std::move(term)),
scorer_(std::move(scorer)),
boost_(boost),
packed_data_(std::move(packed_data)) {
// Failure here means the term will produce no docs (next_doc returns
// NO_MORE_DOCS). bp_iter_.open() already logs the underlying parse error;
// surface it once more here with the term context for easier triage.
if (bp_iter_.open(packed_data_.data(), packed_data_.size()) != 0) {
LOG_ERROR(
"TermDocIterator: failed to open bitpacked posting for term[%s], "
"iterator will yield no documents",
term_.c_str());
}
df_ = bp_iter_.cost();
// Apply boost to max_score_val_ so that DisjunctionIterator's WAND pivot
// computation matches the actual scores returned by score() below.
max_score_val_ = bp_iter_.max_score() * boost_;
cached_max_score_ = max_score_val_;
idf_weight_ = scorer_->idf(df_);
}
// ============================================================
// Iterator interface
// ============================================================
uint32_t TermDocIterator::next_doc() {
if (mode_ == Mode::BITPACKED) {
cached_doc_id_ = bp_iter_.next_doc();
return cached_doc_id_;
}
// Roaring mode: stream via roaring_uint32_iterator_t
if (!roaring_iter_started_) {
// First call: iterator already points at the first element after
// roaring_init_iterator in the constructor.
roaring_iter_started_ = true;
} else {
roaring_advance_uint32_iterator(&roaring_iter_);
}
if (!roaring_iter_.has_value) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
cached_doc_id_ = roaring_iter_.current_value;
return cached_doc_id_;
}
uint32_t TermDocIterator::advance(uint32_t target) {
if (mode_ == Mode::BITPACKED) {
cached_doc_id_ = bp_iter_.advance(target);
return cached_doc_id_;
}
// Roaring mode: skip to the first doc_id >= target
roaring_iter_started_ = true;
if (!roaring_move_uint32_iterator_equalorlarger(&roaring_iter_, target)) {
cached_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
cached_doc_id_ = roaring_iter_.current_value;
return cached_doc_id_;
}
float TermDocIterator::score() {
if (cached_doc_id_ == NO_MORE_DOCS) {
return 0.0f;
}
if (mode_ == Mode::BITPACKED) {
// Fast path: read tf/doc_len from inline payload (zero I/O)
const uint32_t tf = bp_iter_.term_freq();
const uint32_t dl = bp_iter_.doc_len();
return scorer_->score_with_idf(idf_weight_, tf, dl, boost_);
}
// Roaring mode: read from RocksDB
const uint32_t tf = read_term_freq(cached_doc_id_);
const uint32_t doc_len = read_doc_len(cached_doc_id_);
return scorer_->score_with_idf(idf_weight_, tf, doc_len, boost_);
}
uint64_t TermDocIterator::cost() const {
if (mode_ == Mode::BITPACKED) {
return bp_iter_.cost();
}
return df_;
}
// ============================================================
// Block-Max WAND support
// ============================================================
DocIterator::BlockMaxInfo TermDocIterator::block_max_info_for(
uint32_t target) const {
if (mode_ == Mode::BITPACKED) {
auto info = bp_iter_.block_max_info_for(target);
// Apply boost so the upper bound matches score() (which multiplies by
// boost_) and stays consistent with max_score_val_ for WAND pivoting.
return {info.block_max_score * boost_, info.block_last_doc};
}
// Roaring mode: fall back to global max_score (already boosted in ctor),
// no block structure available.
return {max_score_val_, NO_MORE_DOCS};
}
// ============================================================
// Roaring mode helpers
// ============================================================
uint32_t TermDocIterator::read_term_freq(uint32_t doc_id) const {
if (!term_freq_cf_) {
return 1; // CF dropped after convert_postings_to_bitpacked
}
const std::string key = fts::make_doc_term_key(term_, doc_id);
std::string value;
if (!ctx_->db_->Get(ctx_->read_opts_, term_freq_cf_, key, &value).ok() ||
value.size() < sizeof(uint32_t)) {
return 1; // Default term frequency is 1
}
uint32_t tf = 0;
std::memcpy(&tf, value.data(), sizeof(uint32_t));
return tf;
}
uint32_t TermDocIterator::read_doc_len(uint32_t doc_id) const {
if (!doc_len_cf_) {
return 1; // CF dropped after convert_postings_to_bitpacked
}
std::string doc_id_key(sizeof(uint32_t), '\0');
std::memcpy(doc_id_key.data(), &doc_id, sizeof(uint32_t));
std::string value;
if (!ctx_->db_->Get(ctx_->read_opts_, doc_len_cf_, doc_id_key, &value).ok() ||
value.size() < sizeof(uint32_t)) {
return 1; // Default document length is 1
}
uint32_t doc_len = 0;
std::memcpy(&doc_len, value.data(), sizeof(uint32_t));
return doc_len;
}
} // namespace zvec::fts

View File

@ -0,0 +1,134 @@
// 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 <cstdint>
#include <string>
#include <roaring/roaring.h>
#include <rocksdb/slice.h>
#include "db/common/rocksdb_context.h"
#include "fts_doc_iterator.h"
#include "../bm25_scorer.h"
#include "../posting/bitpacked_posting_list.h"
namespace zvec::fts {
/*! Term document iterator
* Supports two internal modes:
* 1. Roaring mode: sorted doc_id array + RocksDB Get for tf/doc_len
* 2. BitPacked mode: inline payloads, zero RocksDB I/O for score()
*/
class TermDocIterator : public DocIterator {
public:
/*! Roaring Bitmap mode constructor.
* Takes ownership of the bitmap and iterates lazily via
* roaring_uint32_iterator_t no N×4-byte doc_id array is materialised.
*
* \param term Processed (tokenized) term string
* \param bitmap Deserialized Roaring bitmap (ownership transferred)
* \param df Document frequency of this term in the segment
* \param scorer BM25 scorer (with segment stats loaded)
* \param max_score_val Precomputed WAND upper bound score for this term
* (caller must NOT pre-multiply by boost the
* constructor applies boost to both score() output
* and max_score_val_ to keep WAND pivot correct)
* \param term_freq_cf $TF column family for reading per-doc term freq
* \param doc_len_cf $DOC_LEN column family for reading doc length
* \param cf_counter CF reference counter for term_freq_cf and doc_len_cf
* \param boost Per-term boost (1.0 = no boost)
*/
TermDocIterator(std::string term, roaring_bitmap_t *bitmap, uint64_t df,
BM25ScorerPtr scorer, float max_score_val,
RocksdbContext *ctx,
rocksdb::ColumnFamilyHandle *term_freq_cf,
rocksdb::ColumnFamilyHandle *doc_len_cf,
std::atomic<int> *cf_counter, float boost = 1.0f);
~TermDocIterator() override;
/*! BitPacked mode constructor.
* All payloads (tf, doc_len, per-block max_score, global max_score) are
* embedded inline in packed_data, so this iterator is completely
* self-contained on the read path:
* - score() reads tf/doc_len from bp_iter_ zero RocksDB I/O.
* - block_max_info_for() / max_score() all read from the BitPacked
* skip-list / block headers no $MAX_TF lookup needed.
* Construction takes neither $TF, $DOC_LEN, nor $MAX_TF column families:
* the immutable segment SST may have these CFs entirely empty (cleared
* by FtsColumnIndexer::convert_postings_to_bitpacked at dump time) and
* this iterator still works correctly.
*
* df and max_score are read from bp_iter_ after open(); on open failure
* cost() returns 0 and callers should treat the iterator as empty.
*
* \param term Processed (tokenized) term string
* \param packed_data Serialized BitPacked posting list (ownership taken)
* \param scorer BM25 scorer (with segment stats loaded)
* \param boost Per-term boost (1.0 = no boost)
*/
TermDocIterator(std::string term, rocksdb::PinnableSlice packed_data,
BM25ScorerPtr scorer, float boost = 1.0f);
// Prevent move/copy: bp_iter_ holds a raw pointer into packed_data_'s
// buffer, so moving would create a dangling pointer.
TermDocIterator(const TermDocIterator &) = delete;
TermDocIterator &operator=(const TermDocIterator &) = delete;
TermDocIterator(TermDocIterator &&) = delete;
TermDocIterator &operator=(TermDocIterator &&) = delete;
uint32_t next_doc() override;
uint32_t advance(uint32_t target) override;
float score() override;
uint64_t cost() const override;
float max_score() const override {
return max_score_val_;
}
// Block-Max WAND support (only effective in BitPacked mode)
BlockMaxInfo block_max_info_for(uint32_t target) const override;
private:
// Read term frequency for the current document (Roaring mode only)
uint32_t read_term_freq(uint32_t doc_id) const;
// Read document length for the current document (Roaring mode only)
uint32_t read_doc_len(uint32_t doc_id) const;
private:
enum class Mode { ROARING, BITPACKED };
Mode mode_;
std::string term_;
uint64_t df_;
BM25ScorerPtr scorer_;
float max_score_val_;
float idf_weight_{0.0f}; // Pre-computed IDF to avoid log() per score()
float boost_{1.0f}; // Per-term boost (collapsed from repeated terms)
// Roaring mode state (owns the bitmap; iterator is stack-allocated)
roaring_bitmap_t *bitmap_{nullptr};
roaring_uint32_iterator_t roaring_iter_{};
bool roaring_iter_started_{false}; // tracks whether first next_doc called
RocksdbContext *ctx_{nullptr};
rocksdb::ColumnFamilyHandle *term_freq_cf_{nullptr};
rocksdb::ColumnFamilyHandle *doc_len_cf_{nullptr};
std::atomic<int> *cf_counter_{nullptr};
// BitPacked mode state
rocksdb::PinnableSlice packed_data_; // owns the serialized data (zero-copy)
BitPackedPostingIterator bp_iter_; // zero-copy iterator over packed_data_
};
} // namespace zvec::fts

View File

@ -0,0 +1,415 @@
// 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_query_parser.h"
#include <zvec/ailego/utility/string_helper.h>
#include "db/index/column/fts_column/gen/FtsLexer.h"
#include "db/index/column/fts_column/gen/FtsParser.h"
#include "antlr4-runtime.h"
using namespace antlr4;
namespace zvec::fts {
// ============================================================
// Error listener that captures the first error message
// ============================================================
class FtsErrorListener : public BaseErrorListener {
public:
void syntaxError(Recognizer * /*recognizer*/,
antlr4::Token * /*offending_symbol*/, size_t line,
size_t char_position_in_line, const std::string &msg,
std::exception_ptr /*exception*/) override {
if (err_msg_.empty()) {
err_msg_ = ailego::StringHelper::Concat(
"[", line, " ", char_position_in_line, " ", msg, "]");
}
}
const std::string &err_msg() const {
return err_msg_;
}
private:
std::string err_msg_;
};
// ============================================================
// AST builder helpers (anonymous namespace)
// ============================================================
namespace {
// Forward declaration
FtsAstNodePtr build_fts_or_expr(FtsParser::Fts_or_exprContext *or_ctx,
const TokenizerPipeline &pipeline,
FtsDefaultOperator default_op,
std::string *err_msg);
// Strip surrounding single or double quotes from a quoted string token.
std::string strip_quotes(const std::string &quoted) {
if (quoted.size() >= 2 &&
((quoted.front() == '\'' && quoted.back() == '\'') ||
(quoted.front() == '"' && quoted.back() == '"'))) {
return quoted.substr(1, quoted.size() - 2);
}
return quoted;
}
// Remove lexer-level escape backslashes: \X → X.
// The lexer uses backslash sequences to include special characters inside
// terms (ESCAPED_CHAR) and quoted strings (DQUOTA_STRING). After tokenization
// boundaries are determined, the backslashes must be stripped so downstream
// tokenizer pipelines see the intended literal characters.
std::string unescape(std::string text) {
size_t write = 0;
for (size_t i = 0; i < text.size(); ++i) {
if (text[i] == '\\' && i + 1 < text.size()) {
++i;
}
text[write++] = text[i];
}
text.resize(write);
return text;
}
// Propagate must/must_not modifier to the root of an already-built AST node.
// Now that must/must_not live on the FtsAstNode base class, this works
// uniformly for terms, phrases and composite (AND/OR) sub-expressions.
// OR-merge with any existing flags so a second application on the same
// node never silently clears modifiers set by a prior pass.
void apply_modifier(FtsAstNode *node, bool is_must, bool is_must_not) {
if (!node || (!is_must && !is_must_not)) {
return;
}
node->must = node->must || is_must;
node->must_not = node->must_not || is_must_not;
}
// atom: fts_field_prefix? fts_primary fts_boost?
//
// fts_field_prefix (e.g. "title:") and fts_boost (e.g. "^2") are parsed by
// the grammar but not supported at query execution time — return an error.
//
// fts_primary: fts_term | fts_phrase | LP fts_or_expr RP
FtsAstNodePtr build_fts_atom(FtsParser::Fts_atomContext *atom_ctx, bool is_must,
bool is_must_not,
const TokenizerPipeline &pipeline,
FtsDefaultOperator default_op,
std::string *err_msg) {
// Reject field-prefixed queries (e.g. "title:cancer")
if (atom_ctx->fts_field_prefix() != nullptr) {
if (err_msg) {
*err_msg = "field-prefixed queries are not supported";
}
return nullptr;
}
// Reject boosted queries (e.g. "term^2")
if (atom_ctx->fts_boost() != nullptr) {
if (err_msg) {
*err_msg = "boost queries are not supported";
}
return nullptr;
}
FtsParser::Fts_primaryContext *primary_ctx = atom_ctx->fts_primary();
if (primary_ctx == nullptr) {
return nullptr;
}
if (primary_ctx->fts_term() != nullptr) {
std::string term_text = unescape(primary_ctx->fts_term()->getText());
auto tokens = pipeline.process(term_text);
if (tokens.empty()) {
// Term filtered out (e.g. stop-word, pure punctuation). Returning
// nullptr here lets the seq/and/or builders skip this child.
return nullptr;
}
if (tokens.size() == 1) {
return std::make_unique<TermNode>(std::move(tokens[0].text), is_must,
is_must_not);
}
// Multi-token bare term: combine via the configured default operator and
// attach must/must_not on the composite root.
FtsAstNodePtr composite;
if (default_op == FtsDefaultOperator::AND) {
auto and_node = std::make_unique<AndNode>();
and_node->children.reserve(tokens.size());
for (auto &t : tokens) {
and_node->children.push_back(
std::make_unique<TermNode>(std::move(t.text)));
}
composite = std::move(and_node);
} else {
auto or_node = std::make_unique<OrNode>();
or_node->children.reserve(tokens.size());
for (auto &t : tokens) {
or_node->children.push_back(
std::make_unique<TermNode>(std::move(t.text)));
}
composite = std::move(or_node);
}
apply_modifier(composite.get(), is_must, is_must_not);
return composite;
}
if (primary_ctx->fts_phrase() != nullptr) {
std::string raw = primary_ctx->fts_phrase()->getText();
std::string phrase_text = unescape(strip_quotes(raw));
auto tokens = pipeline.process(phrase_text);
auto phrase_node = std::make_unique<PhraseNode>();
phrase_node->must = is_must;
phrase_node->must_not = is_must_not;
phrase_node->terms.reserve(tokens.size());
for (auto &t : tokens) {
phrase_node->terms.push_back(std::move(t.text));
}
return phrase_node;
}
if (primary_ctx->fts_or_expr() != nullptr) {
// Parenthesised sub-expression — propagate default_op so that adjacent
// bare terms inside the parentheses share the same implicit semantics.
auto inner = build_fts_or_expr(primary_ctx->fts_or_expr(), pipeline,
default_op, err_msg);
apply_modifier(inner.get(), is_must, is_must_not);
return inner;
}
return nullptr;
}
// unary: (PLUS_SIGN | MINUS_SIGN)? atom
// NOT is no longer a unary modifier — it is handled as a binary operator in
// build_fts_and_expr. antlr4 generates separate subclasses for each labeled
// alternative.
FtsAstNodePtr build_fts_unary(FtsParser::Fts_unaryContext *unary_ctx,
const TokenizerPipeline &pipeline,
FtsDefaultOperator default_op,
std::string *err_msg) {
if (auto *must_ctx = dynamic_cast<FtsParser::Must_atomContext *>(unary_ctx)) {
return build_fts_atom(must_ctx->fts_atom(), /*is_must=*/true,
/*is_must_not=*/false, pipeline, default_op, err_msg);
}
if (auto *must_not_ctx =
dynamic_cast<FtsParser::Must_not_atomContext *>(unary_ctx)) {
return build_fts_atom(must_not_ctx->fts_atom(), /*is_must=*/false,
/*is_must_not=*/true, pipeline, default_op, err_msg);
}
// Plain_atomContext (no modifier)
if (auto *plain_ctx =
dynamic_cast<FtsParser::Plain_atomContext *>(unary_ctx)) {
return build_fts_atom(plain_ctx->fts_atom(), /*is_must=*/false,
/*is_must_not=*/false, pipeline, default_op, err_msg);
}
return nullptr;
}
// seqExpr: unary+
// Adjacent terms use the implicit default operator passed in (OR or AND).
// This is the only place where FtsDefaultOperator actually changes the AST
// structure; all other build_* helpers simply propagate the value.
FtsAstNodePtr build_fts_seq_expr(FtsParser::Fts_seq_exprContext *seq_ctx,
const TokenizerPipeline &pipeline,
FtsDefaultOperator default_op,
std::string *err_msg) {
auto unary_list = seq_ctx->fts_unary();
if (unary_list.size() == 1) {
return build_fts_unary(unary_list[0], pipeline, default_op, err_msg);
}
// Parse all children first
std::vector<FtsAstNodePtr> children;
for (auto *unary_ctx : unary_list) {
auto child = build_fts_unary(unary_ctx, pipeline, default_op, err_msg);
if (!child) {
if (err_msg && !err_msg->empty()) {
return nullptr;
}
continue;
}
children.push_back(std::move(child));
}
if (children.size() == 1) {
return std::move(children[0]);
}
// Assign children to the appropriate node type
if (default_op == FtsDefaultOperator::AND) {
auto and_node = std::make_unique<AndNode>();
and_node->children = std::move(children);
return and_node;
}
auto or_node = std::make_unique<OrNode>();
or_node->children = std::move(children);
return or_node;
}
// andExpr: seqExpr ((AND | NOT) seqExpr)*
//
// NOT shares the same precedence as AND. Each `NOT seqExpr` on the right of
// the operator marks the produced child as must_not, then the whole
// sub-expression collapses into a single AndNode. Example:
// `a NOT b` => And[a, b{must_not}]
// `a AND b NOT c` => And[a, b, c{must_not}]
FtsAstNodePtr build_fts_and_expr(FtsParser::Fts_and_exprContext *and_ctx,
const TokenizerPipeline &pipeline,
FtsDefaultOperator default_op,
std::string *err_msg) {
auto and_node = std::make_unique<AndNode>();
bool next_is_not = false;
for (auto *raw : and_ctx->children) {
if (auto *term = dynamic_cast<antlr4::tree::TerminalNode *>(raw)) {
const auto token_type = term->getSymbol()->getType();
if (token_type == FtsParser::AND) {
next_is_not = false;
} else if (token_type == FtsParser::NOT) {
next_is_not = true;
}
continue;
}
auto *seq_ctx = dynamic_cast<FtsParser::Fts_seq_exprContext *>(raw);
if (seq_ctx == nullptr) {
continue;
}
auto child = build_fts_seq_expr(seq_ctx, pipeline, default_op, err_msg);
bool is_not_for_this_child = next_is_not;
next_is_not = false;
if (!child) {
if (err_msg && !err_msg->empty()) {
return nullptr;
}
continue;
}
if (is_not_for_this_child) {
apply_modifier(child.get(), /*is_must=*/false, /*is_must_not=*/true);
}
and_node->children.push_back(std::move(child));
}
if (and_node->children.empty()) {
return nullptr;
}
if (and_node->children.size() == 1) {
return std::move(and_node->children[0]);
}
return and_node;
}
// orExpr: andExpr (OR andExpr)*
FtsAstNodePtr build_fts_or_expr(FtsParser::Fts_or_exprContext *or_ctx,
const TokenizerPipeline &pipeline,
FtsDefaultOperator default_op,
std::string *err_msg) {
auto and_list = or_ctx->fts_and_expr();
if (and_list.size() == 1) {
return build_fts_and_expr(and_list[0], pipeline, default_op, err_msg);
}
auto or_node = std::make_unique<OrNode>();
for (auto *and_ctx : and_list) {
auto child = build_fts_and_expr(and_ctx, pipeline, default_op, err_msg);
if (!child) {
if (err_msg && !err_msg->empty()) {
return nullptr;
}
continue;
}
or_node->children.push_back(std::move(child));
}
if (or_node->children.size() == 1) {
return std::move(or_node->children[0]);
}
return or_node;
}
} // anonymous namespace
// ============================================================
// FtsQueryParser::parse()
// ============================================================
FtsAstNodePtr FtsQueryParser::parse(const std::string &query,
const TokenizerPipelinePtr &pipeline,
FtsDefaultOperator default_op) {
err_msg_.clear();
if (!pipeline) {
err_msg_ = "fts parser: pipeline is required";
return nullptr;
}
try {
ANTLRInputStream input(query);
FtsLexer lexer(&input);
FtsErrorListener lexer_error_listener;
lexer.removeErrorListeners();
lexer.addErrorListener(&lexer_error_listener);
CommonTokenStream tokens(&lexer);
FtsParser parser(&tokens);
FtsErrorListener parser_error_listener;
parser.removeErrorListeners();
parser.addErrorListener(&parser_error_listener);
// First attempt with SLL prediction mode (fast path)
parser.getInterpreter<atn::ParserATNSimulator>()->setPredictionMode(
atn::PredictionMode::SLL);
FtsParser::Fts_query_unitContext *tree = parser.fts_query_unit();
// Fall back to full LL mode if SLL produced errors
if (lexer.getNumberOfSyntaxErrors() > 0 ||
parser.getNumberOfSyntaxErrors() > 0) {
tokens.reset();
parser.reset();
parser.getInterpreter<atn::ParserATNSimulator>()->setPredictionMode(
atn::PredictionMode::LL);
tree = parser.fts_query_unit();
}
if (lexer.getNumberOfSyntaxErrors() > 0) {
err_msg_ = "fts lexer error " + lexer_error_listener.err_msg();
return nullptr;
}
if (parser.getNumberOfSyntaxErrors() > 0) {
err_msg_ = "fts syntax error " + parser_error_listener.err_msg();
return nullptr;
}
if (tree == nullptr || tree->fts_or_expr() == nullptr) {
err_msg_ = "fts parse error: empty or invalid query";
return nullptr;
}
auto result = build_fts_or_expr(tree->fts_or_expr(), *pipeline, default_op,
&err_msg_);
if (!result && !err_msg_.empty()) {
return nullptr;
}
if (!result) {
// Grammar valid but analyzer dropped every term: return EmptyNode so
// callers don't have to treat zero-doc queries as parse errors.
return std::make_unique<EmptyNode>();
}
return result;
} catch (const std::exception &exception) {
err_msg_ = "fts parse exception: " + std::string(exception.what());
return nullptr;
}
}
} // namespace zvec::fts

View File

@ -0,0 +1,67 @@
// 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 "db/index/column/fts_column/fts_query_ast.h"
#include "db/index/column/fts_column/tokenizer/tokenizer_factory.h"
namespace zvec::fts {
/*! Default boolean operator applied to adjacent bare terms that are not
* separated by an explicit operator (AND / OR / + / -).
* This is equivalent to Lucene/Elasticsearch's `default_operator` semantics.
*/
enum class FtsDefaultOperator {
OR, // Adjacent bare terms are combined with OR (historical default).
AND, // Adjacent bare terms are combined with AND.
};
/*! FTS query parser
* Thread-compatible but not thread-safe: create one instance per parse call
* or protect with a mutex.
*/
class FtsQueryParser {
public:
FtsQueryParser() = default;
/*! Parse an FTS query expression string into an AST.
* \param query Query string, e.g. '+vector -slow "exact phrase"
* AND '
* \param pipeline Tokenizer pipeline used to tokenize phrase contents
* and bare terms so that query-side segmentation
* matches the doc-side index. Must be non-null.
* \param default_op Default operator for adjacent bare terms with no
* explicit operator. Defaults to OR for backward
* compatibility. Does not change the semantics of
* explicit AND / OR / + / - usages.
* \return Root AST node, or nullptr on parse failure. Call err_msg() to
* retrieve the error description.
*/
FtsAstNodePtr parse(const std::string &query,
const TokenizerPipelinePtr &pipeline,
FtsDefaultOperator default_op = FtsDefaultOperator::OR);
/*! Return the error message from the most recent failed parse() call. */
const std::string &err_msg() const {
return err_msg_;
}
private:
std::string err_msg_;
};
} // namespace zvec::fts

View File

@ -0,0 +1,704 @@
// 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 "bitpacked_posting_list.h"
#include <bitpackinghelpers.h>
#include <cstdlib>
#include <memory>
#include <zvec/ailego/logger/logger.h>
#include "bitpacked_simd_dispatch.h"
#ifdef _MSC_VER
#include <intrin.h>
#include <malloc.h>
#endif
namespace zvec::fts {
// ============================================================
// BitPacked Posting List on-disk format
// ============================================================
//
// Encodes doc_id deltas, term frequencies, and document lengths using
// per-block bitpacking. Each block stores up to 128 entries and carries
// a precomputed BM25 score upper bound to support Block-Max WAND pruning.
//
// File layout:
// [Header 16B] [SkipList N*12B] [Block0] [Block1] ...
//
// Block layout:
// [BlockHeader 16B] [packed_deltas] [packed_tfs] [packed_dlens]
namespace {
/// Round up \p value to the next multiple of \p alignment.
constexpr size_t align_up(size_t value, size_t alignment) {
return (value + alignment - 1) & ~(alignment - 1);
}
/// Allocate 16-byte-aligned memory for \p count uint32_t values, returned as
/// a unique_ptr with a custom deleter.
inline auto make_aligned_uint32_array(size_t count) {
const size_t num_bytes = align_up(count * sizeof(uint32_t), 16);
#ifdef _MSC_VER
auto *ptr = static_cast<uint32_t *>(_aligned_malloc(num_bytes, 16));
return std::unique_ptr<uint32_t[], decltype(&_aligned_free)>(ptr,
_aligned_free);
#else
auto *ptr = static_cast<uint32_t *>(std::aligned_alloc(16, num_bytes));
return std::unique_ptr<uint32_t[], decltype(&std::free)>(ptr, std::free);
#endif
}
} // namespace
// ============================================================
// Low-level bitpacking primitives
// ============================================================
uint8_t BitPackedPostingList::bits_needed(uint32_t max_value) {
if (max_value == 0) return 0;
#ifdef _MSC_VER
unsigned long index = 0;
_BitScanReverse(&index, max_value);
return static_cast<uint8_t>(index + 1);
#else
return static_cast<uint8_t>(32 - __builtin_clz(max_value));
#endif
}
void BitPackedPostingList::pack_uint32(const uint32_t *in, uint8_t bitwidth,
uint32_t count, uint8_t *out) {
if (bitwidth == 0 || count == 0) return;
// Full block path: 128 values at once via dispatch (SIMD or scalar)
if (count == DOCS_PER_BLOCK) {
simd::get_dispatch().pack_uint32_128(in, bitwidth, out);
return;
}
// Tail block path (count < 128): use scalar fastpack, 32 at a time
const size_t total_bytes = packed_byte_size(bitwidth, count);
std::memset(out, 0, total_bytes);
uint32_t *out32 = reinterpret_cast<uint32_t *>(out);
uint32_t offset = 0;
while (offset + 32 <= count) {
FastPForLib::fastpackwithoutmask(in + offset, out32, bitwidth);
out32 += bitwidth;
offset += 32;
}
// Tail: fewer than 32 integers
if (offset < count) {
alignas(16) uint32_t padded_in[32] = {};
std::memcpy(padded_in, in + offset, (count - offset) * sizeof(uint32_t));
alignas(16) uint32_t padded_out[32] = {};
FastPForLib::fastpackwithoutmask(padded_in, padded_out, bitwidth);
size_t tail_bytes = packed_byte_size(bitwidth, count - offset);
std::memcpy(out32, padded_out, tail_bytes);
}
}
void BitPackedPostingList::unpack_uint32(const uint8_t *in, uint8_t bitwidth,
uint32_t count, uint32_t *out) {
if (bitwidth == 0 || count == 0) {
for (uint32_t i = 0; i < count; ++i) {
out[i] = 0;
}
return;
}
// Full block path: 128 values at once via dispatch (SIMD or scalar)
if (count == DOCS_PER_BLOCK) {
simd::get_dispatch().unpack_uint32_128(in, bitwidth, out);
return;
}
// Tail block path (count < 128): use scalar fastunpack, 32 at a time
const uint32_t *in32 = reinterpret_cast<const uint32_t *>(in);
uint32_t offset = 0;
while (offset + 32 <= count) {
FastPForLib::fastunpack(in32, out + offset, bitwidth);
in32 += bitwidth;
offset += 32;
}
// Tail: fewer than 32 integers
if (offset < count) {
const size_t tail_bytes = packed_byte_size(bitwidth, count - offset);
alignas(16) uint32_t padded_in[32] = {};
std::memcpy(padded_in, in32, tail_bytes);
alignas(16) uint32_t padded_out[32] = {};
FastPForLib::fastunpack(padded_in, padded_out, bitwidth);
std::memcpy(out + offset, padded_out, (count - offset) * sizeof(uint32_t));
}
}
// ============================================================
// Encoder
// ============================================================
std::string BitPackedPostingList::encode(const uint32_t *doc_ids,
const uint32_t *tfs,
const uint32_t *doc_lens, size_t count,
uint64_t df,
const BM25Scorer &scorer) {
if (count == 0) {
// Encode an empty posting list (just the header)
Header hdr{};
hdr.magic = MAGIC;
hdr.version = VERSION;
hdr.num_docs = 0;
hdr.num_blocks = 0;
std::string result(HEADER_SIZE, '\0');
std::memcpy(result.data(), &hdr, HEADER_SIZE);
return result;
}
const uint32_t num_blocks =
static_cast<uint32_t>((count + DOCS_PER_BLOCK - 1) / DOCS_PER_BLOCK);
// ---- Phase 1: Compute delta-encoded doc_ids ----
// Use 16-byte-aligned allocation so SIMD pack/max paths can use aligned loads
auto deltas = make_aligned_uint32_array(count);
deltas[0] = doc_ids[0];
for (size_t i = 1; i < count; ++i) {
deltas[i] = doc_ids[i] - doc_ids[i - 1];
}
// ---- Phase 2: Compute per-block metadata and packed sizes ----
struct BlockInfo {
size_t start; // index into the arrays
uint32_t num_docs; // number of docs in this block
uint8_t bw_id; // bitwidth for doc_id deltas
uint8_t bw_tf; // bitwidth for tfs
uint8_t bw_dl; // bitwidth for doc_lens
float max_score; // block max BM25 score
size_t packed_size; // total packed data size for this block
};
std::vector<BlockInfo> blocks(num_blocks);
for (uint32_t b = 0; b < num_blocks; ++b) {
const size_t start = static_cast<size_t>(b) * DOCS_PER_BLOCK;
const uint32_t num_docs = static_cast<uint32_t>(
std::min(static_cast<size_t>(DOCS_PER_BLOCK), count - start));
// Find max values in block for bitwidth computation
uint32_t max_delta = 0, max_tf = 0, max_dl = 0;
float block_max = 0.0f;
if (num_docs == DOCS_PER_BLOCK) {
// Dispatch max for full blocks (SSE4.1 or scalar fallback)
simd::get_dispatch().max_128(deltas.get(), tfs, doc_lens, start,
DOCS_PER_BLOCK, max_delta, max_tf, max_dl);
// block_max_score still needs scalar loop (float BM25 scoring)
for (uint32_t i = 0; i < DOCS_PER_BLOCK; ++i) {
float s = scorer.score(df, tfs[start + i], doc_lens[start + i]);
block_max = std::max(block_max, s);
}
} else {
// Scalar path for tail blocks
for (uint32_t i = 0; i < num_docs; ++i) {
max_delta = std::max(max_delta, deltas[start + i]);
max_tf = std::max(max_tf, tfs[start + i]);
max_dl = std::max(max_dl, doc_lens[start + i]);
float s = scorer.score(df, tfs[start + i], doc_lens[start + i]);
block_max = std::max(block_max, s);
}
}
blocks[b].start = start;
blocks[b].num_docs = num_docs;
blocks[b].bw_id = bits_needed(max_delta);
blocks[b].bw_tf = bits_needed(max_tf);
blocks[b].bw_dl = bits_needed(max_dl);
blocks[b].max_score = block_max;
// Full block (128 values): use SIMD packed size; tail block: use scalar
if (num_docs == DOCS_PER_BLOCK) {
blocks[b].packed_size = simd_packed_byte_size(blocks[b].bw_id) +
simd_packed_byte_size(blocks[b].bw_tf) +
simd_packed_byte_size(blocks[b].bw_dl);
} else {
blocks[b].packed_size = packed_byte_size(blocks[b].bw_id, num_docs) +
packed_byte_size(blocks[b].bw_tf, num_docs) +
packed_byte_size(blocks[b].bw_dl, num_docs);
}
}
// ---- Phase 3: Compute total size and block offsets ----
const size_t skip_list_size = num_blocks * sizeof(BlockMeta);
const size_t block_header_size = sizeof(BlockHeader);
// Compute block offsets, aligning each block start to a 16-byte boundary
// so that SIMD decode paths can use aligned loads on the packed data.
size_t current_offset = align_up(HEADER_SIZE + skip_list_size, 16);
std::vector<uint32_t> block_offsets(num_blocks);
for (uint32_t b = 0; b < num_blocks; ++b) {
block_offsets[b] = static_cast<uint32_t>(current_offset);
current_offset = align_up(
current_offset + block_header_size + blocks[b].packed_size, 16);
}
const size_t total_size = current_offset;
// ---- Phase 4: Serialize ----
std::string result(total_size, '\0');
char *buf = result.data();
// File Header
Header hdr{};
hdr.magic = MAGIC;
hdr.version = VERSION;
hdr.num_docs = static_cast<uint32_t>(count);
hdr.num_blocks = num_blocks;
std::memcpy(buf, &hdr, HEADER_SIZE);
// Skip List
BlockMeta *skip = reinterpret_cast<BlockMeta *>(buf + HEADER_SIZE);
for (uint32_t b = 0; b < num_blocks; ++b) {
const size_t last_idx = blocks[b].start + blocks[b].num_docs - 1;
skip[b].max_doc_id = doc_ids[last_idx];
skip[b].block_offset = block_offsets[b];
skip[b].block_max_score = blocks[b].max_score;
}
// Blocks
for (uint32_t b = 0; b < num_blocks; ++b) {
char *block_ptr = buf + block_offsets[b];
// Block Header
BlockHeader bhdr{};
bhdr.min_doc_id = doc_ids[blocks[b].start];
bhdr.bitwidth_id = blocks[b].bw_id;
bhdr.bitwidth_tf = blocks[b].bw_tf;
bhdr.bitwidth_dl = blocks[b].bw_dl;
bhdr.num_docs = static_cast<uint8_t>(blocks[b].num_docs);
bhdr.block_max_score = blocks[b].max_score;
std::memcpy(block_ptr, &bhdr, sizeof(BlockHeader));
uint8_t *packed_ptr =
reinterpret_cast<uint8_t *>(block_ptr + sizeof(BlockHeader));
const bool is_full_block = (blocks[b].num_docs == DOCS_PER_BLOCK);
// Pack doc_id deltas
const size_t id_bytes =
is_full_block ? simd_packed_byte_size(blocks[b].bw_id)
: packed_byte_size(blocks[b].bw_id, blocks[b].num_docs);
pack_uint32(&deltas[blocks[b].start], blocks[b].bw_id, blocks[b].num_docs,
packed_ptr);
packed_ptr += id_bytes;
// Pack term frequencies
const size_t tf_bytes =
is_full_block ? simd_packed_byte_size(blocks[b].bw_tf)
: packed_byte_size(blocks[b].bw_tf, blocks[b].num_docs);
pack_uint32(&tfs[blocks[b].start], blocks[b].bw_tf, blocks[b].num_docs,
packed_ptr);
packed_ptr += tf_bytes;
// Pack document lengths
pack_uint32(&doc_lens[blocks[b].start], blocks[b].bw_dl, blocks[b].num_docs,
packed_ptr);
}
return result;
}
// ============================================================
// Iterator
// ============================================================
int BitPackedPostingIterator::open(const char *data, size_t size) {
if (!data || size < BitPackedPostingList::HEADER_SIZE) {
LOG_ERROR(
"BitPackedPostingIterator open failed: truncated data, "
"size[%zu] expected_min[%zu]",
size, BitPackedPostingList::HEADER_SIZE);
return -1;
}
// Parse file header
BitPackedPostingList::Header hdr{};
std::memcpy(&hdr, data, sizeof(hdr));
if (hdr.magic != BitPackedPostingList::MAGIC) {
LOG_ERROR(
"BitPackedPostingIterator open failed: bad magic, "
"got[0x%x] expected[0x%x]",
hdr.magic, BitPackedPostingList::MAGIC);
return -1;
}
if (hdr.version != BitPackedPostingList::VERSION) {
LOG_ERROR(
"BitPackedPostingIterator open failed: unsupported version, "
"got[%u] expected[%u]",
hdr.version, BitPackedPostingList::VERSION);
return -1;
}
num_docs_ = hdr.num_docs;
num_blocks_ = hdr.num_blocks;
data_ = data;
data_size_ = size;
if (num_docs_ == 0) {
current_doc_id_ = NO_MORE_DOCS;
return 0;
}
// Validate skip list fits
const size_t skip_list_offset = BitPackedPostingList::HEADER_SIZE;
const size_t skip_list_size =
num_blocks_ * sizeof(BitPackedPostingList::BlockMeta);
if (skip_list_offset + skip_list_size > size) {
LOG_ERROR(
"BitPackedPostingIterator open failed: skip list overruns buffer, "
"num_blocks[%u] data_size[%zu] need[%zu]",
num_blocks_, size, skip_list_offset + skip_list_size);
return -1;
}
skip_list_ = reinterpret_cast<const BitPackedPostingList::BlockMeta *>(
data + skip_list_offset);
// Compute global max score
global_max_score_ = 0.0f;
for (uint32_t b = 0; b < num_blocks_; ++b) {
global_max_score_ =
std::max(global_max_score_, skip_list_[b].block_max_score);
}
// Initialize to before-first-block state
current_block_idx_ = 0;
in_block_pos_ = 0;
current_block_size_ = 0;
block_decoded_ = false;
current_doc_id_ = NO_MORE_DOCS;
// Cache SIMD dispatch function pointers to avoid PLT overhead on hot path
const auto &dispatch = simd::get_dispatch();
prefix_sum_fn_ = dispatch.prefix_sum_128;
find_first_ge_fn_ = dispatch.find_first_ge;
unpack_fn_ = dispatch.unpack_uint32_128;
return 0;
}
void BitPackedPostingIterator::decode_block(size_t block_idx) {
if (block_idx >= num_blocks_) {
LOG_WARN(
"BitPackedPostingIterator decode_block out of range: "
"block_idx[%zu] num_blocks[%u]",
block_idx, num_blocks_);
current_block_size_ = 0;
block_decoded_ = false;
return;
}
const auto &meta = skip_list_[block_idx];
const char *block_ptr = data_ + meta.block_offset;
// Parse block header
BitPackedPostingList::BlockHeader bhdr{};
std::memcpy(&bhdr, block_ptr, sizeof(bhdr));
current_block_size_ = bhdr.num_docs;
current_block_idx_ = block_idx;
in_block_pos_ = 0;
const uint8_t *packed_ptr =
reinterpret_cast<const uint8_t *>(block_ptr + sizeof(bhdr));
const bool is_full_block =
(bhdr.num_docs == BitPackedPostingList::DOCS_PER_BLOCK);
// Unpack doc_id deltas
const size_t id_bytes =
is_full_block
? BitPackedPostingList::simd_packed_byte_size(bhdr.bitwidth_id)
: BitPackedPostingList::packed_byte_size(bhdr.bitwidth_id,
bhdr.num_docs);
alignas(16) uint32_t deltas[BitPackedPostingList::DOCS_PER_BLOCK];
if (is_full_block) {
// Fast path: use cached function pointer directly for full blocks
unpack_fn_(packed_ptr, bhdr.bitwidth_id, deltas);
} else {
BitPackedPostingList::unpack_uint32(packed_ptr, bhdr.bitwidth_id,
bhdr.num_docs, deltas);
}
packed_ptr += id_bytes;
// Reconstruct absolute doc_ids from deltas using prefix-sum
if (is_full_block) {
prefix_sum_fn_(deltas, bhdr.min_doc_id,
BitPackedPostingList::DOCS_PER_BLOCK, block_doc_ids_);
} else {
// Scalar prefix-sum for tail block
block_doc_ids_[0] = bhdr.min_doc_id;
for (uint32_t i = 1; i < bhdr.num_docs; ++i) {
block_doc_ids_[i] = block_doc_ids_[i - 1] + deltas[i];
}
}
// Lazy decode: record packed data pointers and bitwidths for tf/doc_len.
// Actual decoding is deferred until term_freq() or doc_len() is called.
const size_t tf_bytes =
is_full_block
? BitPackedPostingList::simd_packed_byte_size(bhdr.bitwidth_tf)
: BitPackedPostingList::packed_byte_size(bhdr.bitwidth_tf,
bhdr.num_docs);
packed_tf_ptr_ = packed_ptr;
current_bitwidth_tf_ = bhdr.bitwidth_tf;
packed_ptr += tf_bytes;
packed_dl_ptr_ = packed_ptr;
current_bitwidth_dl_ = bhdr.bitwidth_dl;
current_block_num_docs_ = bhdr.num_docs;
current_block_is_full_ = is_full_block;
// Reset lazy decode flags
tf_decoded_ = false;
dl_decoded_ = false;
block_decoded_ = true;
}
uint32_t BitPackedPostingIterator::next_doc() {
if (num_docs_ == 0) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// If no block is decoded yet, decode the first block
if (!block_decoded_) {
decode_block(0);
if (current_block_size_ == 0) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
current_doc_id_ = block_doc_ids_[0];
in_block_pos_ = 0;
return current_doc_id_;
}
// Advance within current block
++in_block_pos_;
if (in_block_pos_ < current_block_size_) {
current_doc_id_ = block_doc_ids_[in_block_pos_];
return current_doc_id_;
}
// Move to next block
size_t next_block = current_block_idx_ + 1;
if (next_block >= num_blocks_) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
decode_block(next_block);
if (current_block_size_ == 0) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
current_doc_id_ = block_doc_ids_[0];
in_block_pos_ = 0;
return current_doc_id_;
}
size_t BitPackedPostingIterator::simd_find_first_ge(uint32_t target,
size_t start) const {
return find_first_ge_fn_(block_doc_ids_, current_block_size_, target, start);
}
uint32_t BitPackedPostingIterator::advance(uint32_t target) {
if (num_docs_ == 0) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// If current doc_id already >= target, return it
if (current_doc_id_ != NO_MORE_DOCS && current_doc_id_ >= target) {
return current_doc_id_;
}
// Use skip list to find the target block via binary search.
// Find the first block whose max_doc_id >= target.
size_t lo = 0, hi = num_blocks_;
// If we have a current block and its max_doc_id >= target,
// we can search within the current block first.
if (block_decoded_ && current_block_idx_ < num_blocks_ &&
skip_list_[current_block_idx_].max_doc_id >= target) {
// Target might be in current block - SIMD scan from current position
{
size_t pos = simd_find_first_ge(target, in_block_pos_);
if (pos < current_block_size_) {
in_block_pos_ = pos;
current_doc_id_ = block_doc_ids_[pos];
return current_doc_id_;
}
}
// Not found in current block (shouldn't happen if skip list is correct)
lo = current_block_idx_ + 1;
} else if (block_decoded_) {
// Current block's max_doc_id < target, start search from next block
lo = current_block_idx_ + 1;
}
// Binary search in skip list for the first block with max_doc_id >= target
size_t target_block = hi; // sentinel: no block found
while (lo < hi) {
size_t mid = lo + (hi - lo) / 2;
if (skip_list_[mid].max_doc_id >= target) {
target_block = mid;
hi = mid;
} else {
lo = mid + 1;
}
}
if (target_block >= num_blocks_) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// Decode the target block
decode_block(target_block);
if (current_block_size_ == 0) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
// SIMD scan within the block for the first doc_id >= target
{
size_t pos = simd_find_first_ge(target, 0);
if (pos < current_block_size_) {
in_block_pos_ = pos;
current_doc_id_ = block_doc_ids_[pos];
return current_doc_id_;
}
}
// All docs in this block are < target (shouldn't happen with correct skip
// list), try next block
size_t next = target_block + 1;
if (next >= num_blocks_) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
decode_block(next);
if (current_block_size_ == 0) {
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
{
size_t pos = simd_find_first_ge(target, 0);
if (pos < current_block_size_) {
in_block_pos_ = pos;
current_doc_id_ = block_doc_ids_[pos];
return current_doc_id_;
}
}
current_doc_id_ = NO_MORE_DOCS;
return NO_MORE_DOCS;
}
void BitPackedPostingIterator::ensure_tf_decoded() {
if (tf_decoded_) {
return;
}
if (current_block_is_full_) {
unpack_fn_(packed_tf_ptr_, current_bitwidth_tf_, block_tfs_);
} else {
BitPackedPostingList::unpack_uint32(packed_tf_ptr_, current_bitwidth_tf_,
current_block_num_docs_, block_tfs_);
}
tf_decoded_ = true;
}
void BitPackedPostingIterator::ensure_dl_decoded() {
if (dl_decoded_) {
return;
}
if (current_block_is_full_) {
unpack_fn_(packed_dl_ptr_, current_bitwidth_dl_, block_doc_lens_);
} else {
BitPackedPostingList::unpack_uint32(packed_dl_ptr_, current_bitwidth_dl_,
current_block_num_docs_,
block_doc_lens_);
}
dl_decoded_ = true;
}
uint32_t BitPackedPostingIterator::term_freq() {
if (!block_decoded_ || in_block_pos_ >= current_block_size_) {
return 0;
}
ensure_tf_decoded();
return block_tfs_[in_block_pos_];
}
uint32_t BitPackedPostingIterator::doc_len() {
if (!block_decoded_ || in_block_pos_ >= current_block_size_) {
return 1;
}
ensure_dl_decoded();
return block_doc_lens_[in_block_pos_];
}
BitPackedPostingIterator::BlockMaxInfo
BitPackedPostingIterator::block_max_info_for(uint32_t target) const {
if (num_blocks_ == 0 || skip_list_ == nullptr) {
return {0.0f, NO_MORE_DOCS};
}
// Fast path: check if target falls within the previously cached block
if (cached_bmi_valid_ && target <= cached_bmi_last_doc_) {
// target is in the same or earlier block as last query.
// Check if it's still in the same block (block_idx is correct).
if (cached_bmi_block_idx_ == 0 ||
target > skip_list_[cached_bmi_block_idx_ - 1].max_doc_id) {
return {cached_bmi_score_, cached_bmi_last_doc_};
}
}
size_t lo = 0, hi = num_blocks_;
while (lo < hi) {
size_t mid = lo + (hi - lo) / 2;
if (skip_list_[mid].max_doc_id >= target) {
hi = mid;
} else {
lo = mid + 1;
}
}
if (lo >= num_blocks_) {
return {0.0f, NO_MORE_DOCS};
}
// Update cache
cached_bmi_block_idx_ = lo;
cached_bmi_last_doc_ = skip_list_[lo].max_doc_id;
cached_bmi_score_ = skip_list_[lo].block_max_score;
cached_bmi_valid_ = true;
return {cached_bmi_score_, cached_bmi_last_doc_};
}
} // namespace zvec::fts

View File

@ -0,0 +1,237 @@
// 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 <cstdint>
#include <cstring>
#include <string>
#include "bitpacked_simd_dispatch.h"
#include "../bm25_scorer.h"
namespace zvec::fts {
// ============================================================
// BitPacked Posting List encoder
// ============================================================
class BitPackedPostingList {
public:
static constexpr uint32_t DOCS_PER_BLOCK = 128;
static constexpr uint32_t MAGIC = 0x42504B44; // "BPKD"
static constexpr uint32_t VERSION = 1;
/// Skip-list entry stored after the file header.
struct BlockMeta {
uint32_t max_doc_id; ///< Last (largest) doc_id in this block
uint32_t block_offset; ///< Byte offset from data start to block header
float block_max_score; ///< BM25 score upper bound for this block
};
/// File header (16 bytes).
struct Header {
uint32_t magic;
uint32_t version;
uint32_t num_docs;
uint32_t num_blocks;
};
static constexpr size_t HEADER_SIZE = sizeof(Header);
/// Block header (16 bytes, padded for SIMD alignment).
struct BlockHeader {
uint32_t min_doc_id;
uint8_t bitwidth_id;
uint8_t bitwidth_tf;
uint8_t bitwidth_dl;
uint8_t num_docs; ///< Number of docs in this block (<=128)
float block_max_score; ///< Redundant copy for fast in-block access
uint32_t padding_{
0}; ///< Padding to make BlockHeader 16 bytes (SIMD alignment)
};
/// Encode a posting list with inline payloads.
/// \param doc_ids Sorted ascending doc_id array
/// \param tfs Term frequency for each doc
/// \param doc_lens Document length for each doc
/// \param count Number of entries
/// \param df Document frequency (used for IDF in block_max_score)
/// \param scorer BM25 scorer with segment stats loaded
/// \return Serialized bitpacked posting list
static std::string encode(const uint32_t *doc_ids, const uint32_t *tfs,
const uint32_t *doc_lens, size_t count, uint64_t df,
const BM25Scorer &scorer);
/// Check if raw data starts with the BitPacked magic number.
static bool is_bitpacked_format(const char *data, size_t size) {
if (size < sizeof(uint32_t)) return false;
uint32_t magic = 0;
std::memcpy(&magic, data, sizeof(uint32_t));
return magic == MAGIC;
}
// ---- Low-level bitpacking primitives ----
/// Pack \p count uint32 values (each using \p bitwidth bits) into \p out.
/// \p out must have at least ceil(bitwidth * count / 8) bytes.
/// \p count must be <= DOCS_PER_BLOCK (128).
static void pack_uint32(const uint32_t *in, uint8_t bitwidth, uint32_t count,
uint8_t *out);
/// Unpack \p count uint32 values (each using \p bitwidth bits) from \p in.
/// \p out must have room for \p count uint32_t values.
static void unpack_uint32(const uint8_t *in, uint8_t bitwidth, uint32_t count,
uint32_t *out);
/// Compute the minimum number of bits needed to represent \p max_value.
/// Returns 0 if max_value == 0.
static uint8_t bits_needed(uint32_t max_value);
/// Compute packed byte size for \p count values at \p bitwidth bits each
/// (scalar format, used for tail blocks with count < DOCS_PER_BLOCK).
static size_t packed_byte_size(uint8_t bitwidth, uint32_t count) {
return (static_cast<size_t>(bitwidth) * count + 7) / 8;
}
/// Compute packed byte size for a full SIMD block (128 values).
/// SIMD format stores bitwidth __m128i values = bitwidth * 16 bytes.
static size_t simd_packed_byte_size(uint8_t bitwidth) {
return static_cast<size_t>(bitwidth) * 16;
}
};
// ============================================================
// BitPacked Posting Iterator (zero-copy, block-at-a-time)
// ============================================================
/// Zero-copy iterator over a serialized BitPacked posting list.
/// Decodes one block at a time into stack-allocated arrays.
class BitPackedPostingIterator {
public:
static constexpr uint32_t NO_MORE_DOCS = UINT32_MAX;
BitPackedPostingIterator() = default;
/// Open from serialized data (zero-copy, does not own the data).
/// \param data Pointer to serialized bitpacked posting list
/// \param size Size of the serialized data in bytes
/// \return 0 on success, -1 on error (bad magic, truncated data, etc.)
int open(const char *data, size_t size);
/// Advance to the next document.
/// \return doc_id of the next document, or NO_MORE_DOCS if exhausted.
uint32_t next_doc();
/// Advance to the first document with doc_id >= target.
/// Uses the skip list for O(log N_blocks) block-level seeking.
/// \return doc_id >= target, or NO_MORE_DOCS if exhausted.
uint32_t advance(uint32_t target);
/// Current document ID (valid after next_doc/advance).
uint32_t doc_id() const {
return current_doc_id_;
}
/// Term frequency of the current document (valid after next_doc/advance).
/// NOTE: non-const because lazy decode may be triggered on first access.
uint32_t term_freq();
/// Document length of the current document (valid after next_doc/advance).
/// NOTE: non-const because lazy decode may be triggered on first access.
uint32_t doc_len();
/// Return both block_max_score and max_doc_id for the block containing
/// \p target in a single binary search on the skip list.
/// Does NOT move the iterator position.
struct BlockMaxInfo {
float block_max_score{0.0f};
uint32_t block_last_doc{NO_MORE_DOCS};
};
BlockMaxInfo block_max_info_for(uint32_t target) const;
/// Total number of documents in this posting list.
uint64_t cost() const {
return num_docs_;
}
/// Maximum block_max_score across all blocks (global upper bound).
float max_score() const {
return global_max_score_;
}
private:
/// Decode block at index \p block_idx into the stack arrays.
void decode_block(size_t block_idx);
/// Lazy decode: ensure tf values are decoded before access.
void ensure_tf_decoded();
/// Lazy decode: ensure doc_len values are decoded before access.
void ensure_dl_decoded();
/// SIMD search: find first index i in block_doc_ids_[start..size)
/// where doc_id >= target. Uses SSE4.1 for 4-wide comparison.
size_t simd_find_first_ge(uint32_t target, size_t start) const;
// File header fields
uint32_t num_docs_{0};
uint32_t num_blocks_{0};
// Skip list (pointer into data_, not owned)
const BitPackedPostingList::BlockMeta *skip_list_{nullptr};
// Raw data pointer (not owned)
const char *data_{nullptr};
size_t data_size_{0};
// Current block state (decoded into stack arrays)
alignas(16) uint32_t block_doc_ids_[BitPackedPostingList::DOCS_PER_BLOCK];
alignas(16) uint32_t block_tfs_[BitPackedPostingList::DOCS_PER_BLOCK];
alignas(16) uint32_t block_doc_lens_[BitPackedPostingList::DOCS_PER_BLOCK];
size_t current_block_idx_{0};
uint32_t current_block_size_{0};
size_t in_block_pos_{0}; ///< Position within current decoded block
bool block_decoded_{false}; ///< Whether current block is decoded
// Lazy decode state: tf and doc_len are decoded on first access
bool tf_decoded_{false};
bool dl_decoded_{false};
// Store packed data pointers for lazy decode
const uint8_t *packed_tf_ptr_{nullptr};
const uint8_t *packed_dl_ptr_{nullptr};
uint8_t current_bitwidth_tf_{0};
uint8_t current_bitwidth_dl_{0};
uint32_t current_block_num_docs_{0}; ///< num_docs for lazy decode dispatch
bool current_block_is_full_{false}; ///< Whether current block is full (128)
uint32_t current_doc_id_{NO_MORE_DOCS};
float global_max_score_{0.0f};
// Cached SIMD dispatch function pointers (initialized in open()).
// Avoids repeated PLT/indirect calls through get_dispatch() on every
// decode_block / simd_find_first_ge invocation.
simd::PrefixSumFunc prefix_sum_fn_{nullptr};
simd::FindFirstGeFunc find_first_ge_fn_{nullptr};
simd::UnpackFunc unpack_fn_{nullptr};
// Cache for block_max_info_for to avoid repeated binary searches.
// If target falls within [cached_bmi_block_min_doc_+1, cached_bmi_last_doc_],
// we can return the cached result directly.
mutable uint32_t cached_bmi_last_doc_{0};
mutable float cached_bmi_score_{0.0f};
mutable size_t cached_bmi_block_idx_{0};
mutable bool cached_bmi_valid_{false};
};
} // namespace zvec::fts

View File

@ -0,0 +1,216 @@
// 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 "bitpacked_simd_avx2.h"
#if defined(__AVX2__) || \
(defined(_MSC_VER) && (defined(_M_X64) || defined(_M_IX86)))
#include <immintrin.h>
#include <cstring>
#include "bitpacked_simd_sse41.h"
#ifdef _MSC_VER
#include <intrin.h>
static inline int ctz_u32(unsigned int v) {
unsigned long index;
_BitScanForward(&index, v);
return static_cast<int>(index);
}
#else
static inline int ctz_u32(unsigned int v) {
return __builtin_ctz(v);
}
#endif
namespace zvec::fts::simd {
// ------------------------------------------------------------
// avx2_max_128
// ------------------------------------------------------------
void avx2_max_128(const uint32_t *deltas, const uint32_t *tfs,
const uint32_t *doc_lens, size_t start, uint32_t count,
uint32_t &max_delta, uint32_t &max_tf, uint32_t &max_dl) {
__m256i vmax_delta = _mm256_setzero_si256();
__m256i vmax_tf = _mm256_setzero_si256();
__m256i vmax_dl = _mm256_setzero_si256();
for (uint32_t i = 0; i < count; i += 8) {
vmax_delta = _mm256_max_epu32(
vmax_delta, _mm256_loadu_si256(
reinterpret_cast<const __m256i *>(&deltas[start + i])));
vmax_tf = _mm256_max_epu32(
vmax_tf,
_mm256_loadu_si256(reinterpret_cast<const __m256i *>(&tfs[start + i])));
vmax_dl = _mm256_max_epu32(
vmax_dl, _mm256_loadu_si256(
reinterpret_cast<const __m256i *>(&doc_lens[start + i])));
}
// Horizontal max: reduce 8 lanes to scalar
auto hmax = [](__m256i v) -> uint32_t {
// Reduce 256-bit to 128-bit by taking max of high and low halves
__m128i lo = _mm256_castsi256_si128(v);
__m128i hi = _mm256_extracti128_si256(v, 1);
__m128i m = _mm_max_epu32(lo, hi);
// Reduce 128-bit to scalar
m = _mm_max_epu32(m, _mm_shuffle_epi32(m, _MM_SHUFFLE(2, 3, 0, 1)));
m = _mm_max_epu32(m, _mm_shuffle_epi32(m, _MM_SHUFFLE(1, 0, 3, 2)));
return static_cast<uint32_t>(_mm_extract_epi32(m, 0));
};
max_delta = hmax(vmax_delta);
max_tf = hmax(vmax_tf);
max_dl = hmax(vmax_dl);
}
// ------------------------------------------------------------
// avx2_pack_uint32_128 — fallback to SSE4.1
// ------------------------------------------------------------
void avx2_pack_uint32_128(const uint32_t *in, uint8_t bitwidth, uint8_t *out) {
// FastPForLib does not provide AVX2 bitpacking; delegate to SSE4.1.
sse41_pack_uint32_128(in, bitwidth, out);
}
// ------------------------------------------------------------
// avx2_unpack_uint32_128 — fallback to SSE4.1
// ------------------------------------------------------------
void avx2_unpack_uint32_128(const uint8_t *in, uint8_t bitwidth,
uint32_t *out) {
// FastPForLib does not provide AVX2 bitpacking; delegate to SSE4.1.
sse41_unpack_uint32_128(in, bitwidth, out);
}
// ------------------------------------------------------------
// avx2_prefix_sum_128
// ------------------------------------------------------------
void avx2_prefix_sum_128(const uint32_t *deltas, uint32_t min_doc_id,
uint32_t /*count*/, uint32_t *out) {
// Process 8 elements per iteration (16 groups of 8 = 128 elements).
// Within each 256-bit register we compute a prefix-sum, then propagate
// the carry (last element) to the next group.
__m256i carry = _mm256_set1_epi32(static_cast<int>(min_doc_id) -
static_cast<int>(deltas[0]));
for (uint32_t g = 0; g < 16; ++g) {
__m256i v =
_mm256_loadu_si256(reinterpret_cast<const __m256i *>(&deltas[g * 8]));
// In-register prefix-sum for 8 elements (two 128-bit lanes independently,
// then cross-lane fixup).
// Step 1: shift by 1 element (4 bytes) within each 128-bit lane
__m256i shifted1 = _mm256_bslli_epi128(v, 4);
v = _mm256_add_epi32(v, shifted1);
// Step 2: shift by 2 elements (8 bytes) within each 128-bit lane
__m256i shifted2 = _mm256_bslli_epi128(v, 8);
v = _mm256_add_epi32(v, shifted2);
// Step 3: cross-lane fixup — high lane needs the sum of the low lane's
// last element (index 3) added to all its elements.
// Broadcast low lane's element[3] to all positions of high lane.
__m128i lo = _mm256_castsi256_si128(v);
__m128i lo_last = _mm_shuffle_epi32(lo, _MM_SHUFFLE(3, 3, 3, 3));
__m256i cross = _mm256_set_m128i(lo_last, _mm_setzero_si128());
v = _mm256_add_epi32(v, cross);
// Add carry from previous group
v = _mm256_add_epi32(v, carry);
_mm256_storeu_si256(reinterpret_cast<__m256i *>(&out[g * 8]), v);
// Broadcast the last element (index 7) as carry for next group.
// Element 7 is in the high lane at position 3.
__m128i hi = _mm256_extracti128_si256(v, 1);
__m128i hi_last = _mm_shuffle_epi32(hi, _MM_SHUFFLE(3, 3, 3, 3));
carry = _mm256_set_m128i(hi_last, hi_last);
}
}
// ------------------------------------------------------------
// avx2_find_first_ge
// ------------------------------------------------------------
size_t avx2_find_first_ge(const uint32_t *arr, uint32_t size, uint32_t target,
size_t start) {
const __m256i vtarget = _mm256_set1_epi32(static_cast<int>(target));
const __m256i sign_bit = _mm256_set1_epi32(static_cast<int>(0x80000000u));
const __m256i starget = _mm256_xor_si256(vtarget, sign_bit);
size_t i = start;
// Scalar until aligned to 4-element boundary (minimum for unaligned AVX2)
for (; i < size && (i & 3); ++i) {
if (arr[i] >= target) {
return i;
}
}
// SIMD scan: 8 elements at a time
for (; i + 8 <= size; i += 8) {
__m256i v = _mm256_loadu_si256(reinterpret_cast<const __m256i *>(&arr[i]));
__m256i sv = _mm256_xor_si256(v, sign_bit);
// cmpgt: sv < starget means arr[i] < target
__m256i cmp = _mm256_cmpgt_epi32(starget, sv);
int mask = _mm256_movemask_ps(_mm256_castsi256_ps(cmp));
if (mask != 0xFF) {
// At least one element >= target
int first = ctz_u32(static_cast<unsigned int>(~mask & 0xFF));
return i + first;
}
}
// Scalar tail
for (; i < size; ++i) {
if (arr[i] >= target) {
return i;
}
}
return size;
}
} // namespace zvec::fts::simd
#else // !defined(__AVX2__) && !(defined(_MSC_VER) && (defined(_M_X64) ||
// defined(_M_IX86)))
// Stub implementations when AVX2 is not available at compile time.
// The runtime dispatch layer (bitpacked_simd_dispatch.cc) will never call
// these on non-AVX2 machines, but the linker still needs the symbols.
namespace zvec::fts::simd {
void avx2_max_128(const uint32_t *, const uint32_t *, const uint32_t *, size_t,
uint32_t, uint32_t &max_delta, uint32_t &max_tf,
uint32_t &max_dl) {
max_delta = 0;
max_tf = 0;
max_dl = 0;
}
void avx2_pack_uint32_128(const uint32_t *, uint8_t, uint8_t *) {}
void avx2_unpack_uint32_128(const uint8_t *, uint8_t, uint32_t *) {}
void avx2_prefix_sum_128(const uint32_t *, uint32_t, uint32_t, uint32_t *) {}
size_t avx2_find_first_ge(const uint32_t *, uint32_t size, uint32_t, size_t) {
return size;
}
} // namespace zvec::fts::simd
#endif // defined(__AVX2__)

View File

@ -0,0 +1,49 @@
// 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 <cstddef>
#include <cstdint>
namespace zvec::fts::simd {
/// Compute element-wise max of 128 uint32 values across three arrays using
/// AVX2 _mm256_max_epu32. \p deltas must be 32-byte aligned; \p tfs and
/// \p doc_lens may be unaligned.
void avx2_max_128(const uint32_t *deltas, const uint32_t *tfs,
const uint32_t *doc_lens, size_t start, uint32_t count,
uint32_t &max_delta, uint32_t &max_tf, uint32_t &max_dl);
/// Pack 128 uint32 values at \p bitwidth bits each into \p out.
/// Falls back to SSE4.1 implementation (FastPForLib lacks AVX2 bitpacking).
void avx2_pack_uint32_128(const uint32_t *in, uint8_t bitwidth, uint8_t *out);
/// Unpack 128 uint32 values at \p bitwidth bits each from \p in.
/// Falls back to SSE4.1 implementation (FastPForLib lacks AVX2 bitpacking).
void avx2_unpack_uint32_128(const uint8_t *in, uint8_t bitwidth, uint32_t *out);
/// Compute prefix-sum over \p count (must be 128) delta values, producing
/// absolute doc_ids. Uses AVX2 SIMD prefix-sum with carry propagation.
/// \p deltas must be 32-byte aligned; \p out must be 32-byte aligned.
void avx2_prefix_sum_128(const uint32_t *deltas, uint32_t min_doc_id,
uint32_t count, uint32_t *out);
/// Find the first index i in arr[start..size) where arr[i] >= target.
/// Uses AVX2 8-wide comparison with unsigned-to-signed trick.
/// \p arr must be 32-byte aligned.
size_t avx2_find_first_ge(const uint32_t *arr, uint32_t size, uint32_t target,
size_t start);
} // namespace zvec::fts::simd

View File

@ -0,0 +1,60 @@
// 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 "bitpacked_simd_dispatch.h"
#include <ailego/internal/cpu_features.h>
#include "bitpacked_simd_scalar.h"
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || \
defined(_M_IX86)
#include "bitpacked_simd_avx2.h"
#include "bitpacked_simd_sse41.h"
#endif
namespace zvec::fts::simd {
static DispatchTable init_dispatch() {
DispatchTable t{};
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || \
defined(_M_IX86)
if (zvec::ailego::internal::CpuFeatures::static_flags_.AVX2) {
t.max_128 = avx2_max_128;
t.pack_uint32_128 = avx2_pack_uint32_128;
t.unpack_uint32_128 = avx2_unpack_uint32_128;
t.prefix_sum_128 = avx2_prefix_sum_128;
t.find_first_ge = avx2_find_first_ge;
return t;
}
if (zvec::ailego::internal::CpuFeatures::static_flags_.SSE4_1) {
t.max_128 = sse41_max_128;
t.pack_uint32_128 = sse41_pack_uint32_128;
t.unpack_uint32_128 = sse41_unpack_uint32_128;
t.prefix_sum_128 = sse41_prefix_sum_128;
t.find_first_ge = sse41_find_first_ge;
return t;
}
#endif
t.max_128 = scalar_max_128;
t.pack_uint32_128 = scalar_pack_uint32_128;
t.unpack_uint32_128 = scalar_unpack_uint32_128;
t.prefix_sum_128 = scalar_prefix_sum_128;
t.find_first_ge = scalar_find_first_ge;
return t;
}
const DispatchTable &get_dispatch() {
static const DispatchTable table = init_dispatch();
return table;
}
} // namespace zvec::fts::simd

View File

@ -0,0 +1,44 @@
// 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 <cstddef>
#include <cstdint>
namespace zvec::fts::simd {
// Function pointer types for SIMD-dispatched operations.
using MaxFunc = void (*)(const uint32_t *, const uint32_t *, const uint32_t *,
size_t, uint32_t, uint32_t &, uint32_t &, uint32_t &);
using PackFunc = void (*)(const uint32_t *, uint8_t, uint8_t *);
using UnpackFunc = void (*)(const uint8_t *, uint8_t, uint32_t *);
using PrefixSumFunc = void (*)(const uint32_t *, uint32_t, uint32_t,
uint32_t *);
using FindFirstGeFunc = size_t (*)(const uint32_t *, uint32_t, uint32_t,
size_t);
/// Dispatch table populated once at startup via CPU feature detection.
struct DispatchTable {
MaxFunc max_128;
PackFunc pack_uint32_128;
UnpackFunc unpack_uint32_128;
PrefixSumFunc prefix_sum_128;
FindFirstGeFunc find_first_ge;
};
/// Get the global dispatch table (initialized on first call).
const DispatchTable &get_dispatch();
} // namespace zvec::fts::simd

View File

@ -0,0 +1,115 @@
// 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 "bitpacked_simd_scalar.h"
#include <bitpackinghelpers.h>
#include <algorithm>
#include <cstring>
#include "bitpacked_posting_list.h"
namespace zvec::fts::simd {
// ------------------------------------------------------------
// scalar_max_128
// ------------------------------------------------------------
void scalar_max_128(const uint32_t *deltas, const uint32_t *tfs,
const uint32_t *doc_lens, size_t start, uint32_t count,
uint32_t &max_delta, uint32_t &max_tf, uint32_t &max_dl) {
uint32_t md = 0, mt = 0, ml = 0;
for (uint32_t i = 0; i < count; ++i) {
md = std::max(md, deltas[start + i]);
mt = std::max(mt, tfs[start + i]);
ml = std::max(ml, doc_lens[start + i]);
}
max_delta = md;
max_tf = mt;
max_dl = ml;
}
// ------------------------------------------------------------
// scalar_pack_uint32_128 / scalar_unpack_uint32_128
// ------------------------------------------------------------
//
// These produce / consume the SAME byte layout as the SSE/AVX2 SIMD packers
// (SIMD_fastpackwithoutmask_32 / SIMD_fastunpack_32), so an index encoded on
// one architecture can be decoded on another. The SIMD layout interleaves the
// 128 values across 4 lanes: lane L (0..3), read across the bitwidth output
// __m128i words, holds the scalar bit-packing of the 32 values
// { in[L], in[4+L], in[8+L], ..., in[124+L] }. We reproduce that exactly by
// packing each lane independently with FastPForLib::fastpackwithoutmask and
// interleaving the resulting 32-bit words at 128-bit (4-lane) granularity.
void scalar_pack_uint32_128(const uint32_t *in, uint8_t bitwidth,
uint8_t *out) {
const size_t total_bytes =
BitPackedPostingList::simd_packed_byte_size(bitwidth);
std::memset(out, 0, total_bytes);
uint32_t *out32 = reinterpret_cast<uint32_t *>(out);
for (uint32_t lane = 0; lane < 4; ++lane) {
uint32_t lane_in[32];
for (uint32_t k = 0; k < 32; ++k) {
lane_in[k] = in[k * 4 + lane];
}
alignas(16) uint32_t lane_packed[32] = {};
FastPForLib::fastpackwithoutmask(lane_in, lane_packed, bitwidth);
for (uint32_t j = 0; j < bitwidth; ++j) {
out32[j * 4 + lane] = lane_packed[j];
}
}
}
void scalar_unpack_uint32_128(const uint8_t *in, uint8_t bitwidth,
uint32_t *out) {
const uint32_t *in32 = reinterpret_cast<const uint32_t *>(in);
for (uint32_t lane = 0; lane < 4; ++lane) {
alignas(16) uint32_t lane_packed[32] = {};
for (uint32_t j = 0; j < bitwidth; ++j) {
lane_packed[j] = in32[j * 4 + lane];
}
uint32_t lane_out[32];
FastPForLib::fastunpack(lane_packed, lane_out, bitwidth);
for (uint32_t k = 0; k < 32; ++k) {
out[k * 4 + lane] = lane_out[k];
}
}
}
// ------------------------------------------------------------
// scalar_prefix_sum_128
// ------------------------------------------------------------
void scalar_prefix_sum_128(const uint32_t *deltas, uint32_t min_doc_id,
uint32_t count, uint32_t *out) {
// First element: min_doc_id corresponds to deltas[0]
out[0] = min_doc_id;
for (uint32_t i = 1; i < count; ++i) {
out[i] = out[i - 1] + deltas[i];
}
}
// ------------------------------------------------------------
// scalar_find_first_ge
// ------------------------------------------------------------
size_t scalar_find_first_ge(const uint32_t *arr, uint32_t size, uint32_t target,
size_t start) {
for (size_t i = start; i < size; ++i) {
if (arr[i] >= target) return i;
}
return size;
}
} // namespace zvec::fts::simd

View File

@ -0,0 +1,48 @@
// 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 <cstddef>
#include <cstdint>
namespace zvec::fts::simd {
/// Scalar fallback: compute element-wise max of up to 128 uint32 values across
/// three arrays using a simple loop.
void scalar_max_128(const uint32_t *deltas, const uint32_t *tfs,
const uint32_t *doc_lens, size_t start, uint32_t count,
uint32_t &max_delta, uint32_t &max_tf, uint32_t &max_dl);
/// Scalar fallback: pack 128 uint32 values at \p bitwidth bits each into \p
/// out, producing the SAME interleaved byte layout as the SSE/AVX2 SIMD packer
/// so that indexes remain portable across architectures.
void scalar_pack_uint32_128(const uint32_t *in, uint8_t bitwidth, uint8_t *out);
/// Scalar fallback: unpack 128 uint32 values at \p bitwidth bits each from
/// \p in, reading the SAME interleaved byte layout as the SSE/AVX2 SIMD packer.
void scalar_unpack_uint32_128(const uint8_t *in, uint8_t bitwidth,
uint32_t *out);
/// Scalar fallback: compute prefix-sum over \p count delta values, producing
/// absolute doc_ids.
void scalar_prefix_sum_128(const uint32_t *deltas, uint32_t min_doc_id,
uint32_t count, uint32_t *out);
/// Scalar fallback: find the first index i in arr[start..size) where
/// arr[i] >= target using a linear scan.
size_t scalar_find_first_ge(const uint32_t *arr, uint32_t size, uint32_t target,
size_t start);
} // namespace zvec::fts::simd

View File

@ -0,0 +1,202 @@
// 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 "bitpacked_simd_sse41.h"
#if defined(__SSE4_1__) || \
(defined(_MSC_VER) && (defined(_M_X64) || defined(_M_IX86)))
#include <bitpackinghelpers.h>
#include <emmintrin.h> // SSE2
#include <simdbitpacking.h>
#include <smmintrin.h> // SSE4.1
#include <cstring>
#include "bitpacked_posting_list.h"
#ifdef _MSC_VER
#include <intrin.h>
static inline int ctz_u32(unsigned int v) {
unsigned long index;
_BitScanForward(&index, v);
return static_cast<int>(index);
}
#else
static inline int ctz_u32(unsigned int v) {
return __builtin_ctz(v);
}
#endif
namespace zvec::fts::simd {
// ------------------------------------------------------------
// sse41_max_128
// ------------------------------------------------------------
void sse41_max_128(const uint32_t *deltas, const uint32_t *tfs,
const uint32_t *doc_lens, size_t start, uint32_t count,
uint32_t &max_delta, uint32_t &max_tf, uint32_t &max_dl) {
__m128i vmax_delta = _mm_setzero_si128();
__m128i vmax_tf = _mm_setzero_si128();
__m128i vmax_dl = _mm_setzero_si128();
for (uint32_t i = 0; i < count; i += 4) {
vmax_delta = _mm_max_epu32(
vmax_delta,
_mm_load_si128(reinterpret_cast<const __m128i *>(&deltas[start + i])));
vmax_tf = _mm_max_epu32(
vmax_tf,
_mm_loadu_si128(reinterpret_cast<const __m128i *>(&tfs[start + i])));
vmax_dl = _mm_max_epu32(
vmax_dl, _mm_loadu_si128(
reinterpret_cast<const __m128i *>(&doc_lens[start + i])));
}
// Horizontal max: reduce 4 lanes to scalar
auto hmax = [](__m128i v) -> uint32_t {
v = _mm_max_epu32(v, _mm_shuffle_epi32(v, _MM_SHUFFLE(2, 3, 0, 1)));
v = _mm_max_epu32(v, _mm_shuffle_epi32(v, _MM_SHUFFLE(1, 0, 3, 2)));
return static_cast<uint32_t>(_mm_extract_epi32(v, 0));
};
max_delta = hmax(vmax_delta);
max_tf = hmax(vmax_tf);
max_dl = hmax(vmax_dl);
}
// ------------------------------------------------------------
// sse41_pack_uint32_128
// ------------------------------------------------------------
void sse41_pack_uint32_128(const uint32_t *in, uint8_t bitwidth, uint8_t *out) {
const size_t total_bytes =
BitPackedPostingList::simd_packed_byte_size(bitwidth);
if ((reinterpret_cast<uintptr_t>(out) & 15) == 0) {
FastPForLib::SIMD_fastpackwithoutmask_32(
in, reinterpret_cast<__m128i *>(out), bitwidth);
} else {
alignas(16) __m128i simd_out[32];
FastPForLib::SIMD_fastpackwithoutmask_32(in, simd_out, bitwidth);
std::memcpy(out, simd_out, total_bytes);
}
}
// ------------------------------------------------------------
// sse41_unpack_uint32_128
// ------------------------------------------------------------
void sse41_unpack_uint32_128(const uint8_t *in, uint8_t bitwidth,
uint32_t *out) {
if ((reinterpret_cast<uintptr_t>(in) & 15) == 0) {
FastPForLib::SIMD_fastunpack_32(reinterpret_cast<const __m128i *>(in), out,
bitwidth);
} else {
const size_t packed_bytes =
BitPackedPostingList::simd_packed_byte_size(bitwidth);
alignas(16) __m128i simd_in[32];
std::memcpy(simd_in, in, packed_bytes);
FastPForLib::SIMD_fastunpack_32(simd_in, out, bitwidth);
}
}
// ------------------------------------------------------------
// sse41_prefix_sum_128
// ------------------------------------------------------------
void sse41_prefix_sum_128(const uint32_t *deltas, uint32_t min_doc_id,
uint32_t /*count*/, uint32_t *out) {
__m128i carry = _mm_set1_epi32(static_cast<int>(min_doc_id) -
static_cast<int>(deltas[0]));
for (uint32_t g = 0; g < 32; ++g) {
__m128i v =
_mm_load_si128(reinterpret_cast<const __m128i *>(&deltas[g * 4]));
// In-register prefix-sum for 4 elements
__m128i shifted1 = _mm_slli_si128(v, 4);
v = _mm_add_epi32(v, shifted1);
__m128i shifted2 = _mm_slli_si128(v, 8);
v = _mm_add_epi32(v, shifted2);
// Add carry from previous group
v = _mm_add_epi32(v, carry);
_mm_store_si128(reinterpret_cast<__m128i *>(&out[g * 4]), v);
// Broadcast the last element as carry for next group
carry = _mm_shuffle_epi32(v, _MM_SHUFFLE(3, 3, 3, 3));
}
}
// ------------------------------------------------------------
// sse41_find_first_ge
// ------------------------------------------------------------
size_t sse41_find_first_ge(const uint32_t *arr, uint32_t size, uint32_t target,
size_t start) {
const __m128i vtarget = _mm_set1_epi32(static_cast<int>(target));
const __m128i sign_bit = _mm_set1_epi32(static_cast<int>(0x80000000u));
const __m128i starget = _mm_xor_si128(vtarget, sign_bit);
size_t i = start;
// Scalar until aligned to 4-element boundary
for (; i < size && (i & 3); ++i) {
if (arr[i] >= target) return i;
}
// SIMD scan: 4 elements at a time
for (; i + 4 <= size; i += 4) {
__m128i v = _mm_load_si128(reinterpret_cast<const __m128i *>(&arr[i]));
__m128i sv = _mm_xor_si128(v, sign_bit);
__m128i cmp = _mm_cmplt_epi32(sv, starget);
int mask = _mm_movemask_ps(_mm_castsi128_ps(cmp));
if (mask != 0xF) {
int first = ctz_u32(static_cast<unsigned int>(~mask));
return i + first;
}
}
// Scalar tail
for (; i < size; ++i) {
if (arr[i] >= target) return i;
}
return size;
}
} // namespace zvec::fts::simd
#else // !defined(__SSE4_1__) && !(defined(_MSC_VER) && (defined(_M_X64) ||
// defined(_M_IX86)))
// Stub implementations when SSE4.1 is not available at compile time.
// The runtime dispatch layer (bitpacked_simd_dispatch.cc) will never call
// these on non-SSE4.1 machines, but the linker still needs the symbols.
namespace zvec::fts::simd {
void sse41_max_128(const uint32_t *, const uint32_t *, const uint32_t *, size_t,
uint32_t, uint32_t &max_delta, uint32_t &max_tf,
uint32_t &max_dl) {
max_delta = 0;
max_tf = 0;
max_dl = 0;
}
void sse41_pack_uint32_128(const uint32_t *, uint8_t, uint8_t *) {}
void sse41_unpack_uint32_128(const uint8_t *, uint8_t, uint32_t *) {}
void sse41_prefix_sum_128(const uint32_t *, uint32_t, uint32_t, uint32_t *) {}
size_t sse41_find_first_ge(const uint32_t *, uint32_t size, uint32_t, size_t) {
return size;
}
} // namespace zvec::fts::simd
#endif // defined(__SSE4_1__)

View File

@ -0,0 +1,50 @@
// 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 <cstddef>
#include <cstdint>
namespace zvec::fts::simd {
/// Compute element-wise max of 128 uint32 values across three arrays using
/// SSE4.1 _mm_max_epu32. \p deltas must be 16-byte aligned; \p tfs and
/// \p doc_lens may be unaligned.
void sse41_max_128(const uint32_t *deltas, const uint32_t *tfs,
const uint32_t *doc_lens, size_t start, uint32_t count,
uint32_t &max_delta, uint32_t &max_tf, uint32_t &max_dl);
/// Pack 128 uint32 values at \p bitwidth bits each into \p out using SSE SIMD
/// interleaved layout (SIMD_fastpackwithoutmask_32).
void sse41_pack_uint32_128(const uint32_t *in, uint8_t bitwidth, uint8_t *out);
/// Unpack 128 uint32 values at \p bitwidth bits each from \p in using SSE SIMD
/// interleaved layout (SIMD_fastunpack_32).
void sse41_unpack_uint32_128(const uint8_t *in, uint8_t bitwidth,
uint32_t *out);
/// Compute prefix-sum over \p count (must be 128) delta values, producing
/// absolute doc_ids. Uses SSE2 SIMD prefix-sum with carry propagation.
/// \p deltas must be 16-byte aligned; \p out must be 16-byte aligned.
void sse41_prefix_sum_128(const uint32_t *deltas, uint32_t min_doc_id,
uint32_t count, uint32_t *out);
/// Find the first index i in arr[start..size) where arr[i] >= target.
/// Uses SSE2 SIMD 4-wide comparison with unsigned-to-signed trick.
/// \p arr must be 16-byte aligned.
size_t sse41_find_first_ge(const uint32_t *arr, uint32_t size, uint32_t target,
size_t start);
} // namespace zvec::fts::simd

View File

@ -0,0 +1,177 @@
// 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 "jieba_tokenizer.h"
#include <cstdlib>
#include <zvec/ailego/logger/logger.h>
// Drop the ERROR macro that cppjieba's transitive <windows.h> defines so it
// does not collide with zvec::GlobalConfig::LogLevel::ERROR below.
#ifdef ERROR
#undef ERROR
#endif
#include <zvec/db/config.h>
namespace zvec::fts {
static std::string get_string_or_default(const ailego::JsonObject &config,
const char *key,
const std::string &default_value) {
auto val = config[key];
if (val.is_string()) {
std::string result = val.as_string().c_str();
if (!result.empty()) {
return result;
}
}
return default_value;
}
// Priority: per-field config > ZVEC_JIEBA_DICT_DIR > GlobalConfig.
static std::string resolve_jieba_dict_dir(const ailego::JsonObject &config) {
std::string dir = get_string_or_default(config, "jieba_dict_dir", "");
if (!dir.empty()) {
return dir;
}
if (const char *env = std::getenv("ZVEC_JIEBA_DICT_DIR"); env && *env) {
return env;
}
return GlobalConfig::Instance().jieba_dict_dir();
}
bool JiebaTokenizer::init(const ailego::JsonObject &config) {
std::string user_dict_path =
get_string_or_default(config, "user_dict_path", "");
std::string mode_str = get_string_or_default(config, "cut_mode", "search");
if (mode_str == "search") {
cut_mode_ = CutMode::kSearch;
} else if (mode_str == "mix") {
cut_mode_ = CutMode::kMix;
} else if (mode_str == "full") {
cut_mode_ = CutMode::kFull;
} else if (mode_str == "hmm") {
cut_mode_ = CutMode::kHmm;
} else {
LOG_ERROR("JiebaTokenizer: unknown cut_mode '%s'", mode_str.c_str());
return false;
}
bool needs_dict = cut_mode_ != CutMode::kHmm;
bool needs_model = cut_mode_ != CutMode::kFull;
std::string dict_dir = resolve_jieba_dict_dir(config);
if ((needs_dict || needs_model) && dict_dir.empty()) {
LOG_ERROR(
"JiebaTokenizer: jieba_dict_dir not configured. Set via "
"extra_params.jieba_dict_dir, ZVEC_JIEBA_DICT_DIR env var, "
"or zvec.set_default_jieba_dict_dir() / "
"zvec.init(jieba_dict_dir=...).");
return false;
}
std::string dict_path = needs_dict ? dict_dir + "/jieba.dict.utf8" : "";
std::string model_path = needs_model ? dict_dir + "/hmm_model.utf8" : "";
reset();
try {
if (needs_dict) {
dict_trie_ =
std::make_unique<cppjieba::DictTrie>(dict_path, user_dict_path);
}
if (needs_model) {
hmm_model_ = std::make_unique<cppjieba::HMMModel>(model_path);
}
switch (cut_mode_) {
case CutMode::kSearch:
query_seg_ = std::make_unique<cppjieba::QuerySegment>(dict_trie_.get(),
hmm_model_.get());
break;
case CutMode::kMix:
mix_seg_ = std::make_unique<cppjieba::MixSegment>(dict_trie_.get(),
hmm_model_.get());
break;
case CutMode::kFull:
full_seg_ = std::make_unique<cppjieba::FullSegment>(dict_trie_.get());
break;
case CutMode::kHmm:
hmm_seg_ = std::make_unique<cppjieba::HMMSegment>(hmm_model_.get());
break;
}
} catch (const std::exception &e) {
LOG_ERROR("JiebaTokenizer init failed: %s", e.what());
reset();
return false;
}
initialized_ = true;
LOG_INFO("JiebaTokenizer init success. dict_dir[%s] cut_mode[%s]",
dict_dir.c_str(), mode_str.c_str());
return true;
}
JiebaTokenizer::~JiebaTokenizer() = default;
void JiebaTokenizer::reset() {
query_seg_.reset();
mix_seg_.reset();
full_seg_.reset();
hmm_seg_.reset();
dict_trie_.reset();
hmm_model_.reset();
initialized_ = false;
}
std::vector<Token> JiebaTokenizer::tokenize(const std::string &text) const {
std::vector<Token> tokens;
if (!initialized_ || text.empty()) {
return tokens;
}
std::vector<cppjieba::Word> words;
switch (cut_mode_) {
case CutMode::kSearch:
query_seg_->Cut(text, words, true);
break;
case CutMode::kMix:
mix_seg_->Cut(text, words, true);
break;
case CutMode::kFull:
full_seg_->Cut(text, words);
break;
case CutMode::kHmm:
hmm_seg_->Cut(text, words);
break;
}
tokens.reserve(words.size());
// Position = output sequence index, not cppjieba's unicode_offset:
// overlapping sub-words emitted after long parents share unicode_offset,
// which breaks PhraseDocIterator's strict anchor+1 adjacency check.
uint32_t seq = 0;
for (const auto &word : words) {
if (word.word.empty()) {
continue;
}
Token token;
token.text = word.word;
token.offset = word.offset;
token.position = seq++;
tokens.push_back(std::move(token));
}
return tokens;
}
} // namespace zvec::fts

View File

@ -0,0 +1,86 @@
// 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 <cppjieba/QuerySegment.hpp>
#include "tokenizer.h"
namespace zvec::fts {
/*! Jieba tokenizer
*
* Wraps cppjieba's low-level segmenters to provide Chinese (and mixed
* Chinese/English) word segmentation. Uses CutForSearch (QuerySegment) by
* default, which produces the finer granularity used for indexing/search.
*
* After init(), the active segmenter is thread-safe for concurrent Cut
* calls, so tokenize() can be invoked from multiple threads.
*/
class JiebaTokenizer : public Tokenizer {
public:
JiebaTokenizer() = default;
~JiebaTokenizer() override;
// Non-copyable
JiebaTokenizer(const JiebaTokenizer &) = delete;
JiebaTokenizer &operator=(const JiebaTokenizer &) = delete;
// JSON config keys:
// "jieba_dict_dir" - directory containing jieba.dict.utf8 + hmm_model.utf8
// "user_dict_path" - optional user.dict.utf8
// "cut_mode" - "search" (default) | "mix" | "full" | "hmm"
//
// jieba_dict_dir resolution: per-field > ZVEC_JIEBA_DICT_DIR >
// zvec::GlobalConfig::jieba_dict_dir() (set by SDK on import or via init).
// Stop-word filtering belongs to a TokenFilter, not here.
bool init(const ailego::JsonObject &config) override;
std::vector<Token> tokenize(const std::string &text) const override;
const char *name() const override {
return "jieba";
}
bool is_valid() const {
return initialized_;
}
// Move-only (unique_ptr members)
JiebaTokenizer(JiebaTokenizer &&) = default;
JiebaTokenizer &operator=(JiebaTokenizer &&) = default;
private:
enum class CutMode { kSearch, kMix, kFull, kHmm };
// Release segmenters first (they hold raw pointers into dict_trie_ /
// hmm_model_), then release the underlying dict/model.
void reset();
// Declared before segmenters: reverse-order destruction keeps the raw
// pointers held by segmenters valid until the segmenters die.
std::unique_ptr<cppjieba::DictTrie> dict_trie_;
std::unique_ptr<cppjieba::HMMModel> hmm_model_;
std::unique_ptr<cppjieba::QuerySegment> query_seg_;
std::unique_ptr<cppjieba::MixSegment> mix_seg_;
std::unique_ptr<cppjieba::FullSegment> full_seg_;
std::unique_ptr<cppjieba::HMMSegment> hmm_seg_;
CutMode cut_mode_{CutMode::kSearch};
bool initialized_{false};
};
} // namespace zvec::fts

View File

@ -0,0 +1,76 @@
// 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 "standard_tokenizer.h"
#include <cctype>
namespace zvec::fts {
bool StandardTokenizer::init(const ailego::JsonObject &config) {
// Read optional max_token_length; keep default (255) if not present or
// if the provided value is zero.
auto length_val = config["max_token_length"];
if (length_val.is_integer()) {
uint32_t configured_length = static_cast<uint32_t>(length_val.as_integer());
if (configured_length > 0) {
max_token_length_ = configured_length;
}
}
return true;
}
std::vector<Token> StandardTokenizer::tokenize(const std::string &text) const {
std::vector<Token> tokens;
uint32_t position = 0;
size_t index = 0;
const size_t text_length = text.size();
while (index < text_length) {
// Skip non-alphanumeric characters (delimiters / punctuation).
while (index < text_length &&
!std::isalnum(static_cast<unsigned char>(text[index]))) {
++index;
}
if (index >= text_length) {
break;
}
// Mark the start of an alphanumeric run.
const uint32_t token_start = static_cast<uint32_t>(index);
// Advance to the end of the alphanumeric run.
while (index < text_length &&
std::isalnum(static_cast<unsigned char>(text[index]))) {
++index;
}
const uint32_t token_length = static_cast<uint32_t>(index) - token_start;
// Discard tokens that exceed the configured length limit.
if (token_length > max_token_length_) {
++position;
continue;
}
Token token;
token.text = text.substr(token_start, token_length);
token.offset = token_start;
token.position = position++;
tokens.push_back(std::move(token));
}
return tokens;
}
} // namespace zvec::fts

View File

@ -0,0 +1,48 @@
// 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 <cstdint>
#include "tokenizer.h"
namespace zvec::fts {
/*! Standard tokenizer
* Splits text on non-alphanumeric characters (punctuation, whitespace, etc.)
* and discards the delimiters. Produces lowercase-ready tokens composed of
* letters and digits only.
*/
class StandardTokenizer : public Tokenizer {
public:
/*! Initialise from JSON config.
* Supported keys:
* "max_token_length" (uint32, default 255): tokens longer than this limit
* are silently discarded.
* Always returns true.
*/
bool init(const ailego::JsonObject &config) override;
std::vector<Token> tokenize(const std::string &text) const override;
const char *name() const override {
return "standard";
}
private:
// Tokens whose byte length exceeds this value are discarded.
uint32_t max_token_length_{255};
};
} // namespace zvec::fts

View File

@ -0,0 +1,32 @@
// 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 "token_filter.h"
#include <algorithm>
#include <cctype>
namespace zvec::fts {
std::vector<Token> LowercaseTokenFilter::filter(
std::vector<Token> tokens) const {
for (auto &token : tokens) {
std::transform(token.text.begin(), token.text.end(), token.text.begin(),
[](unsigned char character) {
return static_cast<char>(std::tolower(character));
});
}
return tokens;
}
} // namespace zvec::fts

View File

@ -0,0 +1,57 @@
// 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 <vector>
#include "tokenizer.h"
namespace zvec::fts {
/*! Token Filter abstract interface
* Post-process tokenization results, such as case conversion, stopword
* filtering, etc.
*/
class TokenFilter {
public:
virtual ~TokenFilter() = default;
/*! Filter/transform a list of tokens.
* \param tokens input token list (may be modified in place)
* \return processed token list
*/
virtual std::vector<Token> filter(std::vector<Token> tokens) const = 0;
/*! Return filter name
*/
virtual const char *name() const = 0;
};
using TokenFilterPtr = std::shared_ptr<TokenFilter>;
/*! Lowercase Token Filter
* Convert all token text to lowercase (only handles ASCII characters)
*/
class LowercaseTokenFilter : public TokenFilter {
public:
std::vector<Token> filter(std::vector<Token> tokens) const override;
const char *name() const override {
return "lowercase";
}
};
} // namespace zvec::fts

View File

@ -0,0 +1,64 @@
// 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 <cstdint>
#include <memory>
#include <string>
#include <vector>
#include <zvec/ailego/encoding/json/mod_json_plus.h>
namespace zvec::fts {
/*! A single token in the tokenization result
*/
struct Token {
// token text content
std::string text;
// start byte offset of token in original text
uint32_t offset{0};
// token position in document (which word, starting from 0)
uint32_t position{0};
};
/*! Abstract tokenizer interface
* All tokenizer implementations must inherit from this interface
*/
class Tokenizer {
public:
virtual ~Tokenizer() = default;
/*! Initialise the tokenizer from a JSON configuration object.
* Must be called once before tokenize().
* \param config JSON object containing tokenizer-specific parameters.
* \return true on success, false on failure.
*/
virtual bool init(const ailego::JsonObject &config) = 0;
/*! Tokenize input text
* \param text UTF-8 encoded input text
* \return Tokenization result list, sorted by position in ascending
* order
*/
virtual std::vector<Token> tokenize(const std::string &text) const = 0;
/*! Return tokenizer name
*/
virtual const char *name() const = 0;
};
using TokenizerPtr = std::shared_ptr<Tokenizer>;
} // namespace zvec::fts

View File

@ -0,0 +1,104 @@
// 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 "tokenizer_factory.h"
#include <zvec/ailego/encoding/json/mod_json_plus.h>
#include <zvec/ailego/logger/logger.h>
#include "jieba_tokenizer.h"
#include "standard_tokenizer.h"
#include "whitespace_tokenizer.h"
namespace zvec::fts {
TokenizerPipelinePtr TokenizerFactory::create(const FtsIndexParams &params) {
// Parse extra_params JSON string into a JsonObject.
// Empty string is treated as an empty object; malformed JSON fails.
ailego::JsonObject extra_json;
if (!params.extra_params.empty()) {
ailego::JsonValue parsed;
if (!parsed.parse(params.extra_params.c_str())) {
LOG_ERROR("[TokenizerFactory] failed to parse extra_params JSON: %s",
params.extra_params.c_str());
return nullptr;
}
if (!parsed.is_object()) {
LOG_ERROR("[TokenizerFactory] extra_params is not a JSON object: %s",
params.extra_params.c_str());
return nullptr;
}
extra_json = parsed.as_object();
}
TokenizerPtr tokenizer = create_tokenizer(params.tokenizer_name, extra_json);
if (!tokenizer) {
LOG_ERROR("[TokenizerFactory] failed to create tokenizer: %s",
params.tokenizer_name.c_str());
return nullptr;
}
std::vector<TokenFilterPtr> filters;
for (const auto &filter_name : params.filters) {
TokenFilterPtr filter = create_filter(filter_name);
if (!filter) {
LOG_ERROR("[TokenizerFactory] failed to create filter: %s",
filter_name.c_str());
return nullptr;
}
filters.push_back(std::move(filter));
}
return std::make_shared<TokenizerPipeline>(std::move(tokenizer),
std::move(filters));
}
std::vector<Token> TokenizerPipeline::process(const std::string &text) const {
std::vector<Token> tokens = tokenizer_->tokenize(text);
for (const auto &filter : filters_) {
tokens = filter->filter(std::move(tokens));
}
return tokens;
}
TokenizerPtr TokenizerFactory::create_tokenizer(
const std::string &tokenizer_name, const ailego::JsonObject &extra_json) {
TokenizerPtr tokenizer;
if (tokenizer_name.empty() || tokenizer_name == "standard") {
tokenizer = std::make_shared<StandardTokenizer>();
} else if (tokenizer_name == "jieba") {
tokenizer = std::make_shared<JiebaTokenizer>();
} else if (tokenizer_name == "whitespace") {
tokenizer = std::make_shared<WhitespaceTokenizer>();
} else {
LOG_ERROR("[TokenizerFactory] unknown tokenizer name: %s",
tokenizer_name.c_str());
return nullptr;
}
if (!tokenizer->init(extra_json)) {
LOG_ERROR("[TokenizerFactory] failed to init tokenizer: %s",
tokenizer_name.c_str());
return nullptr;
}
return tokenizer;
}
TokenFilterPtr TokenizerFactory::create_filter(const std::string &filter_name) {
if (filter_name == "lowercase") {
return std::make_shared<LowercaseTokenFilter>();
}
LOG_ERROR("[TokenizerFactory] unknown filter name: %s", filter_name.c_str());
return nullptr;
}
} // namespace zvec::fts

View File

@ -0,0 +1,64 @@
// 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 <vector>
#include "token_filter.h"
#include "tokenizer.h"
#include "../fts_types.h"
namespace zvec::fts {
/*! Tokenizer pipeline: contains one tokenizer and a set of token filters
* Execution order: tokenizer filter[0] filter[1] ...
*/
class TokenizerPipeline {
public:
TokenizerPipeline(TokenizerPtr tokenizer, std::vector<TokenFilterPtr> filters)
: tokenizer_(std::move(tokenizer)), filters_(std::move(filters)) {}
/*! Tokenize text and apply all filters
*/
std::vector<Token> process(const std::string &text) const;
private:
TokenizerPtr tokenizer_;
std::vector<TokenFilterPtr> filters_;
};
using TokenizerPipelinePtr = std::shared_ptr<TokenizerPipeline>;
/*! Tokenizer factory
* Create TokenizerPipeline based on FtsIndexParams configuration.
*/
class TokenizerFactory {
public:
/*! Create tokenizer pipeline from FtsIndexParams.
* \param params FTS index parameters containing tokenizer_name, filters,
* and extra_params (JSON string for tokenizer-specific
* configuration).
* \return Tokenizer pipeline, returns nullptr on failure
*/
static TokenizerPipelinePtr create(const FtsIndexParams &params);
private:
static TokenizerPtr create_tokenizer(const std::string &tokenizer_name,
const ailego::JsonObject &extra_json);
static TokenFilterPtr create_filter(const std::string &filter_name);
};
} // namespace zvec::fts

View File

@ -0,0 +1,124 @@
// 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 "tokenizer_pipeline_manager.h"
#include <mutex>
#include <shared_mutex>
#include <zvec/ailego/logger/logger.h>
namespace zvec::fts {
// ============================================================
// Key generation
// ============================================================
std::string TokenizerPipelineManager::make_key(const FtsIndexParams &params) {
// Build a stable cache key from the three FtsIndexParams fields.
// Format: "tokenizer_name|filter0,filter1,...|extra_params_json"
std::string key;
key += params.tokenizer_name;
key += "|";
for (size_t i = 0; i < params.filters.size(); ++i) {
if (i > 0) {
key += ",";
}
key += params.filters[i];
}
key += "|";
key += params.extra_params;
return key;
}
// ============================================================
// acquire
// ============================================================
TokenizerPipelinePtr TokenizerPipelineManager::acquire(
const FtsIndexParams &params) {
const std::string key = make_key(params);
// Fast path: pipeline already exists.
{
std::unique_lock<std::shared_mutex> lock(mutex_);
auto it = pipelines_.find(key);
if (it != pipelines_.end()) {
it->second.ref_count++;
LOG_DEBUG(
"TokenizerPipelineManager: reuse pipeline key[%s] ref_count[%d]",
key.c_str(), it->second.ref_count);
return it->second.pipeline;
}
}
// Create the pipeline outside of the lock to avoid blocking other
// acquire/release calls during the (potentially expensive) construction.
TokenizerPipelinePtr pipeline = TokenizerFactory::create(params);
if (!pipeline) {
LOG_ERROR(
"TokenizerPipelineManager: failed to create pipeline for "
"tokenizer[%s] key[%s]",
params.tokenizer_name.c_str(), key.c_str());
return nullptr;
}
// Re-acquire the lock and check whether another thread has already
// created a pipeline with the same key while we were constructing ours.
std::unique_lock<std::shared_mutex> lock(mutex_);
auto it = pipelines_.find(key);
if (it != pipelines_.end()) {
it->second.ref_count++;
LOG_DEBUG(
"TokenizerPipelineManager: another thread created pipeline first, "
"discard newly created one. key[%s] ref_count[%d]",
key.c_str(), it->second.ref_count);
return it->second.pipeline;
}
Entry entry;
entry.pipeline = pipeline;
entry.ref_count = 1;
pipelines_.emplace(key, std::move(entry));
LOG_DEBUG("TokenizerPipelineManager: created pipeline key[%s]", key.c_str());
return pipeline;
}
// ============================================================
// release
// ============================================================
void TokenizerPipelineManager::release(const FtsIndexParams &params) {
const std::string key = make_key(params);
std::unique_lock<std::shared_mutex> lock(mutex_);
auto it = pipelines_.find(key);
if (it == pipelines_.end()) {
LOG_WARN("TokenizerPipelineManager: release called for unknown key[%s]",
key.c_str());
return;
}
it->second.ref_count--;
LOG_DEBUG("TokenizerPipelineManager: release key[%s] ref_count[%d]",
key.c_str(), it->second.ref_count);
if (it->second.ref_count <= 0) {
pipelines_.erase(it);
LOG_DEBUG("TokenizerPipelineManager: destroyed pipeline key[%s]",
key.c_str());
}
}
} // namespace zvec::fts

View File

@ -0,0 +1,88 @@
// 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 <shared_mutex>
#include <string>
#include <unordered_map>
#include <zvec/ailego/pattern/singleton.h>
#include "tokenizer_factory.h"
namespace zvec::fts {
/*!
* TokenizerPipelineManager
*
* Global singleton that creates, caches and reference-counts
* TokenizerPipeline instances. Two callers that request a pipeline with
* the same FtsIndexParams will receive the same shared_ptr, and the
* underlying pipeline is destroyed only when the last caller releases it.
*
* The cache key is built from tokenizer_name, filters and extra_params
* fields of FtsIndexParams, producing a deterministic string.
*
* Thread-safety: all public methods are protected by a std::shared_mutex.
* acquire() and release() take an exclusive (write) lock; the map itself is
* never read concurrently with a write.
*/
class TokenizerPipelineManager
: public ailego::Singleton<TokenizerPipelineManager> {
public:
/*!
* Build a canonical cache key from the given FtsIndexParams.
* The key is deterministic: tokenizer_name + sorted filters + extra_params.
*
* \param params FTS index parameters
* \return Canonical string key
*/
static std::string make_key(const FtsIndexParams &params);
/*!
* Acquire a shared pipeline for the given configuration.
* If a pipeline with the same key already exists its reference count is
* incremented and the existing instance is returned. Otherwise a new
* pipeline is created via TokenizerFactory::create().
*
* \param params FTS index parameters
* \return Shared pipeline pointer, or nullptr on failure
*/
TokenizerPipelinePtr acquire(const FtsIndexParams &params);
/*!
* Release a previously acquired pipeline identified by its FtsIndexParams.
* Decrements the reference count; when it reaches zero the entry is
* removed from the map and the pipeline is destroyed.
*
* \param params Same FtsIndexParams used during acquire()
*/
void release(const FtsIndexParams &params);
protected:
//! Constructor (protected, accessed via Singleton<T>::Instance())
TokenizerPipelineManager() = default;
friend class ailego::Singleton<TokenizerPipelineManager>;
private:
//! Internal map entry
struct Entry {
TokenizerPipelinePtr pipeline;
int ref_count{0};
};
std::shared_mutex mutex_;
std::unordered_map<std::string, Entry> pipelines_;
};
} // namespace zvec::fts

View File

@ -0,0 +1,56 @@
// 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 "whitespace_tokenizer.h"
#include <cctype>
namespace zvec::fts {
std::vector<Token> WhitespaceTokenizer::tokenize(
const std::string &text) const {
std::vector<Token> tokens;
uint32_t position = 0;
size_t index = 0;
const size_t text_length = text.size();
while (index < text_length) {
// skip whitespace characters
while (index < text_length &&
std::isspace(static_cast<unsigned char>(text[index]))) {
++index;
}
if (index >= text_length) {
break;
}
// find token start position
const uint32_t token_start = static_cast<uint32_t>(index);
// find token end position
while (index < text_length &&
!std::isspace(static_cast<unsigned char>(text[index]))) {
++index;
}
Token token;
token.text = text.substr(token_start, index - token_start);
token.offset = token_start;
token.position = position++;
tokens.push_back(std::move(token));
}
return tokens;
}
} // namespace zvec::fts

View File

@ -0,0 +1,39 @@
// 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 "tokenizer.h"
namespace zvec::fts {
/*! Whitespace tokenizer
* Split text by whitespace characters (space, tab, newline, etc.), used as
* default tokenizer
*/
class WhitespaceTokenizer : public Tokenizer {
public:
// WhitespaceTokenizer requires no configuration; always succeeds.
bool init(const ailego::JsonObject & /*config*/) override {
return true;
}
std::vector<Token> tokenize(const std::string &text) const override;
const char *name() const override {
return "whitespace";
}
};
} // namespace zvec::fts

View File

@ -12,8 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#include <mutex>
#include <new>
#include <sstream>
#include <zvec/ailego/logger/logger.h>
#include <zvec/db/index_params.h>
#include "db/index/column/fts_column/fts_pipeline.h"
#include "db/index/column/fts_column/fts_types.h"
#include "db/index/column/fts_column/tokenizer/tokenizer_pipeline_manager.h"
#include "type_helper.h"
namespace zvec {
@ -38,4 +44,93 @@ std::string VectorIndexParams::vector_index_params_to_string(
return oss.str();
}
// ============================================================
// FtsIndexParams — helpers
// ============================================================
static fts::FtsIndexParams to_internal(const FtsIndexParams &params) {
fts::FtsIndexParams p;
p.tokenizer_name = params.tokenizer_name();
p.filters = params.filters();
p.extra_params = params.extra_params();
return p;
}
// ============================================================
// FtsIndexParams — opaque pipeline state (Pimpl)
// ============================================================
namespace detail {
struct FtsState {
std::once_flag once;
std::shared_ptr<fts::TokenizerPipeline> pipeline;
bool created{false};
};
struct FtsPipelineHelper {
static std::unique_ptr<FtsState> &state(FtsIndexParams &p) {
return p.state_;
}
};
} // namespace detail
// ============================================================
// FtsIndexParams — ctor / dtor / move
// ============================================================
FtsIndexParams::FtsIndexParams(std::string tokenizer_name,
std::vector<std::string> filters,
std::string extra_params)
: IndexParams(IndexType::FTS),
tokenizer_name_(std::move(tokenizer_name)),
filters_(std::move(filters)),
extra_params_(std::move(extra_params)),
state_(std::make_unique<detail::FtsState>()) {}
FtsIndexParams::FtsIndexParams(FtsIndexParams &&other) noexcept
: IndexParams(IndexType::FTS),
tokenizer_name_(std::move(other.tokenizer_name_)),
filters_(std::move(other.filters_)),
extra_params_(std::move(other.extra_params_)),
state_(std::move(other.state_)) {}
FtsIndexParams::~FtsIndexParams() {
if (state_ && state_->created) {
auto internal = to_internal(*this);
fts::TokenizerPipelineManager::Instance().release(internal);
}
}
// ============================================================
// FtsIndexParams — pipeline acquisition (internal)
// ============================================================
namespace detail {
Result<std::shared_ptr<fts::TokenizerPipeline>> AcquireFtsPipeline(
FtsIndexParams &params) {
auto &state_uptr = FtsPipelineHelper::state(params);
if (!state_uptr) {
// Lazily reconstruct after a move-from; not thread-safe vs. a concurrent
// move on the same instance, but moves on a live instance already need
// external synchronisation.
state_uptr = std::make_unique<FtsState>();
}
auto &st = *state_uptr;
std::call_once(st.once, [&]() {
auto internal = to_internal(params);
st.pipeline = fts::TokenizerPipelineManager::Instance().acquire(internal);
if (st.pipeline) {
st.created = true;
}
});
if (!st.pipeline) {
return tl::make_unexpected(
Status::InternalError("Failed to create tokenizer pipeline"));
}
return st.pipeline;
}
} // namespace detail
} // namespace zvec

View File

@ -144,6 +144,28 @@ proto::InvertIndexParams ProtoConverter::ToPb(const InvertIndexParams *params) {
return params_pb;
}
// FtsIndexParams
FtsIndexParams::Ptr ProtoConverter::FromPb(
const proto::FtsIndexParams &params_pb) {
std::vector<std::string> filters;
filters.reserve(params_pb.filters_size());
for (const auto &filter : params_pb.filters()) {
filters.push_back(filter);
}
return std::make_shared<FtsIndexParams>(
params_pb.tokenizer_name(), std::move(filters), params_pb.extra_params());
}
proto::FtsIndexParams ProtoConverter::ToPb(const FtsIndexParams *params) {
proto::FtsIndexParams params_pb;
params_pb.set_tokenizer_name(params->tokenizer_name());
for (const auto &filter : params->filters()) {
params_pb.add_filters(filter);
}
params_pb.set_extra_params(params->extra_params());
return params_pb;
}
// FieldSchema
FieldSchema::Ptr ProtoConverter::FromPb(const proto::FieldSchema &schema_pb) {
auto schema = std::make_shared<FieldSchema>();
@ -215,6 +237,8 @@ IndexParams::Ptr ProtoConverter::FromPb(const proto::IndexParams &params_pb) {
return ProtoConverter::FromPb(params_pb.hnsw_rabitq());
} else if (params_pb.has_vamana()) {
return ProtoConverter::FromPb(params_pb.vamana());
} else if (params_pb.has_fts()) {
return ProtoConverter::FromPb(params_pb.fts());
}
return nullptr;
@ -286,6 +310,13 @@ proto::IndexParams ProtoConverter::ToPb(const IndexParams *params) {
}
break;
}
case IndexType::FTS: {
auto fts_params = dynamic_cast<const FtsIndexParams *>(params);
if (fts_params) {
params_pb.mutable_fts()->CopyFrom(ProtoConverter::ToPb(fts_params));
}
break;
}
default:
break;
}

View File

@ -48,6 +48,10 @@ struct ProtoConverter {
const proto::InvertIndexParams &params_pb);
static proto::InvertIndexParams ToPb(const InvertIndexParams *params);
// FtsIndexParams
static FtsIndexParams::Ptr FromPb(const proto::FtsIndexParams &params_pb);
static proto::FtsIndexParams ToPb(const FtsIndexParams *params);
// IndexParams
static IndexParams::Ptr FromPb(const proto::IndexParams &params_pb);
static proto::IndexParams ToPb(const IndexParams *params);

View File

@ -34,6 +34,7 @@ Status SearchQuery::validate_and_sanitize(const FieldSchema *schema) {
}
auto *vc = target_.get_vector_clause();
auto *fc = target_.get_fts_clause();
auto &field_name = target_.field_name_;
// A "scalar-only filter" query has no vector payload — either the clause
// is not a VectorClause (e.g., FtsClause) or its fields are all empty.
@ -41,6 +42,12 @@ Status SearchQuery::validate_and_sanitize(const FieldSchema *schema) {
vc->sparse_indices_.empty());
if (schema == nullptr) {
if (fc != nullptr) {
// FTS query requires a valid field_name_ that resolves to an FTS field.
return Status::InvalidArgument(
"Invalid query: fts requires a valid FTS field, but field[",
field_name, "] does not exist in the collection");
}
if (no_vector_payload) {
// Scalar-only filter query
return Status::OK();
@ -54,6 +61,17 @@ Status SearchQuery::validate_and_sanitize(const FieldSchema *schema) {
}
}
// FTS query: field must be an FTS-indexed field.
if (fc != nullptr) {
if (schema->index_type() != IndexType::FTS) {
return Status::InvalidArgument(
"Invalid query: fts requires an FTS-indexed field, but field[",
field_name, "] has index type ",
IndexTypeCodeBook::AsString(schema->index_type()));
}
return Status::OK();
}
// Schema is non-null from here on: a vector payload is required.
if (no_vector_payload) {
return Status::InvalidArgument(

View File

@ -301,11 +301,11 @@ Status CollectionSchema::validate() const {
"schema validate failed: max_doc_count_per_segment must >= ",
MAX_DOC_COUNT_PER_SEGMENT_MIN_THRESHOLD);
}
auto v_fields = vector_fields();
if (v_fields.empty()) {
return Status::InvalidArgument(
"schema validate failed: vector fields is empty");
if (fields_.empty()) {
return Status::InvalidArgument("schema validate failed: collection[", name_,
"] has no fields");
}
auto v_fields = vector_fields();
if (v_fields.size() > kMaxVectorFieldSize) {
return Status::InvalidArgument(
"schema validate failed: collection[", name_,
@ -549,6 +549,35 @@ FieldSchemaPtrList CollectionSchema::vector_fields() const {
return vector_fields;
}
FieldSchemaPtrList CollectionSchema::invert_fields() const {
FieldSchemaPtrList invert;
for (const auto &field : fields_) {
if (field->index_type() == IndexType::INVERT) {
invert.push_back(field);
}
}
return invert;
}
bool CollectionSchema::has_fts_field() const {
for (const auto &field : fields_) {
if (field->index_type() == IndexType::FTS) {
return true;
}
}
return false;
}
FieldSchemaPtrList CollectionSchema::fts_fields() const {
FieldSchemaPtrList fts;
for (const auto &field : fields_) {
if (field->index_type() == IndexType::FTS) {
fts.push_back(field);
}
}
return fts;
}
uint64_t CollectionSchema::max_doc_count_per_segment() const {
return max_doc_count_per_segment_;
}

View File

@ -44,6 +44,8 @@ struct IndexTypeCodeBook {
return IndexType::VAMANA;
case proto::IT_INVERT:
return IndexType::INVERT;
case proto::IT_FTS:
return IndexType::FTS;
default:
break;
}
@ -65,6 +67,8 @@ struct IndexTypeCodeBook {
return proto::IT_VAMANA;
case IndexType::INVERT:
return proto::IT_INVERT;
case IndexType::FTS:
return proto::IT_FTS;
default:
break;
}
@ -86,6 +90,8 @@ struct IndexTypeCodeBook {
return "VAMANA";
case IndexType::INVERT:
return "INVERT";
case IndexType::FTS:
return "FTS";
default:
break;
}

View File

@ -45,6 +45,9 @@
#include "db/common/file_helper.h"
#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/inverted_column/inverted_indexer.h"
#include "db/index/column/vector_column/engine_helper.hpp"
#include "db/index/column/vector_column/vector_column_indexer.h"
@ -68,6 +71,7 @@
namespace zvec {
void global_init() {
static std::once_flag once;
// run once
@ -160,6 +164,13 @@ class SegmentImpl : public Segment,
InvertedColumnIndexer::Ptr get_scalar_indexer(
const std::string &field_name) const override;
fts::FtsColumnIndexerPtr get_fts_indexer(
const std::string &field_name) const override;
Result<std::vector<fts::FtsResult>> fts_search(
const std::string &field_name, const fts::FtsAstNode &ast,
const fts::FtsQueryParams &params) override;
const IndexFilter::Ptr get_filter() override;
Status create_all_vector_index(
@ -279,6 +290,7 @@ class SegmentImpl : public Segment,
const vector_column_params::VectorDataBuffer &buf, Doc *doc);
Status insert_scalar_indexer(Doc &doc);
Status insert_fts_indexer(Doc &doc);
Status insert_vector_indexer(Doc &doc);
Status internal_insert(Doc &doc);
Status internal_update(Doc &doc);
@ -298,6 +310,12 @@ class SegmentImpl : public Segment,
Status reopen_invert_indexer(bool read_only = false);
// FTS helpers
Status open_fts_indexers(bool create);
Status close_fts_indexers();
Status flush_fts_indexers();
Status dump_fts_indexers();
Status insert_array_to_invert_indexer(
const FieldSchema::Ptr &schema,
const std::shared_ptr<arrow::ChunkedArray> &data,
@ -322,6 +340,11 @@ class SegmentImpl : public Segment,
// scalar index (uses segment-local doc ID)
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_;
bool has_fts_{false};
// vector index (uses block-local doc ID, each indexer starts from 0)
std::unordered_map<std::string, VectorColumnIndexer::Ptr>
memory_vector_indexers_;
@ -447,6 +470,10 @@ Status SegmentImpl::Open(const SegmentOptions &options) {
s = load_scalar_index_blocks();
CHECK_RETURN_STATUS(s);
// load FTS indexes
s = open_fts_indexers(false);
CHECK_RETURN_STATUS(s);
// load vector indexes
s = load_vector_index_blocks();
CHECK_RETURN_STATUS(s);
@ -510,6 +537,9 @@ Status SegmentImpl::Create(const SegmentOptions &options, uint64_t min_doc_id) {
auto s = load_scalar_index_blocks(true);
CHECK_RETURN_STATUS(s);
s = open_fts_indexers(true);
CHECK_RETURN_STATUS(s);
doc_id_allocator_.store(min_doc_id);
return Status::OK();
@ -520,6 +550,7 @@ Status SegmentImpl::close() {
if (invert_indexers_) {
invert_indexers_.reset();
}
close_fts_indexers();
for (const auto &[name, indexers] : vector_indexers_) {
for (auto indexer : indexers) {
indexer->Close();
@ -828,6 +859,9 @@ Status SegmentImpl::internal_insert(Doc &doc) {
if (!s.ok() && s.code() != StatusCode::ALREADY_EXISTS) {
return s;
}
// write FTS index
s = insert_fts_indexer(doc);
CHECK_RETURN_STATUS(s);
// write vector index
s = insert_vector_indexer(doc);
if (!s.ok() && s != Status::AlreadyExists()) {
@ -1965,7 +1999,7 @@ Status SegmentImpl::create_scalar_index(const std::vector<std::string> &columns,
s = invert_indexers_->create_snapshot(new_invert_index_path);
CHECK_RETURN_STATUS(s);
auto inverted_fields_ptr = collection_schema_->forward_fields_with_index();
auto inverted_fields_ptr = collection_schema_->invert_fields();
std::vector<FieldSchema> inverted_fields;
std::vector<std::string> inverted_field_names;
for (auto field : inverted_fields_ptr) {
@ -2153,6 +2187,9 @@ Status SegmentImpl::dump() {
CHECK_RETURN_STATUS(s);
}
s = dump_fts_indexers();
CHECK_RETURN_STATUS(s);
sealed_ = true;
return Status::OK();
@ -2185,6 +2222,12 @@ Status SegmentImpl::flush() {
CHECK_RETURN_STATUS(s);
}
// flush FTS indexers
if (has_fts_) {
s = flush_fts_indexers();
CHECK_RETURN_STATUS(s);
}
// flush vector indexer
for (const auto &indexer : memory_vector_indexers_) {
if (indexer.second) {
@ -3000,7 +3043,7 @@ Status SegmentImpl::reopen_invert_indexer(bool read_only) {
// build invert index fields
std::vector<std::string> inverted_field_names;
auto inverted_fields_ptr = collection_schema_->forward_fields_with_index();
auto inverted_fields_ptr = collection_schema_->invert_fields();
std::vector<FieldSchema> inverted_fields;
for (auto field : inverted_fields_ptr) {
inverted_fields.push_back(*field);
@ -4428,4 +4471,212 @@ Result<Segment::Ptr> Segment::Open(const std::string &path,
return segment;
}
////////////////////////////////////////////////////////////////////////////////////
// FTS integration
////////////////////////////////////////////////////////////////////////////////////
Status SegmentImpl::open_fts_indexers(bool create) {
if (!collection_schema_->has_fts_field()) {
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());
}
fts_indexers_[name] = indexer;
}
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());
}
}
auto s = fts_ctx_->flush();
CHECK_RETURN_STATUS(s);
return Status::OK();
}
Status SegmentImpl::close_fts_indexers() {
fts_indexers_.clear();
if (fts_ctx_) {
auto s = fts_ctx_->close();
fts_ctx_.reset();
return s;
}
return Status::OK();
}
Status SegmentImpl::insert_fts_indexer(Doc &doc) {
if (!has_fts_) {
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());
}
}
}
return Status::OK();
}
Status SegmentImpl::dump_fts_indexers() {
if (!has_fts_) {
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();
}
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;
}
return nullptr;
}
Result<std::vector<fts::FtsResult>> SegmentImpl::fts_search(
const std::string &field_name, const fts::FtsAstNode &ast,
const fts::FtsQueryParams &params) {
auto indexer = get_fts_indexer(field_name);
if (!indexer) {
return tl::make_unexpected(
Status::NotFound("FTS indexer not found: ", field_name));
}
auto ret = indexer->search(ast, params);
if (!ret.has_value()) {
return tl::make_unexpected(Status::InternalError(
"FTS search failed: ", field_name, " ", ret.error().message()));
}
return std::move(ret.value());
}
} // namespace zvec

View File

@ -25,6 +25,7 @@
#include <zvec/db/options.h>
#include <zvec/db/schema.h>
#include <zvec/db/status.h>
#include "db/index/column/fts_column/fts_column_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"
@ -172,6 +173,14 @@ 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;
virtual Result<std::vector<fts::FtsResult>> fts_search(
const std::string &field_name, const fts::FtsAstNode &ast,
const fts::FtsQueryParams &params) = 0;
virtual const IndexFilter::Ptr get_filter() = 0;
// for others

Some files were not shown because too many files have changed in this diff Show More