mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(rag): close audit gaps in the in-house engine
An adversarial audit of the piragi -> in-house swap surfaced nine confirmed issues; this fixes all of them. - Re-ingest now REPLACES a source's chunks instead of appending. Add VectorStore.delete_by_source and BaseIndexPlugin.replace_on_reingest (default True), called before add_chunks in both ingest paths. Without it every startup / periodic / manual reindex appended a fresh copy of each doc's chunks, growing the tables unbounded and crowding out distinct results. Conversations opt OUT (replace_on_reingest=False): their many messages share one source URI, so delete-by-source would wipe history. - index_* now honor the plugin IngestResult. The explicit record endpoints (error / standard / decision / review / learning) raise on failure instead of writing a green tracking row for content that never persisted; conversation / journal indexing stays best-effort but skips the tracking row when the embed fails. index_message / index_entry return IngestResult. - A deprecated index type (code) now returns 404 instead of a 500 leaked from _get_plugin's missing-plugin error: add OptimalService.is_index_registered and guard the stats / clear / refresh routes. The panel drops the dead 'Code' category, filter, badge, label, and mock data. - Panel: getContext reads 'results' (matches SearchResponse) instead of a non-existent 'context' field; the reindex toast no longer reports phantom '0 code files'; the stats 'Updated' label uses the max timestamp across indexes rather than indexes[0]; ProactiveContextItem matches the wire shape. - Drop the always-zero per-document chunk_count from the documents API. - Remove dead RAG settings (hybrid_search, cross_encoder) the engine never consumed, and correct stale piragi / BM25 references in code, README, and CLAUDE.md. Delete the unused duplicate roboco/kb embedder package the swap shipped. Adds tests for replace-on-reingest (incl. the conversations carve-out) and the deprecated-index 404.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
A dependency-light helper (only ``json`` + ``pathlib``) so callers that need
|
||||
durable token counts — notably the orchestrator's session-finalization path —
|
||||
can read them without importing the agent SDK server, which pulls in the
|
||||
FastAPI / RAG (piragi / openai) stack.
|
||||
FastAPI / RAG (in-house pgvector / openai) stack.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -495,6 +495,11 @@ async def get_single_index_stats(
|
||||
) from e
|
||||
|
||||
service = await get_optimal_service()
|
||||
if not service.is_index_registered(idx_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Index '{index_type}' is not available",
|
||||
)
|
||||
stats = await service.get_index_stats(idx_type)
|
||||
|
||||
return SingleIndexStatsResponse(
|
||||
@@ -544,6 +549,11 @@ async def clear_index(
|
||||
) from e
|
||||
|
||||
service = await get_optimal_service()
|
||||
if not service.is_index_registered(idx_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Index '{index_type}' is not available",
|
||||
)
|
||||
await service.clear_index(idx_type)
|
||||
|
||||
return ClearIndexResponse(status="cleared", index_type=index_type)
|
||||
@@ -583,7 +593,6 @@ async def list_documents(
|
||||
metadata={
|
||||
"title": d["title"],
|
||||
"preview": d["preview"],
|
||||
"chunk_count": d["chunk_count"],
|
||||
**d["extra_data"],
|
||||
},
|
||||
)
|
||||
@@ -618,6 +627,11 @@ async def refresh_index(
|
||||
) from e
|
||||
|
||||
service = await get_optimal_service()
|
||||
if not service.is_index_registered(idx_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Index '{request.index_type}' is not available",
|
||||
)
|
||||
|
||||
# Empty sources = "refresh everything currently registered in this
|
||||
# index". The service knows how to enumerate them so the UI's
|
||||
|
||||
@@ -136,12 +136,6 @@ class Settings(BaseSettings):
|
||||
description="Use HyDE (hypothetical document embeddings). "
|
||||
"Makes one LLM call per query for better semantic matching.",
|
||||
)
|
||||
rag_use_hybrid_search: bool = Field(
|
||||
default=True, description="Use BM25 + vector hybrid search"
|
||||
)
|
||||
rag_use_cross_encoder: bool = Field(
|
||||
default=True, description="Use neural reranking (slower but more accurate)"
|
||||
)
|
||||
rag_auto_update_enabled: bool = Field(default=True)
|
||||
rag_auto_update_interval: int = Field(
|
||||
default=300, ge=60, description="Seconds between auto-updates"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
@@ -1,858 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -15,7 +15,7 @@ Servers:
|
||||
Do NOT eagerly re-export server factories here. Each server is launched
|
||||
as its own subprocess via ``python -m roboco.mcp.<name>``, and importing
|
||||
the ``roboco.mcp`` package first forces every sibling module to load —
|
||||
most notably ``optimal_server``, which pulls in piragi/ollama and adds
|
||||
most notably ``optimal_server``, which pulls in the pgvector/ollama stack and adds
|
||||
~6s to startup. Claude Code times out slow MCP servers during
|
||||
init, which manifests as "roboco-flow/do tools never register". Keep
|
||||
this file empty-of-imports; callers import the specific module they
|
||||
|
||||
+57
-11
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Optimal API Service (Refactored with Plugin Architecture)
|
||||
|
||||
Knowledge base, RAG queries, and prompt optimization using piragi.
|
||||
Knowledge base, RAG queries, and prompt optimization over an in-house
|
||||
PostgreSQL/pgvector engine.
|
||||
This service provides semantic search across documentation,
|
||||
conversations, journal entries, errors, standards, decisions, reviews, and learnings.
|
||||
|
||||
@@ -15,7 +16,7 @@ import asyncio
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
@@ -49,6 +50,9 @@ from roboco.services.optimal_brain.indexes.reviews import (
|
||||
RecordReviewParams as ReviewParams,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from roboco.services.optimal_brain.indexes.base import IngestResult
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Max chars per citation content - increased for better synthesis quality
|
||||
@@ -147,8 +151,8 @@ class OptimalService:
|
||||
"""
|
||||
Service for knowledge base operations and RAG queries.
|
||||
|
||||
Uses a plugin-based architecture with piragi and PostgreSQL/pgvector
|
||||
for vector storage. Manages multiple indexes for different content types:
|
||||
Uses a plugin-based architecture over an in-house PostgreSQL/pgvector
|
||||
vector store. Manages multiple indexes for different content types:
|
||||
|
||||
Existing:
|
||||
- Code: Repositories, functions, classes
|
||||
@@ -594,6 +598,15 @@ class OptimalService:
|
||||
)
|
||||
return plugin
|
||||
|
||||
def is_index_registered(self, index_type: IndexType) -> bool:
|
||||
"""Whether a live plugin is registered for *index_type*.
|
||||
|
||||
Distinguishes a valid-but-deprecated index (e.g. ``code``) from an
|
||||
active one, so callers can return 404 instead of leaking a 500 from
|
||||
:meth:`_get_plugin`.
|
||||
"""
|
||||
return self._initialized and index_type in self._plugins
|
||||
|
||||
# =========================================================================
|
||||
# INDEXING OPERATIONS (Existing - Backwards Compatible)
|
||||
# =========================================================================
|
||||
@@ -741,9 +754,9 @@ class OptimalService:
|
||||
"""Index a conversation message."""
|
||||
plugin = self._get_plugin(IndexType.CONVERSATIONS)
|
||||
if isinstance(plugin, ConversationsIndexPlugin):
|
||||
await plugin.index_message(params)
|
||||
result = await plugin.index_message(params)
|
||||
else:
|
||||
await plugin.ingest(
|
||||
result = await plugin.ingest(
|
||||
content=params.content,
|
||||
channel_id=params.channel_id,
|
||||
session_id=params.session_id,
|
||||
@@ -760,6 +773,16 @@ class OptimalService:
|
||||
"build doc-source with placeholder. Caller must pass a "
|
||||
"flushed session UUID."
|
||||
)
|
||||
# Best-effort: an actual failure (e.g. embedder down) must not record
|
||||
# the message as indexed. A successful-but-empty result (short message
|
||||
# filtered to zero chunks) still tracks, matching prior behavior.
|
||||
if not result.success:
|
||||
logger.warning(
|
||||
"Conversation indexing failed; skipping tracking row",
|
||||
session_id=str(params.session_id),
|
||||
error=result.error,
|
||||
)
|
||||
return
|
||||
source = f"roboco://conversations/{params.session_id}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.CONVERSATIONS,
|
||||
@@ -777,9 +800,9 @@ class OptimalService:
|
||||
"""Index a journal entry."""
|
||||
plugin = self._get_plugin(IndexType.JOURNALS)
|
||||
if isinstance(plugin, JournalsIndexPlugin):
|
||||
await plugin.index_entry(params)
|
||||
result = await plugin.index_entry(params)
|
||||
else:
|
||||
await plugin.ingest(
|
||||
result = await plugin.ingest(
|
||||
content=params.content,
|
||||
entry_id=params.entry_id,
|
||||
agent_id=params.agent_id,
|
||||
@@ -799,6 +822,14 @@ class OptimalService:
|
||||
"events without a journal entry must use a different "
|
||||
"indexing path."
|
||||
)
|
||||
# Best-effort: don't record a failed embed as an indexed entry.
|
||||
if not result.success:
|
||||
logger.warning(
|
||||
"Journal indexing failed; skipping tracking row",
|
||||
entry_id=str(params.entry_id),
|
||||
error=result.error,
|
||||
)
|
||||
return
|
||||
source = f"roboco://journals/{params.entry_id}"
|
||||
await self._track_indexed_document(
|
||||
IndexType.JOURNALS,
|
||||
@@ -820,8 +851,11 @@ class OptimalService:
|
||||
async def index_error(self, params: IndexErrorParams) -> None:
|
||||
"""Index an error pattern with solution."""
|
||||
plugin = self._get_plugin(IndexType.ERRORS)
|
||||
result: IngestResult | None = None
|
||||
if isinstance(plugin, ErrorsIndexPlugin):
|
||||
await plugin.record_error(params)
|
||||
result = await plugin.record_error(params)
|
||||
if result is not None and not result.success:
|
||||
raise RuntimeError(f"Failed to index error pattern: {result.error}")
|
||||
|
||||
# Track in database
|
||||
import hashlib
|
||||
@@ -845,8 +879,11 @@ class OptimalService:
|
||||
async def index_standard(self, params: IndexStandardParams) -> None:
|
||||
"""Index a coding/security/workflow standard."""
|
||||
plugin = self._get_plugin(IndexType.STANDARDS)
|
||||
result: IngestResult | None = None
|
||||
if isinstance(plugin, StandardsIndexPlugin):
|
||||
await plugin.index_standard(params)
|
||||
result = await plugin.index_standard(params)
|
||||
if result is not None and not result.success:
|
||||
raise RuntimeError(f"Failed to index standard: {result.error}")
|
||||
|
||||
# Track in database
|
||||
source = f"roboco://standards/{params.domain or 'general'}"
|
||||
@@ -866,8 +903,11 @@ class OptimalService:
|
||||
async def index_decision(self, params: IndexDecisionParams) -> None:
|
||||
"""Index an architectural/design decision."""
|
||||
plugin = self._get_plugin(IndexType.DECISIONS)
|
||||
result: IngestResult | None = None
|
||||
if isinstance(plugin, DecisionsIndexPlugin):
|
||||
await plugin.record_decision(params)
|
||||
result = await plugin.record_decision(params)
|
||||
if result is not None and not result.success:
|
||||
raise RuntimeError(f"Failed to index decision: {result.error}")
|
||||
|
||||
# Track in database
|
||||
import hashlib
|
||||
@@ -895,6 +935,7 @@ class OptimalService:
|
||||
"""Record a code review for future reference."""
|
||||
plugin = self._get_plugin(IndexType.REVIEWS)
|
||||
doc_id = ""
|
||||
result: IngestResult | None = None
|
||||
if isinstance(plugin, ReviewsIndexPlugin):
|
||||
review_params = ReviewParams(
|
||||
comment=params.summary,
|
||||
@@ -906,6 +947,8 @@ class OptimalService:
|
||||
)
|
||||
result = await plugin.record_review(review_params)
|
||||
doc_id = result.doc_id
|
||||
if result is not None and not result.success:
|
||||
raise RuntimeError(f"Failed to record review: {result.error}")
|
||||
|
||||
# Track in database. Refuse to fall back to 'unknown' — file_path
|
||||
# is typed `str` (required) and an empty value means the caller is
|
||||
@@ -935,9 +978,12 @@ class OptimalService:
|
||||
"""Record a learning for cross-agent knowledge sharing."""
|
||||
plugin = self._get_plugin(IndexType.LEARNINGS)
|
||||
doc_id = ""
|
||||
result: IngestResult | None = None
|
||||
if isinstance(plugin, LearningsIndexPlugin):
|
||||
result = await plugin.record_learning(params)
|
||||
doc_id = result.doc_id
|
||||
if result is not None and not result.success:
|
||||
raise RuntimeError(f"Failed to record learning: {result.error}")
|
||||
|
||||
# Track in database
|
||||
import hashlib
|
||||
|
||||
@@ -68,8 +68,6 @@ class IndexConfig:
|
||||
chunk_size: int = 512
|
||||
chunk_overlap: int = 50
|
||||
use_hyde: bool = True
|
||||
use_hybrid_search: bool = True
|
||||
use_cross_encoder: bool = False
|
||||
embedding_model: str = "qwen3-embedding:0.6b"
|
||||
llm_model: str = "glm-5:cloud"
|
||||
llm_base_url: str = "http://roboco-ollama:11434/v1"
|
||||
@@ -91,8 +89,6 @@ class IndexConfig:
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=settings.rag_chunk_overlap,
|
||||
use_hyde=settings.rag_use_hyde,
|
||||
use_hybrid_search=settings.rag_use_hybrid_search,
|
||||
use_cross_encoder=settings.rag_use_cross_encoder,
|
||||
embedding_model=settings.default_embedding_model,
|
||||
llm_model=settings.local_llm_model,
|
||||
llm_base_url=settings.local_llm_base_url,
|
||||
@@ -137,6 +133,13 @@ class BaseIndexPlugin(ABC):
|
||||
- validate_content() for content validation
|
||||
"""
|
||||
|
||||
# When True (default), re-ingesting a source first deletes that source's
|
||||
# existing chunks so a reindex *replaces* rather than appends — preventing
|
||||
# unbounded duplicate-chunk growth on startup/periodic/manual reindex.
|
||||
# Plugins whose multiple records share one source URI (conversations) set
|
||||
# this False to preserve append semantics.
|
||||
replace_on_reingest: bool = True
|
||||
|
||||
def __init__(self, config: IndexConfig | None = None) -> None:
|
||||
"""Initialize the plugin with optional config override."""
|
||||
self._config = config
|
||||
@@ -463,6 +466,8 @@ class BaseIndexPlugin(ABC):
|
||||
embedder.embed_chunks, chunks
|
||||
)
|
||||
|
||||
if self.replace_on_reingest:
|
||||
await store.delete_by_source(doc.source)
|
||||
await store.add_chunks(chunks_with_embeddings)
|
||||
return len(chunks)
|
||||
|
||||
@@ -564,6 +569,10 @@ class BaseIndexPlugin(ABC):
|
||||
chunks_with_embeddings = await asyncio.to_thread(
|
||||
embedder.embed_chunks, all_chunks
|
||||
)
|
||||
if self.replace_on_reingest:
|
||||
for idx, (doc, _, _) in enumerate(docs_to_process):
|
||||
if chunk_counts.get(idx, 0) > 0:
|
||||
await store.delete_by_source(doc.source)
|
||||
await store.add_chunks(chunks_with_embeddings)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -8,7 +8,11 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.models.optimal import IndexConversationParams, IndexType
|
||||
from roboco.services.optimal_brain.indexes.base import BaseIndexPlugin, build_doc_source
|
||||
from roboco.services.optimal_brain.indexes.base import (
|
||||
BaseIndexPlugin,
|
||||
IngestResult,
|
||||
build_doc_source,
|
||||
)
|
||||
|
||||
|
||||
class ConversationsIndexPlugin(BaseIndexPlugin):
|
||||
@@ -21,6 +25,11 @@ class ConversationsIndexPlugin(BaseIndexPlugin):
|
||||
- Channel conversations
|
||||
"""
|
||||
|
||||
# Many messages share one source URI per session+agent, so deleting by
|
||||
# source on re-ingest would wipe earlier messages. Keep append semantics
|
||||
# for this index (the only one whose source is not 1:1 with a record).
|
||||
replace_on_reingest = False
|
||||
|
||||
@property
|
||||
def index_type(self) -> IndexType:
|
||||
return IndexType.CONVERSATIONS
|
||||
@@ -55,14 +64,17 @@ class ConversationsIndexPlugin(BaseIndexPlugin):
|
||||
combined = f"{raw_session}-{agent_id}"
|
||||
return build_doc_source(kind="conversations", id_=combined)
|
||||
|
||||
async def index_message(self, params: IndexConversationParams) -> None:
|
||||
async def index_message(self, params: IndexConversationParams) -> IngestResult:
|
||||
"""
|
||||
Index a conversation message.
|
||||
|
||||
Args:
|
||||
params: IndexConversationParams containing message details
|
||||
|
||||
Returns:
|
||||
IngestResult so the caller can tell whether the message persisted.
|
||||
"""
|
||||
await self.ingest(
|
||||
return await self.ingest(
|
||||
content=params.content,
|
||||
doc_id=f"{params.session_id}-{params.agent_id}"[:50],
|
||||
channel_id=params.channel_id,
|
||||
|
||||
@@ -8,7 +8,11 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.models.optimal import IndexJournalEntryParams, IndexType
|
||||
from roboco.services.optimal_brain.indexes.base import BaseIndexPlugin, build_doc_source
|
||||
from roboco.services.optimal_brain.indexes.base import (
|
||||
BaseIndexPlugin,
|
||||
IngestResult,
|
||||
build_doc_source,
|
||||
)
|
||||
|
||||
|
||||
class JournalsIndexPlugin(BaseIndexPlugin):
|
||||
@@ -49,14 +53,17 @@ class JournalsIndexPlugin(BaseIndexPlugin):
|
||||
entry_id = str(raw) if raw is not None else None
|
||||
return build_doc_source(kind="journals", id_=entry_id)
|
||||
|
||||
async def index_entry(self, params: IndexJournalEntryParams) -> None:
|
||||
async def index_entry(self, params: IndexJournalEntryParams) -> IngestResult:
|
||||
"""
|
||||
Index a journal entry.
|
||||
|
||||
Args:
|
||||
params: IndexJournalEntryParams containing entry details
|
||||
|
||||
Returns:
|
||||
IngestResult so the caller can tell whether the entry persisted.
|
||||
"""
|
||||
await self.ingest(
|
||||
return await self.ingest(
|
||||
content=params.content,
|
||||
doc_id=str(params.entry_id)[:50],
|
||||
entry_id=params.entry_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Text Chunker — in-house character-based sliding-window chunker.
|
||||
|
||||
This module replaces the piragi ``Chunker`` and provides the data-transfer
|
||||
This module is the in-house ``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
|
||||
@@ -25,7 +25,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transfer types — replicate the piragi.types surface used by the stack
|
||||
# Transfer types — the data-transfer surface used across the RAG stack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -196,6 +196,20 @@ class VectorStore:
|
||||
records,
|
||||
)
|
||||
|
||||
async def delete_by_source(self, source: str) -> None:
|
||||
"""Delete every chunk row for *source* (idempotent — no-op if absent).
|
||||
|
||||
Makes re-ingestion of a source *replace* its chunks instead of
|
||||
appending a duplicate set on each reindex. ``source`` is bound as a
|
||||
query parameter; only the validated table identifier is interpolated.
|
||||
"""
|
||||
pool = self._require_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
self._q("DELETE FROM {table} WHERE source = $1"),
|
||||
source,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user