From cee97c9e435acebb5fa69255423fc5dfa066dbb6 Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 13:32:22 +0400 Subject: [PATCH 01/10] feat: add test for single route single utterance --- tests/unit/test_router.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index ef36e0ab..b7484650 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -148,6 +148,11 @@ def routes_4(): Route(name="Route 2", utterances=["Asparagus"]), ] +@pytest.fixture +def route_single_utterance(): + return [ + Route(name="Route 1", utterances=["Hello"]), + ] @pytest.fixture def dynamic_routes(): @@ -251,6 +256,21 @@ def test_initialization_dynamic_route( ) assert route_layer.score_threshold == openai_encoder.score_threshold + def test_initialization_single_utterance( + self, route_single_utterance, openai_encoder, index_cls + ): + index = init_index(index_cls) + route_layer = SemanticRouter( + encoder=openai_encoder, + routes=route_single_utterance, + index=index, + auto_sync="local", + ) + assert route_layer.score_threshold == openai_encoder.score_threshold + if index_cls is PineconeIndex: + time.sleep(PINECONE_SLEEP) # allow for index to be updated + assert len(route_layer.index.get_utterances()) == 1 + def test_delete_index(self, openai_encoder, routes, index_cls): # TODO merge .delete_index() and .delete_all() and get working index = init_index(index_cls) From ec78a75bef1c45ecefa5602d19955008c5560966 Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 13:35:26 +0400 Subject: [PATCH 02/10] chore: lint --- tests/unit/test_router.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index b7484650..0dece3d7 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -148,12 +148,14 @@ def routes_4(): Route(name="Route 2", utterances=["Asparagus"]), ] + @pytest.fixture def route_single_utterance(): return [ Route(name="Route 1", utterances=["Hello"]), ] + @pytest.fixture def dynamic_routes(): return [ From f269a24e9e6548a7a2d3a6c6faab50491da0bcf2 Mon Sep 17 00:00:00 2001 From: Ismail Ashraq Date: Sun, 15 Dec 2024 17:54:24 +0800 Subject: [PATCH 03/10] fix vector shape for single utterance --- semantic_router/routers/base.py | 4 ++-- semantic_router/routers/semantic.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/semantic_router/routers/base.py b/semantic_router/routers/base.py index 328cf2b7..18d71ced 100644 --- a/semantic_router/routers/base.py +++ b/semantic_router/routers/base.py @@ -522,7 +522,7 @@ def _retrieve_top_route( """ # get relevant results (scores and routes) results = self._retrieve( - xq=np.array(vector), top_k=self.top_k, route_filter=route_filter + xq=vector[0], top_k=self.top_k, route_filter=route_filter ) # decide most relevant routes top_class, top_class_scores = self._semantic_classify(results) @@ -535,7 +535,7 @@ async def _async_retrieve_top_route( ) -> Tuple[Optional[Route], List[float]]: # get relevant results (scores and routes) results = await self._async_retrieve( - xq=np.array(vector), top_k=self.top_k, route_filter=route_filter + xq=vector[0], top_k=self.top_k, route_filter=route_filter ) # decide most relevant routes top_class, top_class_scores = await self._async_semantic_classify(results) diff --git a/semantic_router/routers/semantic.py b/semantic_router/routers/semantic.py index 33af2a32..41c92d53 100644 --- a/semantic_router/routers/semantic.py +++ b/semantic_router/routers/semantic.py @@ -40,14 +40,12 @@ def _encode(self, text: list[str]) -> Any: """Given some text, encode it.""" # create query vector xq = np.array(self.encoder(text)) - xq = np.squeeze(xq) # Reduce to 1d array. return xq async def _async_encode(self, text: list[str]) -> Any: """Given some text, encode it.""" # create query vector xq = np.array(await self.encoder.acall(docs=text)) - xq = np.squeeze(xq) # Reduce to 1d array. return xq def add(self, routes: List[Route] | Route): From a89724ded19a30c23500ee1e62ded5f1a3376815 Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 14:19:43 +0400 Subject: [PATCH 04/10] fix: test for single utt --- tests/unit/test_router.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index 0dece3d7..c7dec080 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -258,16 +258,16 @@ def test_initialization_dynamic_route( ) assert route_layer.score_threshold == openai_encoder.score_threshold - def test_initialization_single_utterance( + def test_add_single_utterance( self, route_single_utterance, openai_encoder, index_cls ): index = init_index(index_cls) route_layer = SemanticRouter( encoder=openai_encoder, - routes=route_single_utterance, index=index, auto_sync="local", ) + route_layer.add(routes=[route_single_utterance]) assert route_layer.score_threshold == openai_encoder.score_threshold if index_cls is PineconeIndex: time.sleep(PINECONE_SLEEP) # allow for index to be updated From 1f2b9f0efdf75262418bc59a1f1c449649b20463 Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 14:22:27 +0400 Subject: [PATCH 05/10] fix: modify test to catch error --- tests/unit/test_router.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index c7dec080..be10e3b6 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -152,7 +152,7 @@ def routes_4(): @pytest.fixture def route_single_utterance(): return [ - Route(name="Route 1", utterances=["Hello"]), + Route(name="Route 3", utterances=["Hello"]), ] @@ -259,11 +259,12 @@ def test_initialization_dynamic_route( assert route_layer.score_threshold == openai_encoder.score_threshold def test_add_single_utterance( - self, route_single_utterance, openai_encoder, index_cls + self, routes, route_single_utterance, openai_encoder, index_cls ): index = init_index(index_cls) route_layer = SemanticRouter( encoder=openai_encoder, + routes=routes, index=index, auto_sync="local", ) From 04d2a87c89f4affa3de239844a00157f8b313d86 Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 14:27:41 +0400 Subject: [PATCH 06/10] fix: better test coverage --- tests/unit/test_router.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index be10e3b6..067e78a9 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -272,6 +272,23 @@ def test_add_single_utterance( assert route_layer.score_threshold == openai_encoder.score_threshold if index_cls is PineconeIndex: time.sleep(PINECONE_SLEEP) # allow for index to be updated + _ = route_layer("Hello") + assert len(route_layer.index.get_utterances()) == 6 + + def test_init_and_add_single_utterance( + self, route_single_utterance, openai_encoder, index_cls + ): + index = init_index(index_cls) + route_layer = SemanticRouter( + encoder=openai_encoder, + index=index, + auto_sync="local", + ) + if index_cls is PineconeIndex: + time.sleep(PINECONE_SLEEP) # allow for index to be updated + route_layer.add(routes=[route_single_utterance]) + assert route_layer.score_threshold == openai_encoder.score_threshold + _ = route_layer("Hello") assert len(route_layer.index.get_utterances()) == 1 def test_delete_index(self, openai_encoder, routes, index_cls): From 3da48cd047dd246a75310e268af2791ec919e10c Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 14:42:42 +0400 Subject: [PATCH 07/10] fix: single utt tests --- tests/unit/test_router.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index 067e78a9..1741865b 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -268,7 +268,7 @@ def test_add_single_utterance( index=index, auto_sync="local", ) - route_layer.add(routes=[route_single_utterance]) + route_layer.add(routes=route_single_utterance) assert route_layer.score_threshold == openai_encoder.score_threshold if index_cls is PineconeIndex: time.sleep(PINECONE_SLEEP) # allow for index to be updated @@ -286,7 +286,7 @@ def test_init_and_add_single_utterance( ) if index_cls is PineconeIndex: time.sleep(PINECONE_SLEEP) # allow for index to be updated - route_layer.add(routes=[route_single_utterance]) + route_layer.add(routes=route_single_utterance) assert route_layer.score_threshold == openai_encoder.score_threshold _ = route_layer("Hello") assert len(route_layer.index.get_utterances()) == 1 From 12195ba2e9a2cd0c793586da197c69539fd3badc Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 14:45:20 +0400 Subject: [PATCH 08/10] chore: update version --- docs/source/conf.py | 2 +- pyproject.toml | 2 +- semantic_router/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 49131e8d..7442f480 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -15,7 +15,7 @@ project = "Semantic Router" copyright = "2024, Aurelio AI" author = "Aurelio AI" -release = "0.1.0.dev3" +release = "0.1.0.dev4" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/pyproject.toml b/pyproject.toml index 14105630..e246a21d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "semantic-router" -version = "0.1.0.dev3" +version = "0.1.0.dev4" description = "Super fast semantic router for AI decision making" authors = ["Aurelio AI "] readme = "README.md" diff --git a/semantic_router/__init__.py b/semantic_router/__init__.py index 93f2fc44..5cac23dc 100644 --- a/semantic_router/__init__.py +++ b/semantic_router/__init__.py @@ -3,4 +3,4 @@ __all__ = ["SemanticRouter", "HybridRouter", "Route", "RouterConfig"] -__version__ = "0.1.0.dev3" +__version__ = "0.1.0.dev4" From dc14624e6ea1bed2a2c1166f99966d19e8d6d13e Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 16:28:43 +0400 Subject: [PATCH 09/10] fix: types and arrays --- semantic_router/routers/base.py | 52 +++++++++++++++++++++++---------- tests/unit/test_router.py | 2 ++ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/semantic_router/routers/base.py b/semantic_router/routers/base.py index 18d71ced..0bfc4eea 100644 --- a/semantic_router/routers/base.py +++ b/semantic_router/routers/base.py @@ -4,6 +4,7 @@ import random import hashlib from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing_extensions import deprecated from pydantic import BaseModel, Field import numpy as np @@ -280,6 +281,20 @@ def get_hash(self) -> ConfigParameter: ) +def xq_reshape(xq: List[float] | np.ndarray) -> np.ndarray: + # convert to numpy array if not already + if not isinstance(xq, np.ndarray): + xq = np.array(xq) + # check if vector is 1D and expand to 2D if necessary + if len(xq.shape) == 1: + xq = np.expand_dims(xq, axis=0) + if xq.shape[0] != 1: + raise ValueError( + f"Expected (1, x) dimensional input for query, got {xq.shape}." + ) + return xq + + class BaseRouter(BaseModel): encoder: DenseEncoder = Field(default_factory=OpenAIEncoder) index: BaseIndex = Field(default_factory=BaseIndex) @@ -402,7 +417,7 @@ def check_for_matching_routes(self, top_class: str) -> Optional[Route]: def __call__( self, text: Optional[str] = None, - vector: Optional[List[float]] = None, + vector: Optional[List[float] | np.ndarray] = None, simulate_static: bool = False, route_filter: Optional[List[str]] = None, ) -> RouteChoice: @@ -411,6 +426,9 @@ def __call__( if text is None: raise ValueError("Either text or vector must be provided") vector = self._encode(text=[text]) + # convert to numpy array if not already + vector = xq_reshape(vector) + # calculate semantics route, top_class_scores = self._retrieve_top_route(vector, route_filter) passed = self._check_threshold(top_class_scores, route) if passed and route is not None and not simulate_static: @@ -444,7 +462,7 @@ def __call__( async def acall( self, text: Optional[str] = None, - vector: Optional[List[float]] = None, + vector: Optional[List[float] | np.ndarray] = None, simulate_static: bool = False, route_filter: Optional[List[str]] = None, ) -> RouteChoice: @@ -453,7 +471,9 @@ async def acall( if text is None: raise ValueError("Either text or vector must be provided") vector = await self._async_encode(text=[text]) - + # convert to numpy array if not already + vector = xq_reshape(vector) + # calculate semantics route, top_class_scores = await self._async_retrieve_top_route( vector, route_filter ) @@ -483,19 +503,21 @@ async def acall( # if no route passes threshold, return empty route choice return RouteChoice() + # TODO: add multiple routes return to __call__ and acall + @deprecated("This method is deprecated. Use `__call__` instead.") def retrieve_multiple_routes( self, text: Optional[str] = None, - vector: Optional[List[float]] = None, + vector: Optional[List[float] | np.ndarray] = None, ) -> List[RouteChoice]: if vector is None: if text is None: raise ValueError("Either text or vector must be provided") - vector_arr = self._encode(text=[text]) - else: - vector_arr = np.array(vector) + vector = self._encode(text=[text]) + # convert to numpy array if not already + vector = xq_reshape(vector) # get relevant utterances - results = self._retrieve(xq=vector_arr) + results = self._retrieve(xq=vector) # decide most relevant routes categories_with_scores = self._semantic_classify_multiple_routes(results) return [ @@ -514,16 +536,14 @@ def retrieve_multiple_routes( # return route_choices def _retrieve_top_route( - self, vector: List[float], route_filter: Optional[List[str]] = None + self, vector: np.ndarray, route_filter: Optional[List[str]] = None ) -> Tuple[Optional[Route], List[float]]: """ Retrieve the top matching route based on the given vector. Returns a tuple of the route (if any) and the scores of the top class. """ # get relevant results (scores and routes) - results = self._retrieve( - xq=vector[0], top_k=self.top_k, route_filter=route_filter - ) + results = self._retrieve(xq=vector, top_k=self.top_k, route_filter=route_filter) # decide most relevant routes top_class, top_class_scores = self._semantic_classify(results) # TODO do we need this check? @@ -531,11 +551,11 @@ def _retrieve_top_route( return route, top_class_scores async def _async_retrieve_top_route( - self, vector: List[float], route_filter: Optional[List[str]] = None + self, vector: np.ndarray, route_filter: Optional[List[str]] = None ) -> Tuple[Optional[Route], List[float]]: # get relevant results (scores and routes) results = await self._async_retrieve( - xq=vector[0], top_k=self.top_k, route_filter=route_filter + xq=vector, top_k=self.top_k, route_filter=route_filter ) # decide most relevant routes top_class, top_class_scores = await self._async_semantic_classify(results) @@ -939,7 +959,7 @@ def _retrieve( """Given a query vector, retrieve the top_k most similar records.""" # get scores and routes scores, routes = self.index.query( - vector=xq, top_k=top_k, route_filter=route_filter + vector=xq[0], top_k=top_k, route_filter=route_filter ) return [{"route": d, "score": s.item()} for d, s in zip(routes, scores)] @@ -949,7 +969,7 @@ async def _async_retrieve( """Given a query vector, retrieve the top_k most similar records.""" # get scores and routes scores, routes = await self.index.aquery( - vector=xq, top_k=top_k, route_filter=route_filter + vector=xq[0], top_k=top_k, route_filter=route_filter ) return [{"route": d, "score": s.item()} for d, s in zip(routes, scores)] diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index 1741865b..1f743f1c 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -826,6 +826,8 @@ def test_retrieve_with_vector(self, openai_encoder, routes, index_cls): auto_sync="local", ) vector = [0.1, 0.2, 0.3] + if index_cls is PineconeIndex: + time.sleep(PINECONE_SLEEP) # allow for index to be populated results = route_layer.retrieve_multiple_routes(vector=vector) assert len(results) >= 1, "Expected at least one result" assert any( From 28d69316b241b420eb0e4a2738607e4e7c3da0f7 Mon Sep 17 00:00:00 2001 From: James Briggs <35938317+jamescalam@users.noreply.github.com> Date: Sun, 15 Dec 2024 16:31:21 +0400 Subject: [PATCH 10/10] fix: types and arrays for hybrid --- semantic_router/routers/hybrid.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/semantic_router/routers/hybrid.py b/semantic_router/routers/hybrid.py index 994fcb2d..54901d5e 100644 --- a/semantic_router/routers/hybrid.py +++ b/semantic_router/routers/hybrid.py @@ -14,7 +14,7 @@ from semantic_router.index import BaseIndex, HybridLocalIndex from semantic_router.schema import RouteChoice, SparseEmbedding, Utterance from semantic_router.utils.logger import logger -from semantic_router.routers.base import BaseRouter +from semantic_router.routers.base import BaseRouter, xq_reshape from semantic_router.llms import BaseLLM @@ -197,18 +197,19 @@ async def _async_encode( def __call__( self, text: Optional[str] = None, - vector: Optional[List[float]] = None, + vector: Optional[List[float] | np.ndarray] = None, simulate_static: bool = False, route_filter: Optional[List[str]] = None, sparse_vector: dict[int, float] | SparseEmbedding | None = None, ) -> RouteChoice: - vector_arr: np.ndarray | None = None potential_sparse_vector: List[SparseEmbedding] | None = None # if no vector provided, encode text to get vector if vector is None: if text is None: raise ValueError("Either text or vector must be provided") - vector_arr, potential_sparse_vector = self._encode(text=[text]) + vector, potential_sparse_vector = self._encode(text=[text]) + # convert to numpy array if not already + vector = xq_reshape(vector) if sparse_vector is None: if text is None: raise ValueError("Either text or sparse_vector must be provided") @@ -217,10 +218,9 @@ def __call__( ) if sparse_vector is None: raise ValueError("Sparse vector is required for HybridLocalIndex.") - vector_arr = vector_arr if vector_arr is not None else np.array(vector) # TODO: add alpha as a parameter scores, route_names = self.index.query( - vector=vector_arr, + vector=vector, top_k=self.top_k, route_filter=route_filter, sparse_vector=sparse_vector,