semantic-router 0.2.0.dev1__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/.gitignore +5 -0
  2. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/LICENSE +1 -1
  3. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/PKG-INFO +23 -41
  4. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/README.md +12 -1
  5. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/pyproject.toml +36 -24
  6. semantic_router-0.3.1/semantic_router/__init__.py +11 -0
  7. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/bm25.py +86 -7
  8. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/clip.py +10 -4
  9. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/fastembed.py +15 -0
  10. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/openai.py +25 -0
  11. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/vit.py +7 -5
  12. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/index/__init__.py +2 -0
  13. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/index/local.py +6 -2
  14. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/index/pinecone.py +40 -23
  15. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/index/postgres.py +41 -16
  16. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/index/qdrant.py +44 -49
  17. semantic_router-0.3.1/semantic_router/index/redis.py +1299 -0
  18. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/__init__.py +10 -0
  19. semantic_router-0.3.1/semantic_router/llms/atlascloud.py +55 -0
  20. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/base.py +25 -0
  21. semantic_router-0.3.1/semantic_router/llms/minimax.py +137 -0
  22. semantic_router-0.3.1/semantic_router/llms/openai_compatible.py +103 -0
  23. semantic_router-0.3.1/semantic_router/llms/orcarouter.py +52 -0
  24. semantic_router-0.3.1/semantic_router/llms/requesty.py +53 -0
  25. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/routers/base.py +1 -3
  26. semantic_router-0.2.0.dev1/semantic_router/__init__.py +0 -6
  27. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/__init__.py +0 -0
  28. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/aurelio.py +0 -0
  29. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/azure_openai.py +0 -0
  30. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/base.py +0 -0
  31. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/bedrock.py +0 -0
  32. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/cohere.py +0 -0
  33. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/encode_input_type.py +0 -0
  34. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/google.py +0 -0
  35. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/huggingface.py +0 -0
  36. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/jina.py +0 -0
  37. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/litellm.py +0 -0
  38. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/local.py +0 -0
  39. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/mistral.py +0 -0
  40. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/nvidia_nim.py +0 -0
  41. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/ollama.py +0 -0
  42. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/tfidf.py +0 -0
  43. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/encoders/voyage.py +0 -0
  44. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/index/base.py +0 -0
  45. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/index/hybrid_local.py +0 -0
  46. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/linear.py +0 -0
  47. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/cohere.py +0 -0
  48. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/grammars/json.gbnf +0 -0
  49. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/llamacpp.py +0 -0
  50. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/mistral.py +0 -0
  51. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/ollama.py +0 -0
  52. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/openai.py +0 -0
  53. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/openrouter.py +0 -0
  54. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/llms/zure.py +0 -0
  55. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/py.typed +0 -0
  56. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/route.py +0 -0
  57. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/routers/__init__.py +0 -0
  58. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/routers/hybrid.py +0 -0
  59. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/routers/semantic.py +0 -0
  60. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/schema.py +0 -0
  61. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/tokenizers.py +0 -0
  62. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/utils/__init__.py +0 -0
  63. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/utils/defaults.py +0 -0
  64. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/utils/function_call.py +0 -0
  65. {semantic_router-0.2.0.dev1 → semantic_router-0.3.1}/semantic_router/utils/logger.py +0 -0
@@ -12,6 +12,8 @@ venv/
12
12
 
13
13
 
14
14
  # local env files
15
+ **/*.env
16
+ *.env
15
17
  .env*.local
16
18
  .env
17
19
  my.secrets
@@ -34,3 +36,6 @@ test_sync.ipynb
34
36
 
35
37
  # docs
36
38
  docs/build
39
+
40
+ # fastembed model cache used by CI
41
+ .fastembed_cache/
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 Aurelio AI
3
+ Copyright (c) 2026 Aurelio AI
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: semantic-router
3
- Version: 0.2.0.dev1
3
+ Version: 0.3.1
4
4
  Summary: Super fast semantic router for AI decision making
5
5
  Author-email: Aurelio AI <hello@aurelio.ai>
6
6
  License-Expression: MIT
7
7
  License-File: LICENSE
8
- Requires-Python: <3.14,>=3.9
8
+ Requires-Python: <3.14,>=3.10
9
9
  Requires-Dist: aiohttp<4,>=3.10.11
10
10
  Requires-Dist: aurelio-sdk>=0.0.19
11
11
  Requires-Dist: cohere<6.00,>=5.9.4
@@ -24,61 +24,30 @@ Requires-Dist: voyageai>=0.4.1
24
24
  Provides-Extra: all
25
25
  Requires-Dist: boto3<2,>=1.34.98; extra == 'all'
26
26
  Requires-Dist: botocore<2,>=1.34.110; extra == 'all'
27
- Requires-Dist: dagger-io>=0.1.1; (python_version >= '3.11') and extra == 'all'
28
- Requires-Dist: fastembed<0.4,>=0.3.0; (python_version < '3.13') and extra == 'all'
27
+ Requires-Dist: fastembed<1,>=0.7.0; extra == 'all'
29
28
  Requires-Dist: google-cloud-aiplatform<2,>=1.45.0; extra == 'all'
30
- Requires-Dist: ipykernel<7,>=6.25.0; extra == 'all'
31
- Requires-Dist: litellm>=1.83.7; extra == 'all'
29
+ Requires-Dist: litellm>=1.84.0; extra == 'all'
32
30
  Requires-Dist: llama-cpp-python<0.2.86,>=0.2.28; (python_version < '3.13') and extra == 'all'
33
- Requires-Dist: mypy<2,>=1.7.1; extra == 'all'
34
31
  Requires-Dist: ollama>=0.1.7; extra == 'all'
35
- Requires-Dist: pillow<11.0.0,>=10.2.0; (python_version < '3.13') and extra == 'all'
32
+ Requires-Dist: pillow>=10.2.0; (python_version < '3.13') and extra == 'all'
36
33
  Requires-Dist: pinecone[asyncio]<8.0.0,>=7.0.0; extra == 'all'
37
34
  Requires-Dist: psycopg[binary]<4,>=3.1.0; extra == 'all'
38
- Requires-Dist: pytest-asyncio<0.25,>=0.24.0; extra == 'all'
39
- Requires-Dist: pytest-cov<5,>=4.1.0; extra == 'all'
40
- Requires-Dist: pytest-mock<4,>=3.12.0; extra == 'all'
41
- Requires-Dist: pytest-timeout; extra == 'all'
42
- Requires-Dist: pytest-xdist<4,>=3.5.0; extra == 'all'
43
- Requires-Dist: pytest~=8.2; extra == 'all'
44
- Requires-Dist: python-dotenv<2,>=1.0.0; extra == 'all'
45
35
  Requires-Dist: qdrant-client<2,>=1.11.1; extra == 'all'
46
- Requires-Dist: requests-mock<2,>=1.12.1; extra == 'all'
47
- Requires-Dist: ruff<0.12,>=0.11.2; extra == 'all'
36
+ Requires-Dist: redis<7,>=5.0.0; extra == 'all'
48
37
  Requires-Dist: sentence-transformers>=5.0.0; (python_version < '3.13') and extra == 'all'
49
38
  Requires-Dist: tokenizers>=0.19; (python_version < '3.13') and extra == 'all'
50
39
  Requires-Dist: torch>=2.6.0; (python_version < '3.13') and extra == 'all'
51
40
  Requires-Dist: torchvision>=0.17.0; (python_version < '3.13') and extra == 'all'
52
41
  Requires-Dist: transformers>=4.36.2; (python_version < '3.13') and extra == 'all'
53
- Requires-Dist: types-pyyaml<7,>=6.0.12.12; extra == 'all'
54
- Requires-Dist: types-requests<3,>=2.31.0; extra == 'all'
55
42
  Provides-Extra: bedrock
56
43
  Requires-Dist: boto3<2,>=1.34.98; extra == 'bedrock'
57
44
  Requires-Dist: botocore<2,>=1.34.110; extra == 'bedrock'
58
- Provides-Extra: dev
59
- Requires-Dist: dagger-io>=0.1.1; (python_version >= '3.11') and extra == 'dev'
60
- Requires-Dist: ipykernel<7,>=6.25.0; extra == 'dev'
61
- Requires-Dist: litellm>=1.83.7; extra == 'dev'
62
- Requires-Dist: mypy<2,>=1.7.1; extra == 'dev'
63
- Requires-Dist: pytest-asyncio<0.25,>=0.24.0; extra == 'dev'
64
- Requires-Dist: pytest-cov<5,>=4.1.0; extra == 'dev'
65
- Requires-Dist: pytest-mock<4,>=3.12.0; extra == 'dev'
66
- Requires-Dist: pytest-timeout; extra == 'dev'
67
- Requires-Dist: pytest-xdist<4,>=3.5.0; extra == 'dev'
68
- Requires-Dist: pytest~=8.2; extra == 'dev'
69
- Requires-Dist: python-dotenv<2,>=1.0.0; extra == 'dev'
70
- Requires-Dist: requests-mock<2,>=1.12.1; extra == 'dev'
71
- Requires-Dist: ruff<0.12,>=0.11.2; extra == 'dev'
72
- Requires-Dist: types-pyyaml<7,>=6.0.12.12; extra == 'dev'
73
- Requires-Dist: types-requests<3,>=2.31.0; extra == 'dev'
74
- Provides-Extra: docs
75
- Requires-Dist: pydoc-markdown>=4.8.2; (python_version < '3.12') and extra == 'docs'
76
45
  Provides-Extra: fastembed
77
- Requires-Dist: fastembed<0.4,>=0.3.0; (python_version < '3.13') and extra == 'fastembed'
46
+ Requires-Dist: fastembed<1,>=0.7.0; extra == 'fastembed'
78
47
  Provides-Extra: google
79
48
  Requires-Dist: google-cloud-aiplatform<2,>=1.45.0; extra == 'google'
80
49
  Provides-Extra: litellm
81
- Requires-Dist: litellm>=1.83.7; extra == 'litellm'
50
+ Requires-Dist: litellm>=1.84.0; extra == 'litellm'
82
51
  Provides-Extra: local
83
52
  Requires-Dist: llama-cpp-python<0.2.86,>=0.2.28; (python_version < '3.13') and extra == 'local'
84
53
  Requires-Dist: sentence-transformers>=5.0.0; (python_version < '3.13') and extra == 'local'
@@ -93,8 +62,10 @@ Provides-Extra: postgres
93
62
  Requires-Dist: psycopg[binary]<4,>=3.1.0; extra == 'postgres'
94
63
  Provides-Extra: qdrant
95
64
  Requires-Dist: qdrant-client<2,>=1.11.1; extra == 'qdrant'
65
+ Provides-Extra: redis
66
+ Requires-Dist: redis<7,>=5.0.0; extra == 'redis'
96
67
  Provides-Extra: vision
97
- Requires-Dist: pillow<11.0.0,>=10.2.0; (python_version < '3.13') and extra == 'vision'
68
+ Requires-Dist: pillow>=10.2.0; (python_version < '3.13') and extra == 'vision'
98
69
  Requires-Dist: torch>=2.6.0; (python_version < '3.13') and extra == 'vision'
99
70
  Requires-Dist: torchvision>=0.17.0; (python_version < '3.13') and extra == 'vision'
100
71
  Requires-Dist: transformers>=4.36.2; (python_version < '3.13') and extra == 'vision'
@@ -109,7 +80,6 @@ Description-Content-Type: text/markdown
109
80
  <img alt="" src="https://img.shields.io/github/repo-size/aurelio-labs/semantic-router" />
110
81
  <a href="https://github.com/aurelio-labs/semantic-router/issues"><img alt="GitHub Issues" src="https://img.shields.io/github/issues/aurelio-labs/semantic-router" />
111
82
  <a href="https://github.com/aurelio-labs/semantic-router/pulls"><img alt="GitHub Pull Requests" src="https://img.shields.io/github/issues-pr/aurelio-labs/semantic-router" />
112
- <img src="https://codecov.io/gh/aurelio-labs/semantic-router/graph/badge.svg?token=H8OOMV2TUF" />
113
83
  <a href="https://github.com/aurelio-labs/semantic-router/blob/main/LICENSE"><img alt="Github License" src="https://img.shields.io/badge/License-MIT-yellow.svg" />
114
84
  </p>
115
85
 
@@ -119,6 +89,18 @@ Semantic Router is a superfast decision-making layer for your LLMs and agents. R
119
89
 
120
90
  ---
121
91
 
92
+ ## Versions
93
+
94
+ Semantic Router has two release lines. The **0.x line** (`v0` branch) is the current stable library and finishes at 0.3.0, after which it receives bug fixes only. The **1.x line** (`main` branch) is a breaking rewrite of the routing layer and ships as 1.0.0. Pin to the line you are on:
95
+
96
+ ```
97
+ pip install "semantic-router<1" # 0.x
98
+ ```
99
+
100
+ Details, and the docs for each line, are on the [versions page](https://docs.aurelio.ai/semantic-router/v0/get-started/versions).
101
+
102
+ ---
103
+
122
104
  ## Quickstart
123
105
 
124
106
  To get started with _semantic-router_ we install it like so:
@@ -7,7 +7,6 @@
7
7
  <img alt="" src="https://img.shields.io/github/repo-size/aurelio-labs/semantic-router" />
8
8
  <a href="https://github.com/aurelio-labs/semantic-router/issues"><img alt="GitHub Issues" src="https://img.shields.io/github/issues/aurelio-labs/semantic-router" />
9
9
  <a href="https://github.com/aurelio-labs/semantic-router/pulls"><img alt="GitHub Pull Requests" src="https://img.shields.io/github/issues-pr/aurelio-labs/semantic-router" />
10
- <img src="https://codecov.io/gh/aurelio-labs/semantic-router/graph/badge.svg?token=H8OOMV2TUF" />
11
10
  <a href="https://github.com/aurelio-labs/semantic-router/blob/main/LICENSE"><img alt="Github License" src="https://img.shields.io/badge/License-MIT-yellow.svg" />
12
11
  </p>
13
12
 
@@ -17,6 +16,18 @@ Semantic Router is a superfast decision-making layer for your LLMs and agents. R
17
16
 
18
17
  ---
19
18
 
19
+ ## Versions
20
+
21
+ Semantic Router has two release lines. The **0.x line** (`v0` branch) is the current stable library and finishes at 0.3.0, after which it receives bug fixes only. The **1.x line** (`main` branch) is a breaking rewrite of the routing layer and ships as 1.0.0. Pin to the line you are on:
22
+
23
+ ```
24
+ pip install "semantic-router<1" # 0.x
25
+ ```
26
+
27
+ Details, and the docs for each line, are on the [versions page](https://docs.aurelio.ai/semantic-router/v0/get-started/versions).
28
+
29
+ ---
30
+
20
31
  ## Quickstart
21
32
 
22
33
  To get started with _semantic-router_ we install it like so:
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "semantic-router"
3
- version = "0.2.0.dev1"
3
+ version = "0.3.1"
4
4
  description = "Super fast semantic router for AI decision making"
5
5
  authors = [{ name = "Aurelio AI", email = "hello@aurelio.ai" }]
6
- requires-python = ">=3.9,<3.14"
6
+ requires-python = ">=3.10,<3.14"
7
7
  readme = "README.md"
8
8
  license = "MIT"
9
9
  dependencies = [
@@ -27,8 +27,9 @@ dependencies = [
27
27
  [project.optional-dependencies]
28
28
  # only required by LiteLLMEncoder; the provider-specific encoders use their own SDKs
29
29
  litellm = [
30
- # https://www.cve.org/CVERecord?id=CVE-2026-42208 explains >=1.83.7
31
- "litellm>=1.83.7",
30
+ # 1.84.0 fixes an authentication bypass via Host header injection
31
+ # (GHSA-4xpc-pv4p-pm3w); 1.83.7 was the previous floor for CVE-2026-42208
32
+ "litellm>=1.84.0",
32
33
  ]
33
34
  ollama = ["ollama>=0.1.7"]
34
35
  local = [
@@ -42,10 +43,11 @@ pinecone = ["pinecone[asyncio]>=7.0.0,<8.0.0"]
42
43
  vision = [
43
44
  "torchvision>=0.17.0 ; python_version < '3.13'",
44
45
  "transformers>=4.36.2 ; python_version < '3.13'",
45
- "pillow>=10.2.0,<11.0.0 ; python_version < '3.13'",
46
+ "pillow>=10.2.0 ; python_version < '3.13'",
46
47
  "torch>=2.6.0 ; python_version < '3.13'"
47
48
  ]
48
49
  qdrant = ["qdrant-client>=1.11.1,<2"]
50
+ redis = ["redis>=5.0.0,<7"]
49
51
  google = ["google-cloud-aiplatform>=1.45.0,<2"]
50
52
  bedrock = [
51
53
  "boto3>=1.34.98,<2",
@@ -54,8 +56,24 @@ bedrock = [
54
56
  postgres = [
55
57
  "psycopg[binary]>=3.1.0,<4",
56
58
  ]
57
- fastembed = ["fastembed>=0.3.0,<0.4 ; python_version < '3.13'"]
58
- docs = ["pydoc-markdown>=4.8.2 ; python_version < '3.12'"]
59
+ fastembed = ["fastembed>=0.7.0,<1"]
60
+ all = [
61
+ "semantic-router[local]",
62
+ "semantic-router[pinecone]",
63
+ "semantic-router[vision]",
64
+ "semantic-router[qdrant]",
65
+ "semantic-router[redis]",
66
+ "semantic-router[google]",
67
+ "semantic-router[bedrock]",
68
+ "semantic-router[postgres]",
69
+ "semantic-router[fastembed]",
70
+ "semantic-router[litellm]",
71
+ "semantic-router[ollama]",
72
+ ]
73
+
74
+ # Development tooling. `uv sync` and `uv run` install the `dev` group by default,
75
+ # so a fresh clone gets pytest, ruff and mypy without asking for any extra.
76
+ [dependency-groups]
59
77
  dev = [
60
78
  "ipykernel>=6.25.0,<7",
61
79
  "ruff>=0.11.2,<0.12",
@@ -69,25 +87,9 @@ dev = [
69
87
  "types-pyyaml>=6.0.12.12,<7",
70
88
  "requests-mock>=1.12.1,<2",
71
89
  "types-requests>=2.31.0,<3",
72
- "dagger-io>=0.1.1 ; python_version >= '3.11'",
73
90
  "python-dotenv>=1.0.0,<2",
74
- # needed so `mypy semantic_router/` can resolve the LiteLLMEncoder import
75
- "semantic-router[litellm]",
76
- ]
77
- all = [
78
- "semantic-router[local]",
79
- "semantic-router[pinecone]",
80
- "semantic-router[vision]",
81
- "semantic-router[qdrant]",
82
- "semantic-router[google]",
83
- "semantic-router[bedrock]",
84
- "semantic-router[postgres]",
85
- "semantic-router[fastembed]",
86
- "semantic-router[litellm]",
87
- "semantic-router[dev]",
88
- "semantic-router[ollama]",
89
- "pytest-timeout",
90
91
  ]
92
+ docs = ["pydoc-markdown>=4.8.2 ; python_version < '3.12'"]
91
93
 
92
94
  [tool.hatch.build.targets.sdist]
93
95
  include = ["semantic_router"]
@@ -105,6 +107,9 @@ build-backend = "hatchling.build"
105
107
  [tool.ruff]
106
108
  line-length = 88
107
109
 
110
+ [tool.ruff.format]
111
+ exclude = ["*.ipynb"]
112
+
108
113
  [tool.ruff.lint]
109
114
  select = ["E", "F", "I", "T201", "NPY201"]
110
115
  ignore = ["E501"]
@@ -132,3 +137,10 @@ torchvision = [
132
137
  { index = "pytorch-nightly", marker = "python_version >= '3.13' and sys_platform == 'darwin'" },
133
138
  { index = "pytorch-cpu", marker = "python_version < '3.13' or sys_platform != 'darwin'" },
134
139
  ]
140
+
141
+ [tool.pytest.ini_options]
142
+ testpaths = ["tests"]
143
+ addopts = "--strict-markers"
144
+ markers = [
145
+ "live: calls a paid third-party API (OpenAI, Cohere, ...). Needs real keys; excluded from the default `make test` run.",
146
+ ]
@@ -0,0 +1,11 @@
1
+ from semantic_router.route import Route
2
+ from semantic_router.routers import HybridRouter, RouterConfig, SemanticRouter
3
+
4
+ __all__ = ["SemanticRouter", "HybridRouter", "Route", "RouterConfig"]
5
+
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ __version__ = version("semantic-router")
10
+ except PackageNotFoundError: # running from a checkout that was never installed
11
+ __version__ = "0.0.0"
@@ -26,12 +26,52 @@ class BM25Encoder(SparseEncoder, FittableMixin, AsymmetricSparseMixin):
26
26
  - we train a BM25 encoder's normalization parameters on a sufficiently large corpus to capture target language distribution
27
27
  - these trained parameter allow us to balance TF & IDF of query & documents for retrieval (read more on how BM25 fixes issues with TF-IDF)
28
28
 
29
+ The reference formula is section 4.1 of the ATIRE paper:
30
+
31
+ RSV_d = Σ_{t ∈ q} log(N / df_t) · (k1 + 1) · tf_td
32
+ / (k1 · ((1 - b) + b · L_d / L_avg) + tf_td)
33
+
34
+ Deviations from the paper
35
+ -------------------------
36
+ This implementation is faithful to the formula above on the document side,
37
+ but deviates from it in three documented ways. Each is deliberate; see the
38
+ inline comments at the point of use for the details.
39
+
40
+ 1. IDF smoothing. We compute `log((N + 1) / (df_t + 0.5))` rather than
41
+ `log(N / df_t)`, to avoid a division by zero for unseen terms. This
42
+ preserves the strictly-positive IDF that the ATIRE variant exists for.
43
+
44
+ 2. Query weight normalization. The paper produces an unbounded relevance
45
+ score, which is fine for a search engine that only ranks within a single
46
+ query. A router instead compares scores against a fixed
47
+ `score_threshold`, so scores must be comparable *across* queries. We
48
+ therefore L1-normalize the query weights and scale them by
49
+ `1 / (k1 + 1)`, which bounds the query·document dot product to [0, 1] --
50
+ the same range as the cosine similarity returned by the dense encoders,
51
+ so the two can be blended in `HybridRouter` and thresholded alike.
52
+
53
+ This is ranking-neutral within a query (it is a single positive constant
54
+ per query) but it does discard cross-query IDF magnitude: two queries
55
+ with different total IDF mass can produce the same score.
56
+
57
+ 3. Parameter defaults. We default to `k1=1.5, b=0.75` rather than the
58
+ paper's `k1=0.9, b=0.4`. See the `k1` and `b` parameter docs below.
59
+
29
60
  ATIRE Paper: https://www.cs.otago.ac.nz/research/student-publications/atire-opensource.pdf
30
61
  Pinecone Implementation: https://github.com/pinecone-io/pinecone-text/blob/8399f9ff28c4652766c35165c0db9b0eff309077/pinecone_text/sparse/bm25_encoder.py
31
62
 
32
- :param k1: normalizer parameter that limits how much a single query term `q_i ∈ q` can affect score for document `D_n`
63
+ :param k1: normalizer parameter that limits how much a single query term `q_i ∈ q` can affect score for document `D_n`.
64
+ Defaults to 1.5, the standard Robertson value, *not* the 0.9 reported in the ATIRE paper. The paper's value was
65
+ trained on the INEX 2008 Wikipedia collection, whose documents average 878 words (paper table 5), whereas this
66
+ encoder is fitted on route utterances averaging on the order of ten tokens. Transferring the paper's parameters
67
+ to utterance-length text is not obviously more correct, and changing the default would silently shift the scores
68
+ that existing `score_threshold` values were tuned against.
33
69
  :type k1: float
34
- :param b: normalizer parameter that balances the effect of a single document length compared to the average document length
70
+ :param b: normalizer parameter that balances the effect of a single document length compared to the average document
71
+ length. Defaults to 0.75 rather than the paper's 0.4, for the same reason as `k1`. Note that `b` is considerably
72
+ more influential on short text: over utterances spanning 0.3x to 2.2x the average length, `b=0.75` spreads the
73
+ document factor by ~2.2x versus ~1.4x for `b=0.4`. Lower it towards the paper's value if length is over-weighted
74
+ for your route set.
35
75
  :type b: float
36
76
  :param corpus_size: number of documents in the trained corpus
37
77
  :type corpus_size: int, optional
@@ -193,16 +233,55 @@ class BM25Encoder(SparseEncoder, FittableMixin, AsymmetricSparseMixin):
193
233
  raise ValueError("No documents provided for encoding")
194
234
 
195
235
  # Convert queries to token counts
196
- queries_ids = self._tokenizer.tokenize(queries)
236
+ queries_ids = self._tokenizer.tokenize(queries, pad=True)
197
237
  df = self._df(queries_ids) # (batch_size, vocab_size)
198
238
  N = self.corpus_size
239
+
240
+ # DEVIATION FROM THE ATIRE PAPER: the paper specifies `log(N / df_t)`,
241
+ # whereas we compute `log((N + 1) / (df_t + 0.5))`. The `+ 0.5` avoids a
242
+ # division by zero and the `+ 1` keeps the numerator above the
243
+ # denominator, so the result is strictly positive. This preserves the
244
+ # property the ATIRE variant was introduced for (IDF is never negative,
245
+ # see footnote 1 of the paper) and stays monotonically decreasing in
246
+ # `df_t`, but it is a smoothed variant rather than the paper's formula.
247
+ # It weights common terms slightly less harshly than `log(N / df_t)`.
199
248
  df = df + np.where(df > 0, 0.5, 0)
200
249
  idf = np.divide(N + 1, df, out=np.zeros_like(df), where=df != 0)
201
250
  idf = np.log(
202
251
  idf, out=np.zeros_like(df), where=df != 0
203
252
  ) # (batch_size, vocab_size)
253
+
254
+ # The paper sums over the query terms `t ∈ q`, so a term repeated in the
255
+ # query contributes once per occurrence. Weighting IDF by the query term
256
+ # frequency is equivalent, and keeps the operation vectorised.
257
+ qtf = self._tf(queries_ids) # (batch_size, vocab_size)
258
+ weighted_idf = idf * qtf
259
+
260
+ # DEVIATION FROM THE ATIRE PAPER: the paper's RSV is an unbounded sum,
261
+ # which is fine when only ranking within one query. A router compares
262
+ # scores against a fixed `score_threshold`, so they must be comparable
263
+ # across queries too. Two steps make that so:
264
+ #
265
+ # 1. L1-normalize, so the weights sum to 1 regardless of how long the
266
+ # query is or how rare its terms are.
267
+ # 2. Divide by `k1 + 1`, so the weights sum to `1 / (k1 + 1)`.
268
+ #
269
+ # `encode_documents` returns values in (0, k1 + 1] (it keeps the paper's
270
+ # `(k1 + 1)` numerator), so the dot product of the two is bounded to
271
+ # [0, 1] -- the same range as the cosine similarity produced by the
272
+ # dense encoders. That lets `HybridRouter` blend the two and lets one
273
+ # threshold apply to both. Without the `k1 + 1` divisor the sparse score
274
+ # would reach `k1 + 1` (2.5 at the default `k1`) and would dominate the
275
+ # dense component of the hybrid score.
276
+ #
277
+ # Both steps are a single positive constant per query, so neither
278
+ # changes the ranking of documents for that query.
279
+ denominator = weighted_idf.sum(axis=1)[:, np.newaxis] * (self.k1 + 1.0)
204
280
  idf_norm = np.divide(
205
- idf, idf.sum(axis=1)[:, np.newaxis], out=np.zeros_like(idf), where=idf != 0
281
+ weighted_idf,
282
+ denominator,
283
+ out=np.zeros_like(weighted_idf),
284
+ where=weighted_idf != 0,
206
285
  )
207
286
 
208
287
  return self._array_to_sparse_embeddings(idf_norm)
@@ -215,7 +294,7 @@ class BM25Encoder(SparseEncoder, FittableMixin, AsymmetricSparseMixin):
215
294
  r"""Returns document term frequency normed by itself & average trained corpus length
216
295
  (This is the right-hand side of the BM25 equation, which gets matmul-ed with the query IDF component)
217
296
 
218
- LaTeX: $\frac{f(d_i, D)}{f(d_i, D) + k_1 \times (1 - b + b \times \frac{|D|}{avgdl})}$
297
+ LaTeX: $\frac{(k_1 + 1) \times f(d_i, D)}{f(d_i, D) + k_1 \times (1 - b + b \times \frac{|D|}{avgdl})}$
219
298
  where:
220
299
  f(d_i, D) is frequency of term `d_i ∈ D`
221
300
  |D| is the document length
@@ -244,9 +323,9 @@ class BM25Encoder(SparseEncoder, FittableMixin, AsymmetricSparseMixin):
244
323
  queries_ids = self._tokenizer.tokenize(documents, pad=True)
245
324
  tf = self._tf(queries_ids) # (batch_size, vocab_size)
246
325
  tf_sum = tf.sum(axis=1) # (batch_size, 1)
247
- tf_normed = tf / (
326
+ tf_normed = ((self.k1 + 1.0) * tf) / (
248
327
  self.k1
249
- * (1.0 - self.b * self.b * (tf_sum[:, np.newaxis] / self._avg_doc_len))
328
+ * ((1.0 - self.b) + self.b * (tf_sum[:, np.newaxis] / self._avg_doc_len))
250
329
  + tf
251
330
  ) # (batch_size, vocab_size)
252
331
 
@@ -96,20 +96,22 @@ class CLIPEncoder(DenseEncoder):
96
96
  :returns: A tuple of the tokenizer, processor, and model.
97
97
  :rtype: Tuple[Any, Any, Any]
98
98
  """
99
+ # torch first: transformers >= 5 imports torch itself, so checking it
100
+ # second would report a missing torch as a missing transformers
99
101
  try:
100
- from transformers import CLIPModel, CLIPProcessor, CLIPTokenizerFast
102
+ import torch
101
103
  except ImportError:
102
104
  raise ImportError(
103
- "Please install transformers to use CLIPEncoder. "
105
+ "Please install Pytorch to use CLIPEncoder. "
104
106
  "You can install it with: "
105
107
  "`pip install semantic-router[vision]`"
106
108
  )
107
109
 
108
110
  try:
109
- import torch
111
+ from transformers import CLIPModel, CLIPProcessor, CLIPTokenizerFast
110
112
  except ImportError:
111
113
  raise ImportError(
112
- "Please install Pytorch to use CLIPEncoder. "
114
+ "Please install transformers to use CLIPEncoder. "
113
115
  "You can install it with: "
114
116
  "`pip install semantic-router[vision]`"
115
117
  )
@@ -166,6 +168,9 @@ class CLIPEncoder(DenseEncoder):
166
168
  ).to(self.device)
167
169
  with self._torch.no_grad():
168
170
  embeds = self._model.get_text_features(**inputs)
171
+ # transformers >= 5 returns a model output whose pooler_output holds the
172
+ # projected features; earlier versions return that tensor directly
173
+ embeds = getattr(embeds, "pooler_output", embeds)
169
174
  embeds = embeds.squeeze(0).cpu().detach().numpy()
170
175
  return embeds
171
176
 
@@ -183,6 +188,7 @@ class CLIPEncoder(DenseEncoder):
183
188
  ].to(self.device)
184
189
  with self._torch.no_grad():
185
190
  embeds = self._model.get_image_features(pixel_values=inputs)
191
+ embeds = getattr(embeds, "pooler_output", embeds) # see _encode_text
186
192
  embeds = embeds.squeeze(0).cpu().detach().numpy()
187
193
  return embeds
188
194
 
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  from typing import Any, List, Optional
2
3
 
3
4
  import numpy as np
@@ -71,3 +72,17 @@ class FastEmbedEncoder(DenseEncoder):
71
72
  return embeddings
72
73
  except Exception as e:
73
74
  raise ValueError(f"FastEmbed embed failed. Error: {e}") from e
75
+
76
+ async def acall(self, docs: List[str]) -> List[List[float]]:
77
+ """Embed a list of documents asynchronously. Supports text only.
78
+
79
+ FastEmbed itself is synchronous, so this offloads the embedding call to a
80
+ thread to avoid blocking the event loop.
81
+
82
+ :param docs: The documents to embed.
83
+ :type docs: List[str]
84
+ :raise ValueError: If the embedding fails.
85
+ :return: The vector embeddings of the documents.
86
+ :rtype: List[List[float]]
87
+ """
88
+ return await asyncio.to_thread(lambda: self.__call__(docs))
@@ -118,6 +118,27 @@ class OpenAIEncoder(DenseEncoder):
118
118
  # get token encoder
119
119
  self._token_encoder = tiktoken.encoding_for_model(name)
120
120
 
121
+ def _validate_docs(self, docs: List[str]) -> None:
122
+ """Raise a clear error for input the embeddings API rejects with a 400.
123
+ Checked before the call, since a 400 is an OpenAIError and gets retried."""
124
+ if isinstance(docs, str):
125
+ raise ValueError("OpenAI encoder input must be a list of strings, got str.")
126
+ if len(docs) == 0:
127
+ raise ValueError("OpenAI encoder input cannot be empty.")
128
+
129
+ for idx, doc in enumerate(docs):
130
+ if not isinstance(doc, str):
131
+ raise ValueError(
132
+ "OpenAI encoder input must contain only strings, got "
133
+ f"{type(doc).__name__} at index {idx}."
134
+ )
135
+ # the API accepts whitespace-only strings, so only "" is rejected, and
136
+ # docs are sent unchanged: stripping would change their embeddings
137
+ if doc == "":
138
+ raise ValueError(
139
+ f"OpenAI encoder input contains an empty string at index {idx}."
140
+ )
141
+
121
142
  def __call__(self, docs: List[str], truncate: bool = True) -> List[List[float]]:
122
143
  """Encode a list of text documents into embeddings using OpenAI API.
123
144
 
@@ -130,6 +151,8 @@ class OpenAIEncoder(DenseEncoder):
130
151
  raise ValueError("OpenAI client is not initialized.")
131
152
  embeds = None
132
153
 
154
+ self._validate_docs(docs)
155
+
133
156
  if truncate:
134
157
  # check if any document exceeds token limit and truncate if so
135
158
  docs = [self._truncate(doc) for doc in docs]
@@ -202,6 +225,8 @@ class OpenAIEncoder(DenseEncoder):
202
225
  raise ValueError("OpenAI async client is not initialized.")
203
226
  embeds = None
204
227
 
228
+ self._validate_docs(docs)
229
+
205
230
  if truncate:
206
231
  # check if any document exceeds token limit and truncate if so
207
232
  docs = [self._truncate(doc) for doc in docs]
@@ -41,21 +41,23 @@ class VitEncoder(DenseEncoder):
41
41
  :return: The processor and model.
42
42
  :rtype: tuple
43
43
  """
44
+ # torch first: transformers >= 5 imports torch itself, so checking it
45
+ # second would report a missing torch as a missing transformers
44
46
  try:
45
- from transformers import ViTImageProcessor, ViTModel
47
+ import torch
48
+ import torchvision.transforms as T
46
49
  except ImportError:
47
50
  raise ImportError(
48
- "Please install transformers to use VitEncoder. "
51
+ "Please install Pytorch to use VitEncoder. "
49
52
  "You can install it with: "
50
53
  "`pip install semantic-router[vision]`"
51
54
  )
52
55
 
53
56
  try:
54
- import torch
55
- import torchvision.transforms as T
57
+ from transformers import ViTImageProcessor, ViTModel
56
58
  except ImportError:
57
59
  raise ImportError(
58
- "Please install Pytorch to use VitEncoder. "
60
+ "Please install transformers to use VitEncoder. "
59
61
  "You can install it with: "
60
62
  "`pip install semantic-router[vision]`"
61
63
  )
@@ -4,6 +4,7 @@ from semantic_router.index.local import LocalIndex
4
4
  from semantic_router.index.pinecone import PineconeIndex
5
5
  from semantic_router.index.postgres import PostgresIndex
6
6
  from semantic_router.index.qdrant import QdrantIndex
7
+ from semantic_router.index.redis import RedisIndex
7
8
 
8
9
  __all__ = [
9
10
  "BaseIndex",
@@ -12,4 +13,5 @@ __all__ = [
12
13
  "QdrantIndex",
13
14
  "PineconeIndex",
14
15
  "PostgresIndex",
16
+ "RedisIndex",
15
17
  ]
@@ -43,6 +43,10 @@ class LocalIndex(BaseIndex):
43
43
  :param metadata_list: List of metadata to add to the index.
44
44
  :type metadata_list: List[Dict[str, Any]]
45
45
  """
46
+ if metadata_list and len(metadata_list) != len(embeddings):
47
+ raise ValueError(
48
+ f"metadata_list length ({len(metadata_list)}) must match embeddings length ({len(embeddings)})."
49
+ )
46
50
  embeds = np.array(embeddings) # type: ignore
47
51
  routes_arr = np.array(routes)
48
52
  if isinstance(utterances[0], str):
@@ -191,7 +195,7 @@ class LocalIndex(BaseIndex):
191
195
  filtered_index.append(vec)
192
196
  filtered_routes.append(route)
193
197
  if not filtered_routes:
194
- raise ValueError("No routes found matching the filter criteria.")
198
+ return np.array([]), []
195
199
  sim = similarity_matrix(vector, np.array(filtered_index))
196
200
  scores, idx = top_scores(sim, top_k)
197
201
  route_names = [filtered_routes[i] for i in idx]
@@ -231,7 +235,7 @@ class LocalIndex(BaseIndex):
231
235
  filtered_index.append(vec)
232
236
  filtered_routes.append(route)
233
237
  if not filtered_routes:
234
- raise ValueError("No routes found matching the filter criteria.")
238
+ return np.array([]), []
235
239
  sim = similarity_matrix(vector, np.array(filtered_index))
236
240
  scores, idx = top_scores(sim, top_k)
237
241
  route_names = [filtered_routes[i] for i in idx]