From 271c50e225ce2a8f14afaf4820cb5a517b5e4aca Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Wed, 24 Dec 2025 12:25:22 +0200 Subject: [PATCH 1/8] pydantic-ai update --- haiku_rag_slim/pyproject.toml | 2 +- uv.lock | 92 ++++++++++++++++++++++++++--------- 2 files changed, 71 insertions(+), 23 deletions(-) diff --git a/haiku_rag_slim/pyproject.toml b/haiku_rag_slim/pyproject.toml index 5a925745..93c57d76 100644 --- a/haiku_rag_slim/pyproject.toml +++ b/haiku_rag_slim/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "lancedb==0.26.0", "pathspec>=0.12.1", "pydantic>=2.12.5", - "pydantic-ai-slim[openai,fastmcp,logfire,ag-ui]==1.36.0", + "pydantic-ai-slim[openai,fastmcp,logfire,ag-ui]>=1.39.0", "python-dotenv>=1.2.1", "pyyaml>=6.0.3", "rich>=14.2.0", diff --git a/uv.lock b/uv.lock index 3229c4d0..7472a763 100644 --- a/uv.lock +++ b/uv.lock @@ -294,30 +294,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.40.75" +version = "1.42.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/2c/0a6e49612ef9868382ef76292da2dd1dee7c74a0f1ec95323e76f6e2ef4b/boto3-1.40.75.tar.gz", hash = "sha256:a5219a2f397f8616462d7908e696c281f120aa2d8458280ff24f7ddeb2108faf", size = 111629, upload-time = "2025-11-17T21:58:37.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/37/22c82e9d314d769a6eaf600ce5e08357927b5c6a614bfbeb1e7b7e7aa036/boto3-1.42.16.tar.gz", hash = "sha256:811391611db88c8a061f6e6fabbd7ca784ad9de04490a879f091cbaa9de7de74", size = 112834, upload-time = "2025-12-23T20:44:21.286Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/85/2b0ea3ca19447d3a681b59b712a8f7861bfd0bc0129efd8a2da09d272837/boto3-1.40.75-py3-none-any.whl", hash = "sha256:c246fb35d9978b285c5b827a20b81c9e77d52f99c9d175fbd91f14396432953f", size = 139360, upload-time = "2025-11-17T21:58:36.181Z" }, + { url = "https://files.pythonhosted.org/packages/f8/93/78d175e7d40941c4b608a6701a14215aeeb1db58499dbbc40467a6fd6116/boto3-1.42.16-py3-none-any.whl", hash = "sha256:37a43d42aebd06a8f93ee801ea1b7b5181ac42a30869ef403c9dadc160a748e5", size = 140574, upload-time = "2025-12-23T20:44:20.121Z" }, ] [[package]] name = "botocore" -version = "1.40.75" +version = "1.42.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/11/a6a07cbe12e0161063f2dac82bb7a8f48f649b394863315cd6f3149b82ac/botocore-1.40.75.tar.gz", hash = "sha256:bf8b067209fee5a9738800d41852e113b8ebdb01bd7f1e8b4541d55ecdbdb8f3", size = 14475952, upload-time = "2025-11-17T21:58:27.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/eb/d91fb1fb288ba896392d68f89881f5f26bc5b51f8da28697c77f05bc44e8/botocore-1.42.16.tar.gz", hash = "sha256:29ee8555cd5d5023350405387cedcf3fe1c7f02fcb8060bf9e01602487482c25", size = 14914600, upload-time = "2025-12-23T20:44:11.025Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/29/15627031629f27230ee38bc7f55328b310794010c3039f0ecd353c06dc63/botocore-1.40.75-py3-none-any.whl", hash = "sha256:e822004688ca8035c518108e27d5b450d3ab0e0b3a73bcb8b87b80a8e5bd1910", size = 14141572, upload-time = "2025-11-17T21:58:23.896Z" }, + { url = "https://files.pythonhosted.org/packages/a2/98/c7c26ff399994e2b1119cc36027aaae46b9d646a49b70a82c2622e44c94b/botocore-1.42.16-py3-none-any.whl", hash = "sha256:b1f584a0f8645c12e07bf6ec9c18e05221a789f2a9b2d3c6291deb42f8c1c542", size = 14585775, upload-time = "2025-12-23T20:44:08.092Z" }, ] [[package]] @@ -1133,15 +1133,15 @@ http = [ [[package]] name = "genai-prices" -version = "0.0.47" +version = "0.0.49" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/47/f25fb84fa40142699dc54ca294628d600625eb3d90fead103a606b4e999a/genai_prices-0.0.47.tar.gz", hash = "sha256:3b8c514f0ce5818b3944a371861586ed9bfe10d02598e62c350b5bd2916d03c2", size = 54501, upload-time = "2025-11-25T18:38:17.695Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/aa/81f76b90f8d1a7dcd9297bd8bf664927ae2a1efe40fe5d1a8856dc721359/genai_prices-0.0.49.tar.gz", hash = "sha256:a7f98f1537e6f89ed54f1cd8f560806e187033dcb42554fbecd4d635567120c5", size = 57852, upload-time = "2025-12-17T10:47:29.345Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/84/d50c52d0eeadb9dbf7f2f86da9b6257e162b7c6a791f5b1009bae912c103/genai_prices-0.0.47-py3-none-any.whl", hash = "sha256:735e45950d2299276f2c00cd18075b77a124cd24ee58243f236ee29af3210594", size = 57000, upload-time = "2025-11-25T18:38:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1e/1d51238dd164dde10c4e3be6ad2d8f26dd34dd262117c277440e2b5dc7c0/genai_prices-0.0.49-py3-none-any.whl", hash = "sha256:dd3efbebcd865d89cd849793530729e7f7e1ca59d2b17a091ad1aa6aa76daf0d", size = 60433, upload-time = "2025-12-17T10:47:28.3Z" }, ] [[package]] @@ -1389,7 +1389,7 @@ requires-dist = [ { name = "pydantic-ai-slim", extras = ["google"], marker = "extra == 'google'" }, { name = "pydantic-ai-slim", extras = ["groq"], marker = "extra == 'groq'" }, { name = "pydantic-ai-slim", extras = ["mistral"], marker = "extra == 'mistral'" }, - { name = "pydantic-ai-slim", extras = ["openai", "fastmcp", "logfire", "ag-ui"], specifier = "==1.36.0" }, + { name = "pydantic-ai-slim", extras = ["openai", "fastmcp", "logfire", "ag-ui"], specifier = ">=1.39.0" }, { name = "pydantic-ai-slim", extras = ["vertexai"], marker = "extra == 'vertexai'" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -3385,7 +3385,7 @@ email = [ [[package]] name = "pydantic-ai-slim" -version = "1.36.0" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "genai-prices" }, @@ -3396,9 +3396,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/ba/5da826cabf9f8608ab7ad2f0fd29041bdd470454e5063591e303ca2afb9a/pydantic_ai_slim-1.36.0.tar.gz", hash = "sha256:f5845b4dbadb2b751ef67f192db786b51fa7190eee7c8dd8ad4c56877c99e2b4", size = 348985, upload-time = "2025-12-19T00:54:32.992Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/cb/542ad43e06da09104ef3443556e629d9aa260f9d584da8f7a410fb3a07e5/pydantic_ai_slim-1.39.0.tar.gz", hash = "sha256:e8cea9fc8f6149347c3e1d489b0ed2d541b4789e0583819f116284145d22fa69", size = 368962, upload-time = "2025-12-24T03:34:11.306Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/a5/2315cf9941b32b8f34ff702edd83f017aac716ad5547be28fe06ef9d49d3/pydantic_ai_slim-1.36.0-py3-none-any.whl", hash = "sha256:6b04361b12b652bda0f447f948d8920fe223423507c8ad42e9b4a7ba6638dcd9", size = 455289, upload-time = "2025-12-19T00:54:25.144Z" }, + { url = "https://files.pythonhosted.org/packages/2a/df/86381632be07b7df2e8e5880a1f18c6ee98122adf5848d329fee239a03b2/pydantic_ai_slim-1.39.0-py3-none-any.whl", hash = "sha256:8669d1781eba7713870bf76783e1e853577d5e55eb2986a27d49bc600889aaaf", size = 484906, upload-time = "2025-12-24T03:34:03.179Z" }, ] [package.optional-dependencies] @@ -3432,6 +3432,7 @@ mistral = [ ] openai = [ { name = "openai" }, + { name = "tiktoken" }, ] vertexai = [ { name = "google-auth" }, @@ -3511,7 +3512,7 @@ wheels = [ [[package]] name = "pydantic-evals" -version = "1.36.0" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3521,14 +3522,14 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/df/5426c81f0798b29fcb43be8e2fcec8de6fd89ae21f7610f9877bf6565dbe/pydantic_evals-1.36.0.tar.gz", hash = "sha256:6407693f111fdd60b1c27587160878dd5b21d179da8f949481df0ede75c0053a", size = 47177, upload-time = "2025-12-19T00:54:34.568Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/20/ec455c7d32fde2022805870daf78581c9c493a4fcae6f32204fae5025658/pydantic_evals-1.39.0.tar.gz", hash = "sha256:6f8a754ca84afff3f2b2de9802fb0e12f69d9fc0a0411e2f7c9709fc09fb43b3", size = 47179, upload-time = "2025-12-24T03:34:12.477Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/f5/51363df66f79e6ecb502a518b5ad75362cbbe83adacd1247c93dad54a979/pydantic_evals-1.36.0-py3-none-any.whl", hash = "sha256:bc4b523d437fc20d1e05ed34fd29b6e0cb2b9516cf8db8aa0a408937650efafe", size = 56347, upload-time = "2025-12-19T00:54:26.915Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c1/6d43ecd3f7acb78a3f683178d40008d486c08583ca848891f000d62c142e/pydantic_evals-1.39.0-py3-none-any.whl", hash = "sha256:18470ade5fea15d17911a517e37ea98700702d9ba011ef2facb707e87eae0564", size = 56347, upload-time = "2025-12-24T03:34:05.111Z" }, ] [[package]] name = "pydantic-graph" -version = "1.36.0" +version = "1.39.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -3536,9 +3537,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/0b/44bb922496ee99e9fc85fcca57ea25944bbe4e0b4205bdb8b8457e98e4f2/pydantic_graph-1.36.0.tar.gz", hash = "sha256:6f7309bac6841fec44eab5067413b35300804781d41d705338e140d43c65f99d", size = 58451, upload-time = "2025-12-19T00:54:35.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/d5/2f45d1fd2ae0ba89b5a70b3bec8c2e910c4891fe0ed7e4fc896ca7e126a0/pydantic_graph-1.39.0.tar.gz", hash = "sha256:08c6f349dbbade6f4cdaaed02de4e8d75b9a37d44f8238e40a14f94f6a31761f", size = 58453, upload-time = "2025-12-24T03:34:13.766Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/df/5897a8c8367da6f5efa26a67f13f127723e006f69619ca2a8b648d066206/pydantic_graph-1.36.0-py3-none-any.whl", hash = "sha256:3337a4908056ff84e596425191fed04abc7fa381bc3b78e739470e04ead4cba1", size = 72327, upload-time = "2025-12-19T00:54:28.892Z" }, + { url = "https://files.pythonhosted.org/packages/65/e2/719de1af767863359278e8b69c538dba9a7dbd19bb94111206e57ca34648/pydantic_graph-1.39.0-py3-none-any.whl", hash = "sha256:e0f89fc2c7ab111ae5f38dd2d88c5d26a0784eaabe95735c2b4087b0b512cc2d", size = 72327, upload-time = "2025-12-24T03:34:06.476Z" }, ] [[package]] @@ -4217,14 +4218,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, ] [[package]] @@ -4523,6 +4524,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/0e/2c3c2972ee810595089d85a51107f45e41c0642f645c24675951c69fd648/textual_image-0.8.4-py3-none-any.whl", hash = "sha256:0f0256993348f5af619c930a4839ea190525a22a56e8d69e1cf0f8e32d59fa3b", size = 109608, upload-time = "2025-09-02T19:09:10.707Z" }, ] +[[package]] +name = "tiktoken" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/00/61/441588ee21e6b5cdf59d6870f86beb9789e532ee9718c251b391b70c68d6/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3", size = 1050802, upload-time = "2025-10-06T20:22:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/1f/05/dcf94486d5c5c8d34496abe271ac76c5b785507c8eae71b3708f1ad9b45a/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160", size = 993995, upload-time = "2025-10-06T20:22:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/a0/70/5163fe5359b943f8db9946b62f19be2305de8c3d78a16f629d4165e2f40e/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa", size = 1128948, upload-time = "2025-10-06T20:22:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/c028aa0babf77315e1cef357d4d768800c5f8a6de04d0eac0f377cb619fa/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be", size = 1151986, upload-time = "2025-10-06T20:22:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5a/886b108b766aa53e295f7216b509be95eb7d60b166049ce2c58416b25f2a/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a", size = 1194222, upload-time = "2025-10-06T20:22:06.265Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/4db272048397636ac7a078d22773dd2795b1becee7bc4922fe6207288d57/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3", size = 1255097, upload-time = "2025-10-06T20:22:07.403Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/45d02e2e0ea2be3a9ed22afc47d93741247e75018aac967b713b2941f8ea/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697", size = 879117, upload-time = "2025-10-06T20:22:08.418Z" }, + { url = "https://files.pythonhosted.org/packages/ce/76/994fc868f88e016e6d05b0da5ac24582a14c47893f4474c3e9744283f1d5/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16", size = 1050309, upload-time = "2025-10-06T20:22:10.939Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b8/57ef1456504c43a849821920d582a738a461b76a047f352f18c0b26c6516/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a", size = 993712, upload-time = "2025-10-06T20:22:12.115Z" }, + { url = "https://files.pythonhosted.org/packages/72/90/13da56f664286ffbae9dbcfadcc625439142675845baa62715e49b87b68b/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27", size = 1128725, upload-time = "2025-10-06T20:22:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/05/df/4f80030d44682235bdaecd7346c90f67ae87ec8f3df4a3442cb53834f7e4/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb", size = 1151875, upload-time = "2025-10-06T20:22:14.559Z" }, + { url = "https://files.pythonhosted.org/packages/22/1f/ae535223a8c4ef4c0c1192e3f9b82da660be9eb66b9279e95c99288e9dab/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e", size = 1194451, upload-time = "2025-10-06T20:22:15.545Z" }, + { url = "https://files.pythonhosted.org/packages/78/a7/f8ead382fce0243cb625c4f266e66c27f65ae65ee9e77f59ea1653b6d730/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25", size = 1253794, upload-time = "2025-10-06T20:22:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/6cc82a562bc6365785a3ff0af27a2a092d57c47d7a81d9e2295d8c36f011/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f", size = 878777, upload-time = "2025-10-06T20:22:18.036Z" }, + { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, +] + [[package]] name = "tokenizers" version = "0.22.1" From 3141c91023fb1700150bf084e08f41a2728a4c61 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Wed, 24 Dec 2025 12:33:44 +0200 Subject: [PATCH 2/8] Add base_url to ModelConfig and EmbeddingModelConfig. Deprecate vllm and lm_studio configs, now through open ai --- haiku_rag_slim/haiku/rag/config/models.py | 6 ++- haiku_rag_slim/haiku/rag/utils.py | 61 +++++------------------ 2 files changed, 17 insertions(+), 50 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index 7048de89..2e6c5e1e 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -12,6 +12,7 @@ class ModelConfig(BaseModel): Attributes: provider: Model provider (ollama, openai, anthropic, etc.) name: Model name/identifier + base_url: Optional base URL for OpenAI-compatible servers (vLLM, LM Studio, etc.) enable_thinking: Control reasoning behavior (true/false/None for default) temperature: Sampling temperature (0.0 to 1.0+) max_tokens: Maximum tokens to generate @@ -19,6 +20,7 @@ class ModelConfig(BaseModel): provider: str = "ollama" name: str = "gpt-oss" + base_url: str | None = None enable_thinking: bool | None = None temperature: float | None = None @@ -29,14 +31,16 @@ class EmbeddingModelConfig(BaseModel): """Configuration for an embedding model. Attributes: - provider: Model provider (ollama, openai, voyageai, vllm, lm_studio) + provider: Model provider (ollama, openai, voyageai, cohere, sentence-transformers) name: Model name/identifier vector_dim: Vector dimensions produced by the model + base_url: Optional base URL for OpenAI-compatible servers (vLLM, LM Studio, etc.) """ provider: str = "ollama" name: str = "qwen3-embedding:4b" vector_dim: int = 2560 + base_url: str | None = None class StorageConfig(BaseModel): diff --git a/haiku_rag_slim/haiku/rag/utils.py b/haiku_rag_slim/haiku/rag/utils.py index 2b8b3fd8..67c15d6b 100644 --- a/haiku_rag_slim/haiku/rag/utils.py +++ b/haiku_rag_slim/haiku/rag/utils.py @@ -135,11 +135,12 @@ def get_model( model_settings, OpenAIChatModelSettings, model_config ) + # Use model-level base_url if set, otherwise fall back to providers config + base_url = model_config.base_url or f"{app_config.providers.ollama.base_url}/v1" + return OpenAIChatModel( model_name=model, - provider=OllamaProvider( - base_url=f"{app_config.providers.ollama.base_url}/v1" - ), + provider=OllamaProvider(base_url=base_url), settings=model_settings, ) @@ -159,6 +160,14 @@ def get_model( openai_settings, OpenAIChatModelSettings, model_config ) + # Use model-level base_url if set (for vLLM, LM Studio, etc.) + if model_config.base_url: + return OpenAIChatModel( + model_name=model, + provider=OpenAIProvider(base_url=model_config.base_url), + settings=openai_settings, + ) + return OpenAIChatModel(model_name=model, settings=openai_settings) elif provider == "anthropic": @@ -264,52 +273,6 @@ def get_model( return BedrockConverseModel(model_name=model, settings=bedrock_settings) - elif provider == "vllm": - vllm_settings = None - - # Apply thinking control for gpt-oss - if model == "gpt-oss" and model_config.enable_thinking is not None: - if model_config.enable_thinking is False: - vllm_settings = OpenAIChatModelSettings(openai_reasoning_effort="low") - else: - vllm_settings = OpenAIChatModelSettings(openai_reasoning_effort="high") - - vllm_settings = apply_common_settings( - vllm_settings, OpenAIChatModelSettings, model_config - ) - - return OpenAIChatModel( - model_name=model, - provider=OpenAIProvider( - base_url=f"{app_config.providers.vllm.research_base_url or app_config.providers.vllm.qa_base_url}/v1", - api_key="none", - ), - settings=vllm_settings, - ) - - elif provider == "lm_studio": - model_settings = None - - # Apply thinking control for gpt-oss - if model == "gpt-oss" and model_config.enable_thinking is not None: - if model_config.enable_thinking is False: - model_settings = OpenAIChatModelSettings(openai_reasoning_effort="low") - else: - model_settings = OpenAIChatModelSettings(openai_reasoning_effort="high") - - model_settings = apply_common_settings( - model_settings, OpenAIChatModelSettings, model_config - ) - - return OpenAIChatModel( - model_name=model, - provider=OpenAIProvider( - base_url=f"{app_config.providers.lm_studio.base_url}/v1", - api_key="dummy", - ), - settings=model_settings, - ) - else: # For any other provider, use string format and let Pydantic AI handle it return f"{provider}:{model}" From 5a30c197aff2aa794c7a2643d07272cb051a1f2d Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Wed, 24 Dec 2025 12:37:17 +0200 Subject: [PATCH 3/8] Delete obsolete embedders, rewrite get_embedder --- .../haiku/rag/embeddings/__init__.py | 91 +++++++++++++------ haiku_rag_slim/haiku/rag/embeddings/base.py | 25 ----- .../haiku/rag/embeddings/lm_studio.py | 28 ------ haiku_rag_slim/haiku/rag/embeddings/ollama.py | 28 ------ haiku_rag_slim/haiku/rag/embeddings/openai.py | 26 ------ haiku_rag_slim/haiku/rag/embeddings/vllm.py | 29 ------ 6 files changed, 62 insertions(+), 165 deletions(-) delete mode 100644 haiku_rag_slim/haiku/rag/embeddings/base.py delete mode 100644 haiku_rag_slim/haiku/rag/embeddings/lm_studio.py delete mode 100644 haiku_rag_slim/haiku/rag/embeddings/ollama.py delete mode 100644 haiku_rag_slim/haiku/rag/embeddings/openai.py delete mode 100644 haiku_rag_slim/haiku/rag/embeddings/vllm.py diff --git a/haiku_rag_slim/haiku/rag/embeddings/__init__.py b/haiku_rag_slim/haiku/rag/embeddings/__init__.py index 510cf01b..75d39f77 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/__init__.py +++ b/haiku_rag_slim/haiku/rag/embeddings/__init__.py @@ -1,13 +1,40 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, overload + +from pydantic_ai.embeddings import Embedder +from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel +from pydantic_ai.providers.ollama import OllamaProvider +from pydantic_ai.providers.openai import OpenAIProvider from haiku.rag.config import AppConfig, Config -from haiku.rag.embeddings.base import EmbedderBase -from haiku.rag.embeddings.ollama import Embedder as OllamaEmbedder if TYPE_CHECKING: from haiku.rag.store.models.chunk import Chunk +class EmbedderWrapper: + """Wrapper around pydantic-ai Embedder to provide simple embed() interface.""" + + def __init__(self, embedder: Embedder, vector_dim: int): + self._embedder = embedder + self._vector_dim = vector_dim + + @overload + async def embed(self, text: str) -> list[float]: ... + + @overload + async def embed(self, text: list[str]) -> list[list[float]]: ... + + async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: + if isinstance(text, str): + result = await self._embedder.embed_query(text) + return list(result.embeddings[0]) + else: + if not text: + return [] + result = await self._embedder.embed_documents(text) + return [list(e) for e in result.embeddings] + + def contextualize(chunks: list["Chunk"]) -> list[str]: """Prepare chunk content for embedding by adding context. @@ -71,9 +98,8 @@ async def embed_chunks( ] -def get_embedder(config: AppConfig = Config) -> EmbedderBase: - """ - Factory function to get the appropriate embedder based on the configuration. +def get_embedder(config: AppConfig = Config) -> EmbedderWrapper: + """Factory function to get the appropriate embedder based on the configuration. Args: config: Configuration to use. Defaults to global Config. @@ -82,11 +108,29 @@ def get_embedder(config: AppConfig = Config) -> EmbedderBase: An embedder instance configured according to the config. """ embedding_model = config.embeddings.model + provider = embedding_model.provider + model_name = embedding_model.name + vector_dim = embedding_model.vector_dim - if embedding_model.provider == "ollama": - return OllamaEmbedder(embedding_model.name, embedding_model.vector_dim, config) + if provider == "ollama": + # Use model-level base_url if set, otherwise fall back to providers config + base_url = embedding_model.base_url or f"{config.providers.ollama.base_url}/v1" + model = OpenAIEmbeddingModel( + model_name, + provider=OllamaProvider(base_url=base_url), + ) + return EmbedderWrapper(Embedder(model), vector_dim) - if embedding_model.provider == "voyageai": + if provider == "openai": + if embedding_model.base_url: + model = OpenAIEmbeddingModel( + model_name, + provider=OpenAIProvider(base_url=embedding_model.base_url), + ) + return EmbedderWrapper(Embedder(model), vector_dim) + return EmbedderWrapper(Embedder(f"openai:{model_name}"), vector_dim) + + if provider == "voyageai": try: from haiku.rag.embeddings.voyageai import Embedder as VoyageAIEmbedder except ImportError: @@ -95,25 +139,14 @@ def get_embedder(config: AppConfig = Config) -> EmbedderBase: "Please install haiku.rag with the 'voyageai' extra: " "uv pip install haiku.rag[voyageai]" ) - return VoyageAIEmbedder( - embedding_model.name, embedding_model.vector_dim, config + return VoyageAIEmbedder(model_name, vector_dim, config) # type: ignore[return-value] + + if provider == "cohere": + return EmbedderWrapper(Embedder(f"cohere:{model_name}"), vector_dim) + + if provider == "sentence-transformers": + return EmbedderWrapper( + Embedder(f"sentence-transformers:{model_name}"), vector_dim ) - if embedding_model.provider == "openai": - from haiku.rag.embeddings.openai import Embedder as OpenAIEmbedder - - return OpenAIEmbedder(embedding_model.name, embedding_model.vector_dim, config) - - if embedding_model.provider == "vllm": - from haiku.rag.embeddings.vllm import Embedder as VllmEmbedder - - return VllmEmbedder(embedding_model.name, embedding_model.vector_dim, config) - - if embedding_model.provider == "lm_studio": - from haiku.rag.embeddings.lm_studio import Embedder as LMStudioEmbedder - - return LMStudioEmbedder( - embedding_model.name, embedding_model.vector_dim, config - ) - - raise ValueError(f"Unsupported embedding provider: {embedding_model.provider}") + raise ValueError(f"Unsupported embedding provider: {provider}") diff --git a/haiku_rag_slim/haiku/rag/embeddings/base.py b/haiku_rag_slim/haiku/rag/embeddings/base.py deleted file mode 100644 index 6049a840..00000000 --- a/haiku_rag_slim/haiku/rag/embeddings/base.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import overload - -from haiku.rag.config import AppConfig, Config - - -class EmbedderBase: - _model: str = Config.embeddings.model.name - _vector_dim: int = Config.embeddings.model.vector_dim - _config: AppConfig = Config - - def __init__(self, model: str, vector_dim: int, config: AppConfig = Config): - self._model = model - self._vector_dim = vector_dim - self._config = config - - @overload - async def embed(self, text: str) -> list[float]: ... - - @overload - async def embed(self, text: list[str]) -> list[list[float]]: ... - - async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: - raise NotImplementedError( - "Embedder is an abstract class. Please implement the embed method in a subclass." - ) diff --git a/haiku_rag_slim/haiku/rag/embeddings/lm_studio.py b/haiku_rag_slim/haiku/rag/embeddings/lm_studio.py deleted file mode 100644 index 86bf825f..00000000 --- a/haiku_rag_slim/haiku/rag/embeddings/lm_studio.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import overload - -from openai import AsyncOpenAI - -from haiku.rag.embeddings.base import EmbedderBase - - -class Embedder(EmbedderBase): # pragma: no cover - @overload - async def embed(self, text: str) -> list[float]: ... - - @overload - async def embed(self, text: list[str]) -> list[list[float]]: ... - - async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: - client = AsyncOpenAI( - base_url=f"{self._config.providers.lm_studio.base_url}/v1", api_key="dummy" - ) - if not text: - return [] - response = await client.embeddings.create( - model=self._model, - input=text, - ) - if isinstance(text, str): - return response.data[0].embedding - else: - return [item.embedding for item in response.data] diff --git a/haiku_rag_slim/haiku/rag/embeddings/ollama.py b/haiku_rag_slim/haiku/rag/embeddings/ollama.py deleted file mode 100644 index 9fccccc9..00000000 --- a/haiku_rag_slim/haiku/rag/embeddings/ollama.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import overload - -from openai import AsyncOpenAI - -from haiku.rag.embeddings.base import EmbedderBase - - -class Embedder(EmbedderBase): - @overload - async def embed(self, text: str) -> list[float]: ... - - @overload - async def embed(self, text: list[str]) -> list[list[float]]: ... - - async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: - client = AsyncOpenAI( - base_url=f"{self._config.providers.ollama.base_url}/v1", api_key="dummy" - ) - if not text: - return [] - response = await client.embeddings.create( - model=self._model, - input=text, - ) - if isinstance(text, str): - return response.data[0].embedding - else: - return [item.embedding for item in response.data] diff --git a/haiku_rag_slim/haiku/rag/embeddings/openai.py b/haiku_rag_slim/haiku/rag/embeddings/openai.py deleted file mode 100644 index 7660a2e9..00000000 --- a/haiku_rag_slim/haiku/rag/embeddings/openai.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import overload - -from openai import AsyncOpenAI - -from haiku.rag.embeddings.base import EmbedderBase - - -class Embedder(EmbedderBase): - @overload - async def embed(self, text: str) -> list[float]: ... - - @overload - async def embed(self, text: list[str]) -> list[list[float]]: ... - - async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: - client = AsyncOpenAI() - if not text: - return [] - response = await client.embeddings.create( - model=self._model, - input=text, - ) - if isinstance(text, str): - return response.data[0].embedding - else: - return [item.embedding for item in response.data] diff --git a/haiku_rag_slim/haiku/rag/embeddings/vllm.py b/haiku_rag_slim/haiku/rag/embeddings/vllm.py deleted file mode 100644 index 7d521fa2..00000000 --- a/haiku_rag_slim/haiku/rag/embeddings/vllm.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import overload - -from openai import AsyncOpenAI - -from haiku.rag.embeddings.base import EmbedderBase - - -class Embedder(EmbedderBase): # pragma: no cover - @overload - async def embed(self, text: str) -> list[float]: ... - - @overload - async def embed(self, text: list[str]) -> list[list[float]]: ... - - async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: - client = AsyncOpenAI( - base_url=f"{self._config.providers.vllm.embeddings_base_url}/v1", - api_key="dummy", - ) - if not text: - return [] - response = await client.embeddings.create( - model=self._model, - input=text, - ) - if isinstance(text, str): - return response.data[0].embedding - else: - return [item.embedding for item in response.data] From 132b8a36bcad0f1add115b9e11eceeeb9258835a Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Wed, 24 Dec 2025 12:43:11 +0200 Subject: [PATCH 4/8] Differentiate between embedding a query and a document --- .../haiku/rag/embeddings/__init__.py | 30 +++++++-------- .../haiku/rag/embeddings/voyageai.py | 38 +++++++++++-------- .../haiku/rag/store/repositories/chunk.py | 4 +- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/embeddings/__init__.py b/haiku_rag_slim/haiku/rag/embeddings/__init__.py index 75d39f77..8c031aba 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/__init__.py +++ b/haiku_rag_slim/haiku/rag/embeddings/__init__.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, overload +from typing import TYPE_CHECKING from pydantic_ai.embeddings import Embedder from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel @@ -12,27 +12,23 @@ if TYPE_CHECKING: class EmbedderWrapper: - """Wrapper around pydantic-ai Embedder to provide simple embed() interface.""" + """Wrapper around pydantic-ai Embedder with explicit query/document methods.""" def __init__(self, embedder: Embedder, vector_dim: int): self._embedder = embedder self._vector_dim = vector_dim - @overload - async def embed(self, text: str) -> list[float]: ... + async def embed_query(self, text: str) -> list[float]: + """Embed a search query.""" + result = await self._embedder.embed_query(text) + return list(result.embeddings[0]) - @overload - async def embed(self, text: list[str]) -> list[list[float]]: ... - - async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: - if isinstance(text, str): - result = await self._embedder.embed_query(text) - return list(result.embeddings[0]) - else: - if not text: - return [] - result = await self._embedder.embed_documents(text) - return [list(e) for e in result.embeddings] + async def embed_documents(self, texts: list[str]) -> list[list[float]]: + """Embed documents/chunks for indexing.""" + if not texts: + return [] + result = await self._embedder.embed_documents(texts) + return [list(e) for e in result.embeddings] def contextualize(chunks: list["Chunk"]) -> list[str]: @@ -80,7 +76,7 @@ async def embed_chunks( embedder = get_embedder(config) texts = contextualize(chunks) - embeddings = await embedder.embed(texts) + embeddings = await embedder.embed_documents(texts) return [ Chunk( diff --git a/haiku_rag_slim/haiku/rag/embeddings/voyageai.py b/haiku_rag_slim/haiku/rag/embeddings/voyageai.py index 4d6af089..ff6f16b0 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/voyageai.py +++ b/haiku_rag_slim/haiku/rag/embeddings/voyageai.py @@ -1,27 +1,33 @@ try: - from typing import overload - from voyageai.client import Client # type: ignore - from haiku.rag.embeddings.base import EmbedderBase + from haiku.rag.config import AppConfig - class Embedder(EmbedderBase): - @overload - async def embed(self, text: str) -> list[float]: ... + class Embedder: + """VoyageAI embedder with explicit query/document methods.""" - @overload - async def embed(self, text: list[str]) -> list[list[float]]: ... + def __init__(self, model: str, vector_dim: int, config: AppConfig): + self._model = model + self._vector_dim = vector_dim + self._config = config - async def embed(self, text: str | list[str]) -> list[float] | list[list[float]]: + async def embed_query(self, text: str) -> list[float]: + """Embed a search query.""" client = Client() - if not text: + res = client.embed( + [text], model=self._model, input_type="query", output_dtype="float" + ) + return res.embeddings[0] # type: ignore[return-value] + + async def embed_documents(self, texts: list[str]) -> list[list[float]]: + """Embed documents/chunks for indexing.""" + if not texts: return [] - if isinstance(text, str): - res = client.embed([text], model=self._model, output_dtype="float") - return res.embeddings[0] # type: ignore[return-value] - else: - res = client.embed(text, model=self._model, output_dtype="float") - return res.embeddings # type: ignore[return-value] + client = Client() + res = client.embed( + texts, model=self._model, input_type="document", output_dtype="float" + ) + return res.embeddings # type: ignore[return-value] except ImportError: pass diff --git a/haiku_rag_slim/haiku/rag/store/repositories/chunk.py b/haiku_rag_slim/haiku/rag/store/repositories/chunk.py index 40cdadae..b5e7799e 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/chunk.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/chunk.py @@ -245,7 +245,7 @@ class ChunkRepository: # Prepare search query based on search type if search_type == "vector": - query_embedding = await self.embedder.embed(query) + query_embedding = await self.embedder.embed_query(query) vector_query = cast( "LanceVectorQueryBuilder", self.store.chunks_table.search( @@ -260,7 +260,7 @@ class ChunkRepository: results = self.store.chunks_table.search(query, query_type="fts") else: # hybrid (default) - query_embedding = await self.embedder.embed(query) + query_embedding = await self.embedder.embed_query(query) # Create RRF reranker reranker = RRFReranker() # Perform native hybrid search with RRF reranking From 3852e961b9d8fc231b116eeaedbda9b0f324825a Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 26 Dec 2025 11:26:56 +0200 Subject: [PATCH 5/8] Update tests and client for explicit embed_query/embed_documents API --- haiku_rag_slim/haiku/rag/client.py | 2 +- tests/test_embedder.py | 122 ++++++++++++----------------- tests/test_embedder_config.py | 95 +++++++++++----------- tests/test_utils.py | 14 ---- 4 files changed, 93 insertions(+), 140 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/client.py b/haiku_rag_slim/haiku/rag/client.py index 241c569a..338182f9 100644 --- a/haiku_rag_slim/haiku/rag/client.py +++ b/haiku_rag_slim/haiku/rag/client.py @@ -1407,7 +1407,7 @@ class HaikuRAG: # Generate new embeddings using contextualize for consistency texts = contextualize(chunks) - embeddings = await self.chunk_repository.embedder.embed(texts) + embeddings = await self.chunk_repository.embedder.embed_documents(texts) # Build updated records for chunk, embedding in zip(chunks, embeddings): diff --git a/tests/test_embedder.py b/tests/test_embedder.py index 4d5d0bfa..2046212a 100644 --- a/tests/test_embedder.py +++ b/tests/test_embedder.py @@ -3,20 +3,16 @@ import os import numpy as np import pytest -from haiku.rag.config import Config -from haiku.rag.embeddings import contextualize, embed_chunks -from haiku.rag.embeddings.ollama import Embedder as OllamaEmbedder -from haiku.rag.embeddings.openai import Embedder as OpenAIEmbedder -from haiku.rag.embeddings.vllm import Embedder as VLLMEmbedder +from haiku.rag.config import AppConfig, EmbeddingModelConfig, EmbeddingsConfig +from haiku.rag.embeddings import contextualize, embed_chunks, get_embedder from haiku.rag.store.models.chunk import Chunk OPENAI_AVAILABLE = bool(os.getenv("OPENAI_API_KEY")) VOYAGEAI_AVAILABLE = bool(os.getenv("VOYAGE_API_KEY")) -VLLM_EMBEDDINGS_AVAILABLE = bool(Config.providers.vllm.embeddings_base_url) -# Calculate cosine similarity def similarities(embeddings, test_embedding): + """Calculate cosine similarity between embeddings and a test embedding.""" return [ np.dot(embedding, test_embedding) / (np.linalg.norm(embedding) * np.linalg.norm(test_embedding)) @@ -26,35 +22,41 @@ def similarities(embeddings, test_embedding): @pytest.mark.asyncio async def test_ollama_embedder(): - embedder = OllamaEmbedder("mxbai-embed-large", 1024) + """Test Ollama embedder via pydantic-ai.""" + config = AppConfig( + embeddings=EmbeddingsConfig( + model=EmbeddingModelConfig( + provider="ollama", name="mxbai-embed-large", vector_dim=1024 + ) + ) + ) + embedder = get_embedder(config) phrases = [ "I enjoy eating great food.", "Python is my favorite programming language.", "I love to travel and see new places.", ] - # Test batch embedding - embeddings = await embedder.embed(phrases) + # Test batch embedding (documents) + embeddings = await embedder.embed_documents(phrases) assert isinstance(embeddings, list) assert len(embeddings) == 3 assert all(isinstance(emb, list) for emb in embeddings) embeddings = [np.array(emb) for emb in embeddings] + # Test query embedding test_phrase = "I am going for a camping trip." - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[2] test_phrase = "When is dinner ready?" - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[0] test_phrase = "I work as a software developer." - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[1] @@ -62,35 +64,41 @@ async def test_ollama_embedder(): @pytest.mark.asyncio @pytest.mark.skipif(not OPENAI_AVAILABLE, reason="OpenAI API key not available") async def test_openai_embedder(): - embedder = OpenAIEmbedder("text-embedding-3-small", 1536) + """Test OpenAI embedder via pydantic-ai.""" + config = AppConfig( + embeddings=EmbeddingsConfig( + model=EmbeddingModelConfig( + provider="openai", name="text-embedding-3-small", vector_dim=1536 + ) + ) + ) + embedder = get_embedder(config) phrases = [ "I enjoy eating great food.", "Python is my favorite programming language.", "I love to travel and see new places.", ] - # Test batch embedding - embeddings = await embedder.embed(phrases) + # Test batch embedding (documents) + embeddings = await embedder.embed_documents(phrases) assert isinstance(embeddings, list) assert len(embeddings) == 3 assert all(isinstance(emb, list) for emb in embeddings) embeddings = [np.array(emb) for emb in embeddings] + # Test query embedding test_phrase = "I am going for a camping trip." - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[2] test_phrase = "When is dinner ready?" - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[0] test_phrase = "I work as a software developer." - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[1] @@ -98,38 +106,42 @@ async def test_openai_embedder(): @pytest.mark.asyncio @pytest.mark.skipif(not VOYAGEAI_AVAILABLE, reason="VoyageAI API key not available") async def test_voyageai_embedder(): + """Test VoyageAI embedder.""" try: - from haiku.rag.embeddings.voyageai import Embedder as VoyageAIEmbedder - - embedder = VoyageAIEmbedder("voyage-3.5", 1024) + config = AppConfig( + embeddings=EmbeddingsConfig( + model=EmbeddingModelConfig( + provider="voyageai", name="voyage-3.5", vector_dim=1024 + ) + ) + ) + embedder = get_embedder(config) phrases = [ "I enjoy eating great food.", "Python is my favorite programming language.", "I love to travel and see new places.", ] - # Test batch embedding - embeddings = await embedder.embed(phrases) + # Test batch embedding (documents) + embeddings = await embedder.embed_documents(phrases) assert isinstance(embeddings, list) assert len(embeddings) == 3 assert all(isinstance(emb, list) for emb in embeddings) embeddings = [np.array(emb) for emb in embeddings] + # Test query embedding test_phrase = "I am going for a camping trip." - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[2] test_phrase = "When is dinner ready?" - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[0] test_phrase = "I work as a software developer." - test_embedding = await embedder.embed(test_phrase) - + test_embedding = await embedder.embed_query(test_phrase) sims = similarities(embeddings, test_embedding) assert max(sims) == sims[1] @@ -137,44 +149,6 @@ async def test_voyageai_embedder(): pytest.skip("VoyageAI package not installed") -@pytest.mark.asyncio -@pytest.mark.skipif( - not VLLM_EMBEDDINGS_AVAILABLE, reason="vLLM embeddings server not configured" -) -async def test_vllm_embedder(): - embedder = VLLMEmbedder("mixedbread-ai/mxbai-embed-large-v1", 512) - phrases = [ - "I enjoy eating great food.", - "Python is my favorite programming language.", - "I love to travel and see new places.", - ] - - # Test batch embedding - embeddings = await embedder.embed(phrases) - assert isinstance(embeddings, list) - assert len(embeddings) == 3 - assert all(isinstance(emb, list) for emb in embeddings) - embeddings = [np.array(emb) for emb in embeddings] - - test_phrase = "I am going for a camping trip." - test_embedding = await embedder.embed(test_phrase) - - sims = similarities(embeddings, test_embedding) - assert max(sims) == sims[2] - - test_phrase = "When is dinner ready?" - test_embedding = await embedder.embed(test_phrase) - - sims = similarities(embeddings, test_embedding) - assert max(sims) == sims[0] - - test_phrase = "I work as a software developer." - test_embedding = await embedder.embed(test_phrase) - - sims = similarities(embeddings, test_embedding) - assert max(sims) == sims[1] - - def test_contextualize_with_headings(): """Test that contextualize prepends headings to chunk content.""" chunks = [ diff --git a/tests/test_embedder_config.py b/tests/test_embedder_config.py index a6e431d4..7dcbc6df 100644 --- a/tests/test_embedder_config.py +++ b/tests/test_embedder_config.py @@ -4,16 +4,14 @@ from haiku.rag.config import ( AppConfig, EmbeddingModelConfig, EmbeddingsConfig, - LMStudioConfig, OllamaConfig, ProvidersConfig, - VLLMConfig, ) from haiku.rag.embeddings import get_embedder -def test_embedder_uses_config_from_get_embedder(): - """Test that embedders use the config passed to get_embedder.""" +def test_ollama_embedder_uses_config(): + """Test that Ollama embedder uses the config passed to get_embedder.""" custom_config = AppConfig( embeddings=EmbeddingsConfig( model=EmbeddingModelConfig( @@ -22,41 +20,16 @@ def test_embedder_uses_config_from_get_embedder(): ), providers=ProvidersConfig( ollama=OllamaConfig(base_url="http://custom-ollama:8080"), - vllm=VLLMConfig(embeddings_base_url="http://custom-vllm:9000"), ), ) embedder = get_embedder(custom_config) - assert embedder._model == "custom-model" assert embedder._vector_dim == 512 - assert embedder._config.providers.ollama.base_url == "http://custom-ollama:8080" - - -def test_vllm_embedder_uses_config(): - """Test that vllm embedder uses the config passed to get_embedder.""" - custom_config = AppConfig( - embeddings=EmbeddingsConfig( - model=EmbeddingModelConfig( - provider="vllm", name="custom-vllm-model", vector_dim=768 - ), - ), - providers=ProvidersConfig( - vllm=VLLMConfig(embeddings_base_url="http://custom-vllm:9001"), - ), - ) - - embedder = get_embedder(custom_config) - - assert embedder._model == "custom-vllm-model" - assert embedder._vector_dim == 768 - assert ( - embedder._config.providers.vllm.embeddings_base_url == "http://custom-vllm:9001" - ) def test_openai_embedder_uses_config(): - """Test that openai embedder uses the config passed to get_embedder.""" + """Test that OpenAI embedder uses the config passed to get_embedder.""" custom_config = AppConfig( embeddings=EmbeddingsConfig( model=EmbeddingModelConfig( @@ -67,48 +40,68 @@ def test_openai_embedder_uses_config(): embedder = get_embedder(custom_config) - assert embedder._model == "text-embedding-3-large" assert embedder._vector_dim == 3072 - assert embedder._config == custom_config -def test_lm_studio_embedder_uses_config(): - """Test that lm_studio embedder uses the config passed to get_embedder.""" +def test_openai_embedder_with_base_url(): + """Test that OpenAI embedder uses custom base_url for vLLM/LM Studio.""" custom_config = AppConfig( embeddings=EmbeddingsConfig( model=EmbeddingModelConfig( - provider="lm_studio", name="custom-lm-studio-model", vector_dim=1024 + provider="openai", + name="some-local-model", + vector_dim=768, + base_url="http://localhost:8000/v1", + ), + ), + ) + + embedder = get_embedder(custom_config) + + assert embedder._vector_dim == 768 + + +def test_cohere_embedder_uses_config(): + """Test that Cohere embedder uses the config passed to get_embedder.""" + custom_config = AppConfig( + embeddings=EmbeddingsConfig( + model=EmbeddingModelConfig( + provider="cohere", name="embed-v4.0", vector_dim=1024 ), ), - providers=ProvidersConfig( - lm_studio=LMStudioConfig(base_url="http://custom-lmstudio:5678"), - ), ) embedder = get_embedder(custom_config) - assert embedder._model == "custom-lm-studio-model" assert embedder._vector_dim == 1024 - assert ( - embedder._config.providers.lm_studio.base_url == "http://custom-lmstudio:5678" - ) -@pytest.mark.skipif( - True, reason="VoyageAI is an optional dependency, may not be installed" -) -def test_voyageai_embedder_uses_config(): - """Test that voyageai embedder uses the config passed to get_embedder.""" +def test_sentence_transformers_embedder_uses_config(): + """Test that SentenceTransformers embedder uses the config.""" custom_config = AppConfig( embeddings=EmbeddingsConfig( model=EmbeddingModelConfig( - provider="voyageai", name="voyage-large-2", vector_dim=1536 + provider="sentence-transformers", + name="all-MiniLM-L6-v2", + vector_dim=384, ), ), ) embedder = get_embedder(custom_config) - assert embedder._model == "voyage-large-2" - assert embedder._vector_dim == 1536 - assert embedder._config == custom_config + assert embedder._vector_dim == 384 + + +def test_unsupported_provider_raises(): + """Test that unsupported provider raises ValueError.""" + custom_config = AppConfig( + embeddings=EmbeddingsConfig( + model=EmbeddingModelConfig( + provider="unsupported-provider", name="model", vector_dim=512 + ), + ), + ) + + with pytest.raises(ValueError, match="Unsupported embedding provider"): + get_embedder(custom_config) diff --git a/tests/test_utils.py b/tests/test_utils.py index f81b76b4..a7edf512 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -270,20 +270,6 @@ def test_get_model_bedrock_with_thinking(): assert isinstance(result, BedrockConverseModel) -def test_get_model_vllm(): - """Test get_model returns OpenAIChatModel for vLLM.""" - model_config = ModelConfig(provider="vllm", name="Qwen/Qwen3-4B") - result = get_model(model_config) - assert isinstance(result, OpenAIChatModel) - - -def test_get_model_vllm_with_thinking(): - """Test get_model configures thinking for gpt-oss on vLLM.""" - model_config = ModelConfig(provider="vllm", name="gpt-oss", enable_thinking=False) - result = get_model(model_config) - assert isinstance(result, OpenAIChatModel) - - def test_get_model_unknown_provider(): """Test get_model returns string format for unknown providers.""" model_config = ModelConfig(provider="mistral", name="mistral-large-latest") From 86e31b8d2952a55748c4afd5a869f6cdb4b1636e Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 26 Dec 2025 11:50:47 +0200 Subject: [PATCH 6/8] VoyageAI embeddings, should end up as a PR for pydantic-ai --- .../haiku/rag/embeddings/__init__.py | 5 +- .../haiku/rag/embeddings/voyageai.py | 199 +++++++++++++++--- 2 files changed, 177 insertions(+), 27 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/embeddings/__init__.py b/haiku_rag_slim/haiku/rag/embeddings/__init__.py index 8c031aba..7894ea69 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/__init__.py +++ b/haiku_rag_slim/haiku/rag/embeddings/__init__.py @@ -128,14 +128,15 @@ def get_embedder(config: AppConfig = Config) -> EmbedderWrapper: if provider == "voyageai": try: - from haiku.rag.embeddings.voyageai import Embedder as VoyageAIEmbedder + from haiku.rag.embeddings.voyageai import VoyageAIEmbeddingModel except ImportError: raise ImportError( "VoyageAI embedder requires the 'voyageai' package. " "Please install haiku.rag with the 'voyageai' extra: " "uv pip install haiku.rag[voyageai]" ) - return VoyageAIEmbedder(model_name, vector_dim, config) # type: ignore[return-value] + model = VoyageAIEmbeddingModel(model_name) + return EmbedderWrapper(Embedder(model), vector_dim) if provider == "cohere": return EmbedderWrapper(Embedder(f"cohere:{model_name}"), vector_dim) diff --git a/haiku_rag_slim/haiku/rag/embeddings/voyageai.py b/haiku_rag_slim/haiku/rag/embeddings/voyageai.py index ff6f16b0..c38b2a23 100644 --- a/haiku_rag_slim/haiku/rag/embeddings/voyageai.py +++ b/haiku_rag_slim/haiku/rag/embeddings/voyageai.py @@ -1,33 +1,182 @@ +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Literal, cast + +from pydantic_ai.embeddings.base import EmbeddingModel +from pydantic_ai.embeddings.result import EmbeddingResult, EmbedInputType +from pydantic_ai.embeddings.settings import EmbeddingSettings +from pydantic_ai.exceptions import ModelAPIError +from pydantic_ai.usage import RequestUsage + try: - from voyageai.client import Client # type: ignore + from voyageai.client_async import AsyncClient + from voyageai.error import VoyageError +except ImportError as _import_error: + raise ImportError( + "Please install `voyageai` to use the VoyageAI embeddings model, " + "you can use — `pip install voyageai`" + ) from _import_error - from haiku.rag.config import AppConfig +LatestVoyageAIEmbeddingModelNames = Literal[ + "voyage-3-large", + "voyage-3.5", + "voyage-3.5-lite", + "voyage-code-3", + "voyage-finance-2", + "voyage-law-2", + "voyage-code-2", +] +"""Latest VoyageAI embedding models. - class Embedder: - """VoyageAI embedder with explicit query/document methods.""" +See [VoyageAI Embeddings](https://docs.voyageai.com/docs/embeddings) +for available models and their capabilities. +""" - def __init__(self, model: str, vector_dim: int, config: AppConfig): - self._model = model - self._vector_dim = vector_dim - self._config = config +VoyageAIEmbeddingModelName = str | LatestVoyageAIEmbeddingModelNames +"""Possible VoyageAI embedding model names.""" - async def embed_query(self, text: str) -> list[float]: - """Embed a search query.""" - client = Client() - res = client.embed( - [text], model=self._model, input_type="query", output_dtype="float" + +class VoyageAIEmbeddingSettings(EmbeddingSettings, total=False): + """Settings used for a VoyageAI embedding model request. + + All fields from [`EmbeddingSettings`][pydantic_ai.embeddings.EmbeddingSettings] are supported, + plus VoyageAI-specific settings prefixed with `voyageai_`. + """ + + # ALL FIELDS MUST BE `voyageai_` PREFIXED SO YOU CAN MERGE THEM WITH OTHER MODELS. + + voyageai_truncation: bool + """Whether to truncate inputs that exceed the model's context length. + + Defaults to True. If False, an error is raised for inputs that are too long. + """ + + voyageai_output_dtype: Literal["float", "int8", "uint8", "binary", "ubinary"] + """The output data type for embeddings. + + - `'float'` (default): 32-bit floats + - `'int8'`: Signed 8-bit integers (quantized) + - `'uint8'`: Unsigned 8-bit integers (quantized) + - `'binary'`: Binary embeddings + - `'ubinary'`: Unsigned binary embeddings + """ + + +_MAX_INPUT_TOKENS: dict[VoyageAIEmbeddingModelName, int] = { + "voyage-3-large": 32000, + "voyage-3.5": 32000, + "voyage-3.5-lite": 32000, + "voyage-code-3": 32000, + "voyage-finance-2": 32000, + "voyage-law-2": 16000, + "voyage-code-2": 16000, +} + + +@dataclass(init=False) +class VoyageAIEmbeddingModel(EmbeddingModel): + """VoyageAI embedding model implementation. + + VoyageAI provides state-of-the-art embedding models optimized for + retrieval, with specialized models for code, finance, and legal domains. + + Example: + ```python + from pydantic_ai.embeddings.voyageai import VoyageAIEmbeddingModel + + model = VoyageAIEmbeddingModel('voyage-3.5') + ``` + """ + + _model_name: VoyageAIEmbeddingModelName = field(repr=False) + _client: AsyncClient = field(repr=False) + + def __init__( + self, + model_name: VoyageAIEmbeddingModelName, + *, + api_key: str | None = None, + max_retries: int = 0, + timeout: int | None = None, + settings: EmbeddingSettings | None = None, + ): + """Initialize a VoyageAI embedding model. + + Args: + model_name: The name of the VoyageAI model to use. + See [VoyageAI models](https://docs.voyageai.com/docs/embeddings) + for available options. + api_key: The VoyageAI API key. If not provided, uses the + `VOYAGE_API_KEY` environment variable. + max_retries: Maximum number of retries for failed requests. + timeout: Request timeout in seconds. + settings: Model-specific [`EmbeddingSettings`][pydantic_ai.embeddings.EmbeddingSettings] + to use as defaults for this model. + """ + self._model_name = model_name + self._client = AsyncClient( + api_key=api_key, + max_retries=max_retries, + timeout=timeout, + ) + + super().__init__(settings=settings) + + @property + def model_name(self) -> VoyageAIEmbeddingModelName: + """The embedding model name.""" + return self._model_name + + @property + def system(self) -> str: + """The embedding model provider.""" + return "voyageai" + + async def embed( + self, + inputs: str | Sequence[str], + *, + input_type: EmbedInputType, + settings: EmbeddingSettings | None = None, + ) -> EmbeddingResult: + inputs, settings = self.prepare_embed(inputs, settings) + settings = cast(VoyageAIEmbeddingSettings, settings) + + voyageai_input_type = "document" if input_type == "document" else "query" + + try: + response = await self._client.embed( + texts=list(inputs), + model=self.model_name, + input_type=voyageai_input_type, + truncation=settings.get("voyageai_truncation", True), + output_dtype=settings.get("voyageai_output_dtype", "float"), + output_dimension=settings.get("dimensions"), ) - return res.embeddings[0] # type: ignore[return-value] + except VoyageError as e: + raise ModelAPIError(model_name=self.model_name, message=str(e)) from e - async def embed_documents(self, texts: list[str]) -> list[list[float]]: - """Embed documents/chunks for indexing.""" - if not texts: - return [] - client = Client() - res = client.embed( - texts, model=self._model, input_type="document", output_dtype="float" - ) - return res.embeddings # type: ignore[return-value] + return EmbeddingResult( + embeddings=response.embeddings, + inputs=inputs, + input_type=input_type, + usage=_map_usage(response.total_tokens, self.model_name), + model_name=self.model_name, + provider_name=self.system, + ) -except ImportError: - pass + async def max_input_tokens(self) -> int | None: + return _MAX_INPUT_TOKENS.get(self.model_name) + + +def _map_usage(total_tokens: int, model: str) -> RequestUsage: + usage_data = {"total_tokens": total_tokens} + response_data = {"model": model, "usage": usage_data} + + return RequestUsage.extract( + response_data, + provider="voyageai", + provider_url="https://api.voyageai.com", + provider_fallback="voyageai", + api_flavor="embeddings", + ) From d4861b6408c0d2b9eeb8eb08fd306162f43929a6 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 26 Dec 2025 11:57:27 +0200 Subject: [PATCH 7/8] Docs & changelog --- CHANGELOG.md | 21 +++++++ docs/configuration/index.md | 6 -- docs/configuration/providers.md | 102 ++++++++++++++++---------------- 3 files changed, 73 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5c717e..b55504b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog ## [Unreleased] +### Changed + +- **Embeddings**: Migrated to pydantic-ai's embeddings module + - Uses pydantic-ai v1.39.0+ embeddings with instrumentation and token counting support + - Explicit `embed_query()` and `embed_documents()` API for query/document distinction + - New providers available: Cohere (`cohere:`), SentenceTransformers (`sentence-transformers:`) + - VoyageAI refactored to extend pydantic-ai's `EmbeddingModel` base class +- **Configuration**: Added `base_url` to `ModelConfig` and `EmbeddingModelConfig` + - Enables custom endpoints for OpenAI-compatible providers (vLLM, LM Studio, etc.) + - Model-level `base_url` takes precedence over provider config + +### Deprecated + +- **vLLM and LM Studio providers**: Use `openai` provider with `base_url` instead + - `provider: vllm` → `provider: openai` with `base_url: http://localhost:8000/v1` + - `provider: lm_studio` → `provider: openai` with `base_url: http://localhost:1234/v1` + +### Removed + +- Deleted obsolete embedder implementations: `ollama.py`, `openai.py`, `vllm.py`, `lm_studio.py`, `base.py` + ## [0.22.0] - 2025-12-19 ### Added diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 9ab1f0c6..b807cbd7 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -135,12 +135,6 @@ providers: ollama: base_url: http://localhost:11434 - vllm: - embeddings_base_url: "" - rerank_base_url: "" - qa_base_url: "" - research_base_url: "" - docling_serve: base_url: http://localhost:5001 api_key: "" diff --git a/docs/configuration/providers.md b/docs/configuration/providers.md index 03da0a66..7d847a46 100644 --- a/docs/configuration/providers.md +++ b/docs/configuration/providers.md @@ -28,6 +28,7 @@ qa: - Higher (0.8-1.0+): Creative, varied responses - **max_tokens**: Maximum tokens in response - **enable_thinking**: Control reasoning behavior (see below) +- **base_url**: Custom endpoint for OpenAI-compatible servers (vLLM, LM Studio, etc.) ### Thinking Control @@ -67,7 +68,7 @@ See the [Pydantic AI thinking documentation](https://ai.pydantic.dev/thinking/) ## Embedding Providers -If you use Ollama, you can use any pulled model that supports embeddings. +Embedding models require three settings: `provider`, `name`, and `vector_dim`. Optionally, use `base_url` for OpenAI-compatible servers. ### Ollama (Default) @@ -135,41 +136,59 @@ Set your API key via environment variable: export OPENAI_API_KEY=your-api-key ``` -### vLLM +### Cohere -For high-performance local inference, you can use vLLM to serve embedding models with OpenAI-compatible APIs: +Cohere embeddings are available via pydantic-ai: ```yaml embeddings: model: - provider: vllm + provider: cohere + name: embed-v4.0 + vector_dim: 1024 +``` + +Set your API key via environment variable: + +```bash +export CO_API_KEY=your-api-key +``` + +### SentenceTransformers + +For local embeddings using HuggingFace models: + +```yaml +embeddings: + model: + provider: sentence-transformers + name: all-MiniLM-L6-v2 + vector_dim: 384 +``` + +### OpenAI-Compatible Servers (vLLM, LM Studio, etc.) + +For local inference servers with OpenAI-compatible APIs, use the `openai` provider with a custom `base_url`: + +```yaml +# vLLM example +embeddings: + model: + provider: openai name: mixedbread-ai/mxbai-embed-large-v1 vector_dim: 512 + base_url: http://localhost:8000/v1 -providers: - vllm: - embeddings_base_url: http://localhost:8000 -``` - -**Note:** You need to run a vLLM server separately with an embedding model loaded. - -### LM Studio - -[LM Studio](https://lmstudio.ai/) provides a local OpenAI-compatible API server for running models: - -```yaml +# LM Studio example embeddings: model: - provider: lm_studio + provider: openai name: text-embedding-qwen3-embedding-4b vector_dim: 2560 - -providers: - lm_studio: - base_url: http://localhost:1234 + base_url: http://localhost:1234/v1 ``` -**Note:** LM Studio must be running with an embedding model loaded. The default URL is `http://localhost:1234`. +**Note:** The `base_url` must include the `/v1` path for OpenAI-compatible endpoints. ## Question Answering Providers @@ -232,45 +251,28 @@ Set your API key via environment variable: export ANTHROPIC_API_KEY=your-api-key ``` -### vLLM +### OpenAI-Compatible Servers (vLLM, LM Studio, etc.) -For high-performance local inference: +For local inference servers with OpenAI-compatible APIs, use the `openai` provider with a custom `base_url`: ```yaml +# vLLM example qa: model: - provider: vllm - name: Qwen/Qwen3-4B # Any model with tool support in vLLM + provider: openai + name: Qwen/Qwen3-4B + base_url: http://localhost:8002/v1 -providers: - vllm: - qa_base_url: http://localhost:8002 -``` - -**Note:** You need to run a vLLM server separately with a model that supports tool calling loaded. Consult the specific model's documentation for proper vLLM serving configuration. - -### LM Studio - -Use LM Studio for local question answering and research: - -```yaml +# LM Studio example qa: model: - provider: lm_studio - name: openai/gpt-oss-20b + provider: openai + name: gpt-oss-20b + base_url: http://localhost:1234/v1 enable_thinking: false - -research: - model: - provider: lm_studio - name: openai/gpt-oss-20b - -providers: - lm_studio: - base_url: http://localhost:1234 ``` -**Note:** LM Studio must be running with a chat model that supports tool calling loaded. +**Note:** The server must be running with a model that supports tool calling. The `base_url` must include the `/v1` path. ### Other Providers From 958fe43f2a0830ae70324e5522d97c57d1d39f8a Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 26 Dec 2025 12:02:55 +0200 Subject: [PATCH 8/8] Remove vllm and lmstudio custom configs, they can now use the openai base --- CHANGELOG.md | 1 + docs/configuration/providers.md | 7 ++---- haiku_rag_slim/haiku/rag/config/__init__.py | 4 --- haiku_rag_slim/haiku/rag/config/models.py | 13 ---------- .../haiku/rag/reranking/__init__.py | 5 +++- haiku_rag_slim/haiku/rag/reranking/vllm.py | 5 ++-- tests/test_qa.py | 25 ------------------- tests/test_reranker.py | 10 +++++--- 8 files changed, 15 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b55504b3..d0840676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Removed - Deleted obsolete embedder implementations: `ollama.py`, `openai.py`, `vllm.py`, `lm_studio.py`, `base.py` +- Removed `VLLMConfig` and `LMStudioConfig` from configuration (use `base_url` in model config instead) ## [0.22.0] - 2025-12-19 diff --git a/docs/configuration/providers.md b/docs/configuration/providers.md index 7d847a46..12bb9603 100644 --- a/docs/configuration/providers.md +++ b/docs/configuration/providers.md @@ -378,10 +378,7 @@ reranking: model: provider: vllm name: mixedbread-ai/mxbai-rerank-base-v2 - -providers: - vllm: - rerank_base_url: http://localhost:8001 + base_url: http://localhost:8001 ``` -**Note:** vLLM reranking uses the `/rerank` API endpoint. You need to run a vLLM server separately with a reranking model loaded. Consult the specific model's documentation for proper vLLM serving configuration. +**Note:** vLLM reranking uses the `/v1/rerank` API endpoint. You need to run a vLLM server separately with a reranking model loaded. diff --git a/haiku_rag_slim/haiku/rag/config/__init__.py b/haiku_rag_slim/haiku/rag/config/__init__.py index c8c8b99d..e4efd7c6 100644 --- a/haiku_rag_slim/haiku/rag/config/__init__.py +++ b/haiku_rag_slim/haiku/rag/config/__init__.py @@ -12,7 +12,6 @@ from haiku.rag.config.models import ( EmbeddingModelConfig, EmbeddingsConfig, LanceDBConfig, - LMStudioConfig, ModelConfig, MonitorConfig, OllamaConfig, @@ -22,7 +21,6 @@ from haiku.rag.config.models import ( RerankingConfig, ResearchConfig, StorageConfig, - VLLMConfig, ) __all__ = [ @@ -33,7 +31,6 @@ __all__ = [ "EmbeddingModelConfig", "EmbeddingsConfig", "LanceDBConfig", - "LMStudioConfig", "ModelConfig", "MonitorConfig", "OllamaConfig", @@ -43,7 +40,6 @@ __all__ = [ "RerankingConfig", "ResearchConfig", "StorageConfig", - "VLLMConfig", "find_config_file", "generate_default_config", "get_config", diff --git a/haiku_rag_slim/haiku/rag/config/models.py b/haiku_rag_slim/haiku/rag/config/models.py index 2e6c5e1e..50b4ffd9 100644 --- a/haiku_rag_slim/haiku/rag/config/models.py +++ b/haiku_rag_slim/haiku/rag/config/models.py @@ -142,27 +142,14 @@ class OllamaConfig(BaseModel): ) -class VLLMConfig(BaseModel): - embeddings_base_url: str = "" - rerank_base_url: str = "" - qa_base_url: str = "" - research_base_url: str = "" - - class DoclingServeConfig(BaseModel): base_url: str = "http://localhost:5001" api_key: str = "" timeout: int = 300 -class LMStudioConfig(BaseModel): - base_url: str = "http://localhost:1234" - - class ProvidersConfig(BaseModel): ollama: OllamaConfig = Field(default_factory=OllamaConfig) - vllm: VLLMConfig = Field(default_factory=VLLMConfig) - lm_studio: LMStudioConfig = Field(default_factory=LMStudioConfig) docling_serve: DoclingServeConfig = Field(default_factory=DoclingServeConfig) diff --git a/haiku_rag_slim/haiku/rag/reranking/__init__.py b/haiku_rag_slim/haiku/rag/reranking/__init__.py index 0fc83e3f..b3502db6 100644 --- a/haiku_rag_slim/haiku/rag/reranking/__init__.py +++ b/haiku_rag_slim/haiku/rag/reranking/__init__.py @@ -45,7 +45,10 @@ def get_reranker(config: AppConfig = Config) -> RerankerBase | None: try: from haiku.rag.reranking.vllm import VLLMReranker - reranker = VLLMReranker(config.reranking.model.name) + base_url = config.reranking.model.base_url + if not base_url: + raise ValueError("vLLM reranker requires base_url in reranking.model") + reranker = VLLMReranker(config.reranking.model.name, base_url) except ImportError: reranker = None diff --git a/haiku_rag_slim/haiku/rag/reranking/vllm.py b/haiku_rag_slim/haiku/rag/reranking/vllm.py index 750d9f85..ab370110 100644 --- a/haiku_rag_slim/haiku/rag/reranking/vllm.py +++ b/haiku_rag_slim/haiku/rag/reranking/vllm.py @@ -1,14 +1,13 @@ import httpx -from haiku.rag.config import Config from haiku.rag.reranking.base import RerankerBase from haiku.rag.store.models.chunk import Chunk class VLLMReranker(RerankerBase): # pragma: no cover - def __init__(self, model: str): + def __init__(self, model: str, base_url: str): self._model = model - self._base_url = Config.providers.vllm.rerank_base_url + self._base_url = base_url async def rerank( self, query: str, chunks: list[Chunk], top_n: int = 10 diff --git a/tests/test_qa.py b/tests/test_qa.py index 52626516..db0e04b0 100644 --- a/tests/test_qa.py +++ b/tests/test_qa.py @@ -5,13 +5,11 @@ from datasets import Dataset from evaluations.evaluators import LLMJudge from haiku.rag.client import HaikuRAG -from haiku.rag.config import Config from haiku.rag.config.models import ModelConfig from haiku.rag.qa.agent import QuestionAnswerAgent OPENAI_AVAILABLE = bool(os.getenv("OPENAI_API_KEY")) ANTHROPIC_AVAILABLE = bool(os.getenv("ANTHROPIC_API_KEY")) -VLLM_QA_AVAILABLE = bool(Config.providers.vllm.qa_base_url) @pytest.mark.asyncio @@ -87,26 +85,3 @@ async def test_qa_anthropic(qa_corpus: Dataset, temp_db_path): assert is_equivalent, ( f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}" ) - - -@pytest.mark.asyncio -@pytest.mark.skipif(not VLLM_QA_AVAILABLE, reason="vLLM QA server not configured") -async def test_qa_vllm(qa_corpus: Dataset, temp_db_path): - """Test vLLM QA with LLM judge.""" - client = HaikuRAG(temp_db_path, create=True) - qa = QuestionAnswerAgent(client, ModelConfig(provider="vllm", name="Qwen/Qwen3-4B")) - llm_judge = LLMJudge() - - doc = qa_corpus[1] - await client.create_document( - content=doc["document_extracted"], uri=doc["document_id"] - ) - - question = doc["question"] - expected_answer = doc["answer"] - answer, _ = await qa.answer(question) - is_equivalent = await llm_judge.judge_answers(question, answer, expected_answer) - - assert is_equivalent, ( - f"Generated answer not equivalent to expected answer.\nQuestion: {question}\nGenerated: {answer}\nExpected: {expected_answer}" - ) diff --git a/tests/test_reranker.py b/tests/test_reranker.py index 80a73b2d..0370d431 100644 --- a/tests/test_reranker.py +++ b/tests/test_reranker.py @@ -2,13 +2,12 @@ import os import pytest -from haiku.rag.config import Config from haiku.rag.reranking.base import RerankerBase from haiku.rag.reranking.vllm import VLLMReranker from haiku.rag.store.models.chunk import Chunk COHERE_AVAILABLE = bool(os.getenv("CO_API_KEY")) -VLLM_RERANK_AVAILABLE = bool(Config.providers.vllm.rerank_base_url) +VLLM_RERANK_BASE_URL = os.getenv("VLLM_RERANK_BASE_URL", "") ZEROENTROPY_AVAILABLE = bool(os.getenv("ZEROENTROPY_API_KEY")) chunks = [ @@ -41,6 +40,7 @@ async def test_reranker_base(): @pytest.mark.asyncio async def test_mxbai_reranker(): try: + from haiku.rag.config import Config from haiku.rag.config.models import ModelConfig from haiku.rag.reranking.mxbai import MxBAIReranker @@ -80,11 +80,13 @@ async def test_cohere_reranker(): @pytest.mark.asyncio @pytest.mark.skipif( - not VLLM_RERANK_AVAILABLE, reason="vLLM rerank server not configured" + not VLLM_RERANK_BASE_URL, reason="vLLM rerank server not configured" ) async def test_vllm_reranker(): try: - reranker = VLLMReranker("mixedbread-ai/mxbai-rerank-base-v2") + reranker = VLLMReranker( + "mixedbread-ai/mxbai-rerank-base-v2", VLLM_RERANK_BASE_URL + ) reranked = await reranker.rerank( "Who wrote 'To Kill a Mockingbird'?", chunks, top_n=2