Skip to content

feat(init): jiebaDictDir and ftsBruteForceByKeysRatio options (#221) - #231

Merged
s2x merged 5 commits into
mainfrom
feat/221-init-jieba-fts-options
Sep 29, 2026
Merged

s2x merged 5 commits into
mainfrom
feat/221-init-jieba-fts-options

Conversation

@s2x

@s2x s2x commented Sep 29, 2026

Copy link
Copy Markdown
Member

Closes #221.

Summary

Two upstream GlobalConfig::ConfigData fields now have PHP equivalents:

ZVec::init(
    // ...existing parameters...
    ?string $jiebaDictDir = null,            // folder with jieba.dict.utf8 + hmm_model.utf8
    ?float $ftsBruteForceByKeysRatio = null, // 0.0-1.0, upstream default 0.05
);

ZVec::getJiebaDictDir(): string
ZVec::getFtsBruteForceByKeysRatio(): float

jiebaDictDir is the folder the jieba FTS tokenizer loads its dictionary from. ftsBruteForceByKeysRatio is the point at which an FTS query stops walking posting lists and starts scoring candidates one by one — that pays off when the scalar filter is very selective, and it is separate from the existing bruteForceByKeysRatio because scoring one FTS candidate costs more.

With neither option given, the dictionary shipped in zvec_data/jieba_dict is still found automatically.

Why the validation has to be in PHP

This is the substantive part of the change, so to be explicit about it:

Upstream validates too late to be safe. GlobalConfig::initialize() sets its "initialized" flag before it validates. If it then rejects the value, the library is still marked initialized, with default settings, and every later init() returns OK without applying anything. A bad value would therefore be a silent misconfiguration rather than an error. Rejecting in PHP, before any FFI call, is the only place this can be caught.

NAN slips through upstream. NAN < 0 and NAN > 1 are both false, so the upstream range test accepts it.

A wrong jieba folder is fatal, not an exception. cppjieba calls abort() rather than returning a Status, so a bad folder kills the process with exit code 134 later — when a jieba FTS index is created — with nothing catchable in PHP:

FATAL exp: [ifs.is_open()] false. open /nonexistent/dir/jieba.dict.utf8 failed.

The check therefore requires both jieba.dict.utf8 and hmm_model.utf8, not just the directory. Six rejection cases are asserted, each followed by isInitialized() still being false:

input message
ftsBruteForceByKeysRatio: 1.5 must be between 0 and 1, got: 1.5
ftsBruteForceByKeysRatio: -0.1 must be between 0 and 1, got: -0.1
ftsBruteForceByKeysRatio: NAN must be between 0 and 1, got: NAN
jiebaDictDir: '' does not exist:
jiebaDictDir: '/nonexistent/zvec/jieba' does not exist: /nonexistent/zvec/jieba
an existing but empty dir is missing jieba.dict.utf8: ...

null rather than 0.0 as the ratio default, because 0.0 is a real value upstream accepts, not a "use the default" marker like the other ratios.

The getters go through global_config_ptr() rather than GlobalConfig::Instance(), per the #215 singleton rule.

One small detail: the ratio message uses var_export(), because interpolating NAN emits a PHP warning (unexpected NAN value was coerced to string).

Verification

The two new tests run in separate processes, because upstream config is applied only once per process.

tests/test_init_fts_options.phpt initializes against a copy of the bundled dictionary and then runs an end-to-end jieba FTS query with no per-field jieba_dict_dir, which is what proves the custom folder is the one actually used:

ratio: 0.20
dir matches: yes
fts hit: j1

Full suite from a clean build:

Number of tests : 194               194
Tests skipped   :   0 (  0.0%)
Tests failed    :   0 (  0.0%)
Expected fail   :   2 (  1.0%)
Tests passed    : 192 ( 99.0%)

tests/test_ffi_load.phpt gained the four FFI symbols (63 → 67). test_dbs/ is left with only .gitignore.

Follow-up worth filing

The same cppjieba abort() is reachable through per-field extraParams jieba_dict_dir / user_dict_path and through the ZVEC_JIEBA_DICT_DIR environment variable, none of which are validated. That is out of scope here (it would mean validating inside ZVecIndexParams::forFts()) but it is the same failure mode.

🤖 Generated with Claude Code

s2x added 5 commits September 28, 2026 20:29
zvec picks an I/O backend for DiskANN disk reads on first use: on Linux
it tries io_uring, then libaio, then falls back to synchronous pread();
macOS arm64 always uses pread(). The choice dominates DiskANN
throughput -- pread on Linux usually means libaio simply is not installed
-- and until now there was no way to observe it from PHP.

Three static getters on ZVec, next to getVersion() and following the
same pattern the Python SDK uses for its module-level zvec.io_backend_type():

    ZVec::getIoBackendType(): int
    ZVec::getIoBackendTypeName(int $type): string
    ZVec::getIoBackendDescription(): string

plus IO_BACKEND_PREAD / IO_BACKEND_LIBAIO / IO_BACKEND_IO_URING (0/1/2),
the same values as the upstream C ABI. The description is where upstream
explains how to install an async backend when pread is in use.

These are deliberately static rather than per-collection: the value is
process-wide, it is not part of GlobalConfig, and upstream probes it
lazily, so the getters work without ZVec::init() at all. The test asserts
that.

Adhering to the #215 singleton rule: the adapter includes only the public
zvec/ailego/io/io_backend.h and calls upstream's exported
current_io_backend_type() / current_io_backend_description(), which are
compiled inside libzvec, so the IOBackend singleton is created only there.
The internal io_backend_def.h is not part of the SDK and IOBackend::Instance()
is never referenced from this module. The comment in ffi/zvec_ffi.cc records
why, so it does not get "fixed" later.

    $ nm -C ffi/build/libzvec_ffi.so | grep -c 'IOBackend::Instance'
    0
    $ nm -D --defined-only ffi/build/libzvec_ffi.so | grep io_backend
    zvec_get_io_backend_description
    zvec_get_io_backend_type
    zvec_get_io_backend_type_name

No status to check: these return plain values, matching getVersion(), so
self::checkStatus() is not involved and FFI::free() is not needed -- the
C side owns the memory (a static literal for the name, a thread-local
buffer for the description).

Tests: 192/192, 0 skipped, 0 failed, 2 expected fail. The new
tests/test_io_backend.phpt covers the constants, the name mapping including
the "unknown" fallback, that the value is valid before init(), that the
description mentions the reported backend, the macOS pread rule, and that
the cached value is stable across repeated calls and across init().
tests/test_ffi_load.phpt gained the three symbols (60 -> 63).
Exposes two upstream GlobalConfig::ConfigData fields that had no PHP
equivalent. jiebaDictDir is the folder holding jieba.dict.utf8 and
hmm_model.utf8 for the jieba FTS tokenizer; ftsBruteForceByKeysRatio
(0.0-1.0, default 0.05) is where an FTS query stops walking posting
lists and starts scoring candidates one by one, which pays off when the
scalar filter is very selective. Read back with getJiebaDictDir() and
getFtsBruteForceByKeysRatio(); with no option given the bundled
zvec_data/jieba_dict is still found automatically.

null rather than 0.0 as the ratio default, because 0.0 is a real value
upstream accepts rather than a "use the default" marker like the other
ratios.

Both values are validated in PHP before any FFI call, which is the only
place the check can live. Upstream GlobalConfig::initialize() sets its
initialized flag *before* validating, so a value it rejects still leaves
the library marked initialized, with defaults, and every later init()
returns OK without applying anything -- a silent misconfiguration. NAN
also passes the upstream range test, since NAN < 0 and NAN > 1 are both
false.

A bad jiebaDictDir is rejected for a second, harder reason: cppjieba
calls abort() rather than returning a Status, so a wrong folder kills the
process with exit 134 later, when a jieba FTS index is created, with no
catchable error. The check therefore requires both dictionary files, not
just the directory. (Confirmed: the same abort is reachable through
per-field extraParams and through ZVEC_JIEBA_DICT_DIR, which is a
separate gap.)

The getters use global_config_ptr() rather than GlobalConfig::Instance(),
per the #215 singleton rule.

The ratio message uses var_export() because interpolating NAN emits a PHP
warning; the message is the same shape as the other init() validation
errors.

Tests: 194/194, 0 skipped, 0 failed, 2 expected fail. The two new tests
run in separate processes because upstream config is applied only once
per process:
- test_init_fts_options.phpt: six rejection cases (1.5, -0.1, NAN, empty
  dir, missing dir, existing dir without the dictionary files) each
  asserting isInitialized() is still false afterwards, then a successful
  init against a *copy* of the bundled dictionary plus an end-to-end
  jieba FTS query that returns j1, proving a custom folder is the one
  actually used.
- test_init_fts_defaults.phpt: the upstream defaults, 0.05 and the
  bundled dictionary, with no new options passed.
…ts-options

# Conflicts:
#	CHANGELOG.md
#	tests/test_ffi_load.phpt
…ts-options

# Conflicts:
#	CHANGELOG.md
#	tests/test_ffi_load.phpt
@s2x
s2x merged commit 0e77177 into main Sep 29, 2026
11 of 12 checks passed
@s2x
s2x deleted the feat/221-init-jieba-fts-options branch September 29, 2026 07:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(init): add jiebaDictDir and ftsBruteForceByKeysRatio options to ZVec::init()

1 participant