nind 2.2.0__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nind-2.3.0/.gitignore +9 -0
- {nind-2.2.0 → nind-2.3.0}/CLAUDE.md +13 -6
- {nind-2.2.0 → nind-2.3.0}/CMakeLists.txt +1 -1
- {nind-2.2.0 → nind-2.3.0}/PKG-INFO +1 -1
- nind-2.3.0/bench/README.md +189 -0
- nind-2.3.0/bench/index_bulk.py +328 -0
- nind-2.3.0/bench/lookup_latency_bench.py +142 -0
- nind-2.3.0/bench/msmarco/convert_to_nind.py +61 -0
- nind-2.3.0/bench/run_bulk_indexing_benchmark.sh +42 -0
- nind-2.3.0/bench/run_indexing_benchmark.sh +93 -0
- {nind-2.2.0 → nind-2.3.0}/pyproject.toml +2 -2
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindAmose/CMakeLists.txt +3 -0
- nind-2.3.0/src/cpp/NindAmose/NindAmoseMerge.cpp +77 -0
- nind-2.3.0/src/cpp/NindAmose/NindAmoseMerge.h +58 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindBasics/NindFile.cpp +15 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindBasics/NindFile.h +6 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindBasics/NindPadFile.cpp +15 -1
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindBasics/NindPadFile.h +4 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/CMakeLists.txt +3 -1
- nind-2.3.0/src/cpp/NindIndex/NindIndexMerge.cpp +287 -0
- nind-2.3.0/src/cpp/NindIndex/NindIndexMerge.h +114 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindLexiconIndex.cpp +55 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindLexiconIndex.h +21 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindLocalIndex.cpp +15 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindLocalIndex.h +5 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindTermIndex.cpp +17 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindTermIndex.h +6 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindLexicon/NindLexiconFile.cpp +7 -1
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindPy/CMakeLists.txt +1 -0
- nind-2.3.0/src/cpp/NindPy/bindings.cpp +482 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/NindFile.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/NindIndex.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/NindLexiconindex.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/NindLocalindex.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/NindPadFile.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/NindRetrolexicon.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/NindTermindex.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/Nind_dumpDocument.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/Nind_search.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/Nind_trouveMotsSansOccurrence.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/Nind_trouveNombresPremiers.py +1 -1
- nind-2.3.0/src/py/nind/_index_store.py +281 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/amose/NindIdentifiantsClef.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/nind_engine.py +226 -91
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindAmose/CMakeLists.txt +1 -0
- nind-2.3.0/tst/cpp/unit/NindAmose/NindAmoseMergeTest.cpp +128 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindIndex/CMakeLists.txt +1 -0
- nind-2.3.0/tst/cpp/unit/NindIndex/NindIndexMergeTest.cpp +379 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindIndex/NindIndexRobustnessTest.cpp +4 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindIndex/NindTermIndexTest.cpp +22 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindLexicon/NindLexiconFileTest.cpp +11 -0
- nind-2.3.0/tst/py/test_native.py +341 -0
- nind-2.3.0/tst/py/test_nind_engine.py +382 -0
- {nind-2.2.0 → nind-2.3.0}/tst/py/test_nindlexiconindex.py +2 -2
- {nind-2.2.0 → nind-2.3.0}/tst/py/test_nindlocalindex.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/tst/py/test_nindtermindex.py +1 -1
- {nind-2.2.0 → nind-2.3.0}/uv.lock +1 -1
- nind-2.2.0/.gitignore +0 -6
- nind-2.2.0/src/cpp/NindPy/bindings.cpp +0 -297
- nind-2.2.0/tst/py/test_native.py +0 -115
- nind-2.2.0/tst/py/test_nind_engine.py +0 -130
- {nind-2.2.0 → nind-2.3.0}/.github/workflows/build.yml +0 -0
- {nind-2.2.0 → nind-2.3.0}/.github/workflows/docs.yml +0 -0
- {nind-2.2.0 → nind-2.3.0}/.github/workflows/publish-pypi.yml +0 -0
- {nind-2.2.0 → nind-2.3.0}/AGENTS.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/Dockerfile-ubuntu24.04 +0 -0
- {nind-2.2.0 → nind-2.3.0}/LICENCE.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/NindConfig-version-src.cmake +0 -0
- {nind-2.2.0 → nind-2.3.0}/NindConfig.cmake +0 -0
- {nind-2.2.0 → nind-2.3.0}/README.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/SystemSpecificInformations.cmake +0 -0
- {nind-2.2.0 → nind-2.3.0}/cicd/extract_package.sh +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/api/engine.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/api/index.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/api/readers.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/architecture.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/cli.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/conf.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/index.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/quickstart.md +0 -0
- {nind-2.2.0 → nind-2.3.0}/docs/requirements.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/gbuild.sh +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/Doxyfile +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindAmose/NindLexiconAmose.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindAmose/NindLexiconAmose.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindAmose/NindLocalAmose.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindAmose/NindLocalAmose.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindAmose/NindTermAmose.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindAmose/NindTermAmose.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindBasics/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindBasics/NindSignalCatcher.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindBasics/NindSignalCatcher.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindCommonExport.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindExceptions.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindIndex.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindIndex.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindLexiconIndexK.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindIndex/NindLexiconIndexK.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindLexicon/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindLexicon/NindLexicon.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindLexicon/NindLexicon.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindLexicon/NindLexiconFile.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindRetrolexicon/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindRetrolexicon/NindRetrolexicon.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/cpp/NindRetrolexicon/NindRetrolexicon.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/Nind_checkAndRepair.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/Nind_checkDocument.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/Nind_checkRejtroAndLexicon.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/__init__.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/amose/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/amose/LatXmlParser.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/amose/Nind_convertitLuceneDump.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/amose/Nind_convertitSampleDump.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/src/py/nind/amose/Nind_convertitXmlClef.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindAmose/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindAmose/NindAmose_effaceDocument.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindAmose/NindAmose_indexeCorpus.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindAmose/NindAmose_litTexteAnalysej.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindAmose/NindAmose_litTexteAnalysej.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindAmose/NindAmose_testLecteur.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindAmose/NindAmose_testSynchro.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindAmose/Nind_indexeCorpus.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindBasics/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindBasics/NindBasics_testLecture.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindDate.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindDate.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindFichiers.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindFichiers.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndexTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndexTest.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndex_indexe.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndex_indexe.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndex_litDumpS2.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndex_litDumpS2.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndex_testEcrivain.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndex_testLecteur.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindIndex_testSynchro.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindLexiconIndex_testEcrivain.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindLexiconIndex_testLecteur.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindTermIndex_testEcrivain.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/NindTermIndex_testLecteur.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/Nind_testLateconNumber.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindIndex/Nind_testNumbersInLocalIndexes.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindLexicon/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindLexicon/NindLexicon_testEcrivain.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindLexicon/NindLexicon_testInter.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindLexicon/NindLexicon_testLecteur.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindLexicon/NindLexicon_testLimites.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindLexicon/NindLexicon_testLimitesB.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindLexicon/NindLexicon_testLimitesC.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindLexicon/NindLexicon_testMemoire.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindSearch/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindSearch/NindGetDocTerms.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindSearch/NindSearch.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindSearch/NindSearchAmose.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindTests/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindTests/NindTestControlC.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindTests/Nind_test1.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/NindTests/Nind_testSynchro.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindAmose/NindLexiconAmoseTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindAmose/NindLocalAmoseTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindAmose/NindTermAmoseTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindBasics/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindBasics/NindBasicsRobustnessTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindBasics/NindFileTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindBasics/NindPadFileTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindIndex/NindLexiconIndexTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindIndex/NindLocalIndexTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindLexicon/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindLexicon/NindLexiconTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindRetrolexicon/CMakeLists.txt +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/NindRetrolexicon/NindRetrolexiconTest.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/TestFileBytes.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/TestTempDir.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/doctest.h +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/cpp/unit/doctest_main.cpp +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/py/test_nindfile.py +0 -0
- {nind-2.2.0 → nind-2.3.0}/tst/py/test_nindretrolexicon.py +0 -0
nind-2.3.0/.gitignore
ADDED
|
@@ -33,7 +33,7 @@ To run tests manually instead of via the full `gbuild.sh` cycle: `ctest --output
|
|
|
33
33
|
|
|
34
34
|
C++ unit tests live in `tst/cpp/unit/<Module>/` (one executable per library, one ctest entry each) and use **doctest**, vendored as the single header `tst/cpp/unit/doctest.h` (v2.4.12) — no system package or download needed, so they are always built. Each executable accepts doctest options, e.g. `NindIndexUnitTests -tc='NindIndexRobustness*' -s`. Corrupt/hostile-input tests live in the `*RobustnessTest.cpp` files: they patch or byte-by-byte corrupt files produced by the writers and require every read to either succeed or throw a `FileException` — run them under `-a ON` so ASan/UBSan/LSan turn any memory error, UB or leak into a failure. (`-a ON` needs the GCC sanitizer runtimes — `libasan`, `libubsan`, `liblsan` packages on RHEL-like systems.)
|
|
35
35
|
|
|
36
|
-
Memory-safety conventions in the C++ core (keep them when touching it): readers must never trust a length/offset/count read from a file — bound it against the buffer (`NindFile::get*` check before advancing) or the real file size (`NindFile::getCurrentFileSize()`); byte assembly is done in `uint32_t`/`uint64_t` (never `int`, which overflows); chains read from files (indirection blocks, compound words) must be provably finite; `NindFile`/`NindPadFile` are non-copyable; critical sections use the RAII `NindCriticalSection`, never raw `beginCriticalSection`/`endCriticalSection
|
|
36
|
+
Memory-safety conventions in the C++ core (keep them when touching it): readers must never trust a length/offset/count read from a file — bound it against the buffer (`NindFile::get*` check before advancing) or the real file size (`NindFile::getCurrentFileSize()`); byte assembly is done in `uint32_t`/`uint64_t` (never `int`, which overflows); chains read from files (indirection blocks, compound words) must be provably finite; `NindFile`/`NindPadFile` are non-copyable; critical sections use the RAII `NindCriticalSection`, never raw `beginCriticalSection`/`endCriticalSection`; a file has at most one writer — `NindPadFile`/`NindLexiconFile` writers take a non-blocking exclusive `flock` (`NindFile::lockExclusive`, no-op on Windows) and create files with `"w+bx"` (never truncating a file another writer just created), so a second writer, even in-process, gets an exception instead of corrupting the file (readers never lock).
|
|
37
37
|
|
|
38
38
|
`-DDEBUG_NIND` is added automatically in `Debug` and `RelWithDebInfo` modes (and in `Release` if configured with `-DWITH_DEBUG_MESSAGES=ON`) to enable conditional debug output.
|
|
39
39
|
|
|
@@ -80,9 +80,11 @@ NindIndex (deps: NindBasics, NindRetrolexicon)
|
|
|
80
80
|
— NindTermIndex: the inverted file itself, as an index file
|
|
81
81
|
— NindLocalIndex: per-document local index file
|
|
82
82
|
— NindIndex: ties the above into a full inverted/local index file
|
|
83
|
+
— NindIndexMerge: offline merge of index shards (disjoint documents) into one standard index
|
|
83
84
|
|
|
84
85
|
NindAmose (deps: NindIndex, NindRetrolexicon)
|
|
85
86
|
— adapts nind to Amose's richer term-type model (see internal doc refs "LAT2015.JYS.448")
|
|
87
|
+
— NindAmoseMerge: NindIndexMerge for Amose indexes (retro lexicon, recomputed term counters)
|
|
86
88
|
|
|
87
89
|
NindPy (deps: NindBasics, NindRetrolexicon, NindLexicon, NindIndex sources, compiled in directly)
|
|
88
90
|
— pybind11 bindings, built only through the Python wheel (scikit-build-core), not gbuild.sh
|
|
@@ -105,7 +107,8 @@ Parallels the C++ layering with lowercase-suffixed module names for the index cl
|
|
|
105
107
|
- `NindFile.py` — low-level binary codec. Historically read-only-oriented (the `litXxx` getters have long existed for every format), but now has a full writer counterpart too: `ejcritNombre1/3/4/5`, `ejcritNombreULat`/`ejcritNombreSLat` (both varint encodings, full tier range matching the readers and the C++ implementation), `ejcritChaine`, `ejcritZejros`.
|
|
106
108
|
- `NindPadFile.py` — the shared "pad file" envelope (header, indirection block(s), specifics, identification trailer); read-only, no writer (mirrors `NindBasics::NindPadFile`, but nothing in Python subclasses it as a writer the way `NindIndex`/`NindRetrolexicon` do in C++).
|
|
107
109
|
- `NindRetrolexicon.py`, `NindLexiconindex.py`, `NindTermindex.py`, `NindLocalindex.py` — ergonomic read-only Python wrappers around the corresponding `NindIndex`-family C++ classes; their hot-path lookups delegate to `nind._native`, as does the shared structural-diagnostic base (`analyseFichierPadFile`/`analyseFichierIndex`, inherited from `NindPadFile.py`/`NindIndex.py`). The remaining, per-format diagnostic/analysis methods (`dumpeFichier`, `debogueIndex`, `donneCollisions`, `donneMax`, `donneClef`, `afficheTerme`, `afficheDocument`, and each class's own `analyseFichierXxx` beyond the shared base) remain hand-rolled pure-Python parsing, since `nind._native` doesn't expose that introspection surface yet (see "Planned follow-up work" below). `NindLexiconindex` is hash-bucketed (`clefB(word) % nombreIndirection`), the other two are directly indexed by id.
|
|
108
|
-
- `nind_engine.py` — `NindIndexer` (writer) and `NindEngine` (BM25-style search) built on top of the above. `NindIndexer` is the *only* Python writer for the index-family formats (`.nindlexiconindex`/`.nindtermindex`/`.nindlocalindex`) — it builds them via `nind._native`'s `is_writer=True` constructors. It does not write `.nindretrolexicon` (not needed by `NindEngine`, which only supports simple, non-compound words). Indexing is parallel but every file keeps a single writer: files are tokenized in worker processes (`workers` argument of `index_files`; in-process below `PARALLEL_MIN_FILES` files), then the term and local index files are written concurrently from two threads through the bulk bindings `NindTermIndex.
|
|
110
|
+
- `nind_engine.py` — `NindIndexer` (writer) and `NindEngine` (BM25-style search) built on top of the above. `NindIndexer` is the *only* Python writer for the index-family formats (`.nindlexiconindex`/`.nindtermindex`/`.nindlocalindex`) — it builds them via `nind._native`'s `is_writer=True` constructors. It does not write `.nindretrolexicon` (not needed by `NindEngine`, which only supports simple, non-compound words). Indexing is parallel but every file keeps a single writer: files are tokenized in worker processes (`workers` argument of `index_files`; in-process below `PARALLEL_MIN_FILES` files), then the term and local index files are written concurrently from two threads through the flat-buffer bulk bindings `NindTermIndex.set_term_defs_arrays`/`NindLocalIndex.set_local_defs_arrays`, which take `array('I')` buffers (building `Document`/`Term`/`Localisation` objects in Python cost ~20× the write itself) and release the GIL. The `*_local_def*_arrays` writers split a term's positions into entries of ≤255 (`<nbreLocalisations>` is one byte). Every build goes into a new generation directory published atomically (see `_index_store.py` below), so rebuilding never disturbs readers. With `shards=N` it instead builds N independent indexes over contiguous parts of the files in worker processes (in a temporary directory inside the new generation), then merges them with `nind._native.merge_indexes`; opt-in because the merge costs about as much as writing the index again (see "Sharded indexing" below), so it only pays off when per-document work dominates.
|
|
111
|
+
- `_index_store.py` — the on-disk layout that makes every `NindIndexer`/`NindEngine` operation safe without the caller doing anything: each build writes a fresh, never-modified generation directory `<prefix>.gen-<n>-<hex>/`, flushed to disk (`fsync`) then published by atomically replacing the pointer file `<prefix>.CURRENT` (a pointer file rather than a symlink: atomic on Windows too). Readers open every file of the generation `CURRENT` names under a shared `flock` on its `generation.lock` (only while opening: open files stay readable after deletion on POSIX); `NindEngine` checks `CURRENT` (one `stat`) at the start of each public call and switches to a newly published build, each call answering from one snapshot (`_Snapshot`), calls serialized by an `RLock` (the native readers aren't thread-safe). Writers of one prefix are serialized by `<prefix>.lock`; after publishing, the writer deletes older generations nobody is opening, crashed builds' leftovers, and flat files of indexes written before generations existed (still readable: no `CURRENT` = flat layout). A failed build never touches `CURRENT`. Lock fds are closed in forked children (`os.register_at_fork`) — otherwise a worker outliving a killed writer would keep the index locked forever — and `NindIndexer`'s pool workers exit when their parent dies. Windows: `msvcrt` locks are exclusive only, so readers don't lock and rely on retrying; generations still open can't be deleted and are retried at the next build.
|
|
109
112
|
- `Nind_*.py` at the package root — standalone CLI scripts (dump a document, convert corpora, search, diagnostics)
|
|
110
113
|
- `amose/` — Amose-specific conversion/parsing scripts (Lucene dump, XML-CLEF, sample corpora)
|
|
111
114
|
|
|
@@ -124,7 +127,11 @@ Binary file formats are specified in EBNF grammar (see README). The Python packa
|
|
|
124
127
|
|
|
125
128
|
Not part of this: `.nindretrolexicon` writing (`NindIndexer` still doesn't write this format — unrelated, no change planned).
|
|
126
129
|
|
|
127
|
-
**Sharded indexing
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
**Sharded indexing — offline merge implemented; follow-ups below.** Nind files are single-writer by design (no locking, readers resync lazily; see `NindPadFile::getEntryPos`), and every write rewrites the shared specifics/identification tail at EOF, so concurrent writers on one file would serialize anyway — sharing a file between writers is explicitly *not* the plan. Writes scale instead across N independent shards (each its own lexicon + term + local files, one single-threaded writer each, over a partition of the documents), merged offline into one standard index that every existing reader uses unchanged (search across shards was considered and rejected: every reader, Amose's included, would have to learn about shards, and BM25 would need corpus-wide statistics summed over them).
|
|
131
|
+
|
|
132
|
+
How the merge works (`NindIndexMerge`, `src/cpp/NindIndex/`): shard lexicons are enumerated with `NindLexiconIndex::getEntries` (walks the hash buckets; no retro lexicon needed) and unioned with words canonicalised as (union id of the prefix, last component) — which also gives the exact merged size, used as the lexicon's hash modulo; documents are listed with `NindLocalIndex::getDocIdents` and checked disjoint *before anything is written*; each merged term's postings are gathered from every shard (per term, so memory stays small) and each document is copied with its term ids remapped (local term order is kept, so Amose's per-occurrence reading order survives). A failed merge removes the target files. `NindAmoseMerge` (`src/cpp/NindAmose/`) forces a retro lexicon and recomputes the 8 term counters, a term's type being derived from its components as in `NindLexiconAmose::getWord` (unique-term counts aren't additive across shards); they're written once at the end with `NindTermIndex::setSpecificWords`. The plain merge refuses shards with term specifics.
|
|
133
|
+
|
|
134
|
+
Follow-ups:
|
|
135
|
+
- **Merge cost is I/O-syscall bound, and so are all writes.** Merging 10k documents / 58k terms from 4 shards makes ~2.3M `lseek`, ~1.5M `read`, ~280k `write` and ~250k `rt_sigaction` calls: `NindIndex::getDefinition` discards the stdio buffer (`flush`) then seeks+reads twice (indirection, then data) on every read, `NindFile::writeBuffer` flushes every write, and every critical section installs/restores a signal handler. A direct build pays the same per definition, so a merge costs about a full rewrite. Reading with `pread` (one syscall per read, no buffer discard) and a bulk mode for a writer creating a fresh file (no per-write flush, tail written once) would speed up merges, direct builds and search alike — mind the reader-resync semantics above when touching this.
|
|
136
|
+
- **CLI for Amose/LIMA** multi-process indexing: a small C++ program (like the legacy `tst/cpp/NindAmose/` tools) running `NindAmoseMerge` over shard prefixes. `NindAmoseMerge` isn't bound in `nind._native` (the wheel doesn't compile `NindAmose`).
|
|
137
|
+
- `NindLexiconAmose::getWord` dereferences past the end of the component list for the lexicon word `§` alone (the first prefix of every named entity): undefined behaviour, seen as a `bad_alloc`. Normal lookups never ask for that id; the merge doesn't call `getWord`.
|
|
@@ -113,7 +113,7 @@ endif ()
|
|
|
113
113
|
|
|
114
114
|
########### setting project verion number ###############
|
|
115
115
|
|
|
116
|
-
set(NIND_VERSION "2.
|
|
116
|
+
set(NIND_VERSION "2.3.0")
|
|
117
117
|
|
|
118
118
|
string(REGEX REPLACE "^([0-9]+)\\.[0-9]+\\.[0-9]+$" "\\1" NIND_VERSION_MAJOR "${NIND_VERSION}")
|
|
119
119
|
string(REGEX REPLACE "^[0-9]+\\.([0-9]+)\\.[0-9]+$" "\\1" NIND_VERSION_MINOR "${NIND_VERSION}")
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# nind performance benchmarks
|
|
2
|
+
|
|
3
|
+
nind is a flat-file indexer, not a full search engine, so it's benchmarked
|
|
4
|
+
here like a storage/indexing engine rather than with an anserini/pyserini-style
|
|
5
|
+
retrieval-effectiveness harness (nDCG/MAP against relevance judgments) — see
|
|
6
|
+
the design discussion this came out of for why. Three things are measured:
|
|
7
|
+
|
|
8
|
+
1. **Indexing throughput & on-disk index size**, across a corpus-size sweep —
|
|
9
|
+
using nind's efficient *bulk-build* write pattern.
|
|
10
|
+
2. **The cost of incremental, per-document indexing** into a growing index —
|
|
11
|
+
a different, also-real usage pattern, with a real and important caveat
|
|
12
|
+
(see below).
|
|
13
|
+
3. **Term/document lookup latency**, through the same `nind._native`-backed
|
|
14
|
+
Python reader classes real callers use.
|
|
15
|
+
|
|
16
|
+
Retrieval effectiveness (nDCG/MAP vs. qrels) is out of scope: `NindEngine`
|
|
17
|
+
would first need stable external document ids and real inverted-list-driven
|
|
18
|
+
candidate generation instead of its current O(N·|Q|) full scan.
|
|
19
|
+
|
|
20
|
+
## Corpus: MS MARCO Passage Ranking
|
|
21
|
+
|
|
22
|
+
Freely downloadable, no license/registration required, and its passage ids
|
|
23
|
+
are already a dense integer sequence from 0 — a good match for nind's
|
|
24
|
+
document-identifier model. Get `collection.tsv` (one passage per line,
|
|
25
|
+
`pid<TAB>text`, ~8.84M rows) from the official MS MARCO Passage Ranking
|
|
26
|
+
release — see <https://github.com/microsoft/MSMARCO-Passage-Ranking> for the
|
|
27
|
+
current download instructions/links (verify the link is still current;
|
|
28
|
+
dataset hosting has moved before).
|
|
29
|
+
|
|
30
|
+
## Prerequisites
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Python side: builds nind._native and installs the reader/writer classes.
|
|
34
|
+
# This alone is enough for the bulk-build benchmark (1) and lookup latency (3).
|
|
35
|
+
uv sync
|
|
36
|
+
|
|
37
|
+
# C++ side, only needed for the incremental-indexing benchmark (2):
|
|
38
|
+
NIND_DIST=/path/to/dist ./gbuild.sh -m Release # Release, not Debug - Debug timings are misleading
|
|
39
|
+
# if cmake/ninja aren't installed system-wide, `uv tool install cmake ninja` works too
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 1. Indexing throughput & size (bulk build)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
./bench/run_bulk_indexing_benchmark.sh /path/to/collection.tsv 10000,100000,1000000
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
For each size in the comma-separated list, this converts the first N
|
|
49
|
+
passages to nind's corpus text format (`bench/msmarco/convert_to_nind.py`,
|
|
50
|
+
offsetting `pid` by +1 since nind treats document id 0 as a sentinel
|
|
51
|
+
elsewhere), then runs `bench/index_bulk.py`, which writes each term's
|
|
52
|
+
definition and each document's local definition to disk **exactly once**,
|
|
53
|
+
via `nind._native`'s `NindLexiconIndex`/`NindTermIndex`/`NindLocalIndex`
|
|
54
|
+
writers (the same write-once pattern `nind.nind_engine.NindIndexer` uses),
|
|
55
|
+
reading from the compact bulk corpus file instead of one file per document.
|
|
56
|
+
|
|
57
|
+
The corpus is split into line-aligned byte ranges handled by a pool of worker
|
|
58
|
+
processes (`--workers`, default: all cores); the main process only drives
|
|
59
|
+
the nind writers, which are single-file and single-threaded:
|
|
60
|
+
|
|
61
|
+
1. *pass 1* (workers): vocabulary and highest document id per chunk; the
|
|
62
|
+
main process then writes the lexicon, terms added in sorted order;
|
|
63
|
+
2. *pass 2* (workers): parsing and term→id mapping. Each chunk's documents
|
|
64
|
+
come back as flat `array('I')` buffers, and the main process writes them
|
|
65
|
+
in document order with one `set_local_defs_arrays` call per chunk. Each
|
|
66
|
+
worker also spills its chunk's postings to a temporary file next to the
|
|
67
|
+
index, sorted by term id and split into term-id ranges (SPIMI-style), so
|
|
68
|
+
no process holds the whole inverted index;
|
|
69
|
+
3. *pass 3* (workers): one term-id range each, merging that range's slice
|
|
70
|
+
of every spill file; the main process writes the terms in id order with
|
|
71
|
+
one `set_term_defs_arrays` call per range.
|
|
72
|
+
|
|
73
|
+
The output is byte-identical to a serial accumulate-in-memory build, apart
|
|
74
|
+
from the lexicon creation timestamp in each file's identification trailer.
|
|
75
|
+
Wall-clock time (`time.perf_counter`), peak RSS of the main process and of
|
|
76
|
+
the largest worker (`resource.getrusage`), and the index file sizes go into
|
|
77
|
+
one row of `bench/results/indexing_bulk.csv`; per-phase timings are printed
|
|
78
|
+
to stderr.
|
|
79
|
+
|
|
80
|
+
Measured on the actual MS MARCO data (48-core machine; the `workers` column
|
|
81
|
+
of the CSV records the pool size of each run):
|
|
82
|
+
|
|
83
|
+
| docs | workers | wall | docs/s | main peak RSS | largest worker RSS | index size |
|
|
84
|
+
|---:|---:|---:|---:|---:|---:|---:|
|
|
85
|
+
| 10K | 24 | 1.0 s | 10,400 | 33 MB | 23 MB | 5.5 MB |
|
|
86
|
+
| 100K | 24 | 5.0 s | 20,100 | 87 MB | 42 MB | 46 MB |
|
|
87
|
+
| 1M | 24 | 42 s | 23,800 | 675 MB | 174 MB | 455 MB |
|
|
88
|
+
| 8.84M (full) | 48 | 255 s | 34,700 | 3.1 GB | 573 MB | 4.1 GB |
|
|
89
|
+
|
|
90
|
+
For comparison, the earlier serial version of this script (whole corpus
|
|
91
|
+
accumulated in RAM as Python objects) did ~2,700-3,000 docs/s and needed
|
|
92
|
+
7.9 GB at 1M docs; its rows are kept in
|
|
93
|
+
`bench/results/indexing_bulk_serial_v1.csv`.
|
|
94
|
+
|
|
95
|
+
The main process is the bottleneck: at 1M docs, 16 and 48 workers take the
|
|
96
|
+
same time. Its time goes almost entirely into the C++ writers, which make
|
|
97
|
+
every entry durable on its own (~13 system calls per definition written:
|
|
98
|
+
seeks, reads of the indirection entry, `fwrite`+`fflush`, and signal-handler
|
|
99
|
+
swaps around each Ctrl-C-safe critical section). A faster bulk load would
|
|
100
|
+
need a C++-side bulk mode (e.g. buffered indirection and sequential writes).
|
|
101
|
+
The main process's memory is mostly worker results waiting to be written;
|
|
102
|
+
at most `2 × workers` chunks (~8 MB of corpus each) are in flight.
|
|
103
|
+
|
|
104
|
+
Don't run two builds of the same index at once: they'd delete and
|
|
105
|
+
overwrite each other's files, so `index_bulk.py` takes a lock on
|
|
106
|
+
`<index-base>.lock` and refuses to start if it's held.
|
|
107
|
+
|
|
108
|
+
Start with a small size (e.g. 10000) as a smoke test before scaling up.
|
|
109
|
+
|
|
110
|
+
## 2. Incremental per-document indexing cost
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
NIND_DIST=/path/to/dist ./bench/run_indexing_benchmark.sh \
|
|
114
|
+
/path/to/collection.tsv 10000,100000
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This drives `Nind_indexeCorpus` instead, which indexes one document at a
|
|
118
|
+
time against the on-disk files as it reads them — a different, also-real
|
|
119
|
+
usage pattern (e.g. appending new documents to an already-large index over
|
|
120
|
+
time, rather than building it once from scratch). **Its scaling is much
|
|
121
|
+
worse: on the actual MS MARCO data, going from 10K to 100K documents made it
|
|
122
|
+
~89x slower, not ~10x.**
|
|
123
|
+
|
|
124
|
+
The reason is structural, not a bug in this particular tool:
|
|
125
|
+
`NindTermIndex::setTermDef` (`src/cpp/NindIndex/NindTermIndex.h:80-88`) only
|
|
126
|
+
supports writing a term's *entire* postings list, no incremental append.
|
|
127
|
+
`Nind_indexeCorpus` calls `getTermDef`+`setTermDef` for every term in every
|
|
128
|
+
document (`tst/cpp/NindAmose/Nind_indexeCorpus.cpp:142-150`), so a term
|
|
129
|
+
appearing in *df* documents has its (growing) postings list read and
|
|
130
|
+
rewritten *df* times — total cost is dominated by common terms and scales
|
|
131
|
+
roughly O(N²) in corpus size. **Don't run this one past ~100K documents** —
|
|
132
|
+
it won't finish in reasonable time and the quadratic trend is already clear
|
|
133
|
+
by then. It exists to document a real limitation (streaming/incremental
|
|
134
|
+
indexing into a large existing base gets expensive), not as nind's headline
|
|
135
|
+
throughput number — use benchmark 1 for that.
|
|
136
|
+
|
|
137
|
+
Same output shape as before: one row per size in
|
|
138
|
+
`bench/results/indexing.csv`.
|
|
139
|
+
|
|
140
|
+
## 3. Lookup latency
|
|
141
|
+
|
|
142
|
+
Run against an index built by either benchmark above (prefer benchmark 1's
|
|
143
|
+
`docs_bulk` index — it's the one built the way nind is actually meant to be
|
|
144
|
+
built at scale):
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
uv run python3 bench/lookup_latency_bench.py \
|
|
148
|
+
bench/results/msmarco_10000/docs.txt \
|
|
149
|
+
bench/results/msmarco_10000/docs_bulk \
|
|
150
|
+
10000
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Samples random terms and document ids from the corpus text file, times
|
|
154
|
+
repeated calls to `NindLexiconindex.donneIdentifiant` (term→id),
|
|
155
|
+
`NindTermindex.donneListeTermesCG` (id→postings), and
|
|
156
|
+
`NindLocalindex.donneListeTermes` (doc→local terms), and appends
|
|
157
|
+
mean/median/p95/p99 latency and throughput to `bench/results/lookup.csv`.
|
|
158
|
+
Run it once per size point to see how lookup latency scales with index size.
|
|
159
|
+
|
|
160
|
+
By default each operation's sample set is run once untimed first
|
|
161
|
+
(`--warmup 1`), so the numbers are steady-state lookup cost. With
|
|
162
|
+
`--warmup 0` you mostly measure first-touch page-cache misses on a freshly
|
|
163
|
+
opened file instead: on MS MARCO that made term→id look like it grew from
|
|
164
|
+
~3µs (10K docs) to ~175µs median (1M docs). Don't run it concurrently with an
|
|
165
|
+
indexing benchmark: I/O contention skews the cold numbers further.
|
|
166
|
+
|
|
167
|
+
Warm, on the bulk-built MS MARCO indexes (median / p99, µs):
|
|
168
|
+
|
|
169
|
+
| docs | term→id | id→postings | doc→local terms |
|
|
170
|
+
|---:|---:|---:|---:|
|
|
171
|
+
| 10K | 2.2 / 3.9 | 2.7 / 9.6 | 15.4 / 28.7 |
|
|
172
|
+
| 100K | 2.7 / 5.4 | 3.2 / 22 | 16.7 / 31.3 |
|
|
173
|
+
| 1M | 3.0 / 5.6 | 3.8 / 107 | 19.5 / 34.0 |
|
|
174
|
+
| 8.84M | 3.1 / 5.1 | 3.7 / 284 | 19.9 / 36.1 |
|
|
175
|
+
|
|
176
|
+
Lookups stay essentially flat from 10K to 8.84M documents: the lexicon's
|
|
177
|
+
hash-bucket count is sized to the vocabulary, so buckets don't lengthen, and
|
|
178
|
+
the term and local indexes are addressed directly by id. The id→postings tail
|
|
179
|
+
is the few very frequent terms in the sample, whose postings lists are long.
|
|
180
|
+
|
|
181
|
+
## Notes
|
|
182
|
+
|
|
183
|
+
- `gbuild.sh` installs binaries with an empty RPATH (see the top-level
|
|
184
|
+
`CMakeLists.txt`), so `run_indexing_benchmark.sh` adds `<bin-dir>/../lib`
|
|
185
|
+
to `LD_LIBRARY_PATH` itself — no manual setup needed.
|
|
186
|
+
- `bench/results/` is git-ignored; it's regenerated by these scripts. Both
|
|
187
|
+
indexing benchmarks reuse the same `bench/results/msmarco_<size>/docs.txt`
|
|
188
|
+
when present, so running benchmark 2 after benchmark 1 (or vice versa)
|
|
189
|
+
doesn't reconvert the corpus.
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bulk-build indexing throughput benchmark, using nind's efficient write
|
|
3
|
+
pattern: each term's definition and each document's local definition is
|
|
4
|
+
written to disk exactly once (as nind.nind_engine.NindIndexer does) - as
|
|
5
|
+
opposed to bench/run_indexing_benchmark.sh, which drives Nind_indexeCorpus
|
|
6
|
+
and does a read-modify-write of each term's growing postings list on every
|
|
7
|
+
document (see bench/README.md for why that shows ~quadratic scaling).
|
|
8
|
+
|
|
9
|
+
The corpus is split into line-aligned byte ranges processed by a pool of
|
|
10
|
+
worker processes; the main process only drives the (single-file,
|
|
11
|
+
single-threaded) nind writers, handing them a whole chunk's documents or a
|
|
12
|
+
whole term-id range's terms per call, as flat buffers (nind._native's
|
|
13
|
+
set_local_defs_arrays/set_term_defs_arrays, which write with the GIL released,
|
|
14
|
+
so the main process keeps receiving worker results meanwhile):
|
|
15
|
+
|
|
16
|
+
- pass 1 (workers): per-chunk vocabulary and highest document id;
|
|
17
|
+
- main: builds the lexicon, terms added in sorted order;
|
|
18
|
+
- pass 2 (workers): parse and map terms to ids; each document's occurrences
|
|
19
|
+
come back as flat arrays and the main process writes its local definition
|
|
20
|
+
right away, in document order. Each worker also spills its chunk's
|
|
21
|
+
postings to a temporary file, sorted by term id and split into term-id
|
|
22
|
+
ranges (SPIMI-style), so no process ever holds the whole inverted index;
|
|
23
|
+
- pass 3 (workers): one term-id range each, merging that range's slice of
|
|
24
|
+
every chunk file into per-term postings; the main process writes them in
|
|
25
|
+
term id order.
|
|
26
|
+
|
|
27
|
+
The output is byte-identical to a serial accumulate-then-write build (apart
|
|
28
|
+
from the lexicon creation timestamp in each file's identification trailer).
|
|
29
|
+
|
|
30
|
+
Reads the same "one line per document" corpus format produced by
|
|
31
|
+
msmarco/convert_to_nind.py (so it works on the compact bulk file, not
|
|
32
|
+
one-file-per-document like NindIndexer.index_files expects).
|
|
33
|
+
|
|
34
|
+
usage: index_bulk.py <docs.txt> <index-base> <size-label> [--workers N] [--csv PATH]
|
|
35
|
+
<index-base> is the .nind* file path prefix to write, e.g. .../docs
|
|
36
|
+
"""
|
|
37
|
+
import argparse
|
|
38
|
+
from array import array
|
|
39
|
+
import bisect
|
|
40
|
+
import fcntl
|
|
41
|
+
import gc
|
|
42
|
+
import multiprocessing
|
|
43
|
+
import os
|
|
44
|
+
import resource
|
|
45
|
+
import sys
|
|
46
|
+
import tempfile
|
|
47
|
+
import time
|
|
48
|
+
from collections import defaultdict, deque
|
|
49
|
+
from itertools import islice
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src" / "py" / "nind"))
|
|
53
|
+
from nind import _native as native
|
|
54
|
+
|
|
55
|
+
# state inherited by forked workers (set in the main process before the pool
|
|
56
|
+
# that needs it is created, so it's shared copy-on-write, not pickled)
|
|
57
|
+
_term_to_id = None
|
|
58
|
+
_spill_dir = None
|
|
59
|
+
_range_bounds = None # term id lower bounds of each pass-3 range, ascending
|
|
60
|
+
|
|
61
|
+
CHUNK_BYTES = 8 * 1024 * 1024 # target corpus bytes per chunk
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def bounded_imap(pool, fn, tasks, window):
|
|
65
|
+
"""Like pool.imap (results in task order), but with at most `window`
|
|
66
|
+
tasks in flight: the main process, which consumes the results, is the
|
|
67
|
+
bottleneck, and pool.imap would otherwise queue every finished result
|
|
68
|
+
in its memory."""
|
|
69
|
+
tasks = iter(tasks)
|
|
70
|
+
pending = deque(pool.apply_async(fn, (task,)) for task in islice(tasks, window))
|
|
71
|
+
while pending:
|
|
72
|
+
result = pending.popleft().get()
|
|
73
|
+
for task in islice(tasks, 1):
|
|
74
|
+
pending.append(pool.apply_async(fn, (task,)))
|
|
75
|
+
yield result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_lines(text):
|
|
79
|
+
"""Yields (doc_id, [(term, position, length), ...]) per non-empty line."""
|
|
80
|
+
for line in text.splitlines():
|
|
81
|
+
fields = line.split()
|
|
82
|
+
if not fields:
|
|
83
|
+
continue
|
|
84
|
+
occurrences = []
|
|
85
|
+
for term, loc in zip(fields[1::2], fields[2::2]):
|
|
86
|
+
pos_str, len_str = loc.split(",")
|
|
87
|
+
occurrences.append((term, int(pos_str), int(len_str)))
|
|
88
|
+
yield int(fields[0]), occurrences
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_chunk(docs_path, start, end):
|
|
92
|
+
with open(docs_path, "rb") as f:
|
|
93
|
+
f.seek(start)
|
|
94
|
+
return f.read(end - start).decode("utf-8")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def split_chunks(docs_path, chunks_nb):
|
|
98
|
+
"""Line-aligned byte ranges covering the whole file, in file order."""
|
|
99
|
+
size = os.path.getsize(docs_path)
|
|
100
|
+
bounds = [0]
|
|
101
|
+
with open(docs_path, "rb") as f:
|
|
102
|
+
for i in range(1, chunks_nb):
|
|
103
|
+
f.seek(max(bounds[-1], size * i // chunks_nb))
|
|
104
|
+
f.readline()
|
|
105
|
+
pos = f.tell()
|
|
106
|
+
if pos >= size:
|
|
107
|
+
break
|
|
108
|
+
if pos > bounds[-1]:
|
|
109
|
+
bounds.append(pos)
|
|
110
|
+
bounds.append(size)
|
|
111
|
+
return list(zip(bounds[:-1], bounds[1:]))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def pass1_worker(task):
|
|
115
|
+
docs_path, start, end = task
|
|
116
|
+
vocab = set()
|
|
117
|
+
max_doc_id = 0
|
|
118
|
+
for doc_id, occurrences in parse_lines(read_chunk(docs_path, start, end)):
|
|
119
|
+
max_doc_id = max(max_doc_id, doc_id)
|
|
120
|
+
vocab.update(term for term, _pos, _len in occurrences)
|
|
121
|
+
return vocab, max_doc_id
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def pass2_worker(task):
|
|
125
|
+
"""Returns the chunk's documents as flat arrays for set_local_def_arrays
|
|
126
|
+
and spills its postings, as (term_id, doc_id, freq) triples sorted by
|
|
127
|
+
term id, to <spill_dir>/<chunk_no>; triple offsets of each pass-3 range
|
|
128
|
+
are returned so pass 3 can read just its slice."""
|
|
129
|
+
chunk_no, docs_path, start, end = task
|
|
130
|
+
doc_ids = array("I")
|
|
131
|
+
doc_ends = array("I") # end offset of each doc in the three arrays below
|
|
132
|
+
term_ids, positions, lengths = array("I"), array("I"), array("I")
|
|
133
|
+
postings = [] # (term_id, doc_id, freq)
|
|
134
|
+
tokens = 0
|
|
135
|
+
for doc_id, occurrences in parse_lines(read_chunk(docs_path, start, end)):
|
|
136
|
+
counts = defaultdict(int)
|
|
137
|
+
for term, pos, length in occurrences:
|
|
138
|
+
tid = _term_to_id[term]
|
|
139
|
+
counts[tid] += 1
|
|
140
|
+
term_ids.append(tid)
|
|
141
|
+
positions.append(pos)
|
|
142
|
+
lengths.append(length)
|
|
143
|
+
tokens += len(occurrences)
|
|
144
|
+
doc_ids.append(doc_id)
|
|
145
|
+
doc_ends.append(len(term_ids))
|
|
146
|
+
postings.extend((tid, doc_id, freq) for tid, freq in counts.items())
|
|
147
|
+
# documents are in ascending id order within the file, so a stable sort
|
|
148
|
+
# on term id keeps each term's documents ascending
|
|
149
|
+
postings.sort(key=lambda p: p[0])
|
|
150
|
+
triples = array("I")
|
|
151
|
+
for p in postings:
|
|
152
|
+
triples.extend(p)
|
|
153
|
+
with open(os.path.join(_spill_dir, str(chunk_no)), "wb") as f:
|
|
154
|
+
triples.tofile(f)
|
|
155
|
+
sorted_tids = [p[0] for p in postings]
|
|
156
|
+
range_offsets = [bisect.bisect_left(sorted_tids, bound) for bound in _range_bounds]
|
|
157
|
+
range_offsets.append(len(postings))
|
|
158
|
+
return doc_ids, doc_ends, term_ids, positions, lengths, tokens, range_offsets
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def pass3_worker(task):
|
|
162
|
+
"""Merges one term-id range across all chunk spill files, in chunk
|
|
163
|
+
(= document) order; returns the range's terms (ascending) as flat arrays
|
|
164
|
+
for set_term_defs_arrays: term ids, end offset of each term's postings,
|
|
165
|
+
and the concatenated postings' doc ids and frequencies."""
|
|
166
|
+
range_no, chunk_slices = task
|
|
167
|
+
by_term = {}
|
|
168
|
+
for chunk_no, first, last in chunk_slices:
|
|
169
|
+
if last == first:
|
|
170
|
+
continue
|
|
171
|
+
triples = array("I")
|
|
172
|
+
with open(os.path.join(_spill_dir, str(chunk_no)), "rb") as f:
|
|
173
|
+
f.seek(first * 3 * triples.itemsize)
|
|
174
|
+
triples.fromfile(f, (last - first) * 3)
|
|
175
|
+
for i in range(0, len(triples), 3):
|
|
176
|
+
tid = triples[i]
|
|
177
|
+
entry = by_term.get(tid)
|
|
178
|
+
if entry is None:
|
|
179
|
+
entry = by_term[tid] = (array("I"), array("I"))
|
|
180
|
+
entry[0].append(triples[i + 1])
|
|
181
|
+
entry[1].append(triples[i + 2])
|
|
182
|
+
term_ids, term_ends, doc_ids, freqs = array("I"), array("I"), array("I"), array("I")
|
|
183
|
+
for tid in sorted(by_term):
|
|
184
|
+
docs, term_freqs = by_term.pop(tid)
|
|
185
|
+
term_ids.append(tid)
|
|
186
|
+
doc_ids.extend(docs)
|
|
187
|
+
freqs.extend(term_freqs)
|
|
188
|
+
term_ends.append(len(doc_ids))
|
|
189
|
+
return term_ids, term_ends, doc_ids, freqs
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_index(docs_path, index_base, workers):
|
|
193
|
+
global _term_to_id, _spill_dir, _range_bounds
|
|
194
|
+
ctx = multiprocessing.get_context("fork")
|
|
195
|
+
phase_start = time.perf_counter()
|
|
196
|
+
|
|
197
|
+
def phase_done(name):
|
|
198
|
+
nonlocal phase_start
|
|
199
|
+
now = time.perf_counter()
|
|
200
|
+
print(f" {name}: {now - phase_start:.2f}s", file=sys.stderr)
|
|
201
|
+
phase_start = now
|
|
202
|
+
chunks = split_chunks(docs_path, max(workers * 4, os.path.getsize(docs_path) // CHUNK_BYTES))
|
|
203
|
+
window = workers * 2
|
|
204
|
+
|
|
205
|
+
# pass 1: vocabulary (NindLexiconIndex needs the entry count up front) and
|
|
206
|
+
# the highest document id (so NindLocalIndex can be opened before pass 2)
|
|
207
|
+
vocab = set()
|
|
208
|
+
max_doc_id = 0
|
|
209
|
+
with ctx.Pool(workers) as pool:
|
|
210
|
+
for chunk_vocab, chunk_max in pool.imap_unordered(
|
|
211
|
+
pass1_worker, [(docs_path, s, e) for s, e in chunks]):
|
|
212
|
+
vocab |= chunk_vocab
|
|
213
|
+
max_doc_id = max(max_doc_id, chunk_max)
|
|
214
|
+
terms_sorted = sorted(vocab)
|
|
215
|
+
del vocab
|
|
216
|
+
phase_done("pass 1 (vocabulary)")
|
|
217
|
+
|
|
218
|
+
lexicon_writer = native.NindLexiconIndex(index_base, is_writer=True,
|
|
219
|
+
indirection_bloc_size=max(1, len(terms_sorted)))
|
|
220
|
+
_term_to_id = {term: lexicon_writer.add_word([term]) for term in terms_sorted}
|
|
221
|
+
del terms_sorted
|
|
222
|
+
vocab_size = len(_term_to_id)
|
|
223
|
+
lexicon_identification = lexicon_writer.get_identification()
|
|
224
|
+
del lexicon_writer
|
|
225
|
+
phase_done("lexicon write")
|
|
226
|
+
|
|
227
|
+
max_term_id = max(_term_to_id.values(), default=0)
|
|
228
|
+
ranges_nb = workers * 4
|
|
229
|
+
_range_bounds = [1 + (max_term_id * r) // ranges_nb for r in range(ranges_nb)]
|
|
230
|
+
|
|
231
|
+
docs = 0
|
|
232
|
+
tokens_total = 0
|
|
233
|
+
local_writer = native.NindLocalIndex(index_base, is_writer=True,
|
|
234
|
+
lexicon_identification=lexicon_identification,
|
|
235
|
+
indirection_bloc_size=max_doc_id + 1)
|
|
236
|
+
term_writer = native.NindTermIndex(index_base, is_writer=True,
|
|
237
|
+
lexicon_identification=lexicon_identification,
|
|
238
|
+
specifics_number=0,
|
|
239
|
+
indirection_bloc_size=max_term_id + 1)
|
|
240
|
+
spill_parent = os.path.dirname(os.path.abspath(index_base))
|
|
241
|
+
with tempfile.TemporaryDirectory(prefix="nind_spill_", dir=spill_parent) as spill_dir:
|
|
242
|
+
_spill_dir = spill_dir
|
|
243
|
+
# keep the inherited lexicon out of the (forked) workers' GC scans, so
|
|
244
|
+
# it isn't copied page by page into each of them
|
|
245
|
+
gc.freeze()
|
|
246
|
+
with ctx.Pool(workers) as pool:
|
|
247
|
+
# pass 2: chunks come back in file order, so local definitions are
|
|
248
|
+
# written in document order
|
|
249
|
+
all_range_offsets = []
|
|
250
|
+
for doc_ids, doc_ends, term_ids, positions, lengths, tokens, range_offsets in bounded_imap(
|
|
251
|
+
pool, pass2_worker, [(i, docs_path, s, e) for i, (s, e) in enumerate(chunks)], window):
|
|
252
|
+
local_writer.set_local_defs_arrays(doc_ids, doc_ends, term_ids, positions, lengths,
|
|
253
|
+
lexicon_identification)
|
|
254
|
+
docs += len(doc_ids)
|
|
255
|
+
tokens_total += tokens
|
|
256
|
+
all_range_offsets.append(range_offsets)
|
|
257
|
+
del local_writer
|
|
258
|
+
phase_done("pass 2 (local index write + postings spill)")
|
|
259
|
+
|
|
260
|
+
# pass 3: ranges come back in term id order
|
|
261
|
+
tasks = [(r, [(c, offsets[r], offsets[r + 1]) for c, offsets in enumerate(all_range_offsets)])
|
|
262
|
+
for r in range(ranges_nb)]
|
|
263
|
+
for term_ids, term_ends, doc_ids, freqs in bounded_imap(pool, pass3_worker, tasks, window):
|
|
264
|
+
term_writer.set_term_defs_arrays(term_ids, term_ends, doc_ids, freqs, lexicon_identification)
|
|
265
|
+
del term_writer
|
|
266
|
+
phase_done("pass 3 (postings merge + term index write)")
|
|
267
|
+
gc.unfreeze()
|
|
268
|
+
|
|
269
|
+
return docs, tokens_total, vocab_size
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main():
|
|
273
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
274
|
+
parser.add_argument("docs_file")
|
|
275
|
+
parser.add_argument("index_base")
|
|
276
|
+
parser.add_argument("size_label")
|
|
277
|
+
parser.add_argument("--workers", type=int, default=os.cpu_count() or 1)
|
|
278
|
+
parser.add_argument("--csv", default=None)
|
|
279
|
+
args = parser.parse_args()
|
|
280
|
+
|
|
281
|
+
csv_path = Path(args.csv) if args.csv else Path(__file__).resolve().parent / "results" / "indexing_bulk.csv"
|
|
282
|
+
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
new_file = not csv_path.exists()
|
|
284
|
+
|
|
285
|
+
# two runs writing the same index would delete/overwrite each other's
|
|
286
|
+
# files and fail (or worse) in the middle; refuse to start instead
|
|
287
|
+
lock_file = open(args.index_base + ".lock", "w")
|
|
288
|
+
try:
|
|
289
|
+
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
290
|
+
except BlockingIOError:
|
|
291
|
+
sys.exit(f"index_bulk.py: another run is already writing {args.index_base}.* (lock held on "
|
|
292
|
+
f"{args.index_base}.lock)")
|
|
293
|
+
|
|
294
|
+
docs_file_size = Path(args.docs_file).stat().st_size
|
|
295
|
+
start = time.perf_counter()
|
|
296
|
+
docs, tokens_total, vocab = build_index(args.docs_file, args.index_base, max(1, args.workers))
|
|
297
|
+
wall_seconds = time.perf_counter() - start
|
|
298
|
+
# process-lifetime peak RSS of the main process, and of the largest
|
|
299
|
+
# worker; accurate for this benchmark since it's run as a fresh
|
|
300
|
+
# subprocess per corpus size, doing nothing else beforehand
|
|
301
|
+
peak_rss_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
302
|
+
peak_worker_rss_kb = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss
|
|
303
|
+
|
|
304
|
+
index_bytes = 0
|
|
305
|
+
for ext in (".nindlexiconindex", ".nindtermindex", ".nindlocalindex"):
|
|
306
|
+
p = Path(args.index_base + ext)
|
|
307
|
+
if p.exists():
|
|
308
|
+
index_bytes += p.stat().st_size
|
|
309
|
+
|
|
310
|
+
docs_per_sec = docs / wall_seconds if wall_seconds > 0 else docs
|
|
311
|
+
mb_per_sec = (docs_file_size / 1048576) / wall_seconds if wall_seconds > 0 else docs_file_size / 1048576
|
|
312
|
+
|
|
313
|
+
with open(csv_path, "a", encoding="utf-8") as f:
|
|
314
|
+
if new_file:
|
|
315
|
+
f.write("size,workers,docs,tokens,vocab,input_bytes,wall_seconds,docs_per_sec,mb_per_sec,"
|
|
316
|
+
"peak_rss_kb,peak_worker_rss_kb,index_bytes\n")
|
|
317
|
+
f.write(f"{args.size_label},{args.workers},{docs},{tokens_total},{vocab},{docs_file_size},"
|
|
318
|
+
f"{wall_seconds:.3f},{docs_per_sec:.3f},{mb_per_sec:.5f},"
|
|
319
|
+
f"{peak_rss_kb},{peak_worker_rss_kb},{index_bytes}\n")
|
|
320
|
+
|
|
321
|
+
print(f"docs={docs} workers={args.workers} wall={wall_seconds:.2f}s docs/sec={docs_per_sec:.1f} "
|
|
322
|
+
f"MB/sec={mb_per_sec:.3f} peak_rss={peak_rss_kb}KB peak_worker_rss={peak_worker_rss_kb}KB "
|
|
323
|
+
f"index_bytes={index_bytes}", file=sys.stderr)
|
|
324
|
+
print(f"Results appended to {csv_path}", file=sys.stderr)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|