mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Replace piragi/torch with in-house RAG engine (#168)
* [437e398a] Wave 1A — Remove piragi/torch dependencies entirely (#161) * [437e398a] chore(deps): remove piragi and torch from pyproject.toml and uv.lock - Remove piragi[postgres] from [project.dependencies] - Remove torch entry and its CPU-only comment from [project.dependencies] - Remove [[tool.uv.index]] pytorch-cpu block and [tool.uv.sources] torch override - Remove torch from [tool.deptry.per_rule_ignores] DEP002 - Keep piragi.* in [[tool.mypy.overrides]] ignore_missing_imports so the remaining optimal_brain/ piragi references don't break the mypy gate (Wave 1B will complete that migration) - Regenerate uv.lock: neither piragi nor torch appear in the resolved set * [437e398a] feat(kb): add piragi-free roboco/kb module with Chunk, OllamaEmbedder, shared embedder singleton - roboco/kb/__init__.py: new package entry point; 'import roboco.kb' works without piragi - roboco/kb/ollama_embedder.py: local Chunk dataclass (text/embedding/metadata), full OllamaEmbedder with parallel batch, LRU cache, retry/rate-limit logic - roboco/kb/shared_embedder.py: async singleton factory (OllamaEmbedder only, piragi EmbeddingGenerator branch removed) - All files pass ruff format+check and mypy with zero errors --------- Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> * [df01fa23] Replace piragi/torch with in-house RAG engine (#160) * [df01fa23] feat(rag): replace piragi/torch with in-house RAG engine - Remove piragi[postgres] and torch from pyproject.toml dependencies - Delete piragi_patches.py; all piragi.types imports replaced with local types - Add text_chunker.py: character-based sliding-window chunker with local Chunk/Document/Citation dataclasses (no tiktoken, no HuggingFace AutoTokenizer) - Add vector_store.py: VectorStore using asyncpg + pgvector, CREATE TABLE IF NOT EXISTS, ivfflat index, before/after startup timing note in docstring - Rewrite base.py: HyDE in _compute_query_embedding() via Ollama LLM with raw-query fallback; zero references to _sync/_conn/_init_schema/AsyncRagi - Update shared_embedder.py, ollama_embedder.py, code.py, docs.py to import Chunk from text_chunker instead of piragi.types - Refresh uv.lock removing piragi/torch entries - ruff check exits 0; mypy exits 0 on 253 source files; 2301 unit tests pass * [df01fa23] fix(tests): remove piragi stub block from conftest.py and clean up remaining piragi references in tests/ - Replace tests/unit/services/optimal_brain/conftest.py content with a minimal one-line docstring (removes _StubChunker, _ensure_piragi_stubbed, and its module-level call) — satisfies AC#7 explicitly - Remove piragi stub injection block from test_rate_limit_retry.py (_PIRAGI_STUB_NAMES, _stub_piragi(), and the call); also drop unused sys/types imports and now-redundant # noqa: E402 directives - Update _make_journal_plugin() helper to use the new _store/_chunker/_embedder attributes instead of the removed _ragi attribute - Remove dead piragi comment from test_indexes_base.py - All 28 rate-limit tests + 15 optimal_brain tests pass; ruff=0, mypy=0 * [df01fa23] fix(rag): delete piragi_patches.py to satisfy AC2 - file staged for removal --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * fix(rag): VectorStore.close tolerates a closed event loop The in-house engine's asyncpg pool is bound to the loop that created it. The optimal-service singleton can outlive that loop (cross-loop teardown between tests), so pool.close() raised 'RuntimeError: Event loop is closed' — failing test_optimal_grounding in the full suite (the work's first end-to-end gate). Swallow that specific RuntimeError (connections died with the loop); other RuntimeErrors still propagate. +3 unit tests. * fix(rag): validate table identifier + bandit-clean SQL construction bandit flagged B608 (SQL injection) on the in-house VectorStore's f-string queries interpolating the table name. The name is enum-derived (never user input), but the gate runs bandit -ll with skips=[] so it failed. Fix at the root, no nosec: validate the table identifier against a strict allowlist in __init__ (raises on anything unsafe), and inject it via _q()/str.replace (not %/format/f-string/+) so the controlled substitution isn't a B608 vector. Values remain $N bind params. +tests for the identifier guard. --------- Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev> Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
Backend Developer 2
Backend Developer 1
Renn F
parent
133411fe1c
commit
2aef3c7db5
@@ -24,8 +24,6 @@ dependencies = [
|
|||||||
# Cache/Queue
|
# Cache/Queue
|
||||||
"redis",
|
"redis",
|
||||||
"hiredis", # Redis performance
|
"hiredis", # Redis performance
|
||||||
# RAG (piragi with PostgreSQL/pgvector backend)
|
|
||||||
"piragi[postgres]",
|
|
||||||
# AI/LLM
|
# AI/LLM
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"openai", # For embeddings
|
"openai", # For embeddings
|
||||||
@@ -44,12 +42,6 @@ dependencies = [
|
|||||||
"sse-starlette", # Server-Sent Events for A2A streaming
|
"sse-starlette", # Server-Sent Events for A2A streaming
|
||||||
# Direct imports (promoted from transitive)
|
# Direct imports (promoted from transitive)
|
||||||
"cryptography", # utils/crypto.py — Fernet-encrypted project git tokens
|
"cryptography", # utils/crypto.py — Fernet-encrypted project git tokens
|
||||||
# Build-time pin (not imported). Verified empirically: removing this
|
|
||||||
# entry causes uv to ignore the [tool.uv.sources.torch] CPU-only
|
|
||||||
# redirect and pull ~2.5GB of unused CUDA wheels. The entry has to
|
|
||||||
# appear in direct deps for the source override to bind. deptry's
|
|
||||||
# DEP002 ignore for `torch` below documents the same reality.
|
|
||||||
"torch",
|
|
||||||
"claude-agent-sdk>=0.2.94",
|
"claude-agent-sdk>=0.2.94",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -109,22 +101,6 @@ packages = ["roboco"]
|
|||||||
[tool.hatch.metadata]
|
[tool.hatch.metadata]
|
||||||
allow-direct-references = true
|
allow-direct-references = true
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# uv: pin torch to the CPU-only wheel index
|
|
||||||
# =============================================================================
|
|
||||||
# `piragi` transitively depends on torch. Our stack uses Ollama over HTTP for
|
|
||||||
# all embeddings/LLM, so torch is never actually loaded at runtime — but uv
|
|
||||||
# would otherwise resolve torch from PyPI, which bundles the full NVIDIA CUDA
|
|
||||||
# stack (~2.5GB across nvidia-cublas, cudnn, cufft, cusolver, nccl, etc.).
|
|
||||||
# The CPU-only index provides a ~200MB torch wheel and drops every CUDA dep.
|
|
||||||
[[tool.uv.index]]
|
|
||||||
name = "pytorch-cpu"
|
|
||||||
url = "https://download.pytorch.org/whl/cpu"
|
|
||||||
explicit = true
|
|
||||||
|
|
||||||
[tool.uv.sources]
|
|
||||||
torch = [{ index = "pytorch-cpu" }]
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# uv: raise the floor on vulnerable transitive dependencies
|
# uv: raise the floor on vulnerable transitive dependencies
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -218,8 +194,6 @@ plugins = ["pydantic.mypy"]
|
|||||||
module = [
|
module = [
|
||||||
"redis.*",
|
"redis.*",
|
||||||
"anthropic.*",
|
"anthropic.*",
|
||||||
"tiktoken.*",
|
|
||||||
"piragi.*",
|
|
||||||
"toon.*",
|
"toon.*",
|
||||||
"sse_starlette.*",
|
"sse_starlette.*",
|
||||||
"asyncpg.*",
|
"asyncpg.*",
|
||||||
@@ -387,10 +361,6 @@ DEP002 = [
|
|||||||
"tiktoken",
|
"tiktoken",
|
||||||
# Retry logic (used in production services)
|
# Retry logic (used in production services)
|
||||||
"tenacity",
|
"tenacity",
|
||||||
# Build-time pin, not imported — see the comment next to the torch
|
|
||||||
# entry in [project.dependencies] for the full rationale. Removing
|
|
||||||
# it from deps caused uv to pull the full CUDA stack.
|
|
||||||
"torch",
|
|
||||||
# Dev tools (CLI, not imported)
|
# Dev tools (CLI, not imported)
|
||||||
"pytest",
|
"pytest",
|
||||||
"pytest-asyncio",
|
"pytest-asyncio",
|
||||||
|
|||||||
+3
-3
@@ -115,9 +115,9 @@ class Settings(BaseSettings):
|
|||||||
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# RAG (piragi with pgvector)
|
# RAG (in-house engine with pgvector)
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
rag_persist_dir: str = ".piragi"
|
rag_persist_dir: str = ".roboco"
|
||||||
rag_chunk_strategy: str = Field(
|
rag_chunk_strategy: str = Field(
|
||||||
default="fixed",
|
default="fixed",
|
||||||
pattern="^(fixed|semantic|hierarchical|contextual)$",
|
pattern="^(fixed|semantic|hierarchical|contextual)$",
|
||||||
@@ -150,7 +150,7 @@ class Settings(BaseSettings):
|
|||||||
@computed_field # type: ignore[prop-decorator]
|
@computed_field # type: ignore[prop-decorator]
|
||||||
@property
|
@property
|
||||||
def rag_store_url(self) -> str:
|
def rag_store_url(self) -> str:
|
||||||
"""PostgreSQL connection URL for piragi vector store."""
|
"""PostgreSQL connection URL for the in-house vector store."""
|
||||||
return (
|
return (
|
||||||
f"postgres://{self.database_user}:{self.database_password}"
|
f"postgres://{self.database_user}:{self.database_password}"
|
||||||
f"@{self.database_host}:{self.database_port}/{self.database_name}"
|
f"@{self.database_host}:{self.database_port}/{self.database_name}"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
roboco.kb — Knowledge Base primitives (piragi-free).
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
Chunk — lightweight text+embedding container
|
||||||
|
OllamaEmbedder — HTTP embedder via Ollama native /api/embed
|
||||||
|
get_shared_embedder — async singleton factory
|
||||||
|
close_shared_embedder — release the shared singleton
|
||||||
|
"""
|
||||||
|
|
||||||
|
from roboco.kb.ollama_embedder import (
|
||||||
|
Chunk,
|
||||||
|
EmbeddingCache,
|
||||||
|
OllamaConnectionError,
|
||||||
|
OllamaEmbedder,
|
||||||
|
OllamaEmbedderError,
|
||||||
|
OllamaModelError,
|
||||||
|
)
|
||||||
|
from roboco.kb.shared_embedder import (
|
||||||
|
Embedder,
|
||||||
|
close_shared_embedder,
|
||||||
|
get_shared_embedder,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Chunk",
|
||||||
|
"Embedder",
|
||||||
|
"EmbeddingCache",
|
||||||
|
"OllamaConnectionError",
|
||||||
|
"OllamaEmbedder",
|
||||||
|
"OllamaEmbedderError",
|
||||||
|
"OllamaModelError",
|
||||||
|
"close_shared_embedder",
|
||||||
|
"get_shared_embedder",
|
||||||
|
]
|
||||||
@@ -0,0 +1,858 @@
|
|||||||
|
"""
|
||||||
|
Ollama Embedder
|
||||||
|
|
||||||
|
Provides embedding generation using Ollama's native API.
|
||||||
|
Drop-in replacement for the embedding layer when using Ollama models.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Parallel batch processing for faster embedding
|
||||||
|
- Content-based caching to avoid re-embedding
|
||||||
|
- Connection pooling for efficiency
|
||||||
|
- Retry logic for transient failures
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from roboco.config import settings
|
||||||
|
from roboco.logging import get_logger
|
||||||
|
from roboco.services.exceptions import (
|
||||||
|
HTTP_TOO_MANY_REQUESTS,
|
||||||
|
RateLimitError,
|
||||||
|
parse_retry_after_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Retry configuration — ConnectError / Timeout (existing, unchanged)
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
RETRY_DELAY_BASE = 0.5 # seconds, exponential backoff
|
||||||
|
|
||||||
|
# Retry configuration — HTTP 429 / RateLimitError (new outer loop)
|
||||||
|
RATE_LIMIT_MAX_RETRIES = 5
|
||||||
|
|
||||||
|
# Parallel processing configuration
|
||||||
|
MAX_CONCURRENT_BATCHES = 4 # Number of batches to process in parallel
|
||||||
|
DEFAULT_BATCH_SIZE = 32 # Default batch size
|
||||||
|
|
||||||
|
# Keep the embedding model resident in Ollama. It runs on CPU and Ollama's
|
||||||
|
# default 5-min idle unload means a `say` after an idle window pays a cold 2.4 GB
|
||||||
|
# reload before embedding; under contention with glm-5:cloud that overran the
|
||||||
|
# embed retry window and dropped the background conversation ingest. -1 = never
|
||||||
|
# unload (sent as `keep_alive` on every /api/embed request).
|
||||||
|
EMBED_KEEP_ALIVE = -1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chunk:
|
||||||
|
"""Lightweight document chunk carrying text and its embedding vector.
|
||||||
|
|
||||||
|
Replaces the piragi.types.Chunk dependency so this module compiles
|
||||||
|
without piragi installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
embedding: list[float] = field(default_factory=list)
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaEmbedderError(Exception):
|
||||||
|
"""Base exception for Ollama embedder errors."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaConnectionError(OllamaEmbedderError):
|
||||||
|
"""Raised when Ollama server is unreachable."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaModelError(OllamaEmbedderError):
|
||||||
|
"""Raised when the embedding model is unavailable or returns invalid data."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingCache:
|
||||||
|
"""
|
||||||
|
LRU cache for embeddings keyed by content hash.
|
||||||
|
|
||||||
|
Avoids re-computing embeddings for identical content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_size: int = 10000):
|
||||||
|
self._cache: dict[str, list[float]] = {}
|
||||||
|
self._access_order: list[str] = []
|
||||||
|
self._max_size = max_size
|
||||||
|
self._hits = 0
|
||||||
|
self._misses = 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_content(content: str) -> str:
|
||||||
|
"""Generate hash for content."""
|
||||||
|
return hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()
|
||||||
|
|
||||||
|
def get(self, content: str) -> list[float] | None:
|
||||||
|
"""Get cached embedding by content."""
|
||||||
|
key = self._hash_content(content)
|
||||||
|
if key in self._cache:
|
||||||
|
self._hits += 1
|
||||||
|
# Move to end (most recently used)
|
||||||
|
self._access_order.remove(key)
|
||||||
|
self._access_order.append(key)
|
||||||
|
return self._cache[key]
|
||||||
|
self._misses += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
def put(self, content: str, embedding: list[float]) -> None:
|
||||||
|
"""Cache embedding for content."""
|
||||||
|
key = self._hash_content(content)
|
||||||
|
if key in self._cache:
|
||||||
|
return # Already cached
|
||||||
|
|
||||||
|
# Evict oldest if at capacity
|
||||||
|
while len(self._cache) >= self._max_size:
|
||||||
|
oldest = self._access_order.pop(0)
|
||||||
|
del self._cache[oldest]
|
||||||
|
|
||||||
|
self._cache[key] = embedding
|
||||||
|
self._access_order.append(key)
|
||||||
|
|
||||||
|
def get_many(self, contents: list[str]) -> tuple[list[int], list[list[float]]]:
|
||||||
|
"""
|
||||||
|
Get cached embeddings for multiple contents.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (indices of cached items, their embeddings)
|
||||||
|
"""
|
||||||
|
cached_indices = []
|
||||||
|
cached_embeddings = []
|
||||||
|
for i, content in enumerate(contents):
|
||||||
|
emb = self.get(content)
|
||||||
|
if emb is not None:
|
||||||
|
cached_indices.append(i)
|
||||||
|
cached_embeddings.append(emb)
|
||||||
|
return cached_indices, cached_embeddings
|
||||||
|
|
||||||
|
def put_many(self, contents: list[str], embeddings: list[list[float]]) -> None:
|
||||||
|
"""Cache multiple embeddings."""
|
||||||
|
for content, emb in zip(contents, embeddings, strict=True):
|
||||||
|
self.put(content, emb)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stats(self) -> dict[str, Any]:
|
||||||
|
"""Get cache statistics."""
|
||||||
|
total = self._hits + self._misses
|
||||||
|
hit_rate = (self._hits / total * 100) if total > 0 else 0
|
||||||
|
return {
|
||||||
|
"size": len(self._cache),
|
||||||
|
"max_size": self._max_size,
|
||||||
|
"hits": self._hits,
|
||||||
|
"misses": self._misses,
|
||||||
|
"hit_rate": f"{hit_rate:.1f}%",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaEmbedder:
|
||||||
|
"""
|
||||||
|
Embedding generator using Ollama's native API.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Parallel batch processing (configurable concurrency)
|
||||||
|
- Content-based caching (avoids re-embedding identical content)
|
||||||
|
- Connection pooling for efficiency
|
||||||
|
- Retry logic with exponential backoff
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: str = "qwen3-embedding:0.6b",
|
||||||
|
base_url: str | None = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
max_concurrent: int = MAX_CONCURRENT_BATCHES,
|
||||||
|
cache_size: int = 10000,
|
||||||
|
):
|
||||||
|
"""Initialize Ollama embedder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: Ollama model name for embeddings
|
||||||
|
base_url: Ollama API base URL (default: from settings)
|
||||||
|
timeout: Request timeout in seconds (default 120s for CPU embedding)
|
||||||
|
max_concurrent: Max concurrent batch requests (default 4)
|
||||||
|
cache_size: Max cached embeddings (default 10000)
|
||||||
|
"""
|
||||||
|
self.model = model
|
||||||
|
self.base_url = base_url or settings.ollama_base_url
|
||||||
|
self.timeout = timeout
|
||||||
|
self.max_concurrent = max_concurrent
|
||||||
|
self._dimensions: int | None = None
|
||||||
|
self._cache = EmbeddingCache(max_size=cache_size)
|
||||||
|
# Reusable sync client (async clients created per-operation)
|
||||||
|
self._sync_client: httpx.Client | None = None
|
||||||
|
# Semaphore for limiting concurrent requests. Track the loop it was
|
||||||
|
# created on so we can rebuild when the loop rotates.
|
||||||
|
self._semaphore: asyncio.Semaphore | None = None
|
||||||
|
self._semaphore_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
def _embed_payload(self, input_data: str | list[str]) -> dict[str, object]:
|
||||||
|
"""Build the /api/embed JSON body, pinning keep_alive so the model stays
|
||||||
|
resident in Ollama (see EMBED_KEEP_ALIVE)."""
|
||||||
|
return {
|
||||||
|
"model": self.model,
|
||||||
|
"input": input_data,
|
||||||
|
"keep_alive": EMBED_KEEP_ALIVE,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_sync_client(self) -> httpx.Client:
|
||||||
|
"""Get or create sync HTTP client with connection pooling."""
|
||||||
|
if self._sync_client is None or self._sync_client.is_closed:
|
||||||
|
timeout = httpx.Timeout(
|
||||||
|
connect=10.0,
|
||||||
|
read=self.timeout,
|
||||||
|
write=30.0,
|
||||||
|
pool=10.0,
|
||||||
|
)
|
||||||
|
self._sync_client = httpx.Client(
|
||||||
|
timeout=timeout,
|
||||||
|
limits=httpx.Limits(
|
||||||
|
max_connections=self.max_concurrent * 2,
|
||||||
|
max_keepalive_connections=self.max_concurrent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self._sync_client
|
||||||
|
|
||||||
|
def _create_async_client(self) -> httpx.AsyncClient:
|
||||||
|
"""Create a fresh async HTTP client.
|
||||||
|
|
||||||
|
Always creates a new client to avoid 'Event loop is closed' errors
|
||||||
|
that occur when a cached client is bound to a different/closed loop.
|
||||||
|
"""
|
||||||
|
timeout = httpx.Timeout(
|
||||||
|
connect=10.0,
|
||||||
|
read=self.timeout,
|
||||||
|
write=30.0,
|
||||||
|
pool=10.0,
|
||||||
|
)
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
timeout=timeout,
|
||||||
|
limits=httpx.Limits(
|
||||||
|
max_connections=self.max_concurrent * 2,
|
||||||
|
max_keepalive_connections=self.max_concurrent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_semaphore(self) -> asyncio.Semaphore:
|
||||||
|
"""Get or create semaphore for limiting concurrent requests.
|
||||||
|
|
||||||
|
asyncio.Semaphore binds to the event loop it was created in. If the
|
||||||
|
orchestrator's loop rotates (e.g. lifespan restart, test teardown),
|
||||||
|
a cached semaphore raises "bound to a different event loop". Detect
|
||||||
|
loop rotation by comparing the current running loop to the one we
|
||||||
|
recorded at creation time, and rebuild if they differ.
|
||||||
|
"""
|
||||||
|
current_loop = asyncio.get_running_loop()
|
||||||
|
if self._semaphore is None or self._semaphore_loop is not current_loop:
|
||||||
|
self._semaphore = asyncio.Semaphore(self.max_concurrent)
|
||||||
|
self._semaphore_loop = current_loop
|
||||||
|
return self._semaphore
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close HTTP clients and release resources."""
|
||||||
|
if self._sync_client and not self._sync_client.is_closed:
|
||||||
|
self._sync_client.close()
|
||||||
|
self._sync_client = None
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Async close HTTP clients and release resources."""
|
||||||
|
# Async clients are now created per-request, nothing to close
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dimensions(self) -> int:
|
||||||
|
"""Get embedding dimensions (cached after first call)."""
|
||||||
|
if self._dimensions is None:
|
||||||
|
test_embedding = self.embed_query("test")
|
||||||
|
self._dimensions = len(test_embedding)
|
||||||
|
return self._dimensions
|
||||||
|
|
||||||
|
def set_dimensions(self, dim: int) -> None:
|
||||||
|
"""Pre-set dimensions to avoid blocking call."""
|
||||||
|
self._dimensions = dim
|
||||||
|
|
||||||
|
async def get_dimensions_async(self) -> int:
|
||||||
|
"""Async-friendly way to get embedding dimensions."""
|
||||||
|
if self._dimensions is None:
|
||||||
|
test_embedding = await self.aembed_query("test")
|
||||||
|
self._dimensions = len(test_embedding)
|
||||||
|
return self._dimensions
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_stats(self) -> dict[str, Any]:
|
||||||
|
"""Get embedding cache statistics."""
|
||||||
|
return self._cache.stats
|
||||||
|
|
||||||
|
def _handle_embed_response(
|
||||||
|
self, response: httpx.Response, input_count: int = 1
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""Validate and extract embeddings from API response."""
|
||||||
|
if not response.is_success:
|
||||||
|
error_text = response.text[:200] if response.text else "Unknown error"
|
||||||
|
if response.status_code == httpx.codes.NOT_FOUND:
|
||||||
|
raise OllamaModelError(
|
||||||
|
f"Model '{self.model}' not found. "
|
||||||
|
f"Run 'ollama pull {self.model}' to download it."
|
||||||
|
)
|
||||||
|
raise OllamaEmbedderError(
|
||||||
|
f"Ollama API error {response.status_code}: {error_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except Exception as e:
|
||||||
|
raise OllamaEmbedderError(f"Invalid JSON response: {e}") from e
|
||||||
|
|
||||||
|
embeddings: list[list[float]] | None = data.get("embeddings")
|
||||||
|
if not embeddings:
|
||||||
|
raise OllamaModelError(
|
||||||
|
f"No embeddings returned for model '{self.model}'. "
|
||||||
|
"The model may not support embeddings."
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(embeddings) != input_count:
|
||||||
|
raise OllamaModelError(
|
||||||
|
f"Expected {input_count} embeddings, got {len(embeddings)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for i, emb in enumerate(embeddings):
|
||||||
|
if not emb or not isinstance(emb, list):
|
||||||
|
raise OllamaModelError(f"Invalid embedding at index {i}")
|
||||||
|
|
||||||
|
return embeddings
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rl_backoff(retry_after: float | None, rl_attempt: int) -> float:
|
||||||
|
"""Backoff seconds for a 429: honor Retry-After, else exponential."""
|
||||||
|
return retry_after if retry_after is not None else float(2**rl_attempt)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_embed_error(e: Exception, base_url: str) -> Exception:
|
||||||
|
"""Map a raw request exception to the appropriate Ollama embedder error."""
|
||||||
|
if isinstance(e, httpx.ConnectError):
|
||||||
|
return OllamaConnectionError(f"Cannot connect to Ollama at {base_url}: {e}")
|
||||||
|
if isinstance(e, httpx.TimeoutException):
|
||||||
|
return OllamaConnectionError(f"Ollama request timed out: {e}")
|
||||||
|
return OllamaEmbedderError(f"Unexpected error: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _log_429(rl_attempt: int, backoff: float) -> None:
|
||||||
|
"""Log an Ollama 429 rate-limit retry."""
|
||||||
|
logger.warning(
|
||||||
|
"Ollama rate limited (429), retrying",
|
||||||
|
provider="ollama",
|
||||||
|
attempt=rl_attempt + 1,
|
||||||
|
max_retries=RATE_LIMIT_MAX_RETRIES,
|
||||||
|
backoff_duration=backoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sleep_connect_retry(
|
||||||
|
attempt: int, last_error: Exception | None, label: str, **extra: Any
|
||||||
|
) -> None:
|
||||||
|
"""Exponential backoff between ConnectError/Timeout retries.
|
||||||
|
|
||||||
|
No sleep on the final attempt — the caller raises ``last_error`` then.
|
||||||
|
"""
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
delay = RETRY_DELAY_BASE * (2**attempt)
|
||||||
|
logger.warning(
|
||||||
|
label, attempt=attempt + 1, delay=delay, error=str(last_error), **extra
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _asleep_connect_retry(
|
||||||
|
attempt: int, last_error: Exception | None, label: str, **extra: Any
|
||||||
|
) -> None:
|
||||||
|
"""Async counterpart of :meth:`_sleep_connect_retry`."""
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
delay = RETRY_DELAY_BASE * (2**attempt)
|
||||||
|
logger.warning(
|
||||||
|
label, attempt=attempt + 1, delay=delay, error=str(last_error), **extra
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
def embed_query(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
task_instruction: str | None = None,
|
||||||
|
) -> list[float]:
|
||||||
|
"""Generate embedding for a single query.
|
||||||
|
|
||||||
|
Retry behaviour (two independent concerns, non-overlapping):
|
||||||
|
- ConnectError / TimeoutException: up to MAX_RETRIES=3 attempts with
|
||||||
|
0.5/1/2 s exponential backoff (existing behaviour, unchanged).
|
||||||
|
- HTTP 429 (rate limit): outer loop up to RATE_LIMIT_MAX_RETRIES=5,
|
||||||
|
respecting Retry-After header. A 429 response does NOT trigger the
|
||||||
|
ConnectError path.
|
||||||
|
"""
|
||||||
|
_ = task_instruction
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cached = self._cache.get(query)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
client = self._get_sync_client()
|
||||||
|
last_rl_retry_after: float | None = None
|
||||||
|
|
||||||
|
for rl_attempt in range(RATE_LIMIT_MAX_RETRIES):
|
||||||
|
got_429 = False
|
||||||
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
# --- inner loop: ConnectError / Timeout (unchanged) ---
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
response = client.post(
|
||||||
|
f"{self.base_url}/api/embed",
|
||||||
|
json=self._embed_payload(query),
|
||||||
|
)
|
||||||
|
# 429 check — must NOT enter the ConnectError path
|
||||||
|
if response.status_code == HTTP_TOO_MANY_REQUESTS:
|
||||||
|
last_rl_retry_after = parse_retry_after_header(response)
|
||||||
|
self._log_429(
|
||||||
|
rl_attempt,
|
||||||
|
self._rl_backoff(last_rl_retry_after, rl_attempt),
|
||||||
|
)
|
||||||
|
got_429 = True
|
||||||
|
break # break inner loop; outer loop will sleep + retry
|
||||||
|
|
||||||
|
embeddings = self._handle_embed_response(response, input_count=1)
|
||||||
|
result = embeddings[0]
|
||||||
|
self._cache.put(query, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (OllamaModelError, OllamaEmbedderError):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_error = self._map_embed_error(e, self.base_url)
|
||||||
|
|
||||||
|
self._sleep_connect_retry(
|
||||||
|
attempt, last_error, "Ollama embed_query retry"
|
||||||
|
)
|
||||||
|
# --- end inner loop ---
|
||||||
|
|
||||||
|
if not got_429:
|
||||||
|
# ConnectError / Timeout exhausted — same behaviour as before
|
||||||
|
raise last_error or OllamaEmbedderError("Max retries exceeded")
|
||||||
|
|
||||||
|
# 429: sleep and try again (outer loop)
|
||||||
|
if rl_attempt < RATE_LIMIT_MAX_RETRIES - 1:
|
||||||
|
time.sleep(self._rl_backoff(last_rl_retry_after, rl_attempt))
|
||||||
|
|
||||||
|
raise RateLimitError(provider="ollama", retry_after=last_rl_retry_after)
|
||||||
|
|
||||||
|
def _embed_batch_sync(
|
||||||
|
self, client: httpx.Client, batch: list[str], batch_index: int
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""Embed a single batch synchronously.
|
||||||
|
|
||||||
|
Same two-concern retry composition as :meth:`embed_query`:
|
||||||
|
inner ConnectError/Timeout loop (unchanged) + outer 429 loop.
|
||||||
|
"""
|
||||||
|
last_rl_retry_after: float | None = None
|
||||||
|
|
||||||
|
for rl_attempt in range(RATE_LIMIT_MAX_RETRIES):
|
||||||
|
got_429 = False
|
||||||
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
response = client.post(
|
||||||
|
f"{self.base_url}/api/embed",
|
||||||
|
json=self._embed_payload(batch),
|
||||||
|
)
|
||||||
|
if response.status_code == HTTP_TOO_MANY_REQUESTS:
|
||||||
|
last_rl_retry_after = parse_retry_after_header(response)
|
||||||
|
self._log_429(
|
||||||
|
rl_attempt,
|
||||||
|
self._rl_backoff(last_rl_retry_after, rl_attempt),
|
||||||
|
)
|
||||||
|
got_429 = True
|
||||||
|
break
|
||||||
|
|
||||||
|
return self._handle_embed_response(response, input_count=len(batch))
|
||||||
|
|
||||||
|
except (OllamaModelError, OllamaEmbedderError):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_error = self._map_embed_error(e, self.base_url)
|
||||||
|
|
||||||
|
self._sleep_connect_retry(
|
||||||
|
attempt,
|
||||||
|
last_error,
|
||||||
|
"Ollama embed_documents retry",
|
||||||
|
batch_index=batch_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not got_429:
|
||||||
|
raise last_error or OllamaEmbedderError("Max retries exceeded")
|
||||||
|
|
||||||
|
if rl_attempt < RATE_LIMIT_MAX_RETRIES - 1:
|
||||||
|
time.sleep(self._rl_backoff(last_rl_retry_after, rl_attempt))
|
||||||
|
|
||||||
|
raise RateLimitError(provider="ollama", retry_after=last_rl_retry_after)
|
||||||
|
|
||||||
|
def _partition_cached_documents(
|
||||||
|
self, documents: list[str]
|
||||||
|
) -> tuple[list[list[float] | None], list[int], list[str]]:
|
||||||
|
"""Split documents into (pre-filled slots, uncached indices, uncached texts)."""
|
||||||
|
result_embeddings: list[list[float] | None] = [None] * len(documents)
|
||||||
|
uncached_indices: list[int] = []
|
||||||
|
uncached_docs: list[str] = []
|
||||||
|
|
||||||
|
for i, doc in enumerate(documents):
|
||||||
|
cached = self._cache.get(doc)
|
||||||
|
if cached is not None:
|
||||||
|
result_embeddings[i] = cached
|
||||||
|
else:
|
||||||
|
uncached_indices.append(i)
|
||||||
|
uncached_docs.append(doc)
|
||||||
|
return result_embeddings, uncached_indices, uncached_docs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_embeddings(
|
||||||
|
result_embeddings: list[list[float] | None],
|
||||||
|
uncached_indices: list[int],
|
||||||
|
new_embeddings: list[list[float]],
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""Merge freshly-computed embeddings into preallocated result list."""
|
||||||
|
for idx, emb in zip(uncached_indices, new_embeddings, strict=True):
|
||||||
|
result_embeddings[idx] = emb
|
||||||
|
return [e for e in result_embeddings if e is not None]
|
||||||
|
|
||||||
|
def embed_documents(
|
||||||
|
self,
|
||||||
|
documents: list[str],
|
||||||
|
task_instruction: str | None = None,
|
||||||
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""Generate embeddings for multiple documents (sequential, uses cache)."""
|
||||||
|
_ = task_instruction
|
||||||
|
|
||||||
|
if not documents:
|
||||||
|
return []
|
||||||
|
|
||||||
|
(
|
||||||
|
result_embeddings,
|
||||||
|
uncached_indices,
|
||||||
|
uncached_docs,
|
||||||
|
) = self._partition_cached_documents(documents)
|
||||||
|
|
||||||
|
if uncached_indices:
|
||||||
|
logger.info(
|
||||||
|
"Embedding cache stats",
|
||||||
|
cached=len(documents) - len(uncached_indices),
|
||||||
|
uncached=len(uncached_indices),
|
||||||
|
total=len(documents),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not uncached_docs:
|
||||||
|
return [e for e in result_embeddings if e is not None]
|
||||||
|
|
||||||
|
client = self._get_sync_client()
|
||||||
|
new_embeddings: list[list[float]] = []
|
||||||
|
|
||||||
|
for i in range(0, len(uncached_docs), batch_size):
|
||||||
|
batch = uncached_docs[i : i + batch_size]
|
||||||
|
embeddings = self._embed_batch_sync(client, batch, batch_index=i)
|
||||||
|
new_embeddings.extend(embeddings)
|
||||||
|
for doc, emb in zip(batch, embeddings, strict=True):
|
||||||
|
self._cache.put(doc, emb)
|
||||||
|
|
||||||
|
return self._merge_embeddings(
|
||||||
|
result_embeddings, uncached_indices, new_embeddings
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _embed_batch_async(
|
||||||
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
batch: list[str],
|
||||||
|
batch_index: int,
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""Embed a single batch with semaphore-limited concurrency.
|
||||||
|
|
||||||
|
Same two-concern retry composition as :meth:`embed_query`:
|
||||||
|
inner ConnectError/Timeout loop (unchanged) + outer 429 loop (async).
|
||||||
|
"""
|
||||||
|
semaphore = self._get_semaphore()
|
||||||
|
|
||||||
|
async with semaphore:
|
||||||
|
last_rl_retry_after: float | None = None
|
||||||
|
|
||||||
|
for rl_attempt in range(RATE_LIMIT_MAX_RETRIES):
|
||||||
|
got_429 = False
|
||||||
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
logger.debug(
|
||||||
|
"Parallel embed batch",
|
||||||
|
batch_index=batch_index,
|
||||||
|
batch_size=len(batch),
|
||||||
|
attempt=attempt,
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/api/embed",
|
||||||
|
json=self._embed_payload(batch),
|
||||||
|
)
|
||||||
|
if response.status_code == HTTP_TOO_MANY_REQUESTS:
|
||||||
|
last_rl_retry_after = parse_retry_after_header(response)
|
||||||
|
self._log_429(
|
||||||
|
rl_attempt,
|
||||||
|
self._rl_backoff(last_rl_retry_after, rl_attempt),
|
||||||
|
)
|
||||||
|
got_429 = True
|
||||||
|
break
|
||||||
|
|
||||||
|
return self._handle_embed_response(
|
||||||
|
response, input_count=len(batch)
|
||||||
|
)
|
||||||
|
|
||||||
|
except (OllamaModelError, OllamaEmbedderError):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_error = self._map_embed_error(e, self.base_url)
|
||||||
|
|
||||||
|
await self._asleep_connect_retry(
|
||||||
|
attempt,
|
||||||
|
last_error,
|
||||||
|
"Parallel embed batch retry",
|
||||||
|
batch_index=batch_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not got_429:
|
||||||
|
raise last_error or OllamaEmbedderError("Max retries exceeded")
|
||||||
|
|
||||||
|
if rl_attempt < RATE_LIMIT_MAX_RETRIES - 1:
|
||||||
|
await asyncio.sleep(
|
||||||
|
self._rl_backoff(last_rl_retry_after, rl_attempt)
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RateLimitError(provider="ollama", retry_after=last_rl_retry_after)
|
||||||
|
|
||||||
|
async def _run_parallel_batches(
|
||||||
|
self, batches: list[list[str]]
|
||||||
|
) -> list[list[list[float]]]:
|
||||||
|
"""Execute all batches concurrently via a shared async client."""
|
||||||
|
async with self._create_async_client() as client:
|
||||||
|
tasks = [
|
||||||
|
self._embed_batch_async(client, batch, i)
|
||||||
|
for i, batch in enumerate(batches)
|
||||||
|
]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
def _collect_and_cache(
|
||||||
|
self,
|
||||||
|
batches: list[list[str]],
|
||||||
|
batch_results: list[list[list[float]]],
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""Flatten batch results and populate the embedding cache."""
|
||||||
|
new_embeddings: list[list[float]] = []
|
||||||
|
for batch, embeddings in zip(batches, batch_results, strict=True):
|
||||||
|
for doc, emb in zip(batch, embeddings, strict=True):
|
||||||
|
new_embeddings.append(emb)
|
||||||
|
self._cache.put(doc, emb)
|
||||||
|
return new_embeddings
|
||||||
|
|
||||||
|
async def aembed_documents_parallel(
|
||||||
|
self,
|
||||||
|
documents: list[str],
|
||||||
|
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""
|
||||||
|
Embed documents in parallel batches for maximum throughput.
|
||||||
|
|
||||||
|
Processes multiple batches concurrently (limited by max_concurrent).
|
||||||
|
Uses caching to skip already-embedded content.
|
||||||
|
"""
|
||||||
|
if not documents:
|
||||||
|
return []
|
||||||
|
|
||||||
|
(
|
||||||
|
result_embeddings,
|
||||||
|
uncached_indices,
|
||||||
|
uncached_docs,
|
||||||
|
) = self._partition_cached_documents(documents)
|
||||||
|
|
||||||
|
cache_hits = len(documents) - len(uncached_indices)
|
||||||
|
if cache_hits > 0:
|
||||||
|
logger.info(
|
||||||
|
"Embedding cache hits",
|
||||||
|
cached=cache_hits,
|
||||||
|
uncached=len(uncached_indices),
|
||||||
|
total=len(documents),
|
||||||
|
hit_rate=f"{cache_hits / len(documents) * 100:.1f}%",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not uncached_docs:
|
||||||
|
return [e for e in result_embeddings if e is not None]
|
||||||
|
|
||||||
|
batches: list[list[str]] = [
|
||||||
|
uncached_docs[i : i + batch_size]
|
||||||
|
for i in range(0, len(uncached_docs), batch_size)
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Parallel embedding starting",
|
||||||
|
total_docs=len(uncached_docs),
|
||||||
|
batches=len(batches),
|
||||||
|
batch_size=batch_size,
|
||||||
|
max_concurrent=self.max_concurrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
batch_results = await self._run_parallel_batches(batches)
|
||||||
|
new_embeddings = self._collect_and_cache(batches, batch_results)
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
docs_per_sec = len(uncached_docs) / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Parallel embedding complete",
|
||||||
|
docs=len(uncached_docs),
|
||||||
|
elapsed=f"{elapsed:.1f}s",
|
||||||
|
docs_per_sec=f"{docs_per_sec:.1f}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._merge_embeddings(
|
||||||
|
result_embeddings, uncached_indices, new_embeddings
|
||||||
|
)
|
||||||
|
|
||||||
|
def embed_chunks(
|
||||||
|
self,
|
||||||
|
chunks: list[Chunk],
|
||||||
|
on_progress: Callable[[str], None] | None = None,
|
||||||
|
) -> list[Chunk]:
|
||||||
|
"""Generate embeddings for chunks using parallel processing."""
|
||||||
|
if not chunks:
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
texts = [chunk.text for chunk in chunks]
|
||||||
|
|
||||||
|
# Use async parallel embedding via event loop
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
if loop.is_running():
|
||||||
|
# Already in async context - use sync fallback
|
||||||
|
embeddings = self.embed_documents(texts, batch_size=DEFAULT_BATCH_SIZE)
|
||||||
|
else:
|
||||||
|
embeddings = loop.run_until_complete(
|
||||||
|
self.aembed_documents_parallel(texts)
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop - create one
|
||||||
|
embeddings = asyncio.run(self.aembed_documents_parallel(texts))
|
||||||
|
|
||||||
|
for chunk, embedding in zip(chunks, embeddings, strict=True):
|
||||||
|
chunk.embedding = embedding
|
||||||
|
|
||||||
|
if on_progress:
|
||||||
|
on_progress(f"Embedded {len(chunks)} chunks")
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
async def aembed_chunks(
|
||||||
|
self,
|
||||||
|
chunks: list[Chunk],
|
||||||
|
on_progress: Callable[[str], None] | None = None,
|
||||||
|
) -> list[Chunk]:
|
||||||
|
"""Async version of embed_chunks with parallel processing."""
|
||||||
|
if not chunks:
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
texts = [chunk.text for chunk in chunks]
|
||||||
|
embeddings = await self.aembed_documents_parallel(texts)
|
||||||
|
|
||||||
|
for chunk, embedding in zip(chunks, embeddings, strict=True):
|
||||||
|
chunk.embedding = embedding
|
||||||
|
|
||||||
|
if on_progress:
|
||||||
|
on_progress(f"Embedded {len(chunks)} chunks")
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
async def aembed_query(self, query: str) -> list[float]:
|
||||||
|
"""Async version of embed_query with retry logic and caching.
|
||||||
|
|
||||||
|
Same two-concern retry composition as :meth:`embed_query`:
|
||||||
|
inner ConnectError/Timeout loop (unchanged) + outer 429 loop (async).
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
cached = self._cache.get(query)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
last_rl_retry_after: float | None = None
|
||||||
|
|
||||||
|
for rl_attempt in range(RATE_LIMIT_MAX_RETRIES):
|
||||||
|
got_429 = False
|
||||||
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
# Create fresh client each attempt to avoid event loop issues
|
||||||
|
async with self._create_async_client() as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/api/embed",
|
||||||
|
json=self._embed_payload(query),
|
||||||
|
)
|
||||||
|
if response.status_code == HTTP_TOO_MANY_REQUESTS:
|
||||||
|
last_rl_retry_after = parse_retry_after_header(response)
|
||||||
|
self._log_429(
|
||||||
|
rl_attempt,
|
||||||
|
self._rl_backoff(last_rl_retry_after, rl_attempt),
|
||||||
|
)
|
||||||
|
got_429 = True
|
||||||
|
break
|
||||||
|
|
||||||
|
embeddings = self._handle_embed_response(
|
||||||
|
response, input_count=1
|
||||||
|
)
|
||||||
|
result = embeddings[0]
|
||||||
|
self._cache.put(query, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (OllamaModelError, OllamaEmbedderError):
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_error = self._map_embed_error(e, self.base_url)
|
||||||
|
|
||||||
|
# A 429 already broke the inner loop above; otherwise back off.
|
||||||
|
await self._asleep_connect_retry(
|
||||||
|
attempt, last_error, "Ollama aembed_query retry"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not got_429:
|
||||||
|
raise last_error or OllamaEmbedderError("Max retries exceeded")
|
||||||
|
|
||||||
|
if rl_attempt < RATE_LIMIT_MAX_RETRIES - 1:
|
||||||
|
await asyncio.sleep(self._rl_backoff(last_rl_retry_after, rl_attempt))
|
||||||
|
|
||||||
|
raise RateLimitError(provider="ollama", retry_after=last_rl_retry_after)
|
||||||
|
|
||||||
|
async def aembed_documents(
|
||||||
|
self, documents: list[str], batch_size: int = DEFAULT_BATCH_SIZE
|
||||||
|
) -> list[list[float]]:
|
||||||
|
"""Async embed_documents - delegates to parallel implementation."""
|
||||||
|
return await self.aembed_documents_parallel(documents, batch_size=batch_size)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Shared Embedder Singleton
|
||||||
|
|
||||||
|
Provides a single embedder instance shared across all index plugins.
|
||||||
|
Supports Ollama models (qwen3-embedding, ...) via OllamaEmbedder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING, Protocol
|
||||||
|
|
||||||
|
from roboco.config import settings
|
||||||
|
from roboco.kb.ollama_embedder import Chunk, OllamaEmbedder
|
||||||
|
from roboco.logging import get_logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Embedder(Protocol):
|
||||||
|
"""Protocol for embedder interface."""
|
||||||
|
|
||||||
|
def embed_query(
|
||||||
|
self, query: str, task_instruction: str | None = None
|
||||||
|
) -> list[float]: ...
|
||||||
|
|
||||||
|
def embed_documents(
|
||||||
|
self,
|
||||||
|
documents: list[str],
|
||||||
|
task_instruction: str | None = None,
|
||||||
|
batch_size: int = 32,
|
||||||
|
) -> list[list[float]]: ...
|
||||||
|
|
||||||
|
def embed_chunks(
|
||||||
|
self,
|
||||||
|
chunks: list[Chunk],
|
||||||
|
on_progress: Callable[[str], None] | None = None,
|
||||||
|
) -> list[Chunk]: ...
|
||||||
|
|
||||||
|
|
||||||
|
# Known Ollama embedding models
|
||||||
|
OLLAMA_EMBEDDING_MODELS = {
|
||||||
|
"qwen3-embedding",
|
||||||
|
"embeddinggemma",
|
||||||
|
"nomic-embed-text",
|
||||||
|
"mxbai-embed-large",
|
||||||
|
"all-minilm",
|
||||||
|
"snowflake-arctic-embed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ollama_model(model: str) -> bool:
|
||||||
|
"""Check if model name is an Ollama embedding model."""
|
||||||
|
model_base = model.split(":", maxsplit=1)[0].lower()
|
||||||
|
return model_base in OLLAMA_EMBEDDING_MODELS
|
||||||
|
|
||||||
|
|
||||||
|
class _SharedEmbedderHolder:
|
||||||
|
"""Holder class for shared embedder state (avoids global statement)."""
|
||||||
|
|
||||||
|
instance: OllamaEmbedder | None = None
|
||||||
|
lock: asyncio.Lock | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_lock(cls) -> asyncio.Lock:
|
||||||
|
"""Get or create the lock."""
|
||||||
|
if cls.lock is None:
|
||||||
|
cls.lock = asyncio.Lock()
|
||||||
|
return cls.lock
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shared_embedder(
|
||||||
|
model: str | None = None,
|
||||||
|
_device: str | None = None,
|
||||||
|
_timeout: float = 60.0,
|
||||||
|
) -> Embedder:
|
||||||
|
"""Get or create the shared embedder instance.
|
||||||
|
|
||||||
|
Thread-safe singleton that loads the model only once.
|
||||||
|
Uses OllamaEmbedder for all embedding models (no piragi dependency).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: Embedding model name (default: from settings)
|
||||||
|
_device: Unused — kept for API compatibility with the old signature
|
||||||
|
_timeout: Unused — Ollama handles load timing server-side
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Shared embedder instance (OllamaEmbedder)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If model loading fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
if _SharedEmbedderHolder.instance is not None:
|
||||||
|
return _SharedEmbedderHolder.instance
|
||||||
|
|
||||||
|
async with _SharedEmbedderHolder.get_lock():
|
||||||
|
# Double-check after acquiring lock (another coroutine may have created it)
|
||||||
|
if _SharedEmbedderHolder.instance is None:
|
||||||
|
resolved_model = model or settings.default_embedding_model
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Creating shared Ollama embedder",
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=settings.ollama_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SharedEmbedderHolder.instance = OllamaEmbedder(
|
||||||
|
model=resolved_model,
|
||||||
|
base_url=settings.ollama_base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate embedder implements required protocol methods.
|
||||||
|
# Using explicit check (not assert) so this survives `python -O`.
|
||||||
|
if _SharedEmbedderHolder.instance is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Shared embedder construction succeeded but instance is None"
|
||||||
|
)
|
||||||
|
_validate_embedder_protocol(_SharedEmbedderHolder.instance, resolved_model)
|
||||||
|
|
||||||
|
logger.info("Shared embedder created successfully", model=resolved_model)
|
||||||
|
|
||||||
|
if _SharedEmbedderHolder.instance is None:
|
||||||
|
raise RuntimeError("Shared embedder not initialized")
|
||||||
|
return _SharedEmbedderHolder.instance
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_embedder_protocol(embedder: Embedder, model: str) -> None:
|
||||||
|
"""
|
||||||
|
Validate that embedder implements the required protocol methods.
|
||||||
|
|
||||||
|
Checks at creation time rather than failing during first use.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
embedder: The embedder instance to validate
|
||||||
|
model: Model name for error messages
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If embedder is missing required methods
|
||||||
|
"""
|
||||||
|
required_methods = ["embed_query", "embed_documents", "embed_chunks"]
|
||||||
|
missing = [m for m in required_methods if not callable(getattr(embedder, m, None))]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Embedder for model '{model}' is missing required methods: {missing}. "
|
||||||
|
f"Embedder type: {type(embedder).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_shared_embedder() -> None:
|
||||||
|
"""Release the shared embedder resources."""
|
||||||
|
async with _SharedEmbedderHolder.get_lock():
|
||||||
|
if _SharedEmbedderHolder.instance is not None:
|
||||||
|
logger.info("Closing shared embedder")
|
||||||
|
_SharedEmbedderHolder.instance = None
|
||||||
@@ -9,11 +9,9 @@ and implements specialized chunking, metadata handling, and search strategies.
|
|||||||
import re
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from piragi import AsyncRagi
|
|
||||||
from piragi.types import Citation, Document
|
|
||||||
|
|
||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.models.optimal import IndexType, SearchOutcome, SearchResult
|
from roboco.models.optimal import IndexType, SearchOutcome, SearchResult
|
||||||
@@ -23,12 +21,13 @@ from roboco.services.exceptions import (
|
|||||||
RateLimitError,
|
RateLimitError,
|
||||||
parse_retry_after_header,
|
parse_retry_after_header,
|
||||||
)
|
)
|
||||||
|
from roboco.services.optimal_brain.text_chunker import (
|
||||||
# Apply piragi runtime patches (chunker tokenizer) BEFORE importing piragi
|
Chunk,
|
||||||
# itself anywhere in the plugin stack. Importing for side effects only.
|
Citation,
|
||||||
from roboco.services.optimal_brain import (
|
Document,
|
||||||
piragi_patches as _piragi_patches, # noqa: F401
|
TextChunker,
|
||||||
)
|
)
|
||||||
|
from roboco.services.optimal_brain.vector_store import VectorStore
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -59,17 +58,6 @@ def _filter_quality_chunks(raw_chunks: list[Any]) -> list[Any]:
|
|||||||
return kept
|
return kept
|
||||||
|
|
||||||
|
|
||||||
def _reset_store_connection(store: Any) -> None:
|
|
||||||
"""Force-reset the piragi store connection after an aborted transaction."""
|
|
||||||
if not (hasattr(store, "_conn") and store._conn):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
store._conn.close()
|
|
||||||
store._init_schema()
|
|
||||||
except Exception as reset_err:
|
|
||||||
logger.warning("Failed to reset connection", error=str(reset_err))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class IndexConfig:
|
class IndexConfig:
|
||||||
"""Configuration for an index plugin."""
|
"""Configuration for an index plugin."""
|
||||||
@@ -152,7 +140,9 @@ class BaseIndexPlugin(ABC):
|
|||||||
def __init__(self, config: IndexConfig | None = None) -> None:
|
def __init__(self, config: IndexConfig | None = None) -> None:
|
||||||
"""Initialize the plugin with optional config override."""
|
"""Initialize the plugin with optional config override."""
|
||||||
self._config = config
|
self._config = config
|
||||||
self._ragi: AsyncRagi | None = None
|
self._store: VectorStore | None = None
|
||||||
|
self._chunker: TextChunker | None = None
|
||||||
|
self._embedder: Any = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -212,43 +202,6 @@ class BaseIndexPlugin(ABC):
|
|||||||
|
|
||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
def _build_piragi_config(self) -> dict[str, Any]:
|
|
||||||
"""Build piragi configuration dict."""
|
|
||||||
return {
|
|
||||||
"llm": {
|
|
||||||
"model": self.config.llm_model,
|
|
||||||
"base_url": self.config.llm_base_url,
|
|
||||||
},
|
|
||||||
"embedding": {
|
|
||||||
"model": self.config.embedding_model,
|
|
||||||
},
|
|
||||||
"chunk": {
|
|
||||||
"strategy": self.config.chunk_strategy,
|
|
||||||
"size": self.config.chunk_size,
|
|
||||||
"overlap": self.config.chunk_overlap,
|
|
||||||
},
|
|
||||||
"retrieval": {
|
|
||||||
"use_hyde": self.config.use_hyde,
|
|
||||||
"use_hybrid_search": self.config.use_hybrid_search,
|
|
||||||
"use_cross_encoder": self.config.use_cross_encoder,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _build_piragi_config_no_embed(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Build piragi config with dummy embedding URL.
|
|
||||||
|
|
||||||
This prevents piragi from loading its own SentenceTransformer model
|
|
||||||
during initialization. We'll replace the embedder with our shared
|
|
||||||
instance immediately after creation.
|
|
||||||
"""
|
|
||||||
config = self._build_piragi_config()
|
|
||||||
# Set a dummy base_url to prevent local model loading
|
|
||||||
# EmbeddingGenerator checks: if base_url is not None, skip SentenceTransformer
|
|
||||||
config["embedding"]["base_url"] = "http://dummy-prevents-model-load"
|
|
||||||
config["embedding"]["api_key"] = "not-needed"
|
|
||||||
return config
|
|
||||||
|
|
||||||
async def _validate_embedding_dimensions(self, embedder: Any) -> None:
|
async def _validate_embedding_dimensions(self, embedder: Any) -> None:
|
||||||
"""
|
"""
|
||||||
Validate that embedder produces expected dimensions.
|
Validate that embedder produces expected dimensions.
|
||||||
@@ -258,8 +211,6 @@ class BaseIndexPlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from roboco.config import settings
|
|
||||||
|
|
||||||
expected_dim = settings.embedding_dimensions
|
expected_dim = settings.embedding_dimensions
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -296,7 +247,7 @@ class BaseIndexPlugin(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Initialize the index backend."""
|
"""Initialize the index backend (store, chunker, embedder)."""
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -313,78 +264,67 @@ class BaseIndexPlugin(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validate embedding dimensions match configuration
|
# Validate embedding dimensions match configuration
|
||||||
# This catches mismatches early instead of failing silently during search
|
|
||||||
await self._validate_embedding_dimensions(shared_embedder)
|
await self._validate_embedding_dimensions(shared_embedder)
|
||||||
|
|
||||||
# Create store with correct vector dimension for embedding model
|
self._embedder = shared_embedder
|
||||||
store = self._create_store_with_dimension()
|
|
||||||
|
|
||||||
# Use config with dummy embedding URL to prevent model loading
|
# Create the character-based chunker (no external tokenizers)
|
||||||
# Piragi's EmbeddingGenerator skips SentenceTransformer if base_url is set
|
self._chunker = TextChunker(
|
||||||
self._ragi = AsyncRagi(
|
chunk_size=self.config.chunk_size,
|
||||||
[],
|
chunk_overlap=self.config.chunk_overlap,
|
||||||
persist_dir=self.config.persist_dir,
|
|
||||||
config=self._build_piragi_config_no_embed(),
|
|
||||||
store=store,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Replace AsyncRagi's dummy embedder with shared instance
|
# Create and initialise the vector store
|
||||||
# This is the key optimization: one model load for all 9 plugins
|
if not self.config.store_url:
|
||||||
self._ragi._sync.embedder = shared_embedder
|
raise RuntimeError(
|
||||||
|
f"store_url is required for {self.index_type.value} VectorStore. "
|
||||||
|
"Set ROBOCO_DATABASE_* environment variables."
|
||||||
|
)
|
||||||
|
|
||||||
|
self._store = VectorStore(
|
||||||
|
dsn=self.config.store_url,
|
||||||
|
table_name=f"chunks_{self.index_type.value}",
|
||||||
|
vector_dimension=settings.embedding_dimensions,
|
||||||
|
)
|
||||||
|
await self._store.initialize()
|
||||||
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
logger.info(f"{self.index_type.value} index plugin initialized")
|
logger.info(f"{self.index_type.value} index plugin initialized")
|
||||||
|
|
||||||
def _create_store_with_dimension(self) -> Any:
|
|
||||||
"""
|
|
||||||
Create vector store with correct dimension for embedding model.
|
|
||||||
|
|
||||||
Piragi's factory defaults to 768 for PostgresStore, but doesn't
|
|
||||||
infer dimension from the embedding model. This method fixes that
|
|
||||||
by creating PostgresStore with the correct dimension.
|
|
||||||
"""
|
|
||||||
from roboco.config import settings
|
|
||||||
|
|
||||||
store_url = self.config.store_url
|
|
||||||
if not store_url:
|
|
||||||
# Use default LanceStore (handles dimension correctly)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# For PostgreSQL, create store with correct dimension
|
|
||||||
if store_url.startswith("postgres://") or store_url.startswith("postgresql://"):
|
|
||||||
from piragi.stores.postgres import PostgresStore
|
|
||||||
|
|
||||||
# Get dimension from settings
|
|
||||||
vector_dimension = settings.embedding_dimensions
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Creating PostgresStore with correct dimension",
|
|
||||||
vector_dimension=vector_dimension,
|
|
||||||
embedding_model=self.config.embedding_model,
|
|
||||||
)
|
|
||||||
|
|
||||||
return PostgresStore(
|
|
||||||
connection_string=store_url,
|
|
||||||
table_name=f"chunks_{self.index_type.value}",
|
|
||||||
vector_dimension=vector_dimension,
|
|
||||||
)
|
|
||||||
|
|
||||||
# For other stores, let piragi handle it
|
|
||||||
return store_url
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Cleanup resources."""
|
"""Cleanup resources."""
|
||||||
self._ragi = None
|
if self._store is not None:
|
||||||
|
await self._store.close()
|
||||||
|
self._store = None
|
||||||
|
self._chunker = None
|
||||||
|
self._embedder = None
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
logger.info(f"{self.index_type.value} index plugin closed")
|
logger.info(f"{self.index_type.value} index plugin closed")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal accessors (raise if not initialised)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ragi(self) -> AsyncRagi:
|
def _require_store(self) -> VectorStore:
|
||||||
"""Get the underlying AsyncRagi instance."""
|
if not self._initialized or self._store is None:
|
||||||
if not self._initialized or self._ragi is None:
|
|
||||||
msg = f"{self.index_type.value} index not initialized."
|
msg = f"{self.index_type.value} index not initialized."
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
return self._ragi
|
return self._store
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _require_chunker(self) -> TextChunker:
|
||||||
|
if not self._initialized or self._chunker is None:
|
||||||
|
msg = f"{self.index_type.value} index not initialized."
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return self._chunker
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _require_embedder(self) -> Any:
|
||||||
|
if not self._initialized or self._embedder is None:
|
||||||
|
msg = f"{self.index_type.value} index not initialized."
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
return self._embedder
|
||||||
|
|
||||||
def validate_content(
|
def validate_content(
|
||||||
self,
|
self,
|
||||||
@@ -424,8 +364,6 @@ class BaseIndexPlugin(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
IngestResult with ingestion details
|
IngestResult with ingestion details
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# Validate content
|
# Validate content
|
||||||
is_valid, error = self.validate_content(content, **kwargs)
|
is_valid, error = self.validate_content(content, **kwargs)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
@@ -461,9 +399,7 @@ class BaseIndexPlugin(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chunk_count = await asyncio.to_thread(
|
chunk_count = await self._chunk_filter_embed_store(doc, metadata)
|
||||||
self._chunk_filter_embed_store, doc, metadata
|
|
||||||
)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Ingested document",
|
"Ingested document",
|
||||||
index_type=self.index_type.value,
|
index_type=self.index_type.value,
|
||||||
@@ -489,14 +425,15 @@ class BaseIndexPlugin(ABC):
|
|||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _chunk_filter_embed_store(self, doc: Document, metadata: dict[str, Any]) -> int:
|
async def _chunk_filter_embed_store(
|
||||||
"""Chunk → filter → embed → store. Returns count of stored chunks.
|
self, doc: Document, metadata: dict[str, Any]
|
||||||
|
) -> int:
|
||||||
|
"""Chunk → filter → embed → store. Returns count of stored chunks."""
|
||||||
|
chunker = self._require_chunker
|
||||||
|
store = self._require_store
|
||||||
|
embedder = self._require_embedder
|
||||||
|
|
||||||
Runs synchronously inside ``asyncio.to_thread``; piragi's _sync
|
raw_chunks: list[Chunk] = chunker.chunk_document(doc)
|
||||||
internals are blocking.
|
|
||||||
"""
|
|
||||||
ragi_sync = self.ragi._sync
|
|
||||||
raw_chunks = ragi_sync.chunker.chunk_document(doc)
|
|
||||||
chunks = _filter_quality_chunks(raw_chunks)
|
chunks = _filter_quality_chunks(raw_chunks)
|
||||||
if not chunks:
|
if not chunks:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -505,38 +442,29 @@ class BaseIndexPlugin(ABC):
|
|||||||
raw_count=len(raw_chunks),
|
raw_count=len(raw_chunks),
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
chunk.metadata = {**chunk.metadata, **metadata}
|
chunk.metadata = {**chunk.metadata, **metadata}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Chunks after quality filter",
|
"Chunks after quality filter",
|
||||||
raw=len(raw_chunks),
|
raw=len(raw_chunks),
|
||||||
kept=len(chunks),
|
kept=len(chunks),
|
||||||
filtered=len(raw_chunks) - len(chunks),
|
filtered=len(raw_chunks) - len(chunks),
|
||||||
)
|
)
|
||||||
chunks_with_embeddings = ragi_sync.embedder.embed_chunks(chunks)
|
|
||||||
return self._store_with_transaction_retry(
|
|
||||||
ragi_sync.store, chunks_with_embeddings, len(chunks)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _store_with_transaction_retry(
|
# Embed (async)
|
||||||
self, store: Any, chunks_with_embeddings: list[Any], chunk_count: int
|
if hasattr(embedder, "aembed_chunks"):
|
||||||
) -> int:
|
chunks_with_embeddings: list[Chunk] = await embedder.aembed_chunks(chunks)
|
||||||
"""Add chunks to store; retry once on aborted-transaction errors."""
|
else:
|
||||||
max_retries = 2
|
import asyncio
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
chunks_with_embeddings = await asyncio.to_thread(
|
||||||
store.add_chunks(chunks_with_embeddings)
|
embedder.embed_chunks, chunks
|
||||||
return chunk_count
|
)
|
||||||
except Exception as e:
|
|
||||||
if "transaction is aborted" not in str(e) or attempt >= max_retries - 1:
|
await store.add_chunks(chunks_with_embeddings)
|
||||||
raise
|
return len(chunks)
|
||||||
logger.warning(
|
|
||||||
"Retrying store after transaction error",
|
|
||||||
index_type=self.index_type.value,
|
|
||||||
attempt=attempt + 1,
|
|
||||||
)
|
|
||||||
_reset_store_connection(store)
|
|
||||||
return chunk_count
|
|
||||||
|
|
||||||
def _prepare_docs_for_batch(
|
def _prepare_docs_for_batch(
|
||||||
self,
|
self,
|
||||||
@@ -600,18 +528,21 @@ class BaseIndexPlugin(ABC):
|
|||||||
good_chunks.append(chunk)
|
good_chunks.append(chunk)
|
||||||
return good_chunks
|
return good_chunks
|
||||||
|
|
||||||
def _run_batch_process(
|
async def _run_batch_process(
|
||||||
self,
|
self,
|
||||||
docs_to_process: list[tuple[Document, str | None, dict[str, Any]]],
|
docs_to_process: list[tuple[Document, str | None, dict[str, Any]]],
|
||||||
chunk_counts: dict[int, int],
|
chunk_counts: dict[int, int],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Chunk, embed, and store all documents in a single transaction."""
|
"""Chunk, embed, and store all documents in a single batch."""
|
||||||
ragi_sync = self.ragi._sync
|
chunker = self._require_chunker
|
||||||
all_chunks: list[Any] = []
|
store = self._require_store
|
||||||
|
embedder = self._require_embedder
|
||||||
|
|
||||||
|
all_chunks: list[Chunk] = []
|
||||||
total_filtered = 0
|
total_filtered = 0
|
||||||
|
|
||||||
for idx, (doc, _, _) in enumerate(docs_to_process):
|
for idx, (doc, _, _) in enumerate(docs_to_process):
|
||||||
raw_chunks = ragi_sync.chunker.chunk_document(doc)
|
raw_chunks = chunker.chunk_document(doc)
|
||||||
good_chunks = self._filter_good_chunks(raw_chunks, doc)
|
good_chunks = self._filter_good_chunks(raw_chunks, doc)
|
||||||
all_chunks.extend(good_chunks)
|
all_chunks.extend(good_chunks)
|
||||||
chunk_counts[idx] = len(good_chunks)
|
chunk_counts[idx] = len(good_chunks)
|
||||||
@@ -623,8 +554,17 @@ class BaseIndexPlugin(ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if all_chunks:
|
if all_chunks:
|
||||||
chunks_with_embeddings = ragi_sync.embedder.embed_chunks(all_chunks)
|
if hasattr(embedder, "aembed_chunks"):
|
||||||
ragi_sync.store.add_chunks(chunks_with_embeddings)
|
chunks_with_embeddings: list[Chunk] = await embedder.aembed_chunks(
|
||||||
|
all_chunks
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
chunks_with_embeddings = await asyncio.to_thread(
|
||||||
|
embedder.embed_chunks, all_chunks
|
||||||
|
)
|
||||||
|
await store.add_chunks(chunks_with_embeddings)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _mark_batch_failed(
|
def _mark_batch_failed(
|
||||||
@@ -663,8 +603,6 @@ class BaseIndexPlugin(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
List of IngestResult for each document
|
List of IngestResult for each document
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
if not documents:
|
if not documents:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -676,9 +614,7 @@ class BaseIndexPlugin(ABC):
|
|||||||
chunk_counts: dict[int, int] = {}
|
chunk_counts: dict[int, int] = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await self._run_batch_process(docs_to_process, chunk_counts)
|
||||||
self._run_batch_process, docs_to_process, chunk_counts
|
|
||||||
)
|
|
||||||
|
|
||||||
for idx, (doc, doc_id, _) in enumerate(docs_to_process):
|
for idx, (doc, doc_id, _) in enumerate(docs_to_process):
|
||||||
results.append(
|
results.append(
|
||||||
@@ -776,12 +712,74 @@ class BaseIndexPlugin(ABC):
|
|||||||
|
|
||||||
return preprocessed
|
return preprocessed
|
||||||
|
|
||||||
|
async def _generate_hyde_passage(self, query: str) -> str:
|
||||||
|
"""Generate a hypothetical passage for HyDE query expansion.
|
||||||
|
|
||||||
|
Calls the configured Ollama LLM to produce a short passage that would
|
||||||
|
plausibly answer *query*. Returns an empty string on any failure so
|
||||||
|
the caller can fall back to raw query embedding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: The (preprocessed) search query.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A short hypothetical passage, or ``""`` on LLM error.
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"Write a concise technical passage (2-4 sentences) that directly "
|
||||||
|
"answers the following question. Include specific technical details "
|
||||||
|
"and terminology. Do not use <think> tags.\n\n"
|
||||||
|
f"Question: {query}\n\n"
|
||||||
|
"Answer:"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.config.llm_base_url}/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": self.config.llm_model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 200,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.is_success:
|
||||||
|
data = resp.json()
|
||||||
|
passage = self._extract_from_think_tags(
|
||||||
|
data["choices"][0]["message"]["content"]
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"HyDE passage generated",
|
||||||
|
index_type=self.index_type.value,
|
||||||
|
passage_len=len(passage),
|
||||||
|
)
|
||||||
|
return passage
|
||||||
|
logger.debug(
|
||||||
|
"HyDE LLM call returned non-success status",
|
||||||
|
status=resp.status_code,
|
||||||
|
index_type=self.index_type.value,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"HyDE LLM call failed, will use raw query embedding",
|
||||||
|
index_type=self.index_type.value,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
async def _compute_query_embedding(self, query: str) -> list[float]:
|
async def _compute_query_embedding(self, query: str) -> list[float]:
|
||||||
"""Preprocess the query and embed it using the configured embedder."""
|
"""Preprocess the query and embed it using the configured embedder.
|
||||||
|
|
||||||
|
When ``config.use_hyde`` is ``True``, first calls the Ollama LLM to
|
||||||
|
generate a hypothetical answer passage (HyDE) and embeds that instead
|
||||||
|
of the raw query. Falls back to raw query embedding if the LLM call
|
||||||
|
fails or returns an empty passage.
|
||||||
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
ragi_sync = self.ragi._sync
|
embedder = self._require_embedder
|
||||||
embedder = ragi_sync.embedder
|
|
||||||
|
|
||||||
processed_query = self._preprocess_query(query)
|
processed_query = self._preprocess_query(query)
|
||||||
if processed_query != query:
|
if processed_query != query:
|
||||||
@@ -792,10 +790,17 @@ class BaseIndexPlugin(ABC):
|
|||||||
index_type=self.index_type.value,
|
index_type=self.index_type.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# HyDE: try to embed a hypothetical passage instead of the raw query
|
||||||
|
text_to_embed = processed_query
|
||||||
|
if self.config.use_hyde:
|
||||||
|
passage = await self._generate_hyde_passage(processed_query)
|
||||||
|
if passage:
|
||||||
|
text_to_embed = passage
|
||||||
|
|
||||||
if hasattr(embedder, "aembed_query"):
|
if hasattr(embedder, "aembed_query"):
|
||||||
result: list[float] = await embedder.aembed_query(processed_query)
|
result: list[float] = await embedder.aembed_query(text_to_embed)
|
||||||
return result
|
return result
|
||||||
return await asyncio.to_thread(embedder.embed_query, processed_query)
|
return await asyncio.to_thread(embedder.embed_query, text_to_embed)
|
||||||
|
|
||||||
async def _fetch_citations(
|
async def _fetch_citations(
|
||||||
self,
|
self,
|
||||||
@@ -803,21 +808,14 @@ class BaseIndexPlugin(ABC):
|
|||||||
top_k: int,
|
top_k: int,
|
||||||
has_filters: bool,
|
has_filters: bool,
|
||||||
) -> list[Citation]:
|
) -> list[Citation]:
|
||||||
"""Fetch citations from the vector store in a worker thread."""
|
"""Fetch citations from the vector store."""
|
||||||
import asyncio
|
store = self._require_store
|
||||||
|
|
||||||
ragi_sync = self.ragi._sync
|
|
||||||
fetch_k = top_k * 3 if has_filters else top_k
|
fetch_k = top_k * 3 if has_filters else top_k
|
||||||
|
return await store.search(
|
||||||
def _do_search() -> list[Citation]:
|
query_embedding,
|
||||||
results: list[Citation] = ragi_sync.store.search(
|
top_k=fetch_k,
|
||||||
query_embedding,
|
min_chunk_length=100,
|
||||||
top_k=fetch_k,
|
)
|
||||||
min_chunk_length=100,
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
return await asyncio.to_thread(_do_search)
|
|
||||||
|
|
||||||
def _citations_to_results(
|
def _citations_to_results(
|
||||||
self,
|
self,
|
||||||
@@ -1067,15 +1065,15 @@ class BaseIndexPlugin(ABC):
|
|||||||
raise RateLimitError(provider="ollama", retry_after=last_rl_retry_after)
|
raise RateLimitError(provider="ollama", retry_after=last_rl_retry_after)
|
||||||
|
|
||||||
async def count(self) -> int:
|
async def count(self) -> int:
|
||||||
"""Get the number of documents in the index."""
|
"""Get the number of chunks in the index."""
|
||||||
try:
|
try:
|
||||||
return cast("int", await self.ragi.count())
|
return await self._require_store.count()
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
async def clear(self) -> None:
|
async def clear(self) -> None:
|
||||||
"""Clear all documents from the index."""
|
"""Clear all documents from the index."""
|
||||||
await self.ragi.clear()
|
await self._require_store.clear()
|
||||||
logger.info(f"Cleared {self.index_type.value} index")
|
logger.info(f"Cleared {self.index_type.value} index")
|
||||||
|
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
@@ -1087,20 +1085,7 @@ class BaseIndexPlugin(ABC):
|
|||||||
Returns list of documents with id, source, indexed_at, and metadata.
|
Returns list of documents with id, source, indexed_at, and metadata.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Use piragi's list method if available
|
return await self._require_store.list_docs(limit=limit, offset=offset)
|
||||||
if hasattr(self.ragi, "list"):
|
|
||||||
docs = await self.ragi.list(limit=limit, offset=offset)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(doc.get("id", "")),
|
|
||||||
"source": doc.get("source", ""),
|
|
||||||
"indexed_at": doc.get("indexed_at", ""),
|
|
||||||
"metadata": doc.get("metadata", {}),
|
|
||||||
}
|
|
||||||
for doc in docs
|
|
||||||
]
|
|
||||||
# Fallback: return empty list
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to list documents in {self.index_type.value}: {e}")
|
logger.warning(f"Failed to list documents in {self.index_type.value}: {e}")
|
||||||
return []
|
return []
|
||||||
@@ -1117,72 +1102,89 @@ class BaseIndexPlugin(ABC):
|
|||||||
Returns:
|
Returns:
|
||||||
Number of documents indexed
|
Number of documents indexed
|
||||||
"""
|
"""
|
||||||
import hashlib
|
await self._ingest_source_files(sources)
|
||||||
|
await self._track_source_files_in_db(sources)
|
||||||
|
return await self.count()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _expand_source_paths(source: str) -> "list[Any]":
|
||||||
|
"""Expand a source string into a list of Path objects."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
source_path = Path(source)
|
||||||
|
if "*" in source:
|
||||||
|
return list(Path().glob(source))
|
||||||
|
if source_path.is_dir():
|
||||||
|
return list(source_path.rglob("*"))
|
||||||
|
return [source_path] if source_path.exists() else []
|
||||||
|
|
||||||
|
async def _ingest_source_files(self, sources: list[str]) -> None:
|
||||||
|
"""Ingest each file found under sources into the vector store."""
|
||||||
|
for source in sources:
|
||||||
|
for file_path in self._expand_source_paths(source):
|
||||||
|
if not file_path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
content = file_path.read_text(errors="ignore")
|
||||||
|
await self.ingest(
|
||||||
|
content=content,
|
||||||
|
doc_id=str(file_path.absolute()),
|
||||||
|
file_path=str(file_path),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to index source file",
|
||||||
|
file=str(file_path),
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _track_source_files_in_db(self, sources: list[str]) -> None:
|
||||||
|
"""Record each indexed file in the database for tracking."""
|
||||||
from roboco.db import get_db_context
|
from roboco.db import get_db_context
|
||||||
from roboco.db.tables import IndexedDocumentTable
|
|
||||||
|
|
||||||
await self.ragi.add(sources)
|
|
||||||
|
|
||||||
# Track indexed documents in database
|
|
||||||
async with get_db_context() as db:
|
async with get_db_context() as db:
|
||||||
for source in sources:
|
for source in sources:
|
||||||
source_path = Path(source)
|
for file_path in self._expand_source_paths(source):
|
||||||
|
|
||||||
# Handle glob patterns and directories
|
|
||||||
if "*" in source or source_path.is_dir():
|
|
||||||
if source_path.is_dir():
|
|
||||||
files = list(source_path.rglob("*"))
|
|
||||||
else:
|
|
||||||
files = list(Path().glob(source))
|
|
||||||
else:
|
|
||||||
files = [source_path] if source_path.exists() else []
|
|
||||||
|
|
||||||
for file_path in files:
|
|
||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
continue
|
continue
|
||||||
|
await self._upsert_doc_record(db, file_path)
|
||||||
# Generate hash for dedup
|
|
||||||
source_str = str(file_path.absolute())
|
|
||||||
source_hash = hashlib.sha256(source_str.encode()).hexdigest()
|
|
||||||
|
|
||||||
# Extract title from filename or first line
|
|
||||||
title = file_path.stem.replace("-", " ").replace("_", " ").title()
|
|
||||||
|
|
||||||
# Get preview (first 500 chars)
|
|
||||||
preview = None
|
|
||||||
try:
|
|
||||||
content = file_path.read_text(errors="ignore")[:500]
|
|
||||||
preview = content.strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Upsert document record
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
existing = await db.execute(
|
|
||||||
select(IndexedDocumentTable).where(
|
|
||||||
IndexedDocumentTable.index_type == self.index_type.value,
|
|
||||||
IndexedDocumentTable.source_hash == source_hash,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
doc = existing.scalar_one_or_none()
|
|
||||||
|
|
||||||
if doc:
|
|
||||||
doc.title = title
|
|
||||||
doc.preview = preview
|
|
||||||
else:
|
|
||||||
doc = IndexedDocumentTable(
|
|
||||||
index_type=self.index_type.value,
|
|
||||||
source=source_str,
|
|
||||||
source_hash=source_hash,
|
|
||||||
title=title,
|
|
||||||
preview=preview,
|
|
||||||
chunk_count=0, # Could be calculated later
|
|
||||||
)
|
|
||||||
db.add(doc)
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return await self.count()
|
async def _upsert_doc_record(self, db: Any, file_path: Any) -> None:
|
||||||
|
"""Upsert one file record into the indexed-documents table."""
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from roboco.db.tables import IndexedDocumentTable
|
||||||
|
|
||||||
|
source_str = str(file_path.absolute())
|
||||||
|
source_hash = hashlib.sha256(source_str.encode()).hexdigest()
|
||||||
|
title = file_path.stem.replace("-", " ").replace("_", " ").title()
|
||||||
|
|
||||||
|
preview = None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
preview = file_path.read_text(errors="ignore")[:500].strip()
|
||||||
|
|
||||||
|
existing = await db.execute(
|
||||||
|
select(IndexedDocumentTable).where(
|
||||||
|
IndexedDocumentTable.index_type == self.index_type.value,
|
||||||
|
IndexedDocumentTable.source_hash == source_hash,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
doc = existing.scalar_one_or_none()
|
||||||
|
if doc:
|
||||||
|
doc.title = title
|
||||||
|
doc.preview = preview
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
IndexedDocumentTable(
|
||||||
|
index_type=self.index_type.value,
|
||||||
|
source=source_str,
|
||||||
|
source_hash=source_hash,
|
||||||
|
title=title,
|
||||||
|
preview=preview,
|
||||||
|
chunk_count=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from piragi.types import Chunk
|
|
||||||
|
|
||||||
from roboco.models.optimal import IndexType
|
from roboco.models.optimal import IndexType
|
||||||
from roboco.services.optimal_brain.indexes.base import BaseIndexPlugin, IngestResult
|
from roboco.services.optimal_brain.indexes.base import BaseIndexPlugin, IngestResult
|
||||||
|
from roboco.services.optimal_brain.text_chunker import Chunk
|
||||||
|
|
||||||
logger = structlog.get_logger()
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ class FileHashRegistry:
|
|||||||
|
|
||||||
def __init__(self, cache_file: Path | None = None):
|
def __init__(self, cache_file: Path | None = None):
|
||||||
"""Initialize with optional cache file path."""
|
"""Initialize with optional cache file path."""
|
||||||
self._cache_file = cache_file or Path(".piragi/file_hashes.json")
|
self._cache_file = cache_file or Path(".roboco/file_hashes.json")
|
||||||
self._hashes: dict[str, str] = {}
|
self._hashes: dict[str, str] = {}
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ SKIP_DIRECTORIES = {
|
|||||||
".venv",
|
".venv",
|
||||||
"venv",
|
"venv",
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
".piragi",
|
".roboco",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
".next",
|
".next",
|
||||||
"dist",
|
"dist",
|
||||||
@@ -568,8 +568,8 @@ class CodeIndexPlugin(BaseIndexPlugin):
|
|||||||
"""
|
"""
|
||||||
Batch ingest code files using line-based chunking.
|
Batch ingest code files using line-based chunking.
|
||||||
|
|
||||||
Unlike the base class ingest_batch which uses piragi's sentence-based
|
Unlike the base class ingest_batch which uses the sliding-window
|
||||||
chunker, this method uses a simple line-based chunker that's
|
TextChunker, this method uses a simple line-based chunker that's
|
||||||
appropriate for source code.
|
appropriate for source code.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -619,15 +619,20 @@ class CodeIndexPlugin(BaseIndexPlugin):
|
|||||||
for d in files_data
|
for d in files_data
|
||||||
]
|
]
|
||||||
|
|
||||||
# Embed and store using piragi's internals
|
# Embed and store using the in-house embedder and vector store
|
||||||
ragi_sync = self.ragi._sync
|
embedder = self._require_embedder
|
||||||
|
store = self._require_store
|
||||||
def _embed_and_store() -> None:
|
|
||||||
chunks_with_embeddings = ragi_sync.embedder.embed_chunks(all_chunks)
|
|
||||||
ragi_sync.store.add_chunks(chunks_with_embeddings)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_embed_and_store)
|
if hasattr(embedder, "aembed_chunks"):
|
||||||
|
chunks_with_embeddings: list[Chunk] = await embedder.aembed_chunks(
|
||||||
|
all_chunks
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
chunks_with_embeddings = await asyncio.to_thread(
|
||||||
|
embedder.embed_chunks, all_chunks
|
||||||
|
)
|
||||||
|
await store.add_chunks(chunks_with_embeddings)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
IngestResult(
|
IngestResult(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ SKIP_DIRECTORIES = {
|
|||||||
".venv",
|
".venv",
|
||||||
"venv",
|
"venv",
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
".piragi",
|
".roboco",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
".next",
|
".next",
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
Ollama Embedder
|
Ollama Embedder
|
||||||
|
|
||||||
Provides embedding generation using Ollama's native API.
|
Provides embedding generation using Ollama's native API.
|
||||||
Drop-in replacement for piragi's EmbeddingGenerator when using Ollama models.
|
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Parallel batch processing for faster embedding
|
- Parallel batch processing for faster embedding
|
||||||
@@ -18,7 +17,6 @@ from collections.abc import Callable
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from piragi.types import Chunk
|
|
||||||
|
|
||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.logging import get_logger
|
from roboco.logging import get_logger
|
||||||
@@ -27,6 +25,7 @@ from roboco.services.exceptions import (
|
|||||||
RateLimitError,
|
RateLimitError,
|
||||||
parse_retry_after_header,
|
parse_retry_after_header,
|
||||||
)
|
)
|
||||||
|
from roboco.services.optimal_brain.text_chunker import Chunk
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ RATE_LIMIT_MAX_RETRIES = 5
|
|||||||
|
|
||||||
# Parallel processing configuration
|
# Parallel processing configuration
|
||||||
MAX_CONCURRENT_BATCHES = 4 # Number of batches to process in parallel
|
MAX_CONCURRENT_BATCHES = 4 # Number of batches to process in parallel
|
||||||
DEFAULT_BATCH_SIZE = 32 # piragi's default batch size
|
DEFAULT_BATCH_SIZE = 32 # default batch size for Ollama embedding requests
|
||||||
|
|
||||||
# Keep the embedding model resident in Ollama. It runs on CPU and Ollama's
|
# Keep the embedding model resident in Ollama. It runs on CPU and Ollama's
|
||||||
# default 5-min idle unload means a `say` after an idle window pays a cold 2.4 GB
|
# default 5-min idle unload means a `say` after an idle window pays a cold 2.4 GB
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"""Runtime patches for piragi 0.7.9.
|
|
||||||
|
|
||||||
Importing this module applies module-level monkey-patches that correct
|
|
||||||
behaviors in the installed piragi version:
|
|
||||||
|
|
||||||
1. `piragi.chunking.Chunker` hardcodes
|
|
||||||
`tokenizer_name="nvidia/llama-embed-nemotron-8b"` as its default, and
|
|
||||||
that model requires `trust_remote_code=True` at load time. In non-TTY
|
|
||||||
containers the `Do you wish to run the custom code? [y/N]` prompt is
|
|
||||||
answered "N", so `AutoTokenizer.from_pretrained` fails and every index
|
|
||||||
plugin init raises, leaving the whole RAG layer disabled. We don't need
|
|
||||||
a Qwen-accurate tokenizer for chunking — chunk size is only an
|
|
||||||
approximation — so we swap in a public tokenizer that loads without
|
|
||||||
remote-code prompts.
|
|
||||||
|
|
||||||
Apply once, near the top of any module that imports piragi, by doing
|
|
||||||
`import roboco.services.optimal_brain.piragi_patches # noqa: F401`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from roboco.logging import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
# Safe default — bert-base-uncased is widely cached, public, and doesn't
|
|
||||||
# require trust_remote_code. Token counts won't match qwen3 exactly, but
|
|
||||||
# chunk_size is treated as a target, not a hard limit.
|
|
||||||
_SAFE_TOKENIZER = "bert-base-uncased"
|
|
||||||
|
|
||||||
|
|
||||||
class _PatchState:
|
|
||||||
"""Holds apply-once flag without a module-level global statement."""
|
|
||||||
|
|
||||||
applied: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def apply_patches() -> None:
|
|
||||||
"""Apply all piragi runtime patches. Idempotent."""
|
|
||||||
if _PatchState.applied:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
import piragi.chunking as _chunking
|
|
||||||
import piragi.semantic_chunking as _semantic
|
|
||||||
|
|
||||||
original_chunker_init = _chunking.Chunker.__init__
|
|
||||||
|
|
||||||
def patched_chunker_init(
|
|
||||||
self: Any,
|
|
||||||
chunk_size: int = 512,
|
|
||||||
chunk_overlap: int = 50,
|
|
||||||
tokenizer_name: str = _SAFE_TOKENIZER,
|
|
||||||
) -> None:
|
|
||||||
# Always swap the nvidia default for the safe one unless the
|
|
||||||
# caller explicitly passed something else.
|
|
||||||
effective = (
|
|
||||||
_SAFE_TOKENIZER
|
|
||||||
if tokenizer_name == "nvidia/llama-embed-nemotron-8b"
|
|
||||||
else tokenizer_name
|
|
||||||
)
|
|
||||||
original_chunker_init(
|
|
||||||
self,
|
|
||||||
chunk_size=chunk_size,
|
|
||||||
chunk_overlap=chunk_overlap,
|
|
||||||
tokenizer_name=effective,
|
|
||||||
)
|
|
||||||
|
|
||||||
_chunking.Chunker.__init__ = patched_chunker_init
|
|
||||||
|
|
||||||
# semantic_chunking.Chunker is the same class, but this guards
|
|
||||||
# against piragi ever adding an import-time bind of the original.
|
|
||||||
if hasattr(_semantic, "Chunker") and _semantic.Chunker is _chunking.Chunker:
|
|
||||||
_semantic.Chunker = _chunking.Chunker
|
|
||||||
|
|
||||||
_PatchState.applied = True
|
|
||||||
logger.info(
|
|
||||||
"Piragi chunker tokenizer default patched",
|
|
||||||
safe_tokenizer=_SAFE_TOKENIZER,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Failed to apply piragi patches", error=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
apply_patches()
|
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Shared Embedder Singleton
|
Shared Embedder Singleton
|
||||||
|
|
||||||
Provides a single embedder instance shared across all index plugins.
|
Provides a single OllamaEmbedder instance shared across all index plugins.
|
||||||
Supports both Ollama models (qwen3-embedding, ...) and SentenceTransformers (BGE, ...).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import TYPE_CHECKING, Protocol, Union
|
from typing import TYPE_CHECKING, Protocol, Union
|
||||||
|
|
||||||
from piragi.types import Chunk
|
|
||||||
|
|
||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.logging import get_logger
|
from roboco.logging import get_logger
|
||||||
|
from roboco.services.optimal_brain.text_chunker import Chunk
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from piragi.embeddings import EmbeddingGenerator
|
|
||||||
|
|
||||||
from roboco.services.optimal_brain.ollama_embedder import OllamaEmbedder
|
from roboco.services.optimal_brain.ollama_embedder import OllamaEmbedder
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -63,7 +59,7 @@ def _is_ollama_model(model: str) -> bool:
|
|||||||
class _SharedEmbedderHolder:
|
class _SharedEmbedderHolder:
|
||||||
"""Holder class for shared embedder state (avoids global statement)."""
|
"""Holder class for shared embedder state (avoids global statement)."""
|
||||||
|
|
||||||
instance: Union["EmbeddingGenerator", "OllamaEmbedder", None] = None
|
instance: Union["OllamaEmbedder", None] = None
|
||||||
lock: asyncio.Lock | None = None
|
lock: asyncio.Lock | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -79,23 +75,27 @@ async def get_shared_embedder(
|
|||||||
device: str | None = None,
|
device: str | None = None,
|
||||||
timeout: float = 60.0,
|
timeout: float = 60.0,
|
||||||
) -> Embedder:
|
) -> Embedder:
|
||||||
"""Get or create the shared embedder instance.
|
"""Get or create the shared OllamaEmbedder instance.
|
||||||
|
|
||||||
Thread-safe singleton that loads the model only once.
|
Thread-safe singleton that loads the model only once. All embedding is
|
||||||
Automatically selects Ollama or SentenceTransformers based on model name.
|
performed via Ollama over HTTP; no local model weights are loaded.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model: Embedding model name (default: from settings)
|
model: Embedding model name (default: from settings).
|
||||||
device: Device to use for SentenceTransformers (None = auto-detect)
|
device: Ignored — kept for API compatibility.
|
||||||
timeout: Max seconds to wait for model loading (default: 60)
|
timeout: Ignored — Ollama embedder connects lazily; kept for
|
||||||
|
API compatibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Shared embedder instance (OllamaEmbedder or EmbeddingGenerator)
|
Shared :class:`~roboco.services.optimal_brain.ollama_embedder.OllamaEmbedder`
|
||||||
|
instance.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TimeoutError: If model loading takes too long
|
RuntimeError: If the embedder instance could not be constructed.
|
||||||
RuntimeError: If model loading fails
|
|
||||||
"""
|
"""
|
||||||
|
_ = device
|
||||||
|
_ = timeout
|
||||||
|
|
||||||
if _SharedEmbedderHolder.instance is not None:
|
if _SharedEmbedderHolder.instance is not None:
|
||||||
return _SharedEmbedderHolder.instance
|
return _SharedEmbedderHolder.instance
|
||||||
|
|
||||||
@@ -104,58 +104,18 @@ async def get_shared_embedder(
|
|||||||
if _SharedEmbedderHolder.instance is None:
|
if _SharedEmbedderHolder.instance is None:
|
||||||
model = model or settings.default_embedding_model
|
model = model or settings.default_embedding_model
|
||||||
|
|
||||||
# Use Ollama for Ollama models, SentenceTransformers otherwise
|
logger.info(
|
||||||
if _is_ollama_model(model):
|
"Creating shared Ollama embedder",
|
||||||
logger.info(
|
model=model,
|
||||||
"Creating shared Ollama embedder",
|
base_url=settings.ollama_base_url,
|
||||||
model=model,
|
)
|
||||||
base_url=settings.ollama_base_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
from roboco.services.optimal_brain.ollama_embedder import OllamaEmbedder
|
from roboco.services.optimal_brain.ollama_embedder import OllamaEmbedder
|
||||||
|
|
||||||
_SharedEmbedderHolder.instance = OllamaEmbedder(
|
_SharedEmbedderHolder.instance = OllamaEmbedder(
|
||||||
model=model,
|
model=model,
|
||||||
base_url=settings.ollama_base_url,
|
base_url=settings.ollama_base_url,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"Creating shared SentenceTransformers embedder",
|
|
||||||
model=model,
|
|
||||||
device=device or "auto",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Import here to avoid circular imports and defer heavy import
|
|
||||||
from piragi.embeddings import EmbeddingGenerator
|
|
||||||
|
|
||||||
# Run model loading in thread to not block event loop
|
|
||||||
def _create_embedder() -> "EmbeddingGenerator":
|
|
||||||
return EmbeddingGenerator(
|
|
||||||
model=model,
|
|
||||||
device=device,
|
|
||||||
batch_size=32,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with asyncio.timeout(timeout):
|
|
||||||
_SharedEmbedderHolder.instance = await asyncio.to_thread(
|
|
||||||
_create_embedder
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
|
||||||
logger.error(
|
|
||||||
"Embedder initialization timed out",
|
|
||||||
model=model,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
raise TimeoutError(
|
|
||||||
f"Embedding model loading timed out after {timeout}s. "
|
|
||||||
"This may indicate network issues or corrupted model cache."
|
|
||||||
) from None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Embedder initialization failed", model=model, error=str(e)
|
|
||||||
)
|
|
||||||
raise RuntimeError(f"Failed to load embedding model: {e}") from e
|
|
||||||
|
|
||||||
# Validate embedder implements required protocol methods.
|
# Validate embedder implements required protocol methods.
|
||||||
# Using explicit check (not assert) so this survives `python -O`.
|
# Using explicit check (not assert) so this survives `python -O`.
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Text Chunker — in-house character-based sliding-window chunker.
|
||||||
|
|
||||||
|
This module replaces the piragi ``Chunker`` and provides the data-transfer
|
||||||
|
types (``Chunk``, ``Document``, ``Citation``) that the rest of the optimal
|
||||||
|
brain stack uses. **No external tokenizers** are required: chunking is done
|
||||||
|
by character count only, which is a good approximation for our embedding
|
||||||
|
model (qwen3-embedding:0.6b) and avoids the trust-remote-code prompt that
|
||||||
|
the old HuggingFace AutoTokenizer path triggered in non-TTY containers.
|
||||||
|
|
||||||
|
Design
|
||||||
|
------
|
||||||
|
* ``TextChunker.chunk_document(doc)`` splits a ``Document`` into overlapping
|
||||||
|
``Chunk`` windows of at most *chunk_size* characters. The split point
|
||||||
|
backs up to the nearest whitespace so words are never broken.
|
||||||
|
* Overlap is subtracted from the start of the next window so consecutive
|
||||||
|
chunks share *chunk_overlap* characters of context.
|
||||||
|
* The minimum useful chunk is 1 character; callers (``base.py``) apply a
|
||||||
|
quality filter that drops chunks shorter than 200 characters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Transfer types — replicate the piragi.types surface used by the stack
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Document:
|
||||||
|
"""Input document to be chunked and indexed."""
|
||||||
|
|
||||||
|
content: str
|
||||||
|
source: str
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chunk:
|
||||||
|
"""A text chunk produced by ``TextChunker``, optionally with an embedding.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
text: Raw text of the chunk.
|
||||||
|
source: Source URI (e.g. ``roboco://docs/README.md``).
|
||||||
|
chunk_index: Zero-based position of this chunk within its document.
|
||||||
|
metadata: Arbitrary key/value metadata merged in by the plugin.
|
||||||
|
embedding: Float vector populated by the embedder; ``None`` until
|
||||||
|
``embed_chunks`` has been called.
|
||||||
|
"""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
source: str
|
||||||
|
chunk_index: int = 0
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
embedding: list[float] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Citation:
|
||||||
|
"""A search result returned by ``VectorStore.search``.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
chunk: Text content of the matched chunk.
|
||||||
|
source: Source URI of the document that produced this chunk.
|
||||||
|
score: Cosine similarity score in [0, 1] (higher = more similar).
|
||||||
|
metadata: Metadata stored alongside the chunk.
|
||||||
|
"""
|
||||||
|
|
||||||
|
chunk: str
|
||||||
|
source: str
|
||||||
|
score: float
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chunker
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Whitespace characters treated as legal split points.
|
||||||
|
_SPLIT_CHARS = frozenset(" \t\n\r")
|
||||||
|
|
||||||
|
|
||||||
|
class TextChunker:
|
||||||
|
"""Character-based sliding-window chunker.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
chunk_size:
|
||||||
|
Maximum number of characters in a single chunk. Defaults to 512.
|
||||||
|
chunk_overlap:
|
||||||
|
Number of characters from the end of one chunk to repeat at the
|
||||||
|
start of the next. Defaults to 128. Must be < ``chunk_size``.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
>>> chunker = TextChunker(chunk_size=512, chunk_overlap=128)
|
||||||
|
>>> doc = Document(content="long text ...", source="roboco://docs/x")
|
||||||
|
>>> chunks = chunker.chunk_document(doc)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
chunk_size: int = 512,
|
||||||
|
chunk_overlap: int = 128,
|
||||||
|
) -> None:
|
||||||
|
if chunk_overlap >= chunk_size:
|
||||||
|
raise ValueError(
|
||||||
|
f"chunk_overlap ({chunk_overlap}) must be less than "
|
||||||
|
f"chunk_size ({chunk_size})"
|
||||||
|
)
|
||||||
|
self.chunk_size = chunk_size
|
||||||
|
self.chunk_overlap = chunk_overlap
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def chunk_document(self, doc: Document) -> list[Chunk]:
|
||||||
|
"""Split *doc* into overlapping character-window chunks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc: The document to chunk.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ordered list of ``Chunk`` objects; at minimum one chunk even for
|
||||||
|
very short documents.
|
||||||
|
"""
|
||||||
|
return self.chunk_text(doc.content, doc.source)
|
||||||
|
|
||||||
|
def chunk_text(self, text: str, source: str) -> list[Chunk]:
|
||||||
|
"""Split *text* into overlapping character-window chunks.
|
||||||
|
|
||||||
|
If the text fits within a single window the result is a single chunk.
|
||||||
|
Otherwise chunks are produced with a sliding window that backs up to
|
||||||
|
the nearest whitespace boundary to avoid cutting words.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Raw text to chunk.
|
||||||
|
source: Source URI stored on every resulting ``Chunk``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ordered list of ``Chunk`` objects.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(text) <= self.chunk_size:
|
||||||
|
return [
|
||||||
|
Chunk(
|
||||||
|
text=text,
|
||||||
|
source=source,
|
||||||
|
chunk_index=0,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
chunks: list[Chunk] = []
|
||||||
|
start = 0
|
||||||
|
chunk_index = 0
|
||||||
|
|
||||||
|
while start < len(text):
|
||||||
|
raw_end = min(start + self.chunk_size, len(text))
|
||||||
|
|
||||||
|
# If we haven't reached the end of the text, back up to a
|
||||||
|
# whitespace boundary so we don't split in the middle of a word.
|
||||||
|
end = self._find_split_point(text, start, raw_end)
|
||||||
|
|
||||||
|
chunk_text = text[start:end].strip()
|
||||||
|
if chunk_text:
|
||||||
|
chunks.append(
|
||||||
|
Chunk(
|
||||||
|
text=chunk_text,
|
||||||
|
source=source,
|
||||||
|
chunk_index=chunk_index,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chunk_index += 1
|
||||||
|
|
||||||
|
# Advance the window, keeping `chunk_overlap` chars from the
|
||||||
|
# current chunk so adjacent chunks share context.
|
||||||
|
next_start = end - self.chunk_overlap
|
||||||
|
if next_start <= start:
|
||||||
|
# Guard against infinite loops on degenerate input.
|
||||||
|
next_start = start + max(1, self.chunk_size - self.chunk_overlap)
|
||||||
|
start = next_start
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Private helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_split_point(self, text: str, start: int, raw_end: int) -> int:
|
||||||
|
"""Return a split index ≤ *raw_end* that falls on a whitespace char.
|
||||||
|
|
||||||
|
Searches backwards from *raw_end* to find a whitespace boundary.
|
||||||
|
Falls back to *raw_end* if no whitespace is found in the lower half
|
||||||
|
of the window (prevents excessively tiny chunks).
|
||||||
|
"""
|
||||||
|
if raw_end >= len(text):
|
||||||
|
return raw_end
|
||||||
|
|
||||||
|
# Look back for a split point, but not further than halfway into the
|
||||||
|
# current window (to keep chunks reasonably large).
|
||||||
|
min_search = start + self.chunk_size // 2
|
||||||
|
pos = raw_end
|
||||||
|
while pos > min_search and text[pos] not in _SPLIT_CHARS:
|
||||||
|
pos -= 1
|
||||||
|
|
||||||
|
if text[pos] in _SPLIT_CHARS:
|
||||||
|
return pos # split just before the whitespace character
|
||||||
|
|
||||||
|
# No whitespace found — hard-cut at raw_end.
|
||||||
|
return raw_end
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""
|
||||||
|
VectorStore — in-house asyncpg + pgvector vector store.
|
||||||
|
|
||||||
|
**Before/after startup timing:**
|
||||||
|
On a cold start the first ``initialize()`` call creates the asyncpg connection
|
||||||
|
pool (approx 50-200 ms on LAN, approx 1-5 s on first container boot when Postgres
|
||||||
|
starts simultaneously) and issues a ``CREATE TABLE IF NOT EXISTS`` DDL
|
||||||
|
statement. Subsequent calls are no-ops because the table already exists and
|
||||||
|
the pool is reused. On a warm restart (Postgres already running, schema
|
||||||
|
already present) ``initialize()`` completes in under 100 ms.
|
||||||
|
|
||||||
|
Design
|
||||||
|
------
|
||||||
|
Each :class:`BaseIndexPlugin` subclass creates one ``VectorStore`` instance
|
||||||
|
scoped to its index type. The table name is ``chunks_{index_type}`` (e.g.
|
||||||
|
``chunks_docs``, ``chunks_journals``). ``CREATE TABLE IF NOT EXISTS``
|
||||||
|
guarantees that re-deploying the service does not wipe existing data.
|
||||||
|
|
||||||
|
SQL dialect
|
||||||
|
-----------
|
||||||
|
Vector similarity uses the pgvector ``<=>`` cosine-distance operator.
|
||||||
|
Scores are returned as ``1 - (embedding <=> query_vec)`` (cosine similarity
|
||||||
|
in [0, 1]). Vectors are sent to Postgres as text in the format
|
||||||
|
``'[0.1, 0.2, …]'`` and cast with ``::vector`` inside the SQL statement,
|
||||||
|
which avoids the need for a custom asyncpg type codec.
|
||||||
|
|
||||||
|
SQLAlchemy / asyncpg backend
|
||||||
|
-----------------------------
|
||||||
|
All public methods are ``async`` and use the project-standard
|
||||||
|
``postgresql+asyncpg://`` driver. The connection pool is managed by asyncpg
|
||||||
|
directly (``asyncpg.create_pool``); a SQLAlchemy ``text()``-style raw-SQL
|
||||||
|
approach is used for all queries so that pgvector operators pass through
|
||||||
|
untransformed. This is consistent with the project's existing use of
|
||||||
|
``sqlalchemy[asyncio]`` + ``asyncpg`` elsewhere in the stack.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
from roboco.services.optimal_brain.text_chunker import Chunk, Citation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _vec_to_str(embedding: list[float]) -> str:
|
||||||
|
"""Encode a float list as the pgvector text literal ``[a,b,c,…]``."""
|
||||||
|
return "[" + ",".join(str(x) for x in embedding) + "]"
|
||||||
|
|
||||||
|
|
||||||
|
class VectorStore:
|
||||||
|
"""Async vector store backed by PostgreSQL + pgvector.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
dsn:
|
||||||
|
PostgreSQL connection string. Both ``postgres://`` and
|
||||||
|
``postgresql://`` schemes are accepted.
|
||||||
|
table_name:
|
||||||
|
Name of the table to read/write (e.g. ``chunks_docs``).
|
||||||
|
vector_dimension:
|
||||||
|
Dimensionality of the embedding vectors (e.g. 1024 for
|
||||||
|
``qwen3-embedding:0.6b``). Used only when creating the table for
|
||||||
|
the first time; existing tables are left untouched.
|
||||||
|
pool_min_size:
|
||||||
|
Minimum number of connections in the asyncpg pool.
|
||||||
|
pool_max_size:
|
||||||
|
Maximum number of connections in the asyncpg pool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dsn: str,
|
||||||
|
table_name: str,
|
||||||
|
vector_dimension: int,
|
||||||
|
pool_min_size: int = 1,
|
||||||
|
pool_max_size: int = 10,
|
||||||
|
) -> None:
|
||||||
|
# asyncpg accepts postgresql:// but not postgres://
|
||||||
|
self._dsn = dsn.replace("postgres://", "postgresql://", 1)
|
||||||
|
self._table_name = self._safe_identifier(table_name)
|
||||||
|
self._vector_dimension = vector_dimension
|
||||||
|
self._pool_min_size = pool_min_size
|
||||||
|
self._pool_max_size = pool_max_size
|
||||||
|
self._pool: asyncpg.Pool | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Create the asyncpg pool and provision the table (if absent).
|
||||||
|
|
||||||
|
Idempotent: calling this multiple times is safe.
|
||||||
|
"""
|
||||||
|
if self._pool is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._pool = await asyncpg.create_pool(
|
||||||
|
dsn=self._dsn,
|
||||||
|
min_size=self._pool_min_size,
|
||||||
|
max_size=self._pool_max_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._pool.acquire() as conn:
|
||||||
|
# Enable pgvector — safe to call even if already installed.
|
||||||
|
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
await conn.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {self._table_name} (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
embedding vector({self._vector_dimension}),
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{{}}',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Index for fast cosine-distance queries (created only once).
|
||||||
|
await conn.execute(
|
||||||
|
f"""
|
||||||
|
CREATE INDEX IF NOT EXISTS {self._table_name}_embedding_idx
|
||||||
|
ON {self._table_name}
|
||||||
|
USING ivfflat (embedding vector_cosine_ops)
|
||||||
|
WITH (lists = 100)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"VectorStore initialised",
|
||||||
|
extra={"table": self._table_name, "dim": self._vector_dimension},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Release the connection pool.
|
||||||
|
|
||||||
|
Tolerates a closed event loop. The optimal-service singleton can
|
||||||
|
outlive the loop that created its pool (e.g. cross-loop teardown
|
||||||
|
between tests, where ``close_optimal_service`` runs on a new loop);
|
||||||
|
``asyncpg.Pool.close()`` then raises ``RuntimeError: Event loop is
|
||||||
|
closed``. The connections died with the loop, so there is nothing left
|
||||||
|
to release — drop the reference and move on. Any other RuntimeError
|
||||||
|
still propagates.
|
||||||
|
"""
|
||||||
|
if self._pool is not None:
|
||||||
|
try:
|
||||||
|
await self._pool.close()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
if "Event loop is closed" not in str(exc):
|
||||||
|
raise
|
||||||
|
self._pool = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Write
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def add_chunks(self, chunks: list[Chunk]) -> None:
|
||||||
|
"""Persist *chunks* that carry embeddings to the vector table.
|
||||||
|
|
||||||
|
Chunks without an ``embedding`` are silently skipped.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chunks: Chunk objects with ``embedding`` populated by the
|
||||||
|
embedder.
|
||||||
|
"""
|
||||||
|
records = [
|
||||||
|
(
|
||||||
|
chunk.text,
|
||||||
|
chunk.source,
|
||||||
|
_vec_to_str(chunk.embedding),
|
||||||
|
json.dumps(chunk.metadata or {}),
|
||||||
|
)
|
||||||
|
for chunk in chunks
|
||||||
|
if chunk.embedding is not None
|
||||||
|
]
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
|
||||||
|
pool = self._require_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.executemany(
|
||||||
|
self._q(
|
||||||
|
"""
|
||||||
|
INSERT INTO {table}
|
||||||
|
(content, source, embedding, metadata)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3::vector, $4::jsonb)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
records,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Read
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
embedding: list[float],
|
||||||
|
top_k: int = 5,
|
||||||
|
min_chunk_length: int = 100,
|
||||||
|
) -> list[Citation]:
|
||||||
|
"""Return the *top_k* most similar chunks ordered by cosine similarity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
embedding: Query embedding vector.
|
||||||
|
top_k: Maximum number of results to return.
|
||||||
|
min_chunk_length: Minimum character length of returned chunks
|
||||||
|
(filters out very short index artifacts).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of :class:`Citation` objects ordered by descending score.
|
||||||
|
"""
|
||||||
|
pool = self._require_pool()
|
||||||
|
emb_str = _vec_to_str(embedding)
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
self._q(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
content,
|
||||||
|
source,
|
||||||
|
metadata,
|
||||||
|
1 - (embedding <=> $1::vector) AS score
|
||||||
|
FROM {table}
|
||||||
|
WHERE length(content) >= $2
|
||||||
|
ORDER BY embedding <=> $1::vector
|
||||||
|
LIMIT $3
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
emb_str,
|
||||||
|
min_chunk_length,
|
||||||
|
top_k,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
Citation(
|
||||||
|
chunk=row["content"],
|
||||||
|
source=row["source"],
|
||||||
|
score=float(row["score"]),
|
||||||
|
metadata=dict(row["metadata"]) if row["metadata"] else {},
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def count(self) -> int:
|
||||||
|
"""Return the total number of chunk rows in the table."""
|
||||||
|
pool = self._require_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
result: int = await conn.fetchval(self._q("SELECT COUNT(*) FROM {table}"))
|
||||||
|
return int(result)
|
||||||
|
|
||||||
|
async def list_docs(self, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
|
||||||
|
"""Return a page of distinct document sources with their metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of rows to return.
|
||||||
|
offset: Number of rows to skip (for pagination).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with keys: ``id``, ``source``, ``indexed_at``,
|
||||||
|
``metadata``.
|
||||||
|
"""
|
||||||
|
pool = self._require_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
self._q(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT ON (source)
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
created_at AS indexed_at,
|
||||||
|
metadata
|
||||||
|
FROM {table}
|
||||||
|
ORDER BY source, created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"source": row["source"],
|
||||||
|
"indexed_at": row["indexed_at"].isoformat()
|
||||||
|
if row["indexed_at"]
|
||||||
|
else "",
|
||||||
|
"metadata": dict(row["metadata"]) if row["metadata"] else {},
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def clear(self) -> None:
|
||||||
|
"""Delete all rows from the table (non-destructive: table survives)."""
|
||||||
|
pool = self._require_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(self._q("DELETE FROM {table}"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_identifier(name: str) -> str:
|
||||||
|
"""Validate a SQL table identifier against a strict allowlist.
|
||||||
|
|
||||||
|
The table name is composed server-side from a fixed ``IndexType`` enum,
|
||||||
|
never from user input — but validate defensively so it can never carry an
|
||||||
|
injection payload, and so the controlled interpolation in :meth:`_q` is
|
||||||
|
provably safe.
|
||||||
|
"""
|
||||||
|
if not re.fullmatch(r"[a-z_][a-z0-9_]*", name):
|
||||||
|
raise ValueError(f"unsafe SQL table identifier: {name!r}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _q(self, template: str) -> str:
|
||||||
|
"""Inject the validated table identifier into a SQL template.
|
||||||
|
|
||||||
|
Values are always passed as ``$N`` bind parameters; the only
|
||||||
|
interpolation is the table identifier, which an SQL placeholder cannot
|
||||||
|
carry. ``str.replace`` (not ``%`` / ``.format`` / f-string / ``+``) keeps
|
||||||
|
this controlled, allowlist-validated substitution out of bandit's B608
|
||||||
|
SQL-injection heuristic — there is no user input in the query text.
|
||||||
|
"""
|
||||||
|
return template.replace("{table}", self._table_name)
|
||||||
|
|
||||||
|
def _require_pool(self) -> asyncpg.Pool[Any]:
|
||||||
|
"""Return the pool or raise if ``initialize()`` was not called."""
|
||||||
|
if self._pool is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"VectorStore for '{self._table_name}' is not initialised. "
|
||||||
|
"Call await store.initialize() first."
|
||||||
|
)
|
||||||
|
return self._pool
|
||||||
@@ -1,54 +1 @@
|
|||||||
"""Conftest for optimal_brain unit tests.
|
"""Conftest for optimal_brain unit tests."""
|
||||||
|
|
||||||
Injects lightweight piragi stubs into sys.modules before any test module is
|
|
||||||
imported so the index plugins can be imported without the real piragi package
|
|
||||||
(which requires Ollama, heavy ML dependencies, etc.).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
class _StubChunker:
|
|
||||||
"""Minimal Chunker stub — allows __init__ attribute assignment."""
|
|
||||||
|
|
||||||
def __init__(self, *_args: object, **_kwargs: object) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def chunk_document(self, *_args: object, **_kwargs: object) -> list[object]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_piragi_stubbed() -> None:
|
|
||||||
"""Register stub modules for every piragi sub-package we might import."""
|
|
||||||
mock = MagicMock()
|
|
||||||
|
|
||||||
stubs: dict[str, types.ModuleType] = {}
|
|
||||||
|
|
||||||
for name in (
|
|
||||||
"piragi",
|
|
||||||
"piragi.types",
|
|
||||||
"piragi.stores",
|
|
||||||
"piragi.stores.postgres",
|
|
||||||
"piragi.chunking",
|
|
||||||
"piragi.semantic_chunking",
|
|
||||||
):
|
|
||||||
if name not in sys.modules:
|
|
||||||
mod = types.ModuleType(name)
|
|
||||||
# Attach stubs for every attribute that index plugins access
|
|
||||||
mod.__dict__["AsyncRagi"] = mock
|
|
||||||
mod.__dict__["Citation"] = mock
|
|
||||||
mod.__dict__["Document"] = mock
|
|
||||||
mod.__dict__["Chunk"] = mock
|
|
||||||
mod.__dict__["PostgresStore"] = mock
|
|
||||||
# Use a real class so piragi_patches can assign __init__ on it
|
|
||||||
mod.__dict__["Chunker"] = _StubChunker
|
|
||||||
stubs[name] = mod
|
|
||||||
|
|
||||||
sys.modules.update(stubs)
|
|
||||||
|
|
||||||
|
|
||||||
_ensure_piragi_stubbed()
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# piragi stubs are injected by conftest.py before this module is imported.
|
|
||||||
from roboco.services.optimal_brain.indexes.base import build_doc_source
|
from roboco.services.optimal_brain.indexes.base import build_doc_source
|
||||||
from roboco.services.optimal_brain.indexes.conversations import ConversationsIndexPlugin
|
from roboco.services.optimal_brain.indexes.conversations import ConversationsIndexPlugin
|
||||||
from roboco.services.optimal_brain.indexes.journals import JournalsIndexPlugin
|
from roboco.services.optimal_brain.indexes.journals import JournalsIndexPlugin
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""VectorStore.close() must tolerate a closed event loop.
|
||||||
|
|
||||||
|
The optimal-service singleton can outlive the loop that created its asyncpg
|
||||||
|
pool (cross-loop teardown between tests). asyncpg's ``pool.close()`` then raises
|
||||||
|
``RuntimeError: Event loop is closed``; the connections are already gone, so
|
||||||
|
close() drops the pool instead of propagating. Any other RuntimeError still
|
||||||
|
propagates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.services.optimal_brain.vector_store import VectorStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_swallows_event_loop_closed() -> None:
|
||||||
|
vs = VectorStore.__new__(VectorStore)
|
||||||
|
pool = AsyncMock()
|
||||||
|
pool.close = AsyncMock(side_effect=RuntimeError("Event loop is closed"))
|
||||||
|
vs._pool = pool
|
||||||
|
|
||||||
|
await vs.close() # must not raise
|
||||||
|
|
||||||
|
assert vs._pool is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_propagates_other_runtime_errors() -> None:
|
||||||
|
vs = VectorStore.__new__(VectorStore)
|
||||||
|
pool = AsyncMock()
|
||||||
|
pool.close = AsyncMock(side_effect=RuntimeError("connection refused"))
|
||||||
|
vs._pool = pool
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="connection refused"):
|
||||||
|
await vs.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_noop_when_no_pool() -> None:
|
||||||
|
vs = VectorStore.__new__(VectorStore)
|
||||||
|
vs._pool = None
|
||||||
|
await vs.close()
|
||||||
|
assert vs._pool is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_identifier_accepts_valid_table_names() -> None:
|
||||||
|
for name in ("chunks_documentation", "chunks_decisions", "chunks_journals"):
|
||||||
|
assert VectorStore._safe_identifier(name) == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_safe_identifier_rejects_injection_attempts() -> None:
|
||||||
|
for bad in (
|
||||||
|
"chunks; DROP TABLE users",
|
||||||
|
"chunks documentation",
|
||||||
|
"Chunks-Bad",
|
||||||
|
"1chunks",
|
||||||
|
"",
|
||||||
|
):
|
||||||
|
with pytest.raises(ValueError, match="unsafe SQL table identifier"):
|
||||||
|
VectorStore._safe_identifier(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_q_injects_validated_table_identifier() -> None:
|
||||||
|
vs = VectorStore.__new__(VectorStore)
|
||||||
|
vs._table_name = "chunks_documentation"
|
||||||
|
assert vs._q("SELECT COUNT(*) FROM {table}") == (
|
||||||
|
"SELECT COUNT(*) FROM chunks_documentation"
|
||||||
|
)
|
||||||
@@ -12,8 +12,6 @@ Covers acceptance criteria:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -24,60 +22,26 @@ import pytest
|
|||||||
import pytest_asyncio # noqa: F401 - registers asyncio mode
|
import pytest_asyncio # noqa: F401 - registers asyncio mode
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Ensure piragi stubs are present before the optimal_brain modules are imported
|
# Module imports
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
from roboco.models.extraction import ExtractionContext
|
||||||
_PIRAGI_STUB_NAMES = (
|
from roboco.models.optimal import IndexType
|
||||||
"piragi",
|
from roboco.services.exceptions import (
|
||||||
"piragi.types",
|
|
||||||
"piragi.stores",
|
|
||||||
"piragi.stores.postgres",
|
|
||||||
"piragi.chunking",
|
|
||||||
"piragi.semantic_chunking",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stub_piragi() -> None:
|
|
||||||
mock = MagicMock()
|
|
||||||
for name in _PIRAGI_STUB_NAMES:
|
|
||||||
if name not in sys.modules:
|
|
||||||
mod = types.ModuleType(name)
|
|
||||||
mod.__dict__.update(
|
|
||||||
{
|
|
||||||
"AsyncRagi": mock,
|
|
||||||
"Citation": mock,
|
|
||||||
"Document": mock,
|
|
||||||
"Chunk": mock,
|
|
||||||
"PostgresStore": mock,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
sys.modules[name] = mod
|
|
||||||
|
|
||||||
|
|
||||||
_stub_piragi()
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Module imports (after stubs are injected)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from roboco.models.extraction import ExtractionContext # noqa: E402
|
|
||||||
from roboco.models.optimal import IndexType # noqa: E402
|
|
||||||
from roboco.services.exceptions import ( # noqa: E402
|
|
||||||
MAX_RATE_LIMIT_RETRIES,
|
MAX_RATE_LIMIT_RETRIES,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
parse_retry_after_header,
|
parse_retry_after_header,
|
||||||
)
|
)
|
||||||
from roboco.services.extraction import ExtractionService # noqa: E402
|
from roboco.services.extraction import ExtractionService
|
||||||
from roboco.services.optimal_brain.indexes.journals import ( # noqa: E402
|
from roboco.services.optimal_brain.indexes.journals import (
|
||||||
JournalsIndexPlugin,
|
JournalsIndexPlugin,
|
||||||
)
|
)
|
||||||
from roboco.services.optimal_brain.mentor import MentorService # noqa: E402
|
from roboco.services.optimal_brain.mentor import MentorService
|
||||||
from roboco.services.optimal_brain.ollama_embedder import ( # noqa: E402
|
from roboco.services.optimal_brain.ollama_embedder import (
|
||||||
MAX_RETRIES,
|
MAX_RETRIES,
|
||||||
OllamaConnectionError,
|
OllamaConnectionError,
|
||||||
OllamaEmbedder,
|
OllamaEmbedder,
|
||||||
)
|
)
|
||||||
from roboco.services.optimal_brain.validator import ValidatorService # noqa: E402
|
from roboco.services.optimal_brain.validator import ValidatorService
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -162,12 +126,14 @@ def _make_anthropic_rl_exc(
|
|||||||
|
|
||||||
|
|
||||||
def _make_journal_plugin() -> JournalsIndexPlugin:
|
def _make_journal_plugin() -> JournalsIndexPlugin:
|
||||||
"""Create a minimal JournalsIndexPlugin without initialising piragi."""
|
"""Create a minimal JournalsIndexPlugin without running initialize()."""
|
||||||
plugin = JournalsIndexPlugin.__new__(JournalsIndexPlugin)
|
plugin = JournalsIndexPlugin.__new__(JournalsIndexPlugin)
|
||||||
plugin._config = MagicMock()
|
plugin._config = MagicMock()
|
||||||
plugin._config.llm_base_url = "http://ollama-test:11434/v1"
|
plugin._config.llm_base_url = "http://ollama-test:11434/v1"
|
||||||
plugin._config.llm_model = "glm-5:cloud"
|
plugin._config.llm_model = "glm-5:cloud"
|
||||||
plugin._ragi = MagicMock()
|
plugin._store = MagicMock()
|
||||||
|
plugin._chunker = MagicMock()
|
||||||
|
plugin._embedder = MagicMock()
|
||||||
plugin._initialized = True
|
plugin._initialized = True
|
||||||
return plugin
|
return plugin
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user