Compare commits

...
144 Commits
Author SHA1 Message Date
Hua.T e981d2d8fc Forward backend options during streaming speech generation (#1389) (#1390) 2026-10-11 17:23:05 -04:00
PerryLink 55fadb4c56 Reject a semicolon in a SQL query instead of silently cutting the statement (#1388) 2026-10-11 17:21:01 -04:00
Rızgar Ozan fe5be99bfa Handle ServiceTask responses without a Content-Type or body (#1387) 2026-10-11 17:17:06 -04:00
David Mezzetti 243aaa191d Merge pull request #1385 from kartsan03/sqlite-load-switch
Switch SQLite ANN connections when load() is given a new path
2026-10-11 17:13:58 -04:00
leeshengis 93a767f004 Preserve workflow and task configuration dictionaries (#1377) 2026-10-11 17:12:41 -04:00
davidmezzetti 62099b9a34 Cleanup open connection in test 2026-10-11 12:03:14 -04:00
David Mezzetti c786f1b5ec Replace continue statement in HTMLToMarkdown list handler (#1357) (#1386) 2026-10-11 11:02:08 -04:00
amengnew 738df97d5f Fall back to row index when Tabular IDs are NaN (#1366) 2026-10-11 10:37:51 -04:00
Raphael Malikian 4605ee001f Fix LogOdds.calibrate to compute the mean once instead of per score (#1365) 2026-10-10 08:06:37 -04:00
Rızgar Ozan 46516fa4e6 Replace continue statement in HTMLToMarkdown list handler (#1357) 2026-10-10 14:24:22 +03:00
kartsan03 fe1fbbc933 Switch SQLite ANN connections when load() is given a new path 2026-10-10 11:11:24 +00:00
Hua.T 084a1eca4d Fix audio trimming of partial detection windows (#1374) (#1375) 2026-10-10 06:35:44 -04:00
Muneeb Ullah ba9d52310a Fix infertopics ignoring topiced neighbors when untopiced ones outnumber them (#1373) 2026-10-10 06:33:12 -04:00
Jasper ca9563daac Use only the query argument of similar() clauses in terms and explain (#1367) 2026-10-09 10:29:46 -04:00
D41910 0e7202caee Honor an empty allow list in the bash agent tool (#1362) 2026-10-08 15:55:25 -04:00
David Mezzetti c1f1686889 Merge pull request #1361 from kartsan03/rdbms-graph-addattribute
Write RDBMS graph attribute changes to the database
2026-10-08 13:06:57 -04:00
kartsan03 04f5bf1998 Write RDBMS graph attribute changes to the database 2026-10-08 12:52:02 +00:00
Gaurav Chintakunta c8e7d813af Preserve ordered-list start values in HTMLToMarkdown (#1355) (#1356) 2026-10-08 07:05:07 -04:00
Yuilona edf61d1d4a Use each query's hybrid weights when batching similar clauses (#1359) 2026-10-07 16:21:36 -04:00
Raphael Malikian 7e92f5a7d7 Fix StorageTask listing failing on tagged storage inputs (#1348) 2026-10-07 14:15:17 -04:00
Raashish Aggarwal b2ff9bd85c Sort NULL values like SQLite when merging ordered results (#1353) 2026-10-07 13:09:08 -04:00
Raashish Aggarwal 9c718f7876 Merge aggregate columns written in uppercase across shards (#1352) 2026-10-07 09:00:39 -04:00
Moinuddin Shaik c486f4706e Return no results from Reranker when a search has no matches (#1350) 2026-10-07 09:00:03 -04:00
Lukas 341184ba46 Keep word spacing and drop empty link targets in HTMLToMarkdown (#1347) 2026-10-07 08:58:51 -04:00
Lukas 647d80f95e Replace HTML line breaks with newlines in HTMLToMarkdown (#1345) 2026-10-07 07:22:03 -04:00
Yash Raj Pandey 408c25d201 Explain DuckDB query errors that abort the transaction (#1341) 2026-10-07 06:34:30 -04:00
davidmezzetti b0f29a11d1 Bump Docker version 2026-10-06 10:18:15 -04:00
davidmezzetti 49a64943b8 Bump version 2026-10-06 10:18:02 -04:00
David Mezzetti af33f2e024 Merge pull request #1333 from HuaTNA/fix-tabular-falsy-idcolumn
Respect explicitly selected falsy ID column names in Tabular
2026-10-05 11:06:24 -04:00
davidmezzetti f908b5d88d Support trust_remote_code in associated AutoModels, closes #1342 2026-10-05 10:25:41 -04:00
David Mezzetti 2e8493dfca Merge pull request #1338 from serhiizghama/fix/duckdb-bind-parameter-order
Keep DuckDB bind values in query order when a parameter repeats
2026-10-05 08:53:15 -04:00
shabeeth2 d7e3e29ac3 docs: match task input tuple in explanation (#1337) 2026-10-05 06:22:06 -04:00
Mohammad Hijjawi 4e2efa2cbc Keep node id 0 in graph path search results (#1335) 2026-10-05 06:19:12 -04:00
Gaurav Chintakunta ffcbc0b815 Skip NULL aggregate values from shards with no matching rows (#1331) 2026-10-05 06:17:05 -04:00
tuanzirwar f14c26c4c7 Fix ordinary streaming output buffering with stripthink enabled (#1311) 2026-10-05 06:15:44 -04:00
serhiizghama 346bb9d282 Test a repeated bind parameter followed by another parameter 2026-10-05 11:12:23 +07:00
serhiizghama e3e0413747 Keep DuckDB bind values in query order when a parameter repeats 2026-10-05 10:47:53 +07:00
David Mezzetti 32655393e0 Merge pull request #1323 from Lesereingrape/fix/concat-merge-falsy-values-1003
Keep 0 and False in concat merge outputs
2026-10-04 11:08:45 -04:00
davidmezzetti 9118875980 Move coverage check to only run for Linux GitHub action 2026-10-04 09:45:08 -04:00
Manobhi Sriram d9b4476187 Decode bash tool output as UTF-8 instead of locale encoding, closes #1326 (#1327) 2026-10-04 09:32:30 -04:00
Grapette.L ff5aec468a Fix DuckDB bind parameters followed by a non-word character not being converted (#1325) 2026-10-04 09:01:16 -04:00
davidmezzetti 3446a4ef13 Fail if code coverage less than 100% 2026-10-04 08:57:07 -04:00
HuaTNA 84aa1c81c7 Fix Tabular handling of explicitly selected falsy ID columns (#1332) 2026-10-03 19:14:23 -04:00
Lesereingrape 6bc76132d0 Keep 0 and False in concat merge outputs 2026-10-03 11:34:59 +08:00
Morgan Carr 893d0424be Cache document token vectors in Reranker (#1321) 2026-10-02 14:32:17 -04:00
Edson 98c965cc3b Keep all rows for native vstack merges with list inputs (#1320) 2026-10-02 14:30:31 -04:00
Yash Raj Pandey 00622f2cf1 Parse true and false as boolean literals in SQL queries (#1316) 2026-10-02 14:28:51 -04:00
Yash Raj Pandey 55d52fda92 Keep graph categories aligned with topics after a delete (#1315) 2026-10-02 14:24:59 -04:00
Ni0ri 20c9b8e0be Add Zvec Sparse ANN (#1307) 2026-10-01 09:53:38 -04:00
Mohammad Hijjawi 3f33fb2918 Format nested HTML lists without duplicating items (#1286) 2026-10-01 06:48:14 -04:00
davidmezzetti 3c7a548923 Remove build script workaround, closes #1239 2026-09-30 13:03:03 -04:00
Pablo Miguel Argudo c78fbd976c Support append and delete in the RaBitQ ivf mode (#1304) 2026-09-30 12:44:19 -04:00
David Mezzetti 2d535ff215 Merge pull request #1303 from kartsan03/fix/ann-delete-numpy-ids
Delete the requested rows when ANN ids are a NumPy array
2026-09-30 12:40:40 -04:00
kartsan03 6765330f4a Delete the requested rows when ANN ids are a NumPy array 2026-09-30 15:26:24 +00:00
davidmezzetti 1d5041b6e7 Fix test merge error 2026-09-30 11:07:02 -04:00
davidmezzetti d467878064 Refactor ANN tests, closes #1305 2026-09-30 11:04:07 -04:00
davidmezzetti 3bc07b3f09 Remove unnecessary note 2026-09-30 11:03:37 -04:00
Hua.T 8ef454d0bf Preserve keyword document ID types across save/load (#1274) (#1275) 2026-09-30 10:57:41 -04:00
Jin 47d3e65756 Fix training with pandas DataFrames, closes #1265 (#1266) 2026-09-30 10:55:44 -04:00
LimbC-C 81f908874b Open lazy SQLite connections before saving indexes (#1290) 2026-09-30 09:30:14 -04:00
David Mezzetti bed91a0b9f Merge pull request #1301 from Bdysj/openai-compliant-chat-completions
Return OpenAI-compliant index, created and finish_reason from chat completions
2026-09-30 09:05:50 -04:00
David Mezzetti 7df50abb61 Merge pull request #1282 from LimbC-C/fix/sqlite-binary-scores
Normalize SQLite binary ANN similarity scores
2026-09-30 09:03:45 -04:00
David Mezzetti 80b8ceff4a Merge pull request #1279 from LimbC-C/fix/array-negative-delete-ids
Ignore negative deletion IDs in NumPy and Torch ANN indexes
2026-09-30 08:59:03 -04:00
David Mezzetti de9e09e9ae Merge pull request #1299 from LimbC-C/fix/tabular-numeric-types
Preserve integer document IDs and numeric column types in Tabular
2026-09-30 08:19:59 -04:00
Morgan Carr 69ede9ec3e Fall back to root tokenizers for ONNX and LiteRT Hub subdirectory models (#1277) 2026-09-30 07:41:03 -04:00
Pablo Miguel Argudo f8689ed903 Store only the quantized index in the RaBitQ backend (#1297) 2026-09-30 07:05:37 -04:00
bob 0abf3763b4 Return OpenAI-compliant index, created and finish_reason from chat completions, closes #1300 2026-09-30 14:51:02 +08:00
LimbC-C 11b2cfe298 Preserve numeric column types in Tabular rows (#1298) 2026-09-30 09:45:58 +08:00
LimbC-C d0cc2bed9c Simplify regression test in existing TestDense class (#1282) 2026-09-30 09:39:23 +08:00
LimbC-C a5ea35d7a7 Simplify regression test in existing TestDense class (#1279) 2026-09-30 09:39:18 +08:00
Yash Raj Pandey 6a15ed8490 Include offset rows in similar() candidates (#1291) 2026-09-29 17:25:52 -04:00
David Mezzetti 76147369fb Merge pull request #1295 from Bdysj/pad-summary-batches
Pad batched inputs in the Summary pipeline
2026-09-29 15:27:58 -04:00
bob 2e89af0845 Pad batched inputs in the Summary pipeline, closes #1294 2026-09-30 01:19:38 +08:00
davidmezzetti d9d6a1cbb1 Update pooling mode check 2026-09-29 11:05:04 -04:00
davidmezzetti f87574de4c Support pooling_mode with models trained with recent Sentence Transformers, closes #1292 2026-09-29 10:23:16 -04:00
Mohammad Hijjawi e313248ff2 Keep uppercase tokens in alphanumeric tokenization with lowercase=False (#1287) 2026-09-29 09:03:03 -04:00
davidmezzetti 5cba72c88a Update pylintrc file and remove warning overrides for new limits 2026-09-29 07:11:03 -04:00
davidmezzetti 45d334f4ce Remove unnecessary new test file and consolidate test into existing class 2026-09-29 07:10:09 -04:00
Jin 95d4c1c881 Label QA spans without the answer with the CLS token, closes #1263 (#1264) 2026-09-29 06:36:47 -04:00
LimbC-C ef28da8735 Reject iterator inputs for multi-action tasks without buffering (#1272) 2026-09-28 10:40:36 -04:00
LimbC-C f1304d8403 Normalize SQLite binary ANN similarity scores (#1281) 2026-09-28 09:43:12 +08:00
davidmezzetti 97ee7e073a Bump job timeout given that the Windows build frequently times out 2026-09-27 05:51:01 -04:00
LimbC-C 1d3f8e9ebf Ignore negative NumPy and Torch ANN deletion IDs (#1278) 2026-09-26 09:43:32 +08:00
davidmezzetti f3ceece76a Add test for missing SQLite ANN coverage 2026-09-25 09:18:02 -04:00
Pablo Miguel Argudo 6737ee4e01 Add RaBitQ as an ANN backend (#1251) 2026-09-25 08:39:39 -04:00
davidmezzetti e30e8edbcc Add test for missing SQLite ANN coverage 2026-09-25 08:00:21 -04:00
davidmezzetti 483dbd3b3b Update test 2026-09-24 21:49:06 -04:00
davidmezzetti c09a6f24f1 Update test 2026-09-24 21:46:09 -04:00
davidmezzetti 6e7f0d260e Update test 2026-09-24 21:12:15 -04:00
davidmezzetti 793ecc8dc2 Require Python 3.11, closes #1273 2026-09-24 16:58:32 -04:00
David Mezzetti 37500887ae Merge pull request #1268 from HuaTNA/fix-rag-input-mutation
Preserve reusable RagTask inputs
2026-09-24 16:35:58 -04:00
David Mezzetti 4e2eb242b6 Support explicit LIKE escapes in wildcard term queries (#1259) 2026-09-24 16:34:16 -04:00
Jin 83c75ea28a Pack rows into chunks up to and including maxlength, closes #1269 (#1270) 2026-09-24 16:31:37 -04:00
LimbC-C f47d389735 Preserve wildcard compatibility and support explicit LIKE escapes 2026-09-24 23:54:33 +08:00
HuaTNA 4bb2141954 Preserve reusable RagTask inputs (#1267) 2026-09-23 23:48:46 -04:00
David Mezzetti 5d8c999ae0 Merge pull request #1262 from HuaTNA/fix-template-falsy-rules
Preserve falsy TemplateTask rule matches
2026-09-23 16:36:47 -04:00
HuaTNA 48a34e21c3 Preserve falsy TemplateTask rule matches (#1261) 2026-09-23 13:22:36 -04:00
davidmezzetti 262b03027e Code cleanup 2026-09-23 08:01:47 -04:00
刘冠杰 36c375b86e Honor the nfeatures setting in IVFSparse (#1250) 2026-09-23 07:53:18 -04:00
David Mezzetti b715812e68 Merge pull request #1248 from LimbC-C/fix/deleted-keyword-results
Keep deleted keyword results excluded after common-term scoring
2026-09-23 07:51:27 -04:00
hylin dcdded892d Keep community nodes that match none of the topic terms (#1243) 2026-09-23 07:40:25 -04:00
LimbC-C e7894cdb00 Escape literal SQL wildcard characters in term expansion (#1258) 2026-09-23 13:06:27 +08:00
LimbC-C 9f9b32e070 Clear deleted scores at the end of common-term merging 2026-09-23 13:00:30 +08:00
David Mezzetti 00167b9f08 Merge pull request #1256 from dingpuyu/codex/fix-retrieve-local-filenames
Preserve local filenames in RetrieveTask
2026-09-22 14:00:19 -04:00
dingpuyu 3c07766199 Preserve local filenames in RetrieveTask 2026-09-23 00:36:08 +08:00
Amir Fathi 7cb6bd09db Fix DuckDB.formatargs dropping repeated named bind parameters (#1253) 2026-09-22 12:22:45 -04:00
Caner Olcay 4b7d70e9e8 Keep SQLite ANN data when saving a modified index to a new path (#1249) 2026-09-22 06:55:29 -04:00
David Mezzetti 2ed4e46ebf Merge pull request #1245 from serhiizghama/fix/cluster-forward-weights-and-encode-parameters
fix(api): forward zero weights and url-encode parameters in Cluster search
2026-09-22 06:54:14 -04:00
LimbC-C 47d5f56768 Keep deleted keyword results excluded after common-term scoring (#1247) 2026-09-19 09:46:09 +08:00
serhiizghama d5db643540 test: cover cluster search argument forwarding 2026-09-18 09:15:19 +07:00
serhiizghama 9f7b4ddd42 fix(api): forward zero weights and url-encode parameters in Cluster search 2026-09-18 08:56:12 +07:00
Morgan Carr 19ff237c37 Set the HNSW delete counter when loading an index (#1242) 2026-09-15 14:21:46 -04:00
Morgan Carr ad77514b32 Store IVFSparse deletes as int (#1241) 2026-09-15 14:20:36 -04:00
davidmezzetti 144a957d42 Test latest version of LiteLLM #1193 2026-09-15 07:51:21 -04:00
Serhii Zghama 2e8a3d7892 fix(database): don't drop rows when sorting or grouping by a JSON field (#1238) 2026-09-14 10:19:52 -04:00
Amir Fathi 72f9ca7605 Fix Cluster combining avg() as an unweighted mean of shard averages (#1237) 2026-09-14 10:18:52 -04:00
Amir Fathi ebe13ae304 Fix quantize: false silently enabling Q8_0 quantization in the GGML backend (#1236) 2026-09-11 08:25:40 -04:00
Serhii Zghama 5d0e995f7f fix(ann): honor quantize: false in the Faiss and SQLite backends (#1234) 2026-09-11 08:22:49 -04:00
davidmezzetti 7965a7f42e Build script workaround #1239 2026-09-10 08:52:56 -04:00
Amir Fathi 2bae6e4d43 Fix Transform.stream() yielding a None text value into the encoder (#1231) 2026-09-08 09:44:53 -04:00
Morgan Carr 767272f2d5 Key the vectors model cache by configuration (#1223) 2026-09-04 16:47:49 -04:00
Morgan Carr 21460347dd Fix ANN delete on quantized Torch stores and tiny GGML indexes (#1228) 2026-09-04 09:54:42 -04:00
Morgan Carr f3945f9120 Select the top documents in LateEncoder by the document axis (#1227) 2026-09-04 09:52:40 -04:00
Paco Cartones 3a7b597d78 fix(workflow): add request timeout to ServiceTask (#1226) 2026-09-04 09:00:49 -04:00
Morgan Carr 20e2dc41e1 Save empty keyword and content indexes without errors (#1224) 2026-09-04 08:21:11 -04:00
Morgan Carr eafe574d92 Keep the archive working directory alive when saving a loaded index (#1222) 2026-09-04 08:19:36 -04:00
Morgan Carr 7e98746d78 Delete every index entry for an id in Terms (#1219) 2026-09-04 08:11:47 -04:00
Morgan Carr 6ebfbbb716 Improve late pooling configuration errors (#1217) 2026-09-03 13:52:46 -04:00
David Mezzetti 8fef6ac454 Merge pull request #1220 from serhiizghama/fix/sparse-vectors-remove-spool-file
fix(vectors): remove the sparse spool file when checkpointing is disabled
2026-09-03 13:05:09 -04:00
davidmezzetti 0eafc0580f Update vectors package to standardize fallback logic, closes #1221 2026-09-03 11:53:55 -04:00
serhiizghama 5ade7881c8 test: cover spool file cleanup for sparse vectors 2026-09-03 20:19:31 +07:00
serhiizghama 948dc42d19 fix(vectors): remove the sparse spool file when checkpointing is disabled 2026-09-03 19:58:47 +07:00
David Mezzetti 2e8af221f4 Keep count() accurate after repeated deletes in GGML and IVFSparse (#1218)
Deleting an id that is already marked deleted no longer decrements count()
again in the GGML and IVFSparse ANN backends. IVFSparse also ignores ids that
were never indexed, matching the dense backends.
2026-09-02 18:18:11 -04:00
Morgan Carr 396b3b8065 Resolve label filters by label id (#1216) 2026-09-02 17:15:45 -04:00
Morgan Carr 80420ee10a Ignore padding rows in LateEncoder scores (#1215) 2026-09-02 17:14:39 -04:00
David Mezzetti 1d45e88569 Merge pull request #1214 from morgan-coded/fix/milvus-load-collection
Load the Milvus collection when opening a saved index
2026-09-02 15:25:19 -04:00
Joe Bergin 007d61077a Add ONNX vectors backend (#1212) 2026-09-02 12:19:57 -04:00
David Mezzetti 84fcd81039 fix(api): parse the graph query parameter as a bool (#1213) 2026-09-02 11:18:16 -04:00
morgan-coded cfe2227f30 Load the Milvus collection when opening a saved index
With milvus-lite 3.2.1, a reopened collection that has an index starts in the
released state, so search failed after save and after load while count worked.
2026-09-02 03:20:29 -05:00
morgan-coded 82b5320da3 Keep count() accurate after repeated deletes in GGML and IVFSparse
Deleting an id that is already marked deleted no longer decrements count()
again in the GGML and IVFSparse ANN backends. IVFSparse also ignores ids that
were never indexed, matching the dense backends.
2026-09-02 02:53:52 -05:00
serhiizghama 18b93be702 test: cover graph parameter parsing in the search API 2026-09-02 11:06:18 +07:00
serhiizghama 027c07deb9 fix(api): parse the graph query parameter as a bool 2026-09-02 10:53:23 +07:00
Paco Cartones 876cd07cf7 Fix bash tool hanging on commands that read stdin (#1210) 2026-09-01 20:04:29 -04:00
davidmezzetti 546e7777f1 Bump version 2026-08-27 12:48:36 -04:00
151 changed files with 5251 additions and 1129 deletions
+6 -4
View File
@@ -10,7 +10,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
timeout-minutes: 60
timeout-minutes: 75
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -18,7 +18,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.11"
- name: Install Java
uses: actions/setup-java@v5
@@ -52,7 +52,7 @@ jobs:
- name: Build
run: |
pip install -U wheel
pip install .[all,dev] "litellm<1.97.0"
pip install .[all,dev]
pip cache purge
python -c "import nltk; nltk.download(['punkt', 'punkt_tab', 'averaged_perceptron_tagger_eng'])"
python --version
@@ -68,7 +68,9 @@ jobs:
if: matrix.os == 'ubuntu-latest'
- name: Test Coverage
run: coveralls --service=github
run: |
coverage report --fail-under=100
coveralls --service=github
if: matrix.os == 'ubuntu-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.11"
- name: Install txtai
run: |
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.11"
- name: Minimal install
run: |
+2
View File
@@ -12,6 +12,8 @@ max-locals=40
max-returns=10
max-attributes=20
min-public-methods=0
max-public-methods=80
[FORMAT]
max-line-length=150
max-module-lines=1500
+2 -2
View File
@@ -49,7 +49,7 @@ Summary of txtai features:
- 🔋 Batteries included with defaults to get up and running fast
- ☁️ Run local or scale out with container orchestration
txtai is built with Python 3.10+, [Hugging Face Transformers](https://github.com/huggingface/transformers), [Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and [FastAPI](https://github.com/tiangolo/fastapi). txtai is open-source under an Apache 2.0 license.
txtai is built with Python 3.11+, [Hugging Face Transformers](https://github.com/huggingface/transformers), [Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and [FastAPI](https://github.com/tiangolo/fastapi). txtai is open-source under an Apache 2.0 license.
> [!NOTE]
>
@@ -190,7 +190,7 @@ The easiest way to install is via pip and PyPI
pip install txtai
```
Python 3.10+ is supported. Using a Python [virtual environment](https://docs.python.org/3/library/venv.html) is recommended.
Python 3.11+ is supported. Using a Python [virtual environment](https://docs.python.org/3/library/venv.html) is recommended.
See the detailed [install instructions](https://neuml.github.io/txtai/install) for more information covering [optional dependencies](https://neuml.github.io/txtai/install/#optional-dependencies), [environment specific prerequisites](https://neuml.github.io/txtai/install/#environment-specific-prerequisites), [installing from source](https://neuml.github.io/txtai/install/#install-from-source), [conda support](https://neuml.github.io/txtai/install/#conda), [lightweight minimal installation](https://neuml.github.io/txtai/install/#minimal-install) and how to [run with containers](https://neuml.github.io/txtai/cloud).
+3 -3
View File
@@ -1,5 +1,5 @@
# Set base image
ARG BASE_IMAGE=python:3.10-slim
ARG BASE_IMAGE=python:3.11-slim
FROM $BASE_IMAGE
# Install GPU-enabled version of PyTorch if set
@@ -8,7 +8,7 @@ ARG GPU
# Target CPU architecture
ARG TARGETARCH
# Set Python version (i.e. 3, 3.10)
# Set Python version (i.e. 3, 3.11)
ARG PYTHON_VERSION=3
# List of txtai components to install
@@ -27,7 +27,7 @@ RUN \
# Install txtai project and dependencies
ln -s /usr/bin/python${PYTHON_VERSION} /usr/bin/python && \
python -m pip install --no-cache-dir -U pip wheel setuptools && \
if [ -z ${GPU} ]; then pip install --no-cache-dir torch==2.12.1+cpu torchvision==0.27.1+cpu -f https://download.pytorch.org/whl/torch -f https://download.pytorch.org/whl/torchvision; fi && \
if [ -z ${GPU} ]; then pip install --no-cache-dir torch==2.14.1+cpu torchvision==0.29.1+cpu -f https://download.pytorch.org/whl/torch -f https://download.pytorch.org/whl/torchvision; fi && \
python -m pip install --no-cache-dir txtai${COMPONENTS} && \
python -c "import sys, importlib.util as util; 1 if util.find_spec('nltk') else sys.exit(); import nltk; nltk.download(['punkt', 'punkt_tab', 'averaged_perceptron_tagger_eng'])" && \
\
+2 -2
View File
@@ -1,11 +1,11 @@
# Set base image
ARG BASE_IMAGE=python:3.10-slim
ARG BASE_IMAGE=python:3.11-slim
FROM $BASE_IMAGE
# Target CPU architecture
ARG TARGETARCH
# Set Python version (i.e. 3, 3.10)
# Set Python version (i.e. 3, 3.11)
ARG PYTHON_VERSION=3
# Locale environment variables
+2 -2
View File
@@ -30,8 +30,8 @@ Examples build commands below.
# Get Dockerfile
wget https://raw.githubusercontent.com/neuml/txtai/master/docker/base/Dockerfile
# Build Ubuntu 22.04 image running Python 3.10
docker build -t txtai --build-arg BASE_IMAGE=ubuntu:22.04 --build-arg PYTHON_VERSION=3.10 .
# Build Ubuntu 22.04 image running Python 3.11
docker build -t txtai --build-arg BASE_IMAGE=ubuntu:22.04 --build-arg PYTHON_VERSION=3.11 .
# Build image with GPU support
docker build -t txtai --build-arg GPU=1 .
+23 -1
View File
@@ -4,7 +4,7 @@ Approximate Nearest Neighbor (ANN) index configuration for storing vector embedd
## backend
```yaml
backend: faiss|hnsw|annoy|ggml|milvus|numpy|torch|turbovec|zvec|pgvector|sqlite|custom
backend: faiss|hnsw|annoy|ggml|milvus|numpy|rabitq|torch|turbovec|zvec|pgvector|sqlite|custom
```
Sets the ANN backend. Defaults to `faiss`. Additional backends are available via the [ann](../../../install/#ann) extras package. Set custom backends via setting this parameter to the fully resolvable class string.
@@ -91,6 +91,26 @@ numpy:
The NumPy backend is a k-nearest neighbors backend. It's designed for simplicity and works well with smaller datasets that fit into memory.
### rabitq
```yaml
rabitq:
mode: index mode (ivf or hnsw) - defaults to "ivf"
nbits: number of quantization bits per dimension (int) - defaults to 1,
supports 1 to 9 in both modes, ivf mode also supports 32 which
keeps raw vectors for reranking
clusters: number of IVF clusters (int) - defaults to
max(1, min(round(4 * sqrt(embeddings count)), embeddings count))
nprobe: search probe setting for ivf mode (int) - defaults to
max(1, round(num_clusters/16))
m: M param for hnsw mode (int) - defaults to 16
efconstruction: ef_construction param for hnsw mode (int) - defaults to 200
efsearch: ef search param for hnsw mode (int) - defaults to None and not set
randomseed: random-seed param for hnsw mode (int) - defaults to 100
```
The [rabitq](https://github.com/VectorDB-NTU/RaBitQ-Library) backend is a quantized index powered by the [RaBitQ algorithm](https://doi.org/10.1145/3725413). It supports ivf and hnsw search modes. Vectors are stored with 1-bit quantization by default. More bits increase accuracy at the cost of memory. The index is stored as a single file and no vectors are kept outside of it. With nbits set to 32, the vectors are stored in the index for reranking. The ivf mode supports upserts and deletes. Deleted rows keep their storage. Rows added after the first index are assigned to the clusters trained when the index was built, so recall can drop if the new data differs from the original data. Rebuild the index to train new clusters. Note that hnsw indexes can not be modified after creation, upserts/deletes and other modifications are not supported.
### torch
```yaml
@@ -119,7 +139,9 @@ The [turbovec](https://github.com/RyanCodrai/turbovec) backend is a k-nearest ne
```yaml
zvec:
efconstruction: ef_construction param for HnswIndexParam (int) - defaults to 200
m: number of HNSW links per element (int) - defaults to 50
efsearch: ef search param for HnswQueryParam (int) - defaults to 300
```
The [zvec](https://github.com/alibaba/zvec) backend is an embedded, path-based vector index.
+21 -2
View File
@@ -36,7 +36,7 @@ batch: Sets the transform batch size
encodebatch: Sets the encode batch size
vectors: additional model init args
encodeargs: additional encode() args
backend: ivfsparse|pgsparse
backend: ivfsparse|pgsparse|zvecsparse
```
Sparse vector scoring options. The sparse scoring instance combines a sparse vector model with a sparse approximate nearest neighbor index (ANN). This method supports both vector normalization and score normalization.
@@ -54,7 +54,7 @@ Score normalization scales the output between 0 and 1. This setting supports:
```yaml
ivfsparse:
sample: percent of data to use for model training (0.0 - 1.0)
nfeatures: top n features to use for model training (int)
nfeatures: top n features to use for model training, defaults to all features (int)
nlist: desired number of clusters (int)
nprobe: search probe setting (int)
minpoints: minimum number of points for a cluster (int)
@@ -66,6 +66,16 @@ Inverted file (IVF) index with flat vector file storage and sparse array support
Sparse ANN backed by Postgres. Supports same options as the [pgvector](../ann/#pgvector) ANN.
#### zvecsparse
```yaml
zvecsparse:
efconstruction: ef_construction param for HnswIndexParam (int) - defaults to 200
m: number of HNSW links per element (int) - defaults to 50
efsearch: ef search param for HnswQueryParam (int) - defaults to 300
```
Sparse ANN backed by [zvec](https://github.com/alibaba/zvec). Stores sparse vectors in an embedded, path-based vector index. Requires the [ann](../../../install/#ann) extras package.
## terms
```yaml
terms: boolean|dict
@@ -81,6 +91,15 @@ Supports a `dict` with the parameters `cachelimit` and `cutoff`.
When `terms` is set to `True`, default parameters are used for the `cachelimit` and `cutoff`. Normally, these defaults are sufficient.
### Wildcard queries
Keyword queries containing `*` expand matching terms using SQL `LIKE`. Within these
expressions, `%` matches any sequence and `_` matches one character. To match a literal
percent sign, underscore or backslash, use `\%`, `\_` or `\\`, respectively.
For example, `report\_*` matches `report_2026`, while `report_*` also matches `reportX2026`.
Use a tokenizer that preserves these characters, such as `tokenizer: {whitespace: true}`,
or pass pre-tokenized query terms. Queries without `*` retain exact-term matching.
## normalize
```yaml
normalize: boolean|str|dict
+2 -1
View File
@@ -176,7 +176,8 @@ muvera:
```
Settings to control the size of MUVERA fixed dimensional outputs. Default is 20 * 2^5 * 16 = 10,240 dimensions.
Set `muvera` to `false` to disable it or `true` to use the default settings.
Set `muvera` to `false` to disable it or `true` to use the default settings. `false` only makes sense when the multi-vector output
is consumed directly, for example through `PoolingFactory`. Embeddings vectors require `muvera` or `lemur`.
### lemur
```yaml
+2
View File
@@ -54,6 +54,8 @@ The number of candidates should be larger than the desired number of results whe
- For a single query filter clause, the default is the query limit
- With multiple filtering clauses, the default is 10x the query limit
When a query orders results by score, its `offset` adds to the query limit.
The index name is only applicable when [subindexes](../configuration/general/#indexes) are enabled. This specifies the index to use for the query.
Weights sets the hybrid score weights when an index has both a sparse and dense index.
+1 -1
View File
@@ -51,7 +51,7 @@ Summary of txtai features:
- 🔋 Batteries included with defaults to get up and running fast
- ☁️ Run local or scale out with container orchestration
txtai is built with Python 3.10+, [Hugging Face Transformers](https://github.com/huggingface/transformers), [Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and [FastAPI](https://github.com/tiangolo/fastapi). txtai is open-source under an Apache 2.0 license.
txtai is built with Python 3.11+, [Hugging Face Transformers](https://github.com/huggingface/transformers), [Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and [FastAPI](https://github.com/tiangolo/fastapi). txtai is open-source under an Apache 2.0 license.
!!! note
+1 -1
View File
@@ -9,7 +9,7 @@ The easiest way to install is via pip and PyPI
pip install txtai
```
Python 3.10+ is supported. Using a Python [virtual environment](https://docs.python.org/3/library/venv.html) is recommended.
Python 3.11+ is supported. Using a Python [virtual environment](https://docs.python.org/3/library/venv.html) is recommended.
## Optional dependencies
+14 -1
View File
@@ -42,6 +42,19 @@ workflow:
::: txtai.workflow.Task.__init__
## Multi-action task inputs
A multi-action `Task` requires reusable inputs. Passing a single-pass iterator, such as a generator or the output of a `StreamTask`, raises a `ValueError` before consuming inputs or running any actions. Automatically buffering an iterator could exhaust memory, so tasks do not convert these inputs to a list.
Iterators passed to a `Workflow` remain supported: the workflow collects bounded batches before passing them to its tasks. For example:
```python
workflow = Workflow([Task([list, list])], batch=2)
list(workflow(iter([1, 2, 3]))) # [(1, 1), (2, 2), (3, 3)]
```
This batching occurs at the workflow input; it does not batch iterator outputs produced by tasks inside that workflow. Single-action tasks continue to accept iterator inputs.
## Multi-action task concurrency
The default processing mode is to run actions sequentially. Multiprocessing support is already built in at a number of levels. Any of the GPU models will maximize GPU utilization for example and even in CPU mode, concurrency is utilized. But there are still use cases for task action concurrency. For example, if the system has multiple GPUs, the task runs external sequential code, or the task has a large number of I/O tasks.
@@ -73,7 +86,7 @@ workflow = Workflow([Task(lambda x: [y * 3 for y in x], unpack=False, column=0)]
list(workflow([(2, 8)]))
```
For the example input tuple of (2, 2), the workflow will only select the first element (2) and run the task against that element.
For the example input tuple of (2, 8), the workflow will only select the first element (2) and run the task against that element.
```python
workflow = Workflow([Task([lambda x: [y * 3 for y in x],
+1 -1
View File
@@ -27,7 +27,7 @@
"- 🔋 Batteries included with defaults to get up and running fast\n",
"- ☁️ Run local or scale out with container orchestration\n",
"\n",
"txtai is built with Python 3.10+, [Hugging Face Transformers](https://github.com/huggingface/transformers), [Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and [FastAPI](https://github.com/tiangolo/fastapi). txtai is open-source under an Apache 2.0 license.\n",
"txtai is built with Python 3.11+, [Hugging Face Transformers](https://github.com/huggingface/transformers), [Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and [FastAPI](https://github.com/tiangolo/fastapi). txtai is open-source under an Apache 2.0 license.\n",
"\n",
"> [NeuML](https://neuml.com) is the company behind txtai and we provide AI consulting services around our stack. [Schedule a meeting](https://cal.com/neuml/intro) or [send a message](mailto:[email protected]) to learn more.\n",
">\n",
@@ -125,7 +125,7 @@
"## Install\n",
"The easiest way to install is via pip and PyPI\n",
"pip install txtai\n",
"Python 3.10+ is supported. Using a Python virtual environment is **recommended** .\n",
"Python 3.11+ is supported. Using a Python virtual environment is **recommended** .\n",
"See the detailed install instructions for more information covering optional dependencies, environment specific prerequisites, installing from source, conda support and how to run with containers.\n",
"\n",
"\n",
@@ -525,8 +525,8 @@
"name": "stdout",
"output_type": "stream",
"text": [
"ANSWER: Python 3.10 and later versions (Python 3.10+) are supported.\n",
"CITATION: [{'id': '24', 'text': 'Python 3.10+ is supported. Using a Python virtual environment is recommended.'}]\n"
"ANSWER: Python 3.11 and later versions (Python 3.11+) are supported.\n",
"CITATION: [{'id': '24', 'text': 'Python 3.11+ is supported. Using a Python virtual environment is recommended.'}]\n"
]
}
],
+2 -2
View File
@@ -100,12 +100,12 @@
"name": "stdout",
"output_type": "stream",
"text": [
"{'indexid': 0, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': '**GitHub - neuml/txtai: 💡 All-in-one open-source embeddings database for semantic search, LLM orchestration and language model workflows**\\n\\n*💡 All-in-one open-source embeddings database for semantic search, LLM orchestration and language model workflows - neuml/txtai*\\n\\n\\n\\n**All-in-one embeddings database** \\ntxtai is an all-in-one embeddings database for semantic search, LLM orchestration and language model workflows.\\n\\nEmbeddings databases are a union of vector indexes (sparse and dense), graph networks and relational databases.\\n\\nThis foundation enables vector search and/or serves as a powerful knowledge source for large language model (LLM) applications.\\n\\nBuild autonomous agents, retrieval augmented generation (RAG) processes, multi-model workflows and more.\\n\\nSummary of txtai features:\\n\\n- 🔎 Vector search with SQL, object storage, topic modeling, graph analysis and multimodal indexing\\n- 📄 Create embeddings for text, documents, audio, images and video\\n- 💡 Pipelines powered by language models that run LLM prompts, question-answering, labeling, transcription, translation, summarization and more\\n- ↪️️ Workflows to join pipelines together and aggregate business logic. txtai processes can be simple microservices or multi-model workflows.\\n- 🤖 Agents that intelligently connect embeddings, pipelines, workflows and other agents together to autonomously solve complex problems\\n- ⚙️ Build with Python or YAML. API bindings available for [JavaScript](https://github.com/neuml/txtai.js) , [Java](https://github.com/neuml/txtai.java) , [Rust](https://github.com/neuml/txtai.rs) and [Go](https://github.com/neuml/txtai.go) .\\n- 🔋 Batteries included with defaults to get up and running fast\\n- ☁️ Run local or scale out with container orchestration\\ntxtai is built with Python 3.10+, [Hugging Face Transformers](https://github.com/huggingface/transformers) , [Sentence Transformers](https://github.'}\n",
"{'indexid': 0, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': '**GitHub - neuml/txtai: 💡 All-in-one open-source embeddings database for semantic search, LLM orchestration and language model workflows**\\n\\n*💡 All-in-one open-source embeddings database for semantic search, LLM orchestration and language model workflows - neuml/txtai*\\n\\n\\n\\n**All-in-one embeddings database** \\ntxtai is an all-in-one embeddings database for semantic search, LLM orchestration and language model workflows.\\n\\nEmbeddings databases are a union of vector indexes (sparse and dense), graph networks and relational databases.\\n\\nThis foundation enables vector search and/or serves as a powerful knowledge source for large language model (LLM) applications.\\n\\nBuild autonomous agents, retrieval augmented generation (RAG) processes, multi-model workflows and more.\\n\\nSummary of txtai features:\\n\\n- 🔎 Vector search with SQL, object storage, topic modeling, graph analysis and multimodal indexing\\n- 📄 Create embeddings for text, documents, audio, images and video\\n- 💡 Pipelines powered by language models that run LLM prompts, question-answering, labeling, transcription, translation, summarization and more\\n- ↪️️ Workflows to join pipelines together and aggregate business logic. txtai processes can be simple microservices or multi-model workflows.\\n- 🤖 Agents that intelligently connect embeddings, pipelines, workflows and other agents together to autonomously solve complex problems\\n- ⚙️ Build with Python or YAML. API bindings available for [JavaScript](https://github.com/neuml/txtai.js) , [Java](https://github.com/neuml/txtai.java) , [Rust](https://github.com/neuml/txtai.rs) and [Go](https://github.com/neuml/txtai.go) .\\n- 🔋 Batteries included with defaults to get up and running fast\\n- ☁️ Run local or scale out with container orchestration\\ntxtai is built with Python 3.11+, [Hugging Face Transformers](https://github.com/huggingface/transformers) , [Sentence Transformers](https://github.'}\n",
"{'indexid': 1, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': 'com/UKPLab/sentence-transformers) and [FastAPI](https://github.com/tiangolo/fastapi) . txtai is open-source under an Apache 2.0 license.\\n\\n*Interested in an easy and secure way to run hosted txtai applications? Then join the* [txtai.cloud](https://txtai.cloud) *preview to learn more.* \\n\\n## Why txtai?\\nNew vector databases, LLM frameworks and everything in between are sprouting up daily. Why build with txtai?\\n\\n- Up and running in minutes with [pip](https://neuml.github.io/txtai/install/) or [Docker](https://neuml.github.io/txtai/cloud/) \\n```\\n# Get started in a couple lines\\nimport txtai\\n\\nembeddings = txtai.Embeddings()\\nembeddings.index([\"Correct\", \"Not what we hoped\"])\\nembeddings.search(\"positive\", 1)\\n#[(0, 0.29862046241760254)]\\n```\\n\\n- Built-in API makes it easy to develop applications using your programming language of choice\\n```\\n# app.yml\\nembeddings:\\n path: sentence-transformers/all-MiniLM-L6-v2\\n```\\n\\n```\\nCONFIG=app.yml uvicorn \"txtai.api:app\"\\ncurl -X GET \"http://localhost:8000/search?query=positive\"\\n```\\n\\n- Run local - no need to ship data off to disparate remote services\\n- Work with micromodels all the way up to large language models (LLMs)\\n- Low footprint - install additional dependencies and scale up when needed\\n- [Learn by example](https://neuml.github.io/txtai/examples) - notebooks cover all available functionality\\n\\n## Use Cases\\nThe following sections introduce common txtai use cases. A comprehensive set of over 60 [example notebooks and applications](https://neuml.github.io/txtai/examples) are also available.\\n\\n\\n### Semantic Search\\nBuild semantic/similarity/vector/neural search applications.'}\n",
"{'indexid': 2, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': 'Traditional search systems use keywords to find data. Semantic search has an understanding of natural language and identifies results that have the same meaning, not necessarily the same keywords.\\n\\nGet started with the following examples.\\n\\n|Notebook|Description||\\n|---|---|---|\\n|[Introducing txtai](https://github.com/neuml/txtai/blob/master/examples/01_Introducing_txtai.ipynb) |Overview of the functionality provided by txtai||\\n|[Similarity search with images](https://github.com/neuml/txtai/blob/master/examples/13_Similarity_search_with_images.ipynb) |Embed images and text into the same space for search||\\n|[Build a QA database](https://github.com/neuml/txtai/blob/master/examples/34_Build_a_QA_database.ipynb) |Question matching with semantic search||\\n|[Semantic Graphs](https://github.com/neuml/txtai/blob/master/examples/38_Introducing_the_Semantic_Graph.ipynb) |Explore topics, data connectivity and run network analysis||\\n\\n### LLM Orchestration\\nAutonomous agents, retrieval augmented generation (RAG), chat with your data, pipelines and workflows that interface with large language models (LLMs).\\n\\nSee below to learn more.\\n\\n|Notebook|Description||\\n|---|---|---|\\n|[Prompt templates and task chains](https://github.com/neuml/txtai/blob/master/examples/44_Prompt_templates_and_task_chains.ipynb) |Build model prompts and connect tasks together with workflows||\\n|[Integrate LLM frameworks](https://github.com/neuml/txtai/blob/master/examples/53_Integrate_LLM_Frameworks.ipynb) |Integrate llama.cpp, LiteLLM and custom generation frameworks||\\n|[Build knowledge graphs with LLMs](https://github.'}\n",
"{'indexid': 3, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': 'com/neuml/txtai/blob/master/examples/57_Build_knowledge_graphs_with_LLM_driven_entity_extraction.ipynb) |Build knowledge graphs with LLM-driven entity extraction||\\n\\n#### Agents\\nAgents connect embeddings, pipelines, workflows and other agents together to autonomously solve complex problems.\\n\\ntxtai agents are built on top of the Transformers Agent framework. This supports all LLMs txtai supports (Hugging Face, llama.cpp, OpenAI / Claude / AWS Bedrock via LiteLLM).\\n\\nSee the link below to learn more.\\n\\n|Notebook|Description||\\n|---|---|---|\\n|[Analyzing Hugging Face Posts with Graphs and Agents](https://github.com/neuml/txtai/blob/master/examples/68_Analyzing_Hugging_Face_Posts_with_Graphs_and_Agents.ipynb) |Explore a rich dataset with Graph Analysis and Agents||\\n|[Granting autonomy to agents](https://github.com/neuml/txtai/blob/master/examples/69_Granting_autonomy_to_agents.ipynb) |Agents that iteratively solve problems as they see fit||\\n|[Analyzing LinkedIn Company Posts with Graphs and Agents](https://github.com/neuml/txtai/blob/master/examples/71_Analyzing_LinkedIn_Company_Posts_with_Graphs_and_Agents.ipynb) |Exploring how to improve social media engagement with AI||\\n\\n#### Retrieval augmented generation\\nRetrieval augmented generation (RAG) reduces the risk of LLM hallucinations by constraining the output with a knowledge base as context. RAG is commonly used to \"chat with your data\".\\n\\nA novel feature of txtai is that it can provide both an answer and source citation.\\n\\n|Notebook|Description||\\n|---|---|---|\\n|[Build RAG pipelines with txtai](https://github.'}\n",
"{'indexid': 4, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': 'com/neuml/txtai/blob/master/examples/52_Build_RAG_pipelines_with_txtai.ipynb) |Guide on retrieval augmented generation including how to create citations||\\n|[How RAG with txtai works](https://github.com/neuml/txtai/blob/master/examples/63_How_RAG_with_txtai_works.ipynb) |Create RAG processes, API services and Docker instances||\\n|[Advanced RAG with graph path traversal](https://github.com/neuml/txtai/blob/master/examples/58_Advanced_RAG_with_graph_path_traversal.ipynb) |Graph path traversal to collect complex sets of data for advanced RAG||\\n|[Speech to Speech RAG](https://github.com/neuml/txtai/blob/master/examples/65_Speech_to_Speech_RAG.ipynb) |Full cycle speech to speech workflow with RAG||\\n\\n### Language Model Workflows\\nLanguage model workflows, also known as semantic workflows, connect language models together to build intelligent applications.\\n\\nWhile LLMs are powerful, there are plenty of smaller, more specialized models that work better and faster for specific tasks. This includes models for extractive question-answering, automatic summarization, text-to-speech, transcription and translation.\\n\\n|Notebook|Description||\\n|---|---|---|\\n|[Run pipeline workflows](https://github.com/neuml/txtai/blob/master/examples/14_Run_pipeline_workflows.ipynb) |Simple yet powerful constructs to efficiently process data||\\n|[Building abstractive text summaries](https://github.com/neuml/txtai/blob/master/examples/09_Building_abstractive_text_summaries.ipynb) |Run abstractive text summarization||\\n|[Transcribe audio to text](https://github.com/neuml/txtai/blob/master/examples/11_Transcribe_audio_to_text.'}\n",
"{'indexid': 5, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': 'ipynb) |Convert audio files to text||\\n|[Translate text between languages](https://github.com/neuml/txtai/blob/master/examples/12_Translate_text_between_languages.ipynb) |Streamline machine translation and language detection||\\n\\n## Installation\\nThe easiest way to install is via pip and PyPI\\n\\n```\\npip install txtai\\n```\\n\\nPython 3.10+ is supported. Using a Python [virtual environment](https://docs.python.org/3/library/venv.html) is recommended.\\n\\nSee the detailed [install instructions](https://neuml.github.io/txtai/install) for more information covering [optional dependencies](https://neuml.github.io/txtai/install/#optional-dependencies) , [environment specific prerequisites](https://neuml.github.io/txtai/install/#environment-specific-prerequisites) , [installing from source](https://neuml.github.io/txtai/install/#install-from-source) , [conda support](https://neuml.github.io/txtai/install/#conda) and how to [run with containers](https://neuml.github.io/txtai/cloud) .\\n\\n\\n## Model guide\\nSee the table below for the current recommended models. These models all allow commercial use and offer a blend of speed and performance.\\n\\n|Component|Model(s)|\\n|---|---|\\n|[Embeddings](https://neuml.github.io/txtai/embeddings) |[all-MiniLM-L6-v2](https://hf.co/sentence-transformers/all-MiniLM-L6-v2) |\\n|[Image Captions](https://neuml.github.io/txtai/pipeline/image/caption) |[BLIP](https://hf.co/Salesforce/blip-image-captioning-base) |'}\n",
"{'indexid': 5, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': 'ipynb) |Convert audio files to text||\\n|[Translate text between languages](https://github.com/neuml/txtai/blob/master/examples/12_Translate_text_between_languages.ipynb) |Streamline machine translation and language detection||\\n\\n## Installation\\nThe easiest way to install is via pip and PyPI\\n\\n```\\npip install txtai\\n```\\n\\nPython 3.11+ is supported. Using a Python [virtual environment](https://docs.python.org/3/library/venv.html) is recommended.\\n\\nSee the detailed [install instructions](https://neuml.github.io/txtai/install) for more information covering [optional dependencies](https://neuml.github.io/txtai/install/#optional-dependencies) , [environment specific prerequisites](https://neuml.github.io/txtai/install/#environment-specific-prerequisites) , [installing from source](https://neuml.github.io/txtai/install/#install-from-source) , [conda support](https://neuml.github.io/txtai/install/#conda) and how to [run with containers](https://neuml.github.io/txtai/cloud) .\\n\\n\\n## Model guide\\nSee the table below for the current recommended models. These models all allow commercial use and offer a blend of speed and performance.\\n\\n|Component|Model(s)|\\n|---|---|\\n|[Embeddings](https://neuml.github.io/txtai/embeddings) |[all-MiniLM-L6-v2](https://hf.co/sentence-transformers/all-MiniLM-L6-v2) |\\n|[Image Captions](https://neuml.github.io/txtai/pipeline/image/caption) |[BLIP](https://hf.co/Salesforce/blip-image-captioning-base) |'}\n",
"{'indexid': 6, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': '|[Labels - Zero Shot](https://neuml.github.io/txtai/pipeline/text/labels) |[BART-Large-MNLI](https://hf.co/facebook/bart-large) |\\n|[Labels - Fixed](https://neuml.github.io/txtai/pipeline/text/labels) |Fine-tune with [training pipeline](https://neuml.github.io/txtai/pipeline/train/trainer) |\\n|[Large Language Model (LLM)](https://neuml.github.io/txtai/pipeline/text/llm) |[Llama 3.1 Instruct](https://hf.co/meta-llama/Llama-3.1-8B-Instruct) |\\n|[Summarization](https://neuml.github.io/txtai/pipeline/text/summary) |[DistilBART](https://hf.co/sshleifer/distilbart-cnn-12-6) |\\n|[Text-to-Speech](https://neuml.github.io/txtai/pipeline/audio/texttospeech) |[ESPnet JETS](https://hf.co/NeuML/ljspeech-jets-onnx) |\\n|[Transcription](https://neuml.github.io/txtai/pipeline/audio/transcription) |[Whisper](https://hf.co/openai/whisper-base) |\\n|[Translation](https://neuml.github.io/txtai/pipeline/text/translation) |[OPUS Model Series](https://hf.co/Helsinki-NLP) |\\nModels can be loaded as either a path from the Hugging Face Hub or a local directory. Model paths are optional, defaults are loaded when not specified. For tasks with no recommended model, txtai uses the default models as shown in the Hugging Face Tasks guide.\\n\\nSee the following links to learn more.\\n\\n\\n## Powered by txtai\\nThe following applications are powered by txtai.'}\n",
"{'indexid': 7, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': \"|Application|Description|\\n|---|---|\\n|[rag](https://github.com/neuml/rag) |Retrieval Augmented Generation (RAG) application|\\n|[ragdata](https://github.com/neuml/ragdata) |Build knowledge bases for RAG|\\n|[paperai](https://github.com/neuml/paperai) |Semantic search and workflows for medical/scientific papers|\\n|[annotateai](https://github.com/neuml/annotateai) |Automatically annotate papers with LLMs|\\nIn addition to this list, there are also many other [open-source projects](https://github.com/neuml/txtai/network/dependents) , [published research](https://scholar.google.com/scholar?q=txtai&hl=en&as_ylo=2022) and closed proprietary/commercial projects that have built on txtai in production.\\n\\n\\n## Further Reading\\n- [Tutorial series on Hashnode](https://neuml.hashnode.dev/series/txtai-tutorial) | [dev.to](https://dev.to/neuml/tutorial-series-on-txtai-ibg) \\n- [What's new in txtai 8.0](https://medium.com/neuml/whats-new-in-txtai-8-0-2d7d0ab4506b) | [7.0](https://medium.com/neuml/whats-new-in-txtai-7-0-855ad6a55440) | [6.0](https://medium.com/neuml/whats-new-in-txtai-6-0-7d93eeedf804) | [5.0](https://medium.com/neuml/whats-new-in-txtai-5-0-e5c75a13b101) | [4.0](https://medium.\"}\n",
"{'indexid': 8, 'id': '0', 'url': 'https://github.com/neuml/txtai', 'text': 'com/neuml/whats-new-in-txtai-4-0-bbc3a65c3d1c) \\n- [Getting started with semantic search](https://medium.com/neuml/getting-started-with-semantic-search-a9fd9d8a48cf) | [workflows](https://medium.com/neuml/getting-started-with-semantic-workflows-2fefda6165d9) | [rag](https://medium.com/neuml/getting-started-with-rag-9a0cca75f748) \\n\\n## Documentation\\n[Full documentation on txtai](https://neuml.github.io/txtai) including configuration settings for embeddings, pipelines, workflows, API and a FAQ with common questions/issues is available.\\n\\n\\n## Contributing\\nFor those who would like to contribute to txtai, please see [this guide](https://github.com/neuml/.github/blob/master/CONTRIBUTING.md) .'}\n",
+15 -15
View File
@@ -714,7 +714,7 @@
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">Observations: ## Search Results\n",
"\n",
"|txtai · PyPI](https://pypi.org/project/txtai/)\n",
"☁️ Run local or scale out with container orchestration txtai is built with Python 3.10+, Hugging Face Transformers,\n",
"☁️ Run local or scale out with container orchestration txtai is built with Python 3.11+, Hugging Face Transformers,\n",
"Sentence Transformers and FastAPI. txtai is open-source under an Apache 2.0 license. |!NOTE] NeuML is the company \n",
"behind txtai and we provide AI consulting services around our stack. Schedule a meeting or send a message to ...\n",
"\n",
@@ -767,7 +767,7 @@
"Observations: ## Search Results\n",
"\n",
"|txtai · PyPI](https://pypi.org/project/txtai/)\n",
"☁️ Run local or scale out with container orchestration txtai is built with Python 3.10+, Hugging Face Transformers,\n",
"☁️ Run local or scale out with container orchestration txtai is built with Python 3.11+, Hugging Face Transformers,\n",
"Sentence Transformers and FastAPI. txtai is open-source under an Apache 2.0 license. |!NOTE] NeuML is the company \n",
"behind txtai and we provide AI consulting services around our stack. Schedule a meeting or send a message to ...\n",
"\n",
@@ -896,7 +896,7 @@
"|Rust](https://github.com/neuml/txtai.rs) and |Go](https://github.com/neuml/txtai.go) .\n",
"- 🔋 Batteries included with defaults to get up and running fast\n",
"- ☁️ Run local or scale out with container orchestration\n",
"txtai is built with Python 3.10+, |Hugging Face Transformers](https://github.com/huggingface/transformers) , \n",
"txtai is built with Python 3.11+, |Hugging Face Transformers](https://github.com/huggingface/transformers) , \n",
"|Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and \n",
"|FastAPI](https://github.com/tiangolo/fastapi) . txtai is open-source under an Apache 2.0 license.\n",
"\n",
@@ -1060,7 +1060,7 @@
"pip install txtai\n",
"```\n",
"\n",
"Python 3.10+ is supported. Using a Python |virtual environment](https://docs.python.org/3/library/venv.html) is \n",
"Python 3.11+ is supported. Using a Python |virtual environment](https://docs.python.org/3/library/venv.html) is \n",
"recommended.\n",
"\n",
"See the detailed |install instructions](https://neuml.github.io/txtai/install) for more information covering \n",
@@ -1169,7 +1169,7 @@
"|Rust](https://github.com/neuml/txtai.rs) and |Go](https://github.com/neuml/txtai.go) .\n",
"- 🔋 Batteries included with defaults to get up and running fast\n",
"- ☁️ Run local or scale out with container orchestration\n",
"txtai is built with Python 3.10+, |Hugging Face Transformers](https://github.com/huggingface/transformers) , \n",
"txtai is built with Python 3.11+, |Hugging Face Transformers](https://github.com/huggingface/transformers) , \n",
"|Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and \n",
"|FastAPI](https://github.com/tiangolo/fastapi) . txtai is open-source under an Apache 2.0 license.\n",
"\n",
@@ -1333,7 +1333,7 @@
"pip install txtai\n",
"```\n",
"\n",
"Python 3.10+ is supported. Using a Python |virtual environment](https://docs.python.org/3/library/venv.html) is \n",
"Python 3.11+ is supported. Using a Python |virtual environment](https://docs.python.org/3/library/venv.html) is \n",
"recommended.\n",
"\n",
"See the detailed |install instructions](https://neuml.github.io/txtai/install) for more information covering \n",
@@ -1554,7 +1554,7 @@
"|Rust](https://github.com/neuml/txtai.rs) and |Go](https://github.com/neuml/txtai.go) .\n",
"- 🔋 Batteries included with defaults to get up and running fast\n",
"- ☁️ Run local or scale out with container orchestration\n",
"txtai is built with Python 3.10+, |Hugging Face Transformers](https://github.com/huggingface/transformers) , \n",
"txtai is built with Python 3.11+, |Hugging Face Transformers](https://github.com/huggingface/transformers) , \n",
"|Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and \n",
"|FastAPI](https://github.com/tiangolo/fastapi) . txtai is open-source under an Apache 2.0 license.\n",
"\n",
@@ -1599,7 +1599,7 @@
"|Rust](https://github.com/neuml/txtai.rs) and |Go](https://github.com/neuml/txtai.go) .\n",
"- 🔋 Batteries included with defaults to get up and running fast\n",
"- ☁️ Run local or scale out with container orchestration\n",
"txtai is built with Python 3.10+, |Hugging Face Transformers](https://github.com/huggingface/transformers) , \n",
"txtai is built with Python 3.11+, |Hugging Face Transformers](https://github.com/huggingface/transformers) , \n",
"|Sentence Transformers](https://github.com/UKPLab/sentence-transformers) and \n",
"|FastAPI](https://github.com/tiangolo/fastapi) . txtai is open-source under an Apache 2.0 license.\n",
"\n",
@@ -1673,7 +1673,7 @@
"pip install txtai\n",
"```\n",
"\n",
"Python 3.10+ is supported. Using a Python |virtual environment](https://docs.python.org/3/library/venv.html) is \n",
"Python 3.11+ is supported. Using a Python |virtual environment](https://docs.python.org/3/library/venv.html) is \n",
"recommended.\n",
"\n",
"## Optional dependencies\n",
@@ -1847,7 +1847,7 @@
"pip install txtai\n",
"```\n",
"\n",
"Python 3.10+ is supported. Using a Python |virtual environment](https://docs.python.org/3/library/venv.html) is \n",
"Python 3.11+ is supported. Using a Python |virtual environment](https://docs.python.org/3/library/venv.html) is \n",
"recommended.\n",
"\n",
"## Optional dependencies\n",
@@ -2043,7 +2043,7 @@
"<pre style=\"white-space:pre;overflow-x:auto;line-height:normal;font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace\">╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n",
"│ Calling tool: 'write' with arguments: {'path': 'txtai_facts.md', 'content': '# txtai - All-in-One AI │\n",
"│ Framework\\n\\n## Overview\\n\\ntxtai is an all-in-one open-source AI framework for semantic search, LLM │\n",
"│ orchestration, and language model workflows. It\\'s built with Python 3.10+, Hugging Face Transformers, Sentence │\n",
"│ orchestration, and language model workflows. It\\'s built with Python 3.11+, Hugging Face Transformers, Sentence │\n",
"│ Transformers, and FastAPI, and is licensed under Apache 2.0.\\n\\n## Key Features\\n\\n- **Vector Search**: │\n",
"│ Semantic search with SQL, object storage, topic modeling, graph analysis, and multimodal indexing\\n- │\n",
"│ **Embeddings**: Create embeddings for text, documents, audio, images, and video\\n- **Language Model │\n",
@@ -2063,7 +2063,7 @@
"│ Installation\\n\\nThe easiest way to install is via pip:\\n\\n```bash\\npip install txtai\\n```\\n\\nOptional │\n",
"│ dependencies can be installed as extras:\\n\\n```bash\\npip install txtai[all] # Install all dependencies\\npip │\n",
"│ install txtai[api] # Serve txtai via a web API\\npip install txtai[workflow] # All workflow tasks\\n```\\n\\n## │\n",
"│ Technology Stack\\n\\n- Built with Python 3.10+\\n- Hugging Face Transformers\\n- Sentence Transformers\\n- │\n",
"│ Technology Stack\\n\\n- Built with Python 3.11+\\n- Hugging Face Transformers\\n- Sentence Transformers\\n- │\n",
"│ FastAPI\\n\\n## Supported Models\\n\\n- Embeddings: all-MiniLM-L6-v2\\n- Image Captions: BLIP\\n- Labels (Zero Shot): │\n",
"│ BART-Large-MNLI\\n- Large Language Model (LLM): gpt-oss-20b\\n- Summarization: DistilBART\\n- Text-to-Speech: │\n",
"│ ESPnet JETS\\n- Transcription: Whisper\\n- Translation: OPUS Model Series\\n\\n## Company Behind txtai\\n\\nNeuML is │\n",
@@ -2081,7 +2081,7 @@
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮\n",
"│ Calling tool: 'write' with arguments: {'path': 'txtai_facts.md', 'content': '# txtai - All-in-One AI │\n",
"│ Framework\\n\\n## Overview\\n\\ntxtai is an all-in-one open-source AI framework for semantic search, LLM │\n",
"│ orchestration, and language model workflows. It\\'s built with Python 3.10+, Hugging Face Transformers, Sentence │\n",
"│ orchestration, and language model workflows. It\\'s built with Python 3.11+, Hugging Face Transformers, Sentence │\n",
"│ Transformers, and FastAPI, and is licensed under Apache 2.0.\\n\\n## Key Features\\n\\n- **Vector Search**: │\n",
"│ Semantic search with SQL, object storage, topic modeling, graph analysis, and multimodal indexing\\n- │\n",
"│ **Embeddings**: Create embeddings for text, documents, audio, images, and video\\n- **Language Model │\n",
@@ -2101,7 +2101,7 @@
"│ Installation\\n\\nThe easiest way to install is via pip:\\n\\n```bash\\npip install txtai\\n```\\n\\nOptional │\n",
"│ dependencies can be installed as extras:\\n\\n```bash\\npip install txtai[all] # Install all dependencies\\npip │\n",
"│ install txtai[api] # Serve txtai via a web API\\npip install txtai[workflow] # All workflow tasks\\n```\\n\\n## │\n",
"│ Technology Stack\\n\\n- Built with Python 3.10+\\n- Hugging Face Transformers\\n- Sentence Transformers\\n- │\n",
"│ Technology Stack\\n\\n- Built with Python 3.11+\\n- Hugging Face Transformers\\n- Sentence Transformers\\n- │\n",
"│ FastAPI\\n\\n## Supported Models\\n\\n- Embeddings: all-MiniLM-L6-v2\\n- Image Captions: BLIP\\n- Labels (Zero Shot): │\n",
"│ BART-Large-MNLI\\n- Large Language Model (LLM): gpt-oss-20b\\n- Summarization: DistilBART\\n- Text-to-Speech: │\n",
"│ ESPnet JETS\\n- Transcription: Whisper\\n- Translation: OPUS Model Series\\n\\n## Company Behind txtai\\n\\nNeuML is │\n",
@@ -2252,7 +2252,7 @@
"\n",
"## Overview\n",
"\n",
"txtai is an all-in-one open-source AI framework for semantic search, LLM orchestration, and language model workflows. It's built with Python 3.10+, Hugging Face Transformers, Sentence Transformers, and FastAPI, and is licensed under Apache 2.0.\n",
"txtai is an all-in-one open-source AI framework for semantic search, LLM orchestration, and language model workflows. It's built with Python 3.11+, Hugging Face Transformers, Sentence Transformers, and FastAPI, and is licensed under Apache 2.0.\n",
"\n",
"## Key Features\n",
"\n",
+4 -2
View File
@@ -62,6 +62,7 @@ extras["ann"] = [
"hnswlib>=0.5.0",
"milvus-lite>=3.1.1",
"pgvector>=0.4.1",
"rabitqlib>=0.5.1",
"scikit-learn>=0.23.1",
"scipy>=1.4.1",
"sqlalchemy>=2.0.20",
@@ -142,6 +143,7 @@ extras["vectors"] = [
"litellm>=1.37.16",
"llama-cpp-python>=0.2.75",
"model2vec>=0.3.0",
"onnxruntime>=1.11.0",
"scikit-learn>=0.23.1",
"scipy>=1.4.1",
"sentence-transformers>=5.0.0",
@@ -179,7 +181,7 @@ extras["all"] = (
setup(
name=package,
version="9.13.0",
version="9.15.0",
author="NeuML",
description="All-in-one open-source AI framework for semantic search, LLM orchestration and language model workflows",
long_description=DESCRIPTION,
@@ -194,7 +196,7 @@ setup(
packages=find_packages(where="src/python"),
package_dir={"": "src/python"},
keywords="search embedding machine-learning nlp",
python_requires=">=3.10",
python_requires=">=3.11",
install_requires=install,
extras_require=extras,
classifiers=[
+15 -3
View File
@@ -14,12 +14,13 @@ class BashTool(Tool):
"""
# pylint: disable=W0231
def __init__(self, allowed=None):
def __init__(self, allowed=None, timeout=30):
"""
Creates a BashTool.
Args:
allowed: list of allowed commands to run, has limited set of defaults which are a best effort not a sandbox
timeout: maximum number of seconds to wait for a command to complete, defaults to 30, None disables the timeout
"""
# Tool parameters
@@ -31,7 +32,10 @@ class BashTool(Tool):
self.output_type = "any"
# Default list of allowed commands
self.allowed = allowed if allowed else ["cat", "cut", "diff", "grep", "head", "ls", "tail"]
self.allowed = allowed if allowed is not None else ["cat", "cut", "diff", "grep", "head", "ls", "tail"]
# Maximum time to wait for a command, None waits indefinitely
self.timeout = timeout
# Validate parameters and initialize tool
super().__init__()
@@ -41,6 +45,9 @@ class BashTool(Tool):
"""
Runs a shell command as a subprocess.
stdin is closed and a timeout is applied. Otherwise an allowed command called with no file arguments
(i.e. `cat`) reads from stdin and blocks the agent indefinitely.
Args:
command: command arguments as a list
@@ -50,6 +57,11 @@ class BashTool(Tool):
output = None
if command and command[0] in self.allowed:
output = subprocess.run(command, capture_output=True, text=True, check=False).stdout
try:
output = subprocess.run(
command, capture_output=True, text=True, encoding="utf-8", check=False, stdin=subprocess.DEVNULL, timeout=self.timeout
).stdout
except subprocess.TimeoutExpired:
output = f"Command timed out after {self.timeout} seconds"
return output
+1
View File
@@ -9,6 +9,7 @@ from .hnsw import HNSW
from .milvus import Milvus
from .numpy import NumPy
from .pgvector import PGVector
from .rabitq import RabitQ
from .torch import Torch
from .turbovec import TurboVec
from .zvec import Zvec
+6 -2
View File
@@ -12,6 +12,7 @@ from .hnsw import HNSW
from .milvus import Milvus
from .numpy import NumPy
from .pgvector import PGVector
from .rabitq import RabitQ
from .sqlite import SQLite
from .torch import Torch
from .turbovec import TurboVec
@@ -35,6 +36,7 @@ class ANNFactory:
ANN
"""
# pylint: disable=R0912
# ANN instance
ann = None
backend = config.get("backend", "faiss" if FAISS else "numpy")
@@ -44,16 +46,18 @@ class ANNFactory:
ann = Annoy(config)
elif backend == "faiss":
ann = Faiss(config)
elif backend == "ggml":
ann = GGML(config)
elif backend == "hnsw":
ann = HNSW(config)
elif backend == "milvus":
ann = Milvus(config)
elif backend == "ggml":
ann = GGML(config)
elif backend == "numpy":
ann = NumPy(config)
elif backend == "pgvector":
ann = PGVector(config)
elif backend == "rabitq":
ann = RabitQ(config)
elif backend == "sqlite":
ann = SQLite(config)
elif backend == "torch":
+1 -1
View File
@@ -142,7 +142,7 @@ class Faiss(ANN):
# Derive quantization. Prefer backend-specific setting. Fallback to root-level parameter.
quantize = self.setting("quantize", self.config.get("quantize"))
quantize = 8 if isinstance(quantize, bool) else quantize
quantize = 8 if isinstance(quantize, bool) and quantize else quantize
# Get storage setting
storage = f"SQ{quantize}" if quantize else "Flat"
+36 -15
View File
@@ -155,7 +155,7 @@ class GGMLTensors:
gguf = ggml.gguf_init_from_file(path.encode("utf-8"), params)
# Load tensors from GGUF file
self.loadtensors(context)
self.loadtensors(gguf, context)
# Create graph operation
self.graph, self.output = self.creategraph()
@@ -218,11 +218,12 @@ class GGMLTensors:
ids: ids to delete
"""
shape = utils.get_shape(self.data)
rows = self.rows()
# Filter any index greater than size of array
ids = [x for x in ids if x < shape[1]]
self.deletes.extend(ids)
# Set index ids as deleted, ignoring ids outside the array and ids already marked deleted
for x in ids:
if x < rows and x not in self.deletes:
self.deletes.append(x)
def search(self, queries):
"""
@@ -250,8 +251,7 @@ class GGMLTensors:
ggml.ggml_backend_graph_compute(self.backend, self.graph)
# Get size of embeddings data
size = utils.get_shape(self.data)
size = size[1] if len(size) > 1 else 1
size = self.rows()
# Get and return results
results = np.zeros((batch.shape[0], size), dtype=np.float32)
@@ -272,7 +272,7 @@ class GGMLTensors:
count
"""
return utils.get_shape(self.data)[1] - len(self.deletes) if self.data else 0
return self.rows() - len(self.deletes) if self.data else 0
def save(self, path):
"""
@@ -288,9 +288,12 @@ class GGMLTensors:
# Init and save data tensor
gguf = ggml.gguf_init_empty()
# Add the data tensor
ggml.ggml_set_name(self.data, b"data")
ggml.gguf_add_tensor(gguf, self.data)
# Add the data tensor. GGUF can't store a tensor with zero rows, only save the dimensions for an empty index.
if self.rows():
ggml.ggml_set_name(self.data, b"data")
ggml.gguf_add_tensor(gguf, self.data)
else:
ggml.gguf_set_val_u32(gguf, b"dimensions", utils.get_shape(self.data)[0])
# Optionally create and add the deletes tensor
if self.deletes:
@@ -413,11 +416,12 @@ class GGMLTensors:
# Copy embeddings data
self.copy(data, self.data, tensortype, 0)
def loadtensors(self, context):
def loadtensors(self, gguf, context):
"""
Loads existing tensors from context.
Args:
gguf: gguf context
context: ggml context
"""
@@ -436,6 +440,10 @@ class GGMLTensors:
# Copy tensor data to backend
ggml.ggml_backend_tensor_set(self.data, ggml.ggml_get_data(data), 0, ggml.ggml_nbytes(data))
else:
# Empty index, create tensors using the saved dimensions
dimensions = ggml.gguf_get_val_u32(gguf, ggml.gguf_find_key(gguf, b"dimensions"))
self.createtensors(np.zeros((0, dimensions), dtype=np.float32))
# Load deletes tensor
data = ggml.ggml_get_tensor(context, b"deletes")
@@ -465,8 +473,7 @@ class GGMLTensors:
queries = ggml.ggml_new_tensor_2d(context, ggml.GGML_TYPE_F32, data.shape[1], self.querysize)
# Embeddings data with space for both existing and new data
shape = utils.get_shape(self.data)
merge = ggml.ggml_new_tensor_2d(context, tensortype, data.shape[1], data.shape[0] + shape[1])
merge = ggml.ggml_new_tensor_2d(context, tensortype, data.shape[1], data.shape[0] + self.rows())
# Create new buffer
buffer = ggml.ggml_backend_alloc_ctx_tensors(context, backend)
@@ -512,7 +519,11 @@ class GGMLTensors:
# Read tensor type
tensortype = self.quantize
tensortype = "Q8_0" if isinstance(tensortype, bool) else f"Q{int(tensortype)}_0" if isinstance(tensortype, int) else tensortype
tensortype = (
"Q8_0"
if isinstance(tensortype, bool) and tensortype
else f"Q{int(tensortype)}_0" if isinstance(tensortype, int) and not isinstance(tensortype, bool) else tensortype
)
tensortype = tensortype.upper() if tensortype else "F32"
# Validate tensor type
@@ -572,3 +583,13 @@ class GGMLTensors:
"""
return [queries[x : x + self.querysize] for x in range(0, len(queries), self.querysize)]
def rows(self):
"""
Number of rows in the data tensor. Tensors with a single row or no rows report a 1-D shape, so the shape can't be used.
Returns:
row count
"""
return ggml.ggml_nrows(self.data)
+3
View File
@@ -35,6 +35,9 @@ class HNSW(ANN):
self.backend = Index(dim=self.config["dimensions"], space=self.config["metric"])
self.backend.load_index(path)
# Default the delete counter for configurations that don't have it
self.config["deletes"] = self.config.get("deletes", 0)
def index(self, embeddings):
# Inner product is equal to cosine similarity on normalized vectors
self.config["metric"] = "ip"
+6 -2
View File
@@ -83,8 +83,9 @@ class Milvus(ANN):
self.metadata()
def delete(self, ids):
if ids:
self.collection.delete(ids)
# Check length, ids can be a NumPy array
if len(ids):
self.collection.delete([int(x) for x in ids])
def search(self, queries, limit):
matches = self.collection.search(queries.tolist(), top_k=limit, metric_type="IP", anns_field="embedding")
@@ -138,3 +139,6 @@ class Milvus(ANN):
self.backend = milvus_lite.MilvusLite(self.path)
self.collection = self.backend.get_collection("txtai")
# Reopened collections with an index start released, load to enable search
self.collection.load()
+4 -4
View File
@@ -60,11 +60,11 @@ class NumPy(ANN):
self.metadata()
def delete(self, ids):
# Filter any index greater than size of array
ids = [x for x in ids if x < self.backend.shape[0]]
# Ignore IDs outside the array; negative IDs must not wrap to live rows.
ids = [x for x in ids if 0 <= x < self.backend.shape[0]]
# Clear specified ids
self.backend[ids] = self.tensor(self.zeros((len(ids), self.backend.shape[1])))
# Clear specified ids, zeros must match the array data type (e.g. uint8 for quantized data)
self.backend[ids] = self.tensor(self.zeros((len(ids), self.backend.shape[1]), dtype=self.backend.dtype))
def search(self, queries, limit):
if self.qbits:
+2 -2
View File
@@ -24,7 +24,6 @@ from ...util import Library
np = Library().numpy()
# pylint: disable=R0904
class PGVector(ANN):
"""
Builds an ANN index backed by a Postgres database.
@@ -72,7 +71,8 @@ class PGVector(ANN):
self.metadata()
def delete(self, ids):
self.database.execute(delete(self.table).where(self.table.c["indexid"].in_(ids)))
# Bind ids as int, NumPy integers don't match the indexid column
self.database.execute(delete(self.table).where(self.table.c["indexid"].in_([int(x) for x in ids])))
def search(self, queries, limit):
results = []
+176
View File
@@ -0,0 +1,176 @@
"""
RabitQ module
"""
import math
# Conditional import
try:
from rabitqlib import HnswIndex, IvfIndex
from sklearn.cluster import KMeans
RABITQ = True
except ImportError:
RABITQ = False
from ..base import ANN
# Core library imports
from ...util import Library
library = Library()
np = library.numpy()
# Sentinel for unfilled top-k slots in native search results
SENTINEL = 0xFFFFFFFF
class RabitQ(ANN):
"""
Builds an ANN index using the rabitqlib library (RaBitQ quantization).
Only the native index is stored, as a single file. No vectors are kept outside of it. ivf indexes support append
and delete natively. The native id of a row is its index id. hnsw indexes can't be extended or have rows removed,
so append and delete are not supported in that mode.
"""
def __init__(self, config):
super().__init__(config)
if not RABITQ:
raise ImportError('rabitqlib is not available - install "ann" extra to enable')
# Number of rows removed from the native index (ivf mode only)
self.config["deletes"] = self.config.get("deletes", 0)
def load(self, path):
self.close()
# Invalid modes raise ValueError before native code runs
self.backend = IvfIndex.load(path) if self.mode() == "ivf" else HnswIndex.load(path)
def index(self, embeddings):
self.close()
# Mode is validated before any state is stored
mode = self.mode()
vectors = np.ascontiguousarray(embeddings, dtype=np.float32)
rows = vectors.shape[0]
# Quantization bits per dimension, validated by rabitqlib
nbits = self.setting("nbits", 1)
# Default clusters to 4 * sqrt(N), bounded to [1, N] so KMeans always fits
clusters = self.setting("clusters", None)
clusters = max(1, min(clusters if clusters else round(4 * math.sqrt(rows)), rows))
cluster = KMeans(n_clusters=clusters, random_state=0, n_init=1).fit(vectors)
centroids = np.ascontiguousarray(cluster.cluster_centers_, dtype=np.float32)
clusterids = np.ascontiguousarray(cluster.labels_, dtype=np.uint32)
dim = self.config["dimensions"]
if mode == "ivf":
backend = IvfIndex(dim=dim, max_elements=rows, num_clusters=clusters, nbits=nbits, metric="ip")
settings = {"mode": mode, "nbits": nbits, "clusters": clusters, "nprobe": self.setting("nprobe", self.nprobe(clusters))}
else:
backend = HnswIndex(
dim=dim,
max_elements=rows,
M=self.setting("m", 16),
ef_construction=self.setting("efconstruction", 200),
nbits=nbits,
metric="ip",
random_seed=self.setting("randomseed", 100),
)
settings = {
"mode": mode,
"nbits": nbits,
"m": self.setting("m", 16),
"efconstruction": self.setting("efconstruction", 200),
"efsearch": self.setting("efsearch", None),
"randomseed": self.setting("randomseed", 100),
}
backend.build(vectors, centroids, clusterids)
self.backend = backend
# Add id offset and index build metadata
self.config["offset"] = rows
self.config["deletes"] = 0
self.metadata(settings)
def append(self, embeddings):
if self.mode() != "ivf":
super().append(embeddings)
# Native ids continue from the last row ever added
embeddings = np.ascontiguousarray(embeddings, dtype=np.float32)
self.backend.add(embeddings)
# Update id offset and index metadata
self.config["offset"] = self.backend.max_elements
self.metadata()
def delete(self, ids):
if self.mode() != "ivf":
super().delete(ids)
# Native remove hides rows from search. Unknown ids are silently ignored.
ids = [uid for uid in ids if 0 <= uid < self.backend.max_elements]
if ids:
self.config["deletes"] += self.backend.remove(np.array(ids, dtype=np.int64))
def search(self, queries, limit):
if not self.count():
return [[] for _ in queries]
queries = np.ascontiguousarray(queries, dtype=np.float32)
# The native index errors when k exceeds its row count
k = min(limit, self.count())
if self.mode() == "ivf":
nprobe = self.setting("nprobe", self.nprobe(self.backend.num_clusters))
ids, distances = self.backend.search(queries, k, nprobe)
else:
ids, distances = self.backend.search(queries, k, self.setting("efsearch", None) or 0)
# Map results to [(id, score)], dropping unfilled sentinel slots
return [
[(int(uid), float(1.0 - dist)) for uid, dist in zip(uids, dists) if uid != SENTINEL]
for uids, dists in zip(ids.tolist(), distances.tolist())
]
def count(self):
return self.backend.max_elements - self.config["deletes"] if self.backend is not None else 0
def save(self, path):
self.backend.save(path)
def mode(self):
"""
Returns the configured index mode.
Returns:
index mode, only "ivf" or "hnsw" are accepted
"""
mode = self.setting("mode", "ivf")
if mode not in ("ivf", "hnsw"):
raise ValueError(f'Invalid RabitQ mode: {mode}. Supported modes are "ivf" and "hnsw".')
return mode
def nprobe(self, clusters):
"""
Returns the default number of clusters to probe at search time.
Args:
clusters: number of IVF clusters
Returns:
default nprobe
"""
return max(1, round(clusters / 16))
+21 -7
View File
@@ -32,9 +32,11 @@ class SQLite(ANN):
# Quantization setting
self.quantize = self.setting("quantize")
self.quantize = 8 if isinstance(self.quantize, bool) else int(self.quantize) if self.quantize else None
self.quantize = 8 if isinstance(self.quantize, bool) and self.quantize else int(self.quantize) if self.quantize else None
def load(self, path):
if self.path != path:
self.close()
self.path = path
def index(self, embeddings):
@@ -55,7 +57,8 @@ class SQLite(ANN):
self.metadata()
def delete(self, ids):
self.database().executemany(self.deletesql(), [(x,) for x in ids])
# Bind ids as int, sqlite3 binds NumPy integers as blobs that match the wrong rows
self.database().executemany(self.deletesql(), [(int(x),) for x in ids])
def search(self, queries, limit):
results = []
@@ -73,6 +76,9 @@ class SQLite(ANN):
return self.cursor.fetchone()[0]
def save(self, path):
# Loading defers opening the connection until the first database operation.
self.database()
# Temporary database
if not self.path:
# Save temporary database
@@ -193,10 +199,13 @@ class SQLite(ANN):
connection.execute(self.tablesql())
# The backup call will hang if there are uncommitted changes, need to copy over
# with iterdump (which is much slower)
for sql in self.connection.iterdump():
if self.tosql('insert into "{table}"') in sql.lower():
connection.execute(sql)
# the rows (which is much slower). Quantized vectors must be inserted with their type.
embedding = "vec_bit(?)" if self.quantize == 1 else "vec_int8(?)" if self.quantize == 8 else "?"
rows = self.connection.execute(self.tosql("SELECT indexid, embedding FROM {table}"))
connection.executemany(self.tosql(f"INSERT INTO {{table}}(indexid, embedding) VALUES (?, {embedding})"), rows)
# Commit copied rows, otherwise they are rolled back when the connection is closed
connection.commit()
else:
# Database is up to date, can do a more efficient copy with SQLite C API
self.connection.backup(connection)
@@ -254,7 +263,12 @@ class SQLite(ANN):
SELECT
"""
return self.tosql(("SELECT indexid, 1 - distance FROM {table} " f"WHERE embedding MATCH {self.embeddingsql()} AND k = ? ORDER BY distance"))
# BIT distance counts differing bits; normalize it before converting to similarity.
# SQLite binary dimensions are the number of bits, not the packed byte count.
distance = f"distance / {float(self.config['dimensions'])}" if self.quantize == 1 else "distance"
return self.tosql(
f"SELECT indexid, 1 - ({distance}) FROM {{table}} " f"WHERE embedding MATCH {self.embeddingsql()} AND k = ? ORDER BY distance"
)
def countsql(self):
"""
+40 -6
View File
@@ -49,6 +49,7 @@ class Zvec(ANN):
self.close()
# Lookup index settings
efconstruction = self.setting("efconstruction", 200)
m = self.setting("m", 50)
# Create collection
@@ -58,9 +59,9 @@ class Zvec(ANN):
name="txtai",
vectors=zvec.VectorSchema(
"embedding",
zvec.DataType.VECTOR_FP32,
self.datatype(),
self.config["dimensions"],
index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP, m=m),
index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP, m=m, ef_construction=efconstruction),
),
)
self.backend = zvec.create_and_open(path=self.path, schema=schema)
@@ -68,26 +69,36 @@ class Zvec(ANN):
# Add items - position in embeddings is used as the id
self.insert(embeddings, 0)
# Build HNSW index
self.backend.optimize()
# Add id offset and index build metadata
self.config["offset"] = embeddings.shape[0]
self.metadata({"m": m, "zvec": zvec.__version__})
self.metadata({"efconstruction": efconstruction, "m": m, "zvec": zvec.__version__})
def append(self, embeddings):
self.insert(embeddings, self.config["offset"])
# Add new items to HNSW index
self.backend.optimize()
# Update id offset and index metadata
self.config["offset"] += embeddings.shape[0]
self.metadata()
def delete(self, ids):
if ids:
# Check length, ids can be a NumPy array
if len(ids):
self.backend.delete([str(uid) for uid in ids])
def search(self, queries, limit):
# Lookup search settings
param = zvec.HnswQueryParam(ef=self.setting("efsearch", 300))
results = []
for query in queries:
matches = self.backend.query(
zvec.Query(field_name="embedding", vector=query.tolist()),
zvec.Query(field_name="embedding", vector=self.prepare(query), param=param),
topk=limit,
)
results.append([(int(match.id), float(match.score)) for match in matches])
@@ -128,5 +139,28 @@ class Zvec(ANN):
for start in range(0, embeddings.shape[0], 1024):
batch = embeddings[start : start + 1024]
self.backend.insert(
[zvec.Doc(id=str(offset + start + uid), vectors={"embedding": embedding.tolist()}) for uid, embedding in enumerate(batch)]
[zvec.Doc(id=str(offset + start + uid), vectors={"embedding": self.prepare(embedding)}) for uid, embedding in enumerate(batch)]
)
def datatype(self):
"""
Gets the vector data type.
Returns:
zvec vector data type
"""
return zvec.DataType.VECTOR_FP32
def prepare(self, data):
"""
Prepares data for the vector field.
Args:
data: input data
Returns:
data formatted for zvec
"""
return data.tolist()
+1
View File
@@ -5,3 +5,4 @@ Sparse ANN imports
from .factory import SparseANNFactory
from .ivfsparse import IVFSparse
from .pgsparse import PGSparse
from .zvecsparse import ZvecSparse
+3
View File
@@ -7,6 +7,7 @@ from ...util import Resolver
from ..base import ANN
from .ivfsparse import IVFSparse
from .pgsparse import PGSparse
from .zvecsparse import ZvecSparse
class SparseANNFactory:
@@ -35,6 +36,8 @@ class SparseANNFactory:
ann = IVFSparse(config)
elif backend == "pgsparse":
ann = PGSparse(config)
elif backend == "zvecsparse":
ann = ZvecSparse(config)
else:
ann = SparseANNFactory.resolve(backend, config)
+10 -6
View File
@@ -115,8 +115,12 @@ class IVFSparse(ANN):
self.metadata()
def delete(self, ids):
# Set index ids as deleted
self.deletes.extend(ids)
# Set index ids as deleted, ignoring ids that were never indexed and ids already marked deleted
# Ids are stored as int, the deletes list is serialized with msgpack
size = self.size()
for x in ids:
if x < size and x not in self.deletes:
self.deletes.append(int(x))
def search(self, queries, limit):
results = []
@@ -204,10 +208,10 @@ class IVFSparse(ANN):
cluster centroids
"""
# Select top n most important features that contribute to L2 vector norm
indices = np.argsort(-norm(train, axis=0))[: self.setting("nfeatures", 25)]
data = train[:, indices]
data = train
# Select top n most important features that contribute to L2 vector norm.
# nfeatures is opt-in: the full feature space is used when it isn't set.
nfeatures = self.setting("nfeatures")
data = train[:, np.argsort(-norm(train, axis=0))[:nfeatures]] if nfeatures else train
# Cluster data using k-means
kmeans = MiniBatchKMeans(n_clusters=clusters, random_state=0, n_init=5).fit(data)
+35
View File
@@ -0,0 +1,35 @@
"""
ZvecSparse module
"""
# Conditional import
try:
import zvec
except ImportError:
pass
from ..dense import Zvec
class ZvecSparse(Zvec):
"""
Builds a Sparse ANN index using the zvec library.
"""
def search(self, queries, limit):
# Lookup search settings
param = zvec.HnswQueryParam(ef=self.setting("efsearch", 300))
results = []
for query in queries:
# zvec requires at least one non-zero value per query
matches = self.backend.query(zvec.Query(field_name="embedding", vector=self.prepare(query), param=param), topk=limit) if query.nnz else []
results.append([(int(match.id), float(match.score)) for match in matches])
return results
def datatype(self):
return zvec.DataType.SPARSE_VECTOR_FP32
def prepare(self, data):
return {int(index): float(value) for index, value in zip(data.indices, data.data)}
+15 -1
View File
@@ -32,7 +32,7 @@ class API(Application):
weights = self.weights(request.query_params.get("weights") if request and hasattr(request, "query_params") else weights)
index = request.query_params.get("index") if request and hasattr(request, "query_params") else index
parameters = request.query_params.get("parameters") if request and hasattr(request, "query_params") else parameters
graph = request.query_params.get("graph") if request and hasattr(request, "query_params") else graph
graph = self.graph(request.query_params.get("graph") if request and hasattr(request, "query_params") else graph)
# Decode parameters
parameters = json.loads(parameters) if parameters and isinstance(parameters, str) else parameters
@@ -157,3 +157,17 @@ class API(Application):
"""
return float(weights) if weights else weights
def graph(self, graph):
"""
Parses the graph parameter from the request.
Args:
graph: graph parameter
Returns:
graph as a bool
"""
# Query parameters are strings - accept the same values the batchsearch endpoint coerces
return graph.strip().lower() in ("1", "true", "yes", "on") if isinstance(graph, str) else bool(graph)
+10 -8
View File
@@ -56,17 +56,19 @@ class Cluster:
"""
# Build URL
action = f"search?query={urllib.parse.quote_plus(query)}"
params = {"query": query}
if limit:
action += f"&limit={limit}"
if weights:
action += f"&weights={weights}"
params["limit"] = limit
if weights is not None:
params["weights"] = weights
if index:
action += f"&index={index}"
params["index"] = index
if parameters:
action += f"&parameters={json.dumps(parameters) if isinstance(parameters, dict) else parameters}"
params["parameters"] = json.dumps(parameters) if isinstance(parameters, dict) else parameters
if graph is not None:
action += f"&graph={graph}"
params["graph"] = graph
action = f"search?{urllib.parse.urlencode(params)}"
# Run query and flatten results into single results list
results = []
@@ -100,7 +102,7 @@ class Cluster:
params = {"queries": queries}
if limit:
params["limit"] = limit
if weights:
if weights is not None:
params["weights"] = weights
if index:
params["index"] = index
+34 -11
View File
@@ -165,9 +165,9 @@ class ChatResponse:
return {
"id": str(uuid.uuid4()),
"object": "chat.completion",
"created": int(time.time() * 1000),
"created": int(time.time()),
"model": model,
"choices": [{"id": 0, "message": {"role": "assistant", "content": result}, "finish_reason": "stop"}],
"choices": [{"index": 0, "message": {"role": "assistant", "content": result}, "finish_reason": "stop"}],
}
@@ -177,15 +177,38 @@ class StreamingChatResponse:
"""
def __call__(self, model, result):
# All chunks of a response share the same id and creation time
uid, created = str(uuid.uuid4()), int(time.time())
for chunk in result:
yield "data: " + json.dumps(
{
"id": str(uuid.uuid4()),
"object": "chat.completion.chunk",
"created": int(time.time() * 1000),
"model": model,
"choices": [{"id": 0, "delta": {"content": chunk}}],
}
) + "\n\n"
yield self.chunk(uid, created, model, {"content": chunk}, None)
# Final chunk signals the end of the response
yield self.chunk(uid, created, model, {}, "stop")
yield "data: [DONE]\n\n"
def chunk(self, uid, created, model, delta, reason):
"""
Builds a server-sent event for a single chat completion chunk.
Args:
uid: response id
created: response creation time in seconds
model: model name
delta: chunk content
reason: finish reason, None until the final chunk
Returns:
server-sent event
"""
data = {
"id": uid,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": reason}],
}
return "data: " + json.dumps(data) + "\n\n"
+1 -2
View File
@@ -18,7 +18,6 @@ from ..util import Library
yaml = Library().yaml()
# pylint: disable=R0904
class Application:
"""
Builds YAML-configured txtai applications.
@@ -259,7 +258,7 @@ class Application:
"""
# Check for task shorthand syntax
task = {"action": task} if isinstance(task, (str, list)) else task
task = {"action": task} if isinstance(task, (str, list)) else task.copy()
if "action" in task:
action = task["action"]
+8 -4
View File
@@ -38,7 +38,10 @@ class Data:
(train, validation)
"""
return (self.prepare(train, self.process, workers, batch), self.prepare(validation, self.process, workers, batch) if validation else None)
return (
self.prepare(train, self.process, workers, batch),
self.prepare(validation, self.process, workers, batch) if validation is not None else None,
)
def prepare(self, data, fn, workers, batch):
"""
@@ -54,7 +57,8 @@ class Data:
tokens
"""
if hasattr(data, "map"):
# Check for column_names, pandas DataFrames also have a map method
if hasattr(data, "column_names"):
# Hugging Face dataset
tokens = data.map(fn, batched=True, batch_size=batch, num_proc=workers, remove_columns=data.column_names)
else:
@@ -93,11 +97,11 @@ class Data:
column = self.columns[-1]
# Return length of labels if it's an array
length = self.length(data[column][0] if hasattr(data, "columns") else data[0][column])
length = self.length(next(iter(data[column])) if hasattr(data, "columns") else data[0][column])
if length:
return length
if hasattr(data, "map"):
if hasattr(data, "column_names"):
# Hugging Face dataset
labels = sorted(data.unique(self.columns[-1]))
elif hasattr(data, "columns"):
+13 -7
View File
@@ -72,14 +72,20 @@ class Questions(Data):
while sequences[end] != (1 if self.rpad else 0):
end -= 1
# Map start character and end character to matching token index
while start < len(offset) and offset[start][0] <= startchar:
start += 1
tokenized["start_positions"].append(start - 1)
# Label with CLS token if the answer isn't fully in this span. Long contexts are split into
# overlapping spans with stride and most spans don't contain the answer.
if not (offset[start][0] <= startchar and offset[end][1] >= endchar):
tokenized["start_positions"].append(clstoken)
tokenized["end_positions"].append(clstoken)
else:
# Map start character and end character to matching token index
while start < len(offset) and offset[start][0] <= startchar:
start += 1
tokenized["start_positions"].append(start - 1)
while offset[end][1] >= endchar:
end -= 1
tokenized["end_positions"].append(end + 1)
while offset[end][1] >= endchar:
end -= 1
tokenized["end_positions"].append(end + 1)
return tokenized
+1 -1
View File
@@ -104,7 +104,7 @@ class Texts(Data):
nextlength = len(rows[x + 1]) if x < len(rows) - 1 else 0
# New row
if (length + nextlength) >= self.maxlength:
if (length + nextlength) > self.maxlength:
for column in columns:
results[column].append(list(chain(*inputs[column][index : x + 1])))
+36 -12
View File
@@ -18,6 +18,7 @@ except ImportError:
from .embedded import Embedded
from .schema import Statement
from .sql import SQLError
class DuckDB(Embedded):
@@ -29,6 +30,11 @@ class DuckDB(Embedded):
DELETE_DOCUMENT = "DELETE FROM documents WHERE id = ?"
DELETE_OBJECT = "DELETE FROM objects WHERE id = ?"
# Added to errors that abort the transaction
ABORTED = (
"This error aborted the DuckDB transaction. Unsaved changes are lost and later operations fail until the index is loaded or built again."
)
def __init__(self, config):
super().__init__(config)
@@ -36,8 +42,15 @@ class DuckDB(Embedded):
raise ImportError('DuckDB is not available - install "database" extra to enable')
def execute(self, function, *args):
# Call parent method with DuckDB compatible arguments
return super().execute(function, *self.formatargs(args))
try:
# Call parent method with DuckDB compatible arguments
return super().execute(function, *self.formatargs(args))
except SQLError as e:
# Runtime errors abort the transaction. DuckDB has no savepoints, so it can't be recovered without a rollback.
if self.aborted():
raise SQLError(f"{e}\n\n{DuckDB.ABORTED}") from None
raise
def insertdocument(self, uid, data, tags, entry):
# Delete existing document
@@ -123,6 +136,20 @@ class DuckDB(Embedded):
return connection
def aborted(self):
"""
Checks if the current transaction is aborted.
Returns:
True if the current transaction is aborted
"""
try:
self.connection.execute("SELECT 1")
return False
except duckdb.TransactionException:
return True
def formatargs(self, args):
"""
DuckDB doesn't support named parameters. This method replaces named parameters with question marks
@@ -139,19 +166,16 @@ class DuckDB(Embedded):
# Unpack query args
query, parameters = args
# Iterate over parameters
# - Replace named parameters with ?'s
# - Build list of value with position indexes
# Match all named parameters in a single pass, so values are listed in the order they appear in the query
# - Match on a word boundary, bind parameters can be followed by any non-word character, i.e. `in (:x)`
params = []
for key, value in parameters.items():
pattern = rf"\:{key}(?=\s|$)"
match = re.search(pattern, query)
if match:
query = re.sub(pattern, "?", query, count=1)
params.append((match.start(), value))
if parameters:
pattern = r"\:(" + "|".join(re.escape(key) for key in parameters) + r")\b"
params = [parameters[match.group(1)] for match in re.finditer(pattern, query)]
query = re.sub(pattern, "?", query)
# Repack query and parameter list
args = (query, [value for _, value in sorted(params, key=lambda x: x[0])])
args = (query, params)
return args
+3
View File
@@ -31,6 +31,9 @@ class Embedded(RDBMS):
self.path = path
def save(self, path):
# Initialize connection if not open
self.initialize()
# Temporary database
if not self.path:
# Save temporary database
+3 -3
View File
@@ -9,7 +9,6 @@ from .base import Database
from .schema import Statement
# pylint: disable=R0904
class RDBMS(Database):
"""
Base relational database class. A relational database uses SQL to insert, update, delete and select from a
@@ -186,8 +185,9 @@ class RDBMS(Database):
if indexids:
select = f"{self.resolve('indexid')}, {self.resolve('score')}"
# Use JOIN when documents table is used to utilize indexes, default to LEFT JOIN
join = "JOIN" if any(x and self.jsonprefix() in x for x in [where, groupby, orderby]) else "LEFT JOIN"
# Use JOIN when a JSON field is filtered, since that filter already excludes rows without a
# documents row. GROUP BY/ORDER BY don't filter, so they keep the LEFT JOIN.
join = "JOIN" if where and self.jsonprefix() in where else "LEFT JOIN"
# Build query text
query = Statement.TABLE_CLAUSE % (select, join)
+39 -11
View File
@@ -6,6 +6,7 @@ import itertools
import operator
from .base import SQL
from .error import SQLError
class Aggregate(SQL):
@@ -62,18 +63,41 @@ class Aggregate(SQL):
aggregates = {}
for column in columns:
column = column.lower()
if column.startswith(("count(", "sum(", "total(")):
# Match function names case-insensitively but keep the column name as returned
name = column.lower()
if name.startswith(("count(", "sum(", "total(")):
aggregates[column] = sum
elif column.startswith("max("):
elif name.startswith("max("):
aggregates[column] = max
elif column.startswith("min("):
elif name.startswith("min("):
aggregates[column] = min
elif column.startswith("avg("):
aggregates[column] = lambda x: sum(x) / len(x)
elif name.startswith("avg("):
aggregates[column] = self.avg
return aggregates
def avg(self, values, counts):
"""
Combines per-shard/per-group average values into a single average, weighted by a
matching count(*) column when one was selected. Without it, there is no way to
recombine the averages correctly, so this raises instead of returning a wrong number.
Args:
values: list of per-shard/per-group average values
counts: list of matching count(*) values, or None
Returns:
combined average
"""
if len(values) == 1:
return values[0]
if not counts or sum(counts) == 0:
raise SQLError("avg() requires a count(*) column to combine results from multiple shards")
return sum(value * count for value, count in zip(values, counts)) / sum(counts)
def aggregate(self, query, results, columns, aggcolumns):
"""
Merges aggregate columns in results.
@@ -94,6 +118,9 @@ class Aggregate(SQL):
else:
results = [results]
# Column providing the row count each group's values were computed over, used to weight avg() columns
countcolumn = next((column for column in columns if column.lower() == "count(*)"), None)
# Compute column values
rows = []
for result in results:
@@ -101,9 +128,10 @@ class Aggregate(SQL):
row = {}
for column in columns:
if column in aggcolumns:
# Calculate aggregate value
function = aggcolumns[column]
row[column] = function([r[column] for r in result])
# Calculate aggregate value, skip NULLs from shards with no matching rows
matches = [r for r in result if r[column] is not None]
values, counts = [r[column] for r in matches], [r[countcolumn] for r in matches] if countcolumn else None
row[column] = (self.avg(values, counts) if column.lower().startswith("avg(") else aggcolumns[column](values)) if values else None
else:
# Non aggregate column value repeat, use first value
row[column] = result[0][column]
@@ -155,9 +183,9 @@ class Aggregate(SQL):
clause = clause.rsplit(" ")[0]
reverse = True
# Order by columns must be in select clause
# Order by columns must be in select clause. NULLs sort first like in SQLite (last when descending).
if clause in query["select"]:
results = sorted(results, key=operator.itemgetter(clause), reverse=reverse)
results = sorted(results, key=lambda row, column=clause: (row[column] is not None, row[column]), reverse=reverse)
return results
+10 -2
View File
@@ -5,6 +5,7 @@ SQL module
from io import StringIO
from shlex import shlex
from .error import SQLError
from .expression import Expression
@@ -42,8 +43,11 @@ class SQL:
clauses = None
if self.issql(query):
# Ignore multiple statements
query = query.split(";")[0]
# Multiple statements are not supported
components = [x for x in query.split(";") if x.strip()]
if len(components) > 1:
raise SQLError("Multiple SQL statements are not supported. Pass values that contain a semicolon as a bind parameter.")
query = components[0]
# Tokenize query
tokens, positions = self.tokenize(query)
@@ -66,6 +70,10 @@ class SQL:
if similar:
clauses["similar"] = similar
# Add select column aliases, if any. Only a query with an offset uses them.
if aliases and clauses["offset"]:
clauses["aliases"] = aliases
# Return clauses, default to full query if this is not a SQL query
return clauses if clauses else {"similar": [[query]]}
+5 -2
View File
@@ -26,6 +26,9 @@ class Token:
# Default list of sort order operators
SORT_ORDER = ["asc", "desc"]
# Default list of boolean literals
BOOLEANS = ["true", "false"]
@staticmethod
def get(tokens, x):
"""
@@ -197,8 +200,8 @@ class Token:
True if this token is a literal, False otherwise
"""
# Literals are wrapped in quotes, parens, wildcards or numeric.
return token and (token.startswith(("'", '"', ",", "(", ")", "*")) or token.replace(".", "", 1).isdigit())
# Literals are wrapped in quotes, parens, wildcards, numeric or boolean.
return token and (token.startswith(("'", '"', ",", "(", ")", "*")) or token.replace(".", "", 1).isdigit() or token.lower() in Token.BOOLEANS)
@staticmethod
def islogicseparator(token):
+6 -3
View File
@@ -18,7 +18,6 @@ from .index import Action, Configuration, Functions, Indexes, IndexIds, Reducer,
from .search import Explain, Ids, Query, Search, Terms
# pylint: disable=C0302,R0904
class Embeddings:
"""
Embeddings databases are the engine that delivers semantic search. Data is transformed into embeddings vectors where similar concepts
@@ -136,7 +135,7 @@ class Embeddings:
self.ann.index(embeddings)
# Save indexids-ids mapping for indexes with no database, except when this is a reindex
if ids and not reindex and not self.database:
if ids is not None and not reindex and not self.database:
self.ids = self.createids(ids)
# Index scoring, if necessary
@@ -549,6 +548,9 @@ class Embeddings:
if cloud:
path = cloud.load(path)
# Reset archive since this replaces the current index
self.archive = None
# Check if this is an archive file and extract
path, apath = self.checkarchive(path)
if apath:
@@ -931,7 +933,8 @@ class Embeddings:
"""
# Create archive instance, if necessary
self.archive = ArchiveFactory.create()
if not self.archive:
self.archive = ArchiveFactory.create()
# Check if path is an archive file
if self.archive.isarchive(path):
@@ -147,10 +147,10 @@ class Transform:
if not self.indexing and not document[1].get(self.text):
document[1][self.text] = str(document[0])
if self.text in document[1]:
if document[1].get(self.text) is not None:
yield (document[0], document[1][self.text], document[2])
offset += 1
elif self.object in document[1]:
elif document[1].get(self.object) is not None:
yield (document[0], document[1][self.object], document[2])
offset += 1
else:
+36 -6
View File
@@ -92,7 +92,7 @@ class Search:
Args:
queries: list of queries
limit: maximum results
weights: hybrid score weights
weights: hybrid score weights, either one set of weights for all queries or a list of [dense, sparse] weights per query
index: index name
Returns:
@@ -114,9 +114,13 @@ class Search:
if isinstance(weights, (int, float)):
weights = [weights, 1 - weights]
# Use the same weights for each query, unless a list of weights per query is passed
if not isinstance(weights[0], (list, tuple)):
weights = [weights] * len(queries)
# Create weighted scores via hybrid fusion strategy
fusion = Hybrid(self.scoring)
return [fusion(vectors, weights, limit) for vectors in zip(dense, sparse)]
return [fusion(vectors, weights[x], limit) for x, vectors in enumerate(zip(dense, sparse))]
# Raise an error if when no indexes are available
if not sparse and not dense:
@@ -228,8 +232,8 @@ class Search:
# Override limit with query limit, if applicable
limit = max(limit, self.limit(queries))
# Bulk index scan
scan = Scan(self.search, limit, weights, index)(queries, parameters)
# Bulk index scan. The database query skips OFFSET rows, so add them to the number of candidates.
scan = Scan(self.search, limit + self.offset(queries), weights, index)(queries, parameters)
# Combine index search results with database search results
results = []
@@ -296,6 +300,32 @@ class Search:
return qlimit
def offset(self, queries):
"""
Parses the largest OFFSET clause from queries ordered by score.
Args:
queries: list of queries
Returns:
largest offset number or 0 if not found
"""
qoffset = 0
for query in queries:
# Skip non-numeric offsets (e.g. a ":n" bind parameter)
o = query.get("offset")
o = int(o) if o and o.isdigit() else 0
# Only a query ordered first by score desc counts its offset. Any other order, or a select alias named score that replaces
# the similarity score in the order, must see the same candidates on every page.
if o > qoffset:
orderby = query.get("orderby")
if (not orderby or orderby.split(",")[0].strip().lower() == "score desc") and "score" not in query.get("aliases", {}):
qoffset = o
return qoffset
def graphsearch(self, queries, limit, weights, index):
"""
Executes an index + graph search.
@@ -316,8 +346,8 @@ class Search:
# Override limit with query limit, if applicable
limit = max(limit, self.limit(queries))
# Bulk index scan
scan = Scan(self.search, limit, weights, index)(queries, None)
# Bulk index scan. The graph query drops SKIP rows, so add them to the number of candidates.
scan = Scan(self.search, limit + self.offset(queries), weights, index)(queries, None)
# Combine index search results with database search results
for x, query in enumerate(queries):
@@ -86,8 +86,9 @@ class Explain:
# Parse query
query = self.database.parse(query)
# Extract query from similar clause
query = " ".join([" ".join(clause) for clause in query["similar"]]) if "similar" in query else None
# Extract query text from similar clauses. Only the first argument is query text, the rest are
# candidates, index and weights.
query = " ".join([clause[0] for clause in query["similar"]]) if "similar" in query else None
# Return original texts if query, text or score not present
if not query or not texts or "score" not in texts[0] or "text" not in texts[0]:
+2 -1
View File
@@ -194,7 +194,8 @@ class LogOdds:
array = [s for s in raw if s > 0]
if array:
median = sorted(array)[len(array) // 2]
std = (sum((x - sum(array) / len(array)) ** 2 for x in array) / len(array)) ** 0.5
mean = sum(array) / len(array)
std = (sum((x - mean) ** 2 for x in array) / len(array)) ** 0.5
alpha = 1.0 / std if std > 0 else 1.0
return median, alpha
+3 -3
View File
@@ -61,9 +61,9 @@ class Scan:
candidates = max(candidates) if candidates else default
# Query weights to pass to batch search
weights = [query.weights for query in iqueries if query.weights is not None]
weights = max(weights) if weights else self.weights
# Query weights to pass to batch search. Each query keeps its own weights, queries without weights use the default.
weights = [query.weights if query.weights is not None else self.weights for query in iqueries]
weights = [[weight, 1 - weight] if isinstance(weight, (int, float)) else weight for weight in weights]
# Index to run query against
index = index if index else self.index
+3 -2
View File
@@ -37,9 +37,10 @@ class Terms:
# Parse query
parse = self.database.parse(query)
# Join terms from similar clauses (absent when the query has no similar() clause)
# Join query text from similar clauses (absent when the query has no similar() clause).
# Only the first argument is query text, the rest are candidates, index and weights.
if "similar" in parse:
terms.append(" ".join(" ".join(s) for s in parse["similar"]))
terms.append(" ".join(s[0] for s in parse["similar"]))
return terms
+23 -4
View File
@@ -7,7 +7,6 @@ from collections import Counter
from .topics import Topics
# pylint: disable=R0904
class Graph:
"""
Base class for Graph instances. This class builds graph networks. Supports topic modeling
@@ -460,6 +459,10 @@ class Graph:
# Also delete topic, if it's empty
if not self.topics[topic]:
# Categories are stored by topic position
if self.categories:
self.categories.pop(list(self.topics).index(topic))
self.topics.pop(topic)
# Delete node
@@ -757,7 +760,7 @@ class Graph:
ids = ids.keys() if ids else None
# Infer topic
topic = Counter(self.attribute(x, "topic") for x in ids).most_common(1)[0][0] if ids else None
topic = self.commonattribute(ids, "topic")
if topic:
# Add id to topic list and set topic attribute
self.topics[topic].append(node)
@@ -767,5 +770,21 @@ class Graph:
self.addattribute(node, "topicrank", len(self.topics[topic]) - 1)
# Infer category
category = Counter(self.attribute(x, "category") for x in ids).most_common(1)[0][0]
self.addattribute(node, "category", category)
category = self.commonattribute([x for x in ids if self.attribute(x, "topic") == topic], "category")
if category:
self.addattribute(node, "category", category)
def commonattribute(self, ids, attribute):
"""
Gets the most common non-empty attribute value across a list of nodes.
Args:
ids: list of node ids
attribute: attribute name
Returns:
most common value or None if no node has the attribute
"""
values = Counter(value for value in (self.attribute(x, attribute) for x in ids or []) if value is not None)
return values.most_common(1)[0][0] if values else None
+1 -2
View File
@@ -24,7 +24,6 @@ from .base import Graph
from .query import Query
# pylint: disable=R0904
class NetworkX(Graph):
"""
Graph instance backed by NetworkX.
@@ -131,7 +130,7 @@ class NetworkX(Graph):
for value in column:
if isinstance(value, list):
# Path group
nodes.update([node for node in value if node and not isinstance(node, dict)])
nodes.update([node for node in value if node is not None and not isinstance(node, dict)])
elif isinstance(value, dict):
# Nodes by id attribute
nodes.update(uid for uid, attr in self.scan(data=True) if attr["id"] == value["id"])
+9 -1
View File
@@ -88,7 +88,7 @@ class Query:
"""
# Parameters
where, limit, nodes, similar = None, None, [], []
where, limit, offset, nodes, similar = None, None, None, [], []
# Parse where clause
match = re.search(r"where(.+?)return", query, flags=re.DOTALL | re.IGNORECASE)
@@ -100,6 +100,13 @@ class Query:
if match:
limit = match.group(1)
# Parse skip clause when results are ordered first by score desc. Graph results with no ORDER BY do not follow score order.
# Most queries have no SKIP, so check for the keyword before the regex.
if "skip" in query.lower():
match = re.search(r"skip\s+(\d+)", query, flags=re.DOTALL | re.IGNORECASE)
if match and re.search(r"order\s+by\s+\w+\.score\s+desc", query, flags=re.DOTALL | re.IGNORECASE):
offset = match.group(1)
# Parse similar clauses
for x, match in enumerate(re.finditer(r"similar\((.+?)\)", query, flags=re.DOTALL | re.IGNORECASE)):
# Replace similar clause with placeholder
@@ -115,6 +122,7 @@ class Query:
"query": query,
"where": where,
"limit": limit,
"offset": offset,
"nodes": nodes,
"similar": similar,
}
+4
View File
@@ -59,6 +59,10 @@ class RDBMS(NetworkX):
else:
yield from super().scan(attribute, data)
def addattribute(self, node, field, value):
if self.hasnode(node):
self.graph.backend.add_node(node, {field: value})
def load(self, path):
# Create graph instance
self.graph, self.database = self.connect()
+4 -2
View File
@@ -82,8 +82,10 @@ class Topics:
# Get topn terms
terms = self.topn(idf, topn)
# Sort community by score descending
community = [uid for uid, _ in scoring.search(terms, len(community))]
# Sort community by score descending. Ensure both scored and unscored nodes are kept in community.
ranked = [uid for uid, _ in scoring.search(terms, len(community))]
unranked = sorted(set(community) - set(ranked), key=lambda x: centrality[x], reverse=True)
community = ranked + unranked
else:
# No text found for topic, generate topic name
terms = ["topic", str(index)]
+3 -1
View File
@@ -36,7 +36,9 @@ class Pooling(Module):
super().__init__()
self.model = Models.load(path, modelargs=modelargs)
self.tokenizer = Models.tokenizer(tokenizer if tokenizer else path)
self.tokenizer = Models.tokenizer(
tokenizer if tokenizer else path, trust_remote_code=modelargs.get("trust_remote_code", False) if modelargs else False
)
self.device = Models.device(device)
# Detect unbounded tokenizer typically found in older models
+7 -3
View File
@@ -85,16 +85,20 @@ class PoolingFactory:
# Load 1_Pooling/config.json file
config = PoolingFactory.load(path, "1_Pooling/config.json")
# Models trained with Sentence Transformers v5.4+ use `pooling_mode` parameter
# Support both the new and legacy parameters
mode = config.get("pooling_mode") if config else None
# Set to CLS pooling if it's enabled and mean pooling is disabled
if config and config.get("pooling_mode_cls_token") and not config.get("pooling_mode_mean_tokens"):
if config and config.get("pooling_mode_cls_token", mode == "cls") and not config.get("pooling_mode_mean_tokens", mode == "mean"):
method = "clspooling"
# Set to last token pooling if it's enabled and mean pooling is disabled
if config and config.get("pooling_mode_lasttoken") and not config.get("pooling_mode_mean_tokens"):
if config and config.get("pooling_mode_lasttoken", mode == "lasttoken") and not config.get("pooling_mode_mean_tokens", mode == "mean"):
method = "lastpooling"
# Set to max pooling if it's enabled and mean pooling is disabled
if config and config.get("pooling_mode_max_tokens") and not config.get("pooling_mode_mean_tokens"):
if config and config.get("pooling_mode_max_tokens", mode == "max") and not config.get("pooling_mode_mean_tokens", mode == "mean"):
method = "maxpooling"
# Check for late interaction pooling
+2 -1
View File
@@ -291,7 +291,8 @@ class LatePooling(Pooling):
config = self.load(path, "artifact.metadata")
params = ["query_token_id", "query_maxlen", "doc_token_id", "doc_maxlen"]
return [config.get(p) for p in params]
# Use defaults when the settings file is missing
return [config.get(p) for p in params] if config else [None] * len(params)
def loadlinear(self, path, models):
"""
+4
View File
@@ -209,6 +209,10 @@ class Lemur:
with open(config, encoding="utf-8") as source:
self.config = json.load(source)
# Encoder repos also have config.json and model.safetensors, check this is a LEMUR artifact
if "modeltype" not in self.config:
raise ValueError(f"{path} is not a LEMUR artifact, create one with LemurTrainer")
self.model = LemurModel(**self.config).to(self.device)
training = self.config.get("training", {})
self.selectedepoch = training.get("selectedepoch")
+6 -2
View File
@@ -169,11 +169,15 @@ class Signal:
audio with silence removed
"""
# Process in 20ms chunks
# Empty audio has no chunks to concatenate.
if len(audio) == 0:
return audio
# Process in chunks, including the final partial chunk
n, offset = int(rate * (20 / 1000.0) * 2), 0
chunks = []
while offset + n <= len(audio):
while offset < len(audio):
# Calculate energy for chunk and detection result
chunk = audio[offset : offset + n]
energyfreq = Signal.energy(chunk, rate)
@@ -92,7 +92,7 @@ class TextToSpeech(Pipeline):
# Streaming response
if stream:
return self.stream(texts, speaker, encoding)
return self.stream(texts, speaker, encoding, **kwargs)
# Transform text to speech
results = [self.execute(x, speaker, encoding, **kwargs) for x in texts]
@@ -137,7 +137,7 @@ class TextToSpeech(Pipeline):
return exists
def stream(self, texts, speaker, encoding):
def stream(self, texts, speaker, encoding, **kwargs):
"""
Iterates over texts, splits into segments and yields snippets of audio.
This method is designed to integrate with streaming LLM generation.
@@ -146,6 +146,7 @@ class TextToSpeech(Pipeline):
texts: list of input texts
speaker: speaker id
encoding: audio encoding format
kwargs: additional keyword args
Returns:
snippets of audio as NumPy arrays or audio bytes depending on encoding parameter
@@ -157,11 +158,11 @@ class TextToSpeech(Pipeline):
if x == "\n" or (x.strip().endswith(".") and len([y for y in buffer if y]) > 2):
data, buffer = "".join(buffer), []
yield self.execute(data, speaker, encoding)
yield self.execute(data, speaker, encoding, **kwargs)
if buffer:
data = "".join(buffer)
yield self.execute(data, speaker, encoding)
yield self.execute(data, speaker, encoding, **kwargs)
def execute(self, text, speaker, encoding, **kwargs):
"""
+60 -12
View File
@@ -4,6 +4,8 @@ HTMLToMarkdown module
import re
from textwrap import indent
# Conditional import
try:
from bs4 import BeautifulSoup, NavigableString
@@ -58,6 +60,14 @@ class HTMLToMarkdown(Pipeline):
for script in soup.find_all(["script", "style"]):
script.decompose()
# Replace line breaks with newlines. Table cells and headings must stay on a single line, use a space there.
for br in soup.find_all("br"):
parent = br.parent
br.replace_with(" " if br.find_parent(["th", "td", "h1", "h2", "h3", "h4", "h5", "h6"]) else "\n")
# Merge the line break with the text around it
parent.smooth()
# Check for article sections
article = next((x for x in ["article", "main"] if soup.find(x)), None)
@@ -184,12 +194,17 @@ class HTMLToMarkdown(Pipeline):
target, text = x if x.name else node, x.text
if text.strip():
# Leading whitespace separates this text from the previous word, keep it outside of the formatting
space = " " if text[0].isspace() and texts and not texts[-1][-1:].isspace() else ""
if target.name in ("b", "strong"):
text = f"**{text.strip()}** "
text = f"{space}**{text.strip()}** "
elif target.name in ("i", "em"):
text = f"*{text.strip()}* "
text = f"{space}*{text.strip()}* "
elif target.name == "a":
text = f"[{text.strip()}]({target.get('href')}) "
# Links without a target are added as plain text
href = target.get("href")
text = f"{space}[{text.strip()}]({href}) " if href else f"{space}{text.strip()} "
texts.append(text)
@@ -253,21 +268,54 @@ class HTMLToMarkdown(Pipeline):
list as markdown
"""
elements = []
for x, element in enumerate(node.find_all("li")):
# Unordered lists use dashes. Ordered lists use numbers.
prefix = "-" if node.name == "ul" else f"{x + 1}."
elements, count = [], 0
if node.name == "ol":
try:
count = int(node.get("start", 1)) - 1
except ValueError:
pass
# List item text
text = self.process(element, article)
for element in node.find_all(("li", "ul", "ol"), recursive=False):
# Nested list that is a direct child of this list
if element.name != "li":
self.nested(element, article, elements)
else:
# Unordered lists use dashes. Ordered lists use numbers.
count += 1
prefix = "-" if node.name == "ul" else f"{count}."
# Add list element
if text:
elements.append(f"{prefix} {text}")
# Detach nested lists, these are formatted separately
nested = [child.extract() for child in element.find_all(("ul", "ol"), recursive=False)]
# List item text
text = self.process(element, article)
# Add list element
if text:
elements.append(f"{prefix} {text}")
# Add nested lists
for child in nested:
self.nested(child, article, elements)
# Join elements together as string
return "\n".join(elements)
def nested(self, node, article, elements):
"""
Nested list handler. Formats a nested list and adds it to elements indented with a tab,
which is preserved by text cleaning.
Args:
node: nested list node
article: True if the main section node is an article
elements: list of formatted list elements
"""
text = self.items(node, article)
if text:
elements.append(indent(text, "\t"))
def code(self, node):
"""
Code block handler. This method transforms a HTML pre or code block into a Markdown formatted
+8 -5
View File
@@ -99,12 +99,15 @@ class Tabular(Pipeline):
columns = self.textcolumns
if not columns:
columns = list(df.columns)
if self.idcolumn:
if self.idcolumn is not None:
columns.remove(self.idcolumn)
# Transform into (id, text, tag) tuples
for index, row in df.iterrows():
uid = row[self.idcolumn] if self.idcolumn else index
# Tuple iteration preserves column types instead of coercing each row to one dtype.
for index, values in zip(df.index, df.itertuples(index=False, name=None)):
row = dict(zip(df.columns, values))
# Coerce NaN ids to None so the row-index fallback applies.
uid = self.column(row[self.idcolumn]) if self.idcolumn is not None else index
uid = uid if uid is not None else index
text = self.concat(row, columns)
@@ -112,10 +115,10 @@ class Tabular(Pipeline):
# Also add row for content
if isinstance(self.content, list):
row = {column: self.column(value) for column, value in row.to_dict().items() if column in self.content}
row = {column: self.column(value) for column, value in row.items() if column in self.content}
rows.append((uid, row, None))
elif self.content:
row = {column: self.column(value) for column, value in row.to_dict().items()}
row = {column: self.column(value) for column, value in row.items()}
rows.append((uid, row, None))
return rows
+1 -1
View File
@@ -82,7 +82,7 @@ class Tokenizer(Pipeline):
# - Strings to be at least 2 characters long AND
# - At least 1 non-trailing alpha character in string
# Note: The standard Python re module is much faster than regex for this expression
self.alphanum = re.compile(r"^\d*[a-z][\-.0-9:_a-z]{1,}$")
self.alphanum = re.compile(r"^\d*[a-zA-Z][\-.0-9:_a-zA-Z]{1,}$")
elif regexp:
# Regular expression for tokenization
self.regexp = regex.compile(regexp)
+5 -1
View File
@@ -64,7 +64,11 @@ class HFModel(Tensors):
model = self.prepare(model)
# Load tokenizer
tokenizer = transformers.AutoTokenizer.from_pretrained(tokenizer) if isinstance(tokenizer, str) else tokenizer
tokenizer = (
transformers.AutoTokenizer.from_pretrained(tokenizer, trust_remote_code=kwargs.get("trust_remote_code", False))
if isinstance(tokenizer, str)
else tokenizer
)
return model, tokenizer
+2 -2
View File
@@ -38,8 +38,8 @@ class Caption(HFModel):
self.model, self.tokenizer, self.processor = path
else:
self.model = transformers.AutoModelForImageTextToText.from_pretrained(path, **kwargs)
self.tokenizer = transformers.AutoTokenizer.from_pretrained(path)
self.processor = transformers.AutoImageProcessor.from_pretrained(path)
self.tokenizer = transformers.AutoTokenizer.from_pretrained(path, trust_remote_code=kwargs.get("trust_remote_code", False))
self.processor = transformers.AutoImageProcessor.from_pretrained(path, trust_remote_code=kwargs.get("trust_remote_code", False))
# Move model to device
self.model = self.model.to(self.device)
+4 -1
View File
@@ -173,12 +173,15 @@ class Generation:
"""
# Consume "thinking" tokens
text, buffer = None, ""
text, buffer = "", ""
for chunk in results:
buffer += chunk
text = self.cleanthink(buffer)
if text != buffer:
break
prefix = buffer.lstrip()
if not any(marker.startswith(prefix) or prefix.startswith(marker) for marker in ("<think>", "<|")):
break
# Yield remaining tokens
yield from text.lstrip()
+8 -10
View File
@@ -120,19 +120,17 @@ class Labels(HFPipeline):
result = [(config.label2id.get(x["label"], 0), x["score"]) for x in result]
if labels:
# Map lowercase label names to label ids
names = {x.lower(): uid for x, uid in config.label2id.items()}
matches = []
for label in labels:
# Lookup label keys from model config
if label.isdigit():
label = int(label)
keys = list(config.id2label.keys())
else:
label = label.lower()
keys = [x.lower() for x in config.label2id.keys()]
# Resolve label to a label id, by id or by name
uid = int(label) if label.isdigit() else names.get(label.lower())
# Find and add label match
if label in keys:
matches.append(keys.index(label))
# Add label match, if found
if uid in config.id2label:
matches.append(uid)
return [(label, score) for label, score in result if label in matches]
+11 -2
View File
@@ -95,12 +95,21 @@ class LateEncoder(Pipeline):
list of (id, score)
"""
# Padding rows are all zeros, true token rows are unit normalized
qmask, dmask = queries.abs().sum(axis=-1) > 0, data.abs().sum(axis=-1) > 0
# Compute bulk dot product using einstein notation
scores = torch.einsum("ash,bth->abst", queries, data).max(axis=-1).values.mean(axis=-1)
scores = torch.einsum("ash,bth->abst", queries, data)
# Max score per query token, ignoring padded data tokens
scores = scores.masked_fill(~dmask[None, :, None, :], torch.finfo(scores.dtype).min).max(axis=-1).values
# Mean score over true query tokens, ignoring padded query tokens
scores = scores.masked_fill(~qmask[:, None, :], 0).sum(axis=-1) / qmask.sum(axis=-1, keepdim=True).clamp(min=1)
scores = scores.cpu().numpy()
# Get top n matching indices and scores
indices = np.argpartition(-scores, limit if limit and limit < scores.shape[0] else scores.shape[0] - 1)[:, :limit]
indices = np.argpartition(-scores, limit if limit and limit < scores.shape[1] else scores.shape[1] - 1)[:, :limit]
scores = np.take_along_axis(scores, indices, axis=1)
# argpartition doesn't order within the top n - sort each row by score descending
+120 -5
View File
@@ -2,7 +2,18 @@
Reranker module
"""
import hashlib
import json
import logging
from collections import OrderedDict
from threading import RLock
from ..base import Pipeline
from ...util import Library
library = Library()
np, torch, safetensors = library.numpy(), library.torch(), library.safetensors()
logger = logging.getLogger(__name__)
class Reranker(Pipeline):
@@ -11,16 +22,23 @@ class Reranker(Pipeline):
embeddings instance for this to work properly.
"""
def __init__(self, embeddings, similarity):
# Cache lock shared by all instances, since rerankers can share one similarity pipeline
lock = RLock()
def __init__(self, embeddings, similarity, cache=None):
"""
Creates a Reranker pipeline.
Args:
embeddings: embeddings instance (content must be enabled)
similarity: similarity instance
cache: optional document token cache; True bounds at 1000, False/None disable; other values use int(cache), non-positive disables
"""
self.embeddings, self.similarity = embeddings, similarity
self.cachesize = 1000 if cache is True else 0 if cache is None or cache is False else int(cache)
self.cache = OrderedDict() if self.cachesize > 0 else None
self.namespace = None
# pylint: disable=W0222
def __call__(self, query, limit=3, factor=10, **kwargs):
@@ -45,13 +63,110 @@ class Reranker(Pipeline):
# Re-rank using similarity pipeline
ranked = []
for x, result in enumerate(results):
texts = [row["text"] for row in result]
# Only score when a search has results
if result:
texts = [row["text"] for row in result]
# Score results and merge
for uid, score in self.similarity(queries[x], texts):
result[uid]["score"] = score
if self.cache is not None and (namespace := self.modelkey()) and all("id" in row for row in result):
with self.lock:
texts = self.vectors(result, namespace)
for uid, score in self.similarity(queries[x], texts):
result[uid]["score"] = score
else:
# Score results and merge
for uid, score in self.similarity(queries[x], texts):
result[uid]["score"] = score
# Sort and take top n sorted results
ranked.append(sorted(result, key=lambda row: row["score"], reverse=True)[:limit])
return ranked[0] if isinstance(query, str) else ranked
def modelkey(self):
"""Compute a stable namespace for batch-independent document encoding."""
encoder = getattr(self.similarity, "lateencoder", None)
if not encoder:
return None
pooling = encoder.model
center = getattr(pooling, "center", None)
if center and center["scope"] == "batch":
return None
mean = center.get("mean") if center else None
identity = {
"pooling": type(pooling).__name__,
"model": getattr(pooling.model.config, "_name_or_path"),
"revision": getattr(pooling.model.config, "_commit_hash", None),
"tokenizer": pooling.tokenizer.name_or_path,
"scope": center["scope"] if center else None,
"mean": hashlib.sha256(np.asarray(mean).tobytes()).hexdigest() if mean is not None else None,
}
return hashlib.sha256(json.dumps(identity, sort_keys=True).encode("utf-8")).hexdigest()
def reset(self, namespace):
"""Clear entries when the encoder namespace changes; caller holds the cache lock."""
if self.namespace != namespace:
self.cache.clear()
self.namespace = namespace
def vectors(self, rows, namespace):
"""Gather cached vectors and encode misses once, preserving candidate positions."""
with self.lock:
self.reset(namespace)
keys, vectors, misses = [], [], []
for row in rows:
key = (namespace, row["id"], hashlib.sha256(row["text"].encode("utf-8")).hexdigest())
vector = self.cache.get(key)
keys.append(key)
vectors.append(vector)
if vector is None:
misses.append(len(vectors) - 1)
else:
self.cache.move_to_end(key)
if misses:
encoded = self.similarity.encode([rows[x]["text"] for x in misses], "data")
for position, vector in zip(misses, encoded):
vectors[position] = vector[vector.abs().sum(dim=-1) > 0].detach().to(device="cpu", dtype=torch.float32)
# Keep local references through padding before LRU insertion can evict any current candidate.
batch = torch.zeros((len(vectors), max(vector.shape[0] for vector in vectors), vectors[0].shape[1]), dtype=torch.float32)
for position, vector in enumerate(vectors):
batch[position, : vector.shape[0]] = vector
for position in misses:
self.cache.update({keys[position]: vectors[position]})
while len(self.cache) > self.cachesize:
self.cache.popitem(last=False)
return batch.to(self.similarity.lateencoder.device)
def save(self, path):
"""Save an independent safetensors snapshot; disabled or unsupported caches do nothing."""
if self.cache is not None and (namespace := self.modelkey()):
with self.lock:
self.reset(namespace)
metadata = {"version": "1", "namespace": namespace, "keys": json.dumps([list(key[1:]) for key in self.cache.keys()])}
safetensors.numpy.save_file({str(x): vector.numpy() for x, vector in enumerate(self.cache.values())}, path, metadata)
def load(self, path):
"""Load a bounded snapshot, warning on model mismatch; disabled or unsupported caches do nothing."""
if self.cache is not None and (namespace := self.modelkey()):
with self.lock:
self.cache.clear()
self.namespace = namespace
with safetensors.safe_open(path, framework="np") as source:
metadata = source.metadata()
if metadata["namespace"] != namespace:
logger.warning("Ignoring reranker cache snapshot with a different encoder namespace")
return
for x, key in enumerate(json.loads(metadata["keys"])):
self.cache.update({(namespace, *key): torch.from_numpy(source.get_tensor(str(x)))})
while len(self.cache) > self.cachesize:
self.cache.popitem(last=False)
+1 -1
View File
@@ -57,7 +57,7 @@ class Similarity(Labels):
return self.crossencoder(query, texts, multilabel, labels=labels)
if self.lateencoder:
return self.lateencoder(query, texts)
return self.lateencoder(query, texts, **kwargs)
# Call Labels pipeline for texts using input query as the candidate label
scores = super().__call__(texts, [query] if isinstance(query, str) else query, multilabel, **kwargs)
+12 -6
View File
@@ -91,14 +91,20 @@ class Summary(HFModel):
if prefix:
inputs = [prefix + x for x in inputs]
# Tokenize inputs
tokens = self.tokenizer(inputs, truncation=kwargs.pop("truncation", False), return_tensors="pt").to(self.device)
truncation = kwargs.pop("truncation", False)
# Generate outputs
outputs = self.model.generate(**tokens, **kwargs)
results = []
for batch in self.batch(inputs, self.batchsize):
# Tokenize inputs, padding texts of different lengths within a batch
tokens = self.tokenizer(batch, padding=True, truncation=truncation, return_tensors="pt").to(self.device)
# Decode and return
return [self.tokenizer.decode(x, skip_special_tokens=True) for x in outputs]
# Generate outputs
outputs = self.model.generate(**tokens, **kwargs)
# Decode outputs
results.extend(self.tokenizer.decode(x, skip_special_tokens=True) for x in outputs)
return results
def clean(self, text):
"""
+32 -12
View File
@@ -13,6 +13,7 @@ from collections import Counter
from threading import RLock
# Core library imports
from ..serialize import SerializeFactory
from ..util import Library
np = Library().numpy()
@@ -37,7 +38,7 @@ class Terms:
INSERT_TERM = "INSERT OR REPLACE INTO terms VALUES (?, ?, ?)"
SELECT_TERMS = "SELECT ids, freqs FROM terms WHERE term = ?"
WILDCARD_TERMS = "SELECT term FROM terms WHERE term LIKE ?"
WILDCARD_TERMS = "SELECT term FROM terms WHERE term LIKE ? ESCAPE '\\'"
# Documents table
CREATE_DOCUMENTS = """
@@ -129,14 +130,21 @@ class Terms:
ids: ids to delete
"""
# Set index ids as deleted, ignoring ids that were never indexed and ids
# already marked deleted. Skipping duplicates keeps count() accurate when
# the same id is deleted more than once (e.g. repeated upsert/delete flows).
for i in ids:
if i in self.ids:
indexid = self.ids.index(i)
if indexid not in self.deletes:
# Walk each requested id from its last match to mark every index id, including re-added ids
deletes = set(self.deletes)
for uid in ids:
start = 0
while True:
try:
indexid = self.ids.index(uid, start)
except ValueError:
break
if indexid not in deletes:
self.deletes.append(indexid)
deletes.add(indexid)
start = indexid + 1
def index(self):
"""
@@ -271,9 +279,13 @@ class Terms:
# Index id - length
self.lengths.append(length)
# Cast ids to int if every id is an integer
if all(uid.isdigit() for uid in self.ids):
# Retain integer inference for legacy indexes with plain TEXT ids.
if all(isinstance(uid, str) and uid.isdigit() for uid in self.ids):
self.ids = [int(uid) for uid in self.ids]
else:
# Serialized ids preserve types, including numeric strings and mixed string/integer ids.
serializer = SerializeFactory.create()
self.ids = [serializer.loadbytes(uid) if isinstance(uid, bytes) else uid for uid in self.ids]
# Clear cache
self.weights.cache_clear()
@@ -286,12 +298,16 @@ class Terms:
path: path to write terms database
"""
# Initialize database, if necessary
self.initialize()
# Clear documents table
self.cursor.execute(Terms.DELETE_DOCUMENTS)
# Save document attributes
# Serialize ids as BLOBs so SQLite TEXT affinity does not discard their types.
serializer = SerializeFactory.create()
for i, uid in enumerate(self.ids):
self.cursor.execute(Terms.INSERT_DOCUMENT, [i, uid, 1 if i in self.deletes else 0, self.lengths[i]])
self.cursor.execute(Terms.INSERT_DOCUMENT, [i, serializer.savebytes(uid), 1 if i in self.deletes else 0, self.lengths[i]])
# Temporary database
if not self.path:
@@ -438,6 +454,7 @@ class Terms:
Expands a list of terms using the following term expansion rules.
- Asterisks (*) for wildcard expressions
- In wildcard expressions, SQL LIKE % and _ remain wildcards; backslash escapes literal characters
Args:
terms: list of terms
@@ -548,6 +565,9 @@ class Terms:
# Update scores
scores[uids] += freq * weights
# Common term scores can restore deleted candidates; clear them after merging.
scores[self.deletes] = 0
def candidates(self, scores, topn):
"""
Gets the topn scored candidates. This method ignores deleted documents.
+13 -3
View File
@@ -96,16 +96,23 @@ class Vectors:
model
"""
# Build a cache key from settings that change the loaded model when a configuration is present.
if self.config:
config = {"path": path, "method": self.config.get("method"), "vectors": self.config.get("vectors", {})}
key = json.dumps(config, sort_keys=True, default=str)
else:
key = path
# Check if model is cached
if self.models and path in self.models:
return self.models[path]
if self.models and key in self.models:
return self.models[key]
# Create new model
model = self.loadmodel(path)
# Store model in cache
if self.models is not None and path:
self.models[path] = model
self.models[key] = model
return model
@@ -378,6 +385,9 @@ class Vectors:
embeddings = self.encode(data, category)
if embeddings is not None:
if embeddings.ndim != 2:
raise ValueError(f"Expected 2 dimensional vectors, produced {embeddings.ndim} dimensions")
# Truncate embeddings, if necessary
if self.dimensionality and self.dimensionality < embeddings.shape[1]:
embeddings = self.truncate(embeddings)
@@ -11,6 +11,7 @@ from .litellm import LiteLLM
from .litert import LiteRT
from .llama import LlamaCpp
from .m2v import Model2Vec
from .onnx import ONNX
from .sbert import STVectors
from .words import WordVectors
@@ -57,6 +58,10 @@ class VectorsFactory:
if method == "model2vec":
return Model2Vec(config, scoring, models)
# ONNX vectors
if method == "onnx":
return ONNX(config, scoring, models)
# Sentence Transformers vectors
if method == "sentence-transformers":
return STVectors(config, scoring, models) if config and config.get("path") else None
@@ -99,6 +104,8 @@ class VectorsFactory:
method = "llama.cpp"
elif Model2Vec.ismodel(path):
method = "model2vec"
elif ONNX.ismodel(path):
method = "onnx"
elif WordVectors.ismodel(path):
method = "words"
else:
+16 -5
View File
@@ -13,7 +13,7 @@ try:
except ImportError:
LITERT = False
from ...util import Download, Library
from ...util import Download, DownloadError, Library
from ..base import Vectors
@@ -51,12 +51,23 @@ class LiteRT(Vectors):
# Check if this is a local path, otherwise download from the HF Hub
model = path if os.path.exists(path) else Download()(path)
# Also local tokenizer file
tokenizer = os.path.dirname(path) + "/" + "tokenizer.json"
tokenizer = tokenizer if os.path.exists(tokenizer) else Download()(tokenizer)
# Derive tokenizer file path
tokenizer = self.config.get("tokenizer")
if not tokenizer:
tokenizer = os.path.dirname(path) + "/" + "tokenizer.json"
try:
tokenizer = tokenizer if os.path.exists(tokenizer) else Download()(tokenizer)
# Model is in a subdirectory of a HF Hub repo, read the tokenizer from the repo root
except DownloadError:
# Local models don't have a HF Hub repo root
if os.path.exists(path):
raise
tokenizer = Download()("/".join(path.split("/")[:2]), "tokenizer.json")
# Load tokenizer and model
tokenizer = Tokenizer.from_file(tokenizer)
tokenizer = Tokenizer.from_file(tokenizer) if os.path.exists(tokenizer) else Tokenizer.from_pretrained(tokenizer)
model = CompiledModel.from_file(
model,
HardwareAccelerator.GPU | HardwareAccelerator.NPU | HardwareAccelerator.CPU if self.config.get("gpu", True) else HardwareAccelerator.CPU,
+2 -2
View File
@@ -41,8 +41,8 @@ class Model2Vec(Vectors):
config = json.load(f)
return config.get("model_type") == "model2vec"
# Ignore this error - invalid repo or directory
except DownloadError:
# Ignore invalid repo/directory and when HF Hub is not available
except (DownloadError, ImportError):
pass
return False
+170
View File
@@ -0,0 +1,170 @@
"""
ONNX module
"""
import os
# Conditional import
try:
import onnxruntime as ort
from tokenizers import Tokenizer
ONNX_RUNTIME = True
except ImportError:
ONNX_RUNTIME = False
from ...util import Download, DownloadError, Library
from ..base import Vectors
# Core library imports
np = Library().numpy()
class ONNX(Vectors):
"""
Builds vectors using ONNX Runtime.
"""
@staticmethod
def ismodel(path):
"""
Checks if path is an ONNX model.
Args:
path: input path
Returns:
True if this is an ONNX model, False otherwise
"""
return isinstance(path, str) and path.lower().endswith(".onnx")
def __init__(self, config, scoring, models):
# Check before parent constructor since it calls loadmodel
if not ONNX_RUNTIME:
raise ImportError('onnxruntime is not available - install "vectors" extra to enable')
super().__init__(config, scoring, models)
def loadmodel(self, path):
# Check if this is a local path, otherwise download from the HF Hub
model = path if os.path.exists(path) else Download()(path)
# Create ONNX session
session = ort.InferenceSession(model, ort.SessionOptions(), self.providers())
# Load tokenizer. ONNX supports dynamic shapes, so pad to the longest sequence in each batch
tokenizer = self.loadtokenizer(path)
tokenizer.enable_padding()
maxlength = self.config.get("maxlength")
if maxlength:
tokenizer.enable_truncation(max_length=maxlength)
return (session, tokenizer)
def loadtokenizer(self, path):
"""
Loads the tokenizer for a model. Reads the `tokenizer` configuration option when set,
otherwise falls back to a tokenizer.json file stored alongside the model or at the root of its HF Hub repo.
Args:
path: model path
Returns:
Tokenizer
"""
# Derive tokenizer file path
tokenizer = self.config.get("tokenizer")
if not tokenizer:
tokenizer = os.path.dirname(path) + "/" + "tokenizer.json"
try:
tokenizer = tokenizer if os.path.exists(tokenizer) else Download()(tokenizer)
# Model is in a subdirectory of a HF Hub repo, read the tokenizer from the repo root
except DownloadError:
# Local models don't have a HF Hub repo root
if os.path.exists(path):
raise
tokenizer = Download()("/".join(path.split("/")[:2]), "tokenizer.json")
# Local tokenizer file vs a model id resolved through the HF Hub
return Tokenizer.from_file(tokenizer) if os.path.exists(tokenizer) else Tokenizer.from_pretrained(tokenizer)
def providers(self):
"""
Returns a list of available and usable providers.
Returns:
list of available and usable providers
"""
# Prefer the CUDA provider when it's available, it requires onnxruntime-gpu
if self.config.get("gpu", True) and "CUDAExecutionProvider" in ort.get_available_providers():
return ["CUDAExecutionProvider", "CPUExecutionProvider"]
return ["CPUExecutionProvider"]
def encode(self, data, category=None):
# Unpack model
session, tokenizer = self.model
results = []
for start in range(0, len(data), self.encodebatch):
batch = data[start : start + self.encodebatch]
inputs, masks = self.inputs(session, tokenizer.encode_batch(batch))
# Run model and pool the outputs into a single vector per input
results.append(self.pool(session.run(None, inputs)[0], masks))
return np.vstack(results)
def inputs(self, session, encoding):
"""
Builds model inputs from a tokenized batch. Only inputs declared by the model are set,
since not every exported model takes token_type_ids.
Args:
session: inference session
encoding: tokenized batch
Returns:
(model inputs, attention masks)
"""
masks = np.array([x.attention_mask for x in encoding], dtype=np.int64)
available = {x.name for x in session.get_inputs()}
features = {
"input_ids": np.array([x.ids for x in encoding], dtype=np.int64),
"attention_mask": masks,
"token_type_ids": np.array([x.type_ids for x in encoding], dtype=np.int64),
}
return {name: value for name, value in features.items() if name in available}, masks
def pool(self, outputs, masks):
"""
Builds a single vector per input. Token-level outputs are mean pooled with the attention
mask, outputs that are already pooled are returned as is.
Args:
outputs: model outputs
masks: attention masks
Returns:
embeddings
"""
# Model already returns a vector per input
if outputs.ndim == 2:
return outputs
# Mean pooling, ignoring padding tokens
masks = np.expand_dims(masks, -1).astype(outputs.dtype)
return np.sum(outputs * masks, axis=1) / np.clip(masks.sum(axis=1), a_min=1e-9, a_max=None)
+2 -2
View File
@@ -97,8 +97,8 @@ class WordVectors(Vectors):
config = json.load(f)
return config.get("model_type") == "staticvectors"
# Ignore this error - invalid repo or directory
except DownloadError:
# Ignore invalid repo/directory and when HF Hub is not available
except (DownloadError, ImportError):
pass
return False
+6
View File
@@ -2,6 +2,8 @@
SparseVectors module
"""
import os
# Conditional import
try:
from scipy.sparse import csr_matrix, vstack
@@ -56,6 +58,10 @@ class SparseVectors(Vectors):
data = self.loadembeddings(queue)
embeddings = vstack((embeddings, data)) if embeddings is not None else data
# Remove temporary file (if checkpointing is disabled)
if not checkpoint:
os.remove(stream)
# Return sparse array
return (ids, dimensions, embeddings)
+1 -1
View File
@@ -3,4 +3,4 @@ Version strings
"""
# Current version tag
__version__ = "9.13.0"
__version__ = "9.15.0"
+5 -1
View File
@@ -24,16 +24,20 @@ class WorkflowFactory:
Workflow
"""
# Copy configuration before resolving tasks
config = config.copy()
# Resolve workflow tasks
tasks = []
for tconfig in config["tasks"]:
tconfig = tconfig.copy()
task = tconfig.pop("task") if "task" in tconfig else ""
tasks.append(TaskFactory.create(tconfig, task))
config["tasks"] = tasks
if "stream" in config:
sconfig = config["stream"]
sconfig = config["stream"].copy()
task = sconfig.pop("task") if "task" in sconfig else "stream"
config["stream"] = TaskFactory.create(sconfig, task)
+20 -3
View File
@@ -6,6 +6,8 @@ import logging
import re
import types
from collections.abc import Iterator
# Core library imports
from ...util import Library
@@ -265,14 +267,21 @@ class Task:
Executes action(s) on elements.
Args:
elements: list of data elements
elements: iterable data elements
executor: execute instance, enables concurrent task actions
Returns:
transformed data elements
Raises:
ValueError: if iterator inputs are passed to a multi-action task
"""
if self.action:
# Reject single-pass inputs without consuming or buffering them.
if len(self.action) > 1 and isinstance(elements, Iterator):
raise ValueError("Iterator inputs are not supported for multi-action tasks")
# Run actions
outputs = []
for x, action in enumerate(self.action):
@@ -359,7 +368,14 @@ class Task:
return outputs
if self.merge == "vstack":
return self.vstack(outputs)
results = self.vstack(outputs)
if isinstance(results, np.ndarray) or torch.is_tensor(results):
size = len(self.action)
results = [OneToMany(list(results[x : x + size])) for x in range(0, len(results), size)]
return results
if self.merge == "concat":
return self.concat(outputs)
@@ -474,7 +490,8 @@ class Task:
list of concat outputs
"""
return [". ".join([str(y) for y in x if y]) for x in self.hstack(outputs)]
# Only skip empty values: 0 and False are data, as the tabular pipeline's concat already treats them
return [". ".join([str(y) for y in x if y is not None and y != ""]) for x in self.hstack(outputs)]
class OneToMany:
@@ -47,6 +47,9 @@ class TaskFactory:
Task
"""
# Copy configuration before binding arguments
config = config.copy()
# Create lambda function if additional arguments present
if "args" in config:
args = config.pop("args")
+4 -4
View File
@@ -44,11 +44,11 @@ class RetrieveTask(Task):
self.safeoutput = SafeOpen(safeopen=directory if safeopen else safeopen, allowurl=False)
def prepare(self, element):
# Extract file path from URL
path = urlparse(element).path
# Validate input element
url, _ = self.safeinput.valid(element)
url, exists = self.safeinput.valid(element)
# Preserve local filenames; only parse remote inputs as URLs
path = url if exists else urlparse(element).path
if url:
if self.flatten:
# Flatten directory structure (default)
+16 -5
View File
@@ -19,7 +19,7 @@ class ServiceTask(Task):
Task to runs requests against remote service urls.
"""
def register(self, url=None, method=None, params=None, batch=True, extract=None):
def register(self, url=None, method=None, params=None, batch=True, extract=None, timeout=30):
"""
Adds service parameters to task. Checks if required dependencies are installed.
@@ -29,6 +29,7 @@ class ServiceTask(Task):
params: default query parameters
batch: if True, all elements are passed in a single batch request, otherwise a service call is executed per element
extract: list of sections to extract from response
timeout: request timeout in seconds, defaults to 30
"""
if not XML_TO_DICT:
@@ -43,6 +44,9 @@ class ServiceTask(Task):
# If True, all elements are passed in a single batch request, otherwise a service call is executed per element
self.batch = batch
# Request timeout in seconds. Prevents a slow or unresponsive endpoint from blocking the workflow indefinitely.
self.timeout = timeout
# Save sections to extract. Supports both a single string and a hierarchical list of sections.
self.extract = extract
if self.extract:
@@ -50,7 +54,10 @@ class ServiceTask(Task):
def execute(self, elements, executor=None):
if self.batch:
elements = self.request(elements)
response = self.request(elements)
# Empty response body returns None for each element, same as batch=False
elements = [None] * len(elements) if response is None else response
else:
elements = [self.request(element) for element in elements]
@@ -83,12 +90,16 @@ class ServiceTask(Task):
# Run request
if self.method and self.method.lower() == "get":
response = requests.get(self.url, params=params)
response = requests.get(self.url, params=params, timeout=self.timeout)
else:
response = requests.post(self.url, json=params)
response = requests.post(self.url, json=params, timeout=self.timeout)
# Empty response body (e.g. 204 No Content), nothing to parse
if not response.content:
return None
# Parse data based on content-type
mimetype = response.headers["Content-Type"].split(";")[0]
mimetype = response.headers.get("Content-Type", "").split(";")[0]
if mimetype.lower().endswith("xml"):
data = xmltodict.parse(response.text)
else:
+2 -2
View File
@@ -54,8 +54,8 @@ class StorageTask(Task):
outputs = []
for element in elements:
if self.matches(element):
# Get directory listing and run actions
outputs.extend(super().__call__(self.list(element), executor))
# Unpack tagged input, list container and re-tag each listing to match input format
outputs.extend(self.pack(element, result) for result in super().__call__(self.list(self.upack(element, True)), executor))
else:
outputs.append(element)
+2 -3
View File
@@ -37,7 +37,7 @@ class TemplateTask(Task):
def prepare(self, element):
# Check if element matches any processing rules
match = self.match(element)
if match:
if match is not None:
return match
# Apply template processing, if applicable
@@ -109,8 +109,7 @@ class RagTask(TemplateTask):
params.pop("query", None)
params["text"] = params.pop("question")
element["question"] = super().prepare(params)
return element
return {**element, "question": super().prepare(params)}
# Default mode is to use element text for both query and question
return {"query": element, "question": super().prepare(element)}
+73 -1
View File
@@ -6,6 +6,7 @@ import os
import tempfile
import unittest
from threading import Thread
from unittest.mock import patch
from datetime import datetime
@@ -13,7 +14,7 @@ from datetime import datetime
from smolagents import CodeAgent, PythonInterpreterTool
from txtai.agent import Agent
from txtai.agent.tool import ToolFactory
from txtai.agent.tool import BashTool, ToolFactory
from txtai.embeddings import Embeddings
# agents.md content
@@ -189,6 +190,77 @@ class TestAgent(unittest.TestCase):
self.assertEqual(tool.name, "bash")
self.assertIsNot(tool, ToolFactory.default("bash"))
def testToolsBashStdin(self):
"""
Test default bash tool doesn't block reading stdin
"""
tool = ToolFactory.default("bash")
# Replace stdin with a pipe that never reaches EOF, simulating a long-lived
# parent process (i.e. an API service) that keeps stdin open
read, write = os.pipe()
stdin = os.dup(0)
result = {}
try:
os.dup2(read, 0)
# `cat` with no file arguments reads stdin - run in a thread to avoid blocking the test suite
thread = Thread(target=lambda: result.update(output=tool(["cat"])), daemon=True)
thread.start()
thread.join(timeout=30)
finally:
os.dup2(stdin, 0)
for descriptor in (stdin, read, write):
os.close(descriptor)
# Command must return and not block forever reading stdin
self.assertFalse(thread.is_alive())
self.assertEqual(result["output"], "")
def testToolsBashTimeout(self):
"""
Test bash tool commands are stopped once they exceed the timeout
"""
tool = BashTool(allowed=["sleep"], timeout=1)
self.assertIn("timed out", tool(["sleep", "30"]))
# Timeout is disabled when set to None
self.assertIsNone(BashTool(timeout=None).timeout)
def testToolsBashAllowedEmpty(self):
"""
Test an empty allowed commands list permits no commands
"""
# An empty allow list is an explicit choice and must be honored, not replaced by the defaults
self.assertEqual(BashTool(allowed=[]).allowed, [])
# No command runs when nothing is allowed
self.assertIsNone(BashTool(allowed=[])(["cat", "file.txt"]))
# The defaults still apply when allowed isn't set, including an explicit None
self.assertEqual(BashTool().allowed, ["cat", "cut", "diff", "grep", "head", "ls", "tail"])
self.assertEqual(BashTool(allowed=None).allowed, ["cat", "cut", "diff", "grep", "head", "ls", "tail"])
@patch("subprocess.run")
def testToolsBashEncoding(self, run):
"""
Test bash tool decodes subprocess output as UTF-8 instead of the locale encoding
"""
run.return_value.stdout = "output"
tool = BashTool(allowed=["cat"])
tool(["cat", "file.txt"])
# Subprocess output must be decoded as UTF-8, not left to the locale's default encoding
self.assertEqual(run.call_args.kwargs.get("encoding"), "utf-8")
def testToolsEmbeddings(self):
"""
Test adding Embeddings as a tool
-707
View File
@@ -1,707 +0,0 @@
"""
Dense ANN module tests
"""
import os
import platform
import sys
import tempfile
import unittest
from unittest.mock import patch
import numpy as np
from sqlalchemy.dialects.postgresql import BIT
from sqlalchemy.ext.compiler import compiles
from txtai.ann import ANNFactory, ANN
from txtai.serialize import SerializeFactory
# pylint: disable=R0904
class TestDense(unittest.TestCase):
"""
Dense ANN tests.
"""
def testAnnoy(self):
"""
Test Annoy backend
"""
self.runTests("annoy", None, False)
def testAnnoyCustom(self):
"""
Test Annoy backend with custom settings
"""
# Test with custom settings
self.runTests("annoy", {"annoy": {"ntrees": 2, "searchk": 1}}, False)
def testCustomBackend(self):
"""
Test resolving a custom backend
"""
self.runTests("txtai.ann.Faiss")
def testCustomBackendInvalid(self):
"""
Test resolving an invalid backend
"""
with self.assertRaises(ImportError):
ANNFactory.create({"backend": "pprint.pprint"})
def testCustomBackendNotFound(self):
"""
Test resolving an unresolvable backend
"""
with self.assertRaises(ImportError):
ANNFactory.create({"backend": "notfound.ann"})
def testFaiss(self):
"""
Test Faiss backend
"""
self.runTests("faiss")
def testFaissBinary(self):
"""
Test Faiss backend with a binary hash index
"""
ann = ANNFactory.create({"backend": "faiss", "quantize": 1, "dimensions": 240 * 8, "faiss": {"components": "BHash32"}})
# Generate and index dummy data
data = np.random.rand(100, 240).astype(np.uint8)
ann.index(data)
# Generate query vector and test search
query = np.random.rand(240).astype(np.uint8)
self.assertGreater(ann.search(np.array([query]), 1)[0][0][1], 0)
def testFaissCustom(self):
"""
Test Faiss backend with custom settings
"""
# Test with custom settings
self.runTests("faiss", {"faiss": {"nprobe": 2, "components": "PCA16,IDMap,SQ8", "sample": 1.0}}, False)
self.runTests("faiss", {"faiss": {"components": "IVF,SQ8"}}, False)
@patch("platform.system")
def testFaissMacOS(self, system):
"""
Test Faiss backend with macOS
"""
# Run test
system.return_value = "Darwin"
# pylint: disable=C0415, W0611
# Force reload of class
name = "txtai.ann.dense.faiss"
module = sys.modules[name]
del sys.modules[name]
import txtai.ann.dense.faiss
# Run tests
self.runTests("faiss")
# Restore original module
sys.modules[name] = module
@unittest.skipIf(os.name == "nt", "mmap not supported on Windows")
def testFaissMmap(self):
"""
Test Faiss backend with mmap enabled
"""
# Test to with mmap enabled
self.runTests("faiss", {"faiss": {"mmap": True}}, False)
def testFaissNprobeClamp(self):
"""
Test Faiss default nprobe clamp
"""
ann, _ = self.faissmodel(5001, {"sample": 0.01})
self.assertGreater(round(ann.cells(ann.count()) / 16), ann.backend.nlist)
self.assertEqual(ann.nprobe(), ann.backend.nlist)
def testFaissNprobeConfigured(self):
"""
Test configured Faiss nprobe values
"""
for nprobe in [1, 3]:
with self.subTest(nprobe=nprobe):
ann, _ = self.faissmodel(100, {"components": "IVF2,Flat", "nprobe": nprobe})
self.assertEqual(ann.nprobe(), nprobe)
def testFaissNprobeResults(self):
"""
Test Faiss default nprobe clamp results
"""
ann, rng = self.faissmodel(5001, {"sample": 0.01})
queries = rng.random((10, 8), dtype=np.float32)
self.normalize(queries)
default = ann.search(queries, 5)
ann.config["faiss"]["nprobe"] = ann.backend.nlist
full = ann.search(queries, 5)
self.assertEqual(default, full)
def testFaissNprobeSmall(self):
"""
Test Faiss default nprobe clamp on a small index
"""
ann, _ = self.faissmodel(100, {"components": "IVF2,Flat"})
self.assertEqual(ann.backend.nlist, 2)
self.assertEqual(ann.nprobe(), ann.backend.nlist)
def testFaissNprobeUnchanged(self):
"""
Test Faiss default nprobe without a clamp
"""
ann, _ = self.faissmodel(5001, {"components": "IVF16,Flat"})
expected = round(ann.cells(ann.count()) / 16)
self.assertLessEqual(expected, ann.backend.nlist)
self.assertEqual(ann.nprobe(), expected)
def testGGML(self):
"""
Test GGML backend
"""
self.runTests("ggml")
def testGGMLQuantization(self):
"""
Test GGML backend with quantization enabled
"""
ann = ANNFactory.create({"backend": "ggml", "ggml": {"quantize": "Q4_0"}})
# Generate and index dummy data
data = np.random.rand(100, 256).astype(np.float32)
ann.index(data)
# Test save and load
index = os.path.join(tempfile.gettempdir(), "ggml.q4_0.v1")
ann.save(index)
ann.load(index)
# Generate query vector and test search
query = np.random.rand(256).astype(np.float32)
self.normalize(query)
self.assertGreater(ann.search(np.array([query]), 1)[0][0][1], 0)
# Validate count
self.assertEqual(ann.count(), 100)
# Test delete
ann.delete([0])
self.assertEqual(ann.count(), 99)
# Save updated index with deletes and reload
index = os.path.join(tempfile.gettempdir(), "ggml.q4_0.v2")
ann.save(index)
ann.load(index)
ann.index(data)
def testGGMLInvalid(self):
"""
Test invalid GGML configurations
"""
data = np.random.rand(100, 240).astype(np.float32)
with self.assertRaises(ValueError):
ann = ANNFactory.create({"backend": "ggml", "ggml": {"quantize": "NOEXIST", "gpu": False}})
ann.index(data)
with self.assertRaises(ValueError):
ann = ANNFactory.create({"backend": "ggml", "ggml": {"quantize": "Q4_K"}})
ann.index(data)
def testHnsw(self):
"""
Test Hnswlib backend
"""
self.runTests("hnsw")
def testHnswCustom(self):
"""
Test Hnswlib backend with custom settings
"""
# Test with custom settings
self.runTests("hnsw", {"hnsw": {"efconstruction": 100, "m": 8, "randomseed": 0, "efsearch": 15}})
def testMilvus(self):
"""
Test milvus-lite backend
"""
self.runTests("milvus")
def testMilvusCustom(self):
"""
Test milvus-lite backend with custom settings
"""
self.runTests("milvus", {"milvus": {"m": 16}})
# Test invalid file path handled
with self.assertRaises(FileNotFoundError):
ann = ANNFactory.create({"backend": "milvus"})
ann.load("non-exist-path")
def testNotImplemented(self):
"""
Test exceptions for non-implemented methods
"""
ann = ANN({})
self.assertRaises(NotImplementedError, ann.load, None)
self.assertRaises(NotImplementedError, ann.index, None)
self.assertRaises(NotImplementedError, ann.append, None)
self.assertRaises(NotImplementedError, ann.delete, None)
self.assertRaises(NotImplementedError, ann.search, None, None)
self.assertRaises(NotImplementedError, ann.count)
self.assertRaises(NotImplementedError, ann.save, None)
def testNumPy(self):
"""
Test NumPy backend
"""
self.runTests("numpy")
@patch.dict(os.environ, {"ALLOW_PICKLE": "True"})
def testNumPyLegacy(self):
"""
Test NumPy backend with legacy pickled data
"""
serializer = SerializeFactory.create("pickle", allowpickle=True)
# Create output directory
output = os.path.join(tempfile.gettempdir(), "ann.npy")
path = os.path.join(output, "embeddings")
os.makedirs(output, exist_ok=True)
# Generate data and save as pickle
data = np.random.rand(100, 240).astype(np.float32)
serializer.save(data, path)
ann = ANNFactory.create({"backend": "numpy"})
ann.load(path)
# Validate count
self.assertEqual(ann.count(), 100)
def testNumPySafetensors(self):
"""
Test NumPy backend with safetensors storage
"""
ann = ANNFactory.create({"backend": "numpy", "numpy": {"safetensors": True}})
# Generate and index dummy data
data = np.random.rand(100, 240).astype(np.float32)
ann.index(data)
# Test save and load
index = os.path.join(tempfile.gettempdir(), "numpy.safetensors")
ann.save(index)
ann.load(index)
# Generate query vector and test search
query = np.random.rand(240).astype(np.float32)
self.normalize(query)
self.assertGreater(ann.search(np.array([query]), 1)[0][0][1], 0)
# Validate count
self.assertEqual(ann.count(), 100)
def testNumPyQuantizeDelete(self):
"""
Test that deleted rows don't resurface in search results for quantized (hamming) NumPy indexes
"""
ann = ANNFactory.create({"backend": "numpy", "quantize": 1, "dimensions": 4})
# Index uint8 vectors
data = np.array([[1, 0, 0, 0], [0, 255, 0, 0], [0, 0, 255, 0], [0, 0, 0, 255]], dtype=np.uint8)
ann.index(data)
# Delete the first row and search with its (former) vector
ann.delete([0])
results = ann.search(np.array([data[0]]), 4)[0]
# Deleted row must score 0 so the caller's score > 0 filter drops it, same as the dot-product path
self.assertEqual(dict(results).get(0), 0)
self.assertEqual(ann.count(), 3)
@patch("sqlalchemy.orm.Query.limit")
def testPGVector(self, query):
"""
Test PGVector backend
"""
# pylint: disable=W0613
@compiles(BIT, "sqlite")
def compile_bit_sqlite(type_, compiler, **kw):
return "BLOB"
# Generate test record
data = np.random.rand(1, 240).astype(np.float32)
# Mock database query
query.return_value = [(x, -1.0) for x in range(data.shape[0])]
configs = [
("full", {"dimensions": 240}, {}, data),
("half", {"dimensions": 240}, {"precision": "half"}, data),
("binary", {"quantize": 1, "dimensions": 240 * 8}, {}, data.astype(np.uint8)),
]
# Create ANN
for name, config, pgvector, data in configs:
path = os.path.join(tempfile.gettempdir(), f"pgvector.{name}.sqlite")
ann = ANNFactory.create(
{**{"backend": "pgvector", "pgvector": {**{"url": f"sqlite:///{path}", "schema": "txtai"}, **pgvector}}, **config}
)
# Test indexing
ann.index(data)
ann.append(data)
# Validate search results
self.assertEqual(ann.search(data, 1), [[(0, 1.0)]])
# Validate save/load/delete
ann.save(None)
ann.load(None)
# Validate count
self.assertEqual(ann.count(), 2)
# Test delete
ann.delete([0])
self.assertEqual(ann.count(), 1)
# Close ANN
ann.close()
@unittest.skipIf(platform.system() == "Darwin", "SQLite extensions not supported on macOS")
def testSQLite(self):
"""
Test SQLite backend
"""
self.runTests("sqlite")
@unittest.skipIf(platform.system() == "Darwin", "SQLite extensions not supported on macOS")
def testSQLiteCustom(self):
"""
Test SQLite backend with custom settings
"""
# Test with custom settings
self.runTests("sqlite", {"sqlite": {"quantize": 1}})
self.runTests("sqlite", {"sqlite": {"quantize": 8}})
# Test saving to a new path
model = self.backend("sqlite")
expected = model.count() - 1
# Test save variations
index = os.path.join(tempfile.gettempdir(), "ann.sqlite")
new = os.path.join(tempfile.gettempdir(), "ann.sqlite.new")
# Save new
model.save(index)
# Save to same path
model.save(index)
# Delete id
model.delete([0])
# Save to another path
model.load(index)
model.save(new)
self.assertEqual(model.count(), expected)
def testTorch(self):
"""
Test Torch backend
"""
self.runTests("torch")
@unittest.skipIf(platform.system() == "Darwin", "Torch quantization not supported on macOS")
def testTorchQuantization(self):
"""
Test Torch backend with quantization enabled
"""
for qtype in ["fp4", "nf4", "int8"]:
ann = ANNFactory.create({"backend": "torch", "torch": {"quantize": {"type": qtype}}})
# Generate and index dummy data
data = np.random.rand(100, 240).astype(np.float32)
ann.index(data)
# Test save and load
index = os.path.join(tempfile.gettempdir(), f"{qtype}.safetensors")
ann.save(index)
ann.load(index)
# Generate query vector and test search
query = np.random.rand(240).astype(np.float32)
self.normalize(query)
self.assertGreater(ann.search(np.array([query]), 1)[0][0][1], 0)
# Validate count
self.assertEqual(ann.count(), 100)
# Test delete
ann.delete([0])
self.assertEqual(ann.count(), 99)
def testTurboVec(self):
"""
Test turbovec backend
"""
self.runTests("turbovec")
def testZvec(self):
"""
Test zvec backend
"""
self.runTests("zvec")
def testZvecCustom(self):
"""
Test zvec backend with custom settings
"""
self.runTests("zvec", {"zvec": {"m": 16}})
# Test invalid file path handled
with self.assertRaises(FileNotFoundError):
ann = ANNFactory.create({"backend": "zvec"})
ann.load("non-exist-path")
def runTests(self, name, params=None, update=True):
"""
Runs a series of standard backend tests.
Args:
name: backend name
params: additional config parameters
update: If append/delete options should be tested
"""
self.assertEqual(self.backend(name, params).config["backend"], name)
self.assertEqual(self.save(name, params).count(), 10000)
if update:
self.assertEqual(self.append(name, params, 500).count(), 10500)
self.assertEqual(self.delete(name, params, [0, 1]).count(), 9998)
self.assertEqual(self.delete(name, params, [100000]).count(), 10000)
self.assertGreater(self.search(name, params), 0)
self.assertGreater(self.limit(name, params), 0)
def backend(self, name, params=None, length=10000):
"""
Test a backend.
Args:
name: backend name
params: additional config parameters
length: number of rows to generate
Returns:
ANN model
"""
# Generate test data
data = np.random.rand(length, 240).astype(np.float32)
self.normalize(data)
config = {"backend": name, "dimensions": data.shape[1]}
if params:
config.update(params)
model = ANNFactory.create(config)
model.index(data)
return model
def faissmodel(self, length, settings):
"""
Builds a seeded Faiss model.
Args:
length: number of rows to generate
settings: Faiss settings
Returns:
Faiss model and random number generator
"""
rng = np.random.default_rng(0)
data = rng.random((length, 8), dtype=np.float32)
self.normalize(data)
model = ANNFactory.create({"backend": "faiss", "dimensions": data.shape[1], "faiss": settings})
model.index(data)
return model, rng
def append(self, name, params=None, length=500):
"""
Appends new data to index.
Args:
name: backend name
params: additional config parameters
length: number of rows to generate
Returns:
ANN model
"""
# Initial model
model = self.backend(name, params)
# Generate test data
data = np.random.rand(length, 240).astype(np.float32)
self.normalize(data)
model.append(data)
return model
def delete(self, name, params=None, ids=None):
"""
Deletes data from index.
Args:
name: backend name
params: additional config parameters
ids: ids to delete
Returns:
ANN model
"""
# Initial model
model = self.backend(name, params)
model.delete(ids)
return model
def save(self, name, params=None):
"""
Test save/load.
Args:
name: backend name
params: additional config parameters
Returns:
ANN model
"""
model = self.backend(name, params)
# Generate temp file path
index = os.path.join(tempfile.gettempdir(), "ann")
# Save and close index
model.save(index)
model.close()
# Reload index
model.load(index)
return model
def search(self, name, params=None):
"""
Test ANN search.
Args:
name: backend name
params: additional config parameters
Returns:
search results
"""
# Generate ANN index
model = self.backend(name, params)
# Generate query vector
query = np.random.rand(240).astype(np.float32)
self.normalize(query)
# Ensure top result has similarity > 0
return model.search(np.array([query]), 1)[0][0][1]
def limit(self, name, params=None):
"""
Test ANN limit search.
Args:
name: backend name
params: additional config parameters
Returns:
search results
"""
# Generate ANN index
model = self.backend(name, params, 50)
# Generate query vector
query = np.random.rand(240).astype(np.float32)
self.normalize(query)
# Ensure limit being > count doesn't throw an error
return model.search(np.array([query]), 100)[0][0][1]
def normalize(self, embeddings):
"""
Normalizes embeddings using L2 normalization. Operation applied directly on array.
Args:
embeddings: input embeddings matrix
"""
# Calculation is different for matrices vs vectors
if len(embeddings.shape) > 1:
embeddings /= np.linalg.norm(embeddings, axis=1)[:, np.newaxis]
else:
embeddings /= np.linalg.norm(embeddings)
+240
View File
@@ -0,0 +1,240 @@
"""
Base class for ANN Dense tests
"""
import os
import tempfile
import time
import unittest
import numpy as np
from txtai.ann import ANNFactory
class DenseTest(unittest.TestCase):
"""
Base class for ANN Dense tests.
"""
def runTests(self, name, params=None, update=True):
"""
Runs a series of standard backend tests.
Args:
name: backend name
params: additional config parameters
update: If append/delete options should be tested
"""
self.assertEqual(self.backend(name, params).config["backend"], name)
self.assertEqual(self.save(name, params).count(), 10000)
if update:
self.assertEqual(self.append(name, params, 500).count(), 10500)
self.assertEqual(self.delete(name, params, [0, 1]).count(), 9998)
self.assertEqual(self.delete(name, params, [100000]).count(), 10000)
self.assertGreater(self.search(name, params), 0)
self.assertGreater(self.limit(name, params), 0)
def backend(self, name, params=None, length=10000):
"""
Test a backend.
Args:
name: backend name
params: additional config parameters
length: number of rows to generate
Returns:
ANN model
"""
# Generate test data
data = np.random.rand(length, 240).astype(np.float32)
self.normalize(data)
config = {"backend": name, "dimensions": data.shape[1]}
if params:
config.update(params)
model = ANNFactory.create(config)
model.index(data)
return model
def faissmodel(self, length, settings):
"""
Builds a seeded Faiss model.
Args:
length: number of rows to generate
settings: Faiss settings
Returns:
Faiss model and random number generator
"""
rng = np.random.default_rng(0)
data = rng.random((length, 8), dtype=np.float32)
self.normalize(data)
model = ANNFactory.create({"backend": "faiss", "dimensions": data.shape[1], "faiss": settings})
model.index(data)
return model, rng
def append(self, name, params=None, length=500):
"""
Appends new data to index.
Args:
name: backend name
params: additional config parameters
length: number of rows to generate
Returns:
ANN model
"""
# Initial model
model = self.backend(name, params)
# Generate test data
data = np.random.rand(length, 240).astype(np.float32)
self.normalize(data)
model.append(data)
return model
def delete(self, name, params=None, ids=None):
"""
Deletes data from index.
Args:
name: backend name
params: additional config parameters
ids: ids to delete
Returns:
ANN model
"""
# Initial model
model = self.backend(name, params)
model.delete(ids)
return model
def deletenumpy(self, name, params=None):
"""
Test deleting ids passed as a NumPy array. Only the requested rows should be deleted.
Args:
name: backend name
params: additional config parameters
"""
model = self.backend(name, params, 10)
model.delete(np.array([3, 4]))
# Generate query vector
query = np.random.rand(240).astype(np.float32)
self.normalize(query)
self.assertEqual(model.count(), 8)
self.assertEqual(sorted(uid for uid, _ in model.search(np.array([query]), 10)[0]), [0, 1, 2, 5, 6, 7, 8, 9])
def save(self, name, params=None):
"""
Test save/load.
Args:
name: backend name
params: additional config parameters
Returns:
ANN model
"""
model = self.backend(name, params)
# Generate temp file path
index = os.path.join(tempfile.gettempdir(), f"ann.{name}.{round(time.time() * 1000)}")
# Generate query vector
query = np.random.rand(240).astype(np.float32)
self.normalize(query)
# Save index and ensure it's still searchable
model.save(index)
self.assertGreater(model.search(np.array([query]), 1)[0][0][1], 0)
# Close and reload index
model.close()
model.load(index)
# Ensure reloaded index is searchable
self.assertGreater(model.search(np.array([query]), 1)[0][0][1], 0)
return model
def search(self, name, params=None):
"""
Test ANN search.
Args:
name: backend name
params: additional config parameters
Returns:
search results
"""
# Generate ANN index
model = self.backend(name, params)
# Generate query vector
query = np.random.rand(240).astype(np.float32)
self.normalize(query)
# Ensure top result has similarity > 0
return model.search(np.array([query]), 1)[0][0][1]
def limit(self, name, params=None):
"""
Test ANN limit search.
Args:
name: backend name
params: additional config parameters
Returns:
search results
"""
# Generate ANN index
model = self.backend(name, params, 50)
# Generate query vector
query = np.random.rand(240).astype(np.float32)
self.normalize(query)
# Ensure limit being > count doesn't throw an error
return model.search(np.array([query]), 100)[0][0][1]
def normalize(self, embeddings):
"""
Normalizes embeddings using L2 normalization. Operation applied directly on array.
Args:
embeddings: input embeddings matrix
"""
# Calculation is different for matrices vs vectors
if len(embeddings.shape) > 1:
embeddings /= np.linalg.norm(embeddings, axis=1)[:, np.newaxis]
else:
embeddings /= np.linalg.norm(embeddings)
@@ -0,0 +1,26 @@
"""
Annoy ANN module tests
"""
from .base import DenseTest
class TestAnnoy(DenseTest):
"""
Annoy ANN tests.
"""
def testAnnoy(self):
"""
Test Annoy backend
"""
self.runTests("annoy", None, False)
def testAnnoyCustom(self):
"""
Test Annoy backend with custom settings
"""
# Test with custom settings
self.runTests("annoy", {"annoy": {"ntrees": 2, "searchk": 1}}, False)
@@ -0,0 +1,51 @@
"""
Custom ANN module tests
"""
from txtai.ann import ANNFactory, ANN
from .base import DenseTest
class TestCustom(DenseTest):
"""
Custom ANN tests.
"""
def testCustomBackend(self):
"""
Test resolving a custom backend
"""
self.runTests("txtai.ann.Faiss")
def testCustomBackendInvalid(self):
"""
Test resolving an invalid backend
"""
with self.assertRaises(ImportError):
ANNFactory.create({"backend": "pprint.pprint"})
def testCustomBackendNotFound(self):
"""
Test resolving an unresolvable backend
"""
with self.assertRaises(ImportError):
ANNFactory.create({"backend": "notfound.ann"})
def testNotImplemented(self):
"""
Test exceptions for non-implemented methods
"""
ann = ANN({})
self.assertRaises(NotImplementedError, ann.load, None)
self.assertRaises(NotImplementedError, ann.index, None)
self.assertRaises(NotImplementedError, ann.append, None)
self.assertRaises(NotImplementedError, ann.delete, None)
self.assertRaises(NotImplementedError, ann.search, None, None)
self.assertRaises(NotImplementedError, ann.count)
self.assertRaises(NotImplementedError, ann.save, None)

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