Merge pull request #209 from ggozad/feat/pydantic-ai-embeddings
Use Pydantic AI embeddings
This commit is contained in:
commit
c44ebcd371
24 changed files with 506 additions and 512 deletions
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,6 +1,28 @@
|
|||
# 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`
|
||||
- Removed `VLLMConfig` and `LMStudioConfig` from configuration (use `base_url` in model config instead)
|
||||
|
||||
## [0.22.0] - 2025-12-19
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -376,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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -138,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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,36 @@
|
|||
from typing import TYPE_CHECKING
|
||||
|
||||
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 with explicit query/document methods."""
|
||||
|
||||
def __init__(self, embedder: Embedder, vector_dim: int):
|
||||
self._embedder = embedder
|
||||
self._vector_dim = vector_dim
|
||||
|
||||
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])
|
||||
|
||||
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]:
|
||||
"""Prepare chunk content for embedding by adding context.
|
||||
|
||||
|
|
@ -53,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(
|
||||
|
|
@ -71,9 +94,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,38 +104,46 @@ 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
|
||||
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(
|
||||
embedding_model.name, embedding_model.vector_dim, config
|
||||
model = VoyageAIEmbeddingModel(model_name)
|
||||
return EmbedderWrapper(Embedder(model), vector_dim)
|
||||
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -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]
|
||||
|
|
@ -1,27 +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 typing import overload
|
||||
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 voyageai.client import Client # type: ignore
|
||||
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.
|
||||
|
||||
from haiku.rag.embeddings.base import EmbedderBase
|
||||
See [VoyageAI Embeddings](https://docs.voyageai.com/docs/embeddings)
|
||||
for available models and their capabilities.
|
||||
"""
|
||||
|
||||
class Embedder(EmbedderBase):
|
||||
@overload
|
||||
async def embed(self, text: str) -> list[float]: ...
|
||||
VoyageAIEmbeddingModelName = str | LatestVoyageAIEmbeddingModelNames
|
||||
"""Possible VoyageAI embedding model names."""
|
||||
|
||||
@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 = Client()
|
||||
if not text:
|
||||
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]
|
||||
class VoyageAIEmbeddingSettings(EmbeddingSettings, total=False):
|
||||
"""Settings used for a VoyageAI embedding model request.
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
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"),
|
||||
)
|
||||
except VoyageError as e:
|
||||
raise ModelAPIError(model_name=self.model_name, message=str(e)) from e
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
92
uv.lock
92
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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue