perf: batch page embeddings via /api/embed and upsert before pruning stale points

- embed_batch now issues one batched /api/embed request (the old loop
  made one /api/embeddings round-trip per chunk) with a per-text
  fallback that preserves None-for-failed semantics
- update_from_page embeds all chunks in that single call and stores
  them in one Qdrant batch upsert (upsert_points)
- reindex order reversed: upsert new points first, then prune stale ids
  (deterministic uuid5 ids make overwrite safe) so a mid-way failure no
  longer leaves the page with zero vectors
- VectorUpdateSummary gains status (success/partial/failed) and
  chunks_skipped; all-embeddings-failed keeps old vectors and reports
  failure instead of success=True

Measured on a 7-chunk page ingest (local server, llm_tester):
~375ms -> ~181ms median over 3 runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbFZyDvYksazX6nYQYZ67L
This commit is contained in:
2026-07-14 13:09:37 +02:00
co-authored by Claude Fable 5
parent c35d3c1fa9
commit 8348b4bf92
6 changed files with 246 additions and 38 deletions
+31 -1
View File
@@ -33,6 +33,7 @@ class OllamaClient:
self.base_url = base_url.rstrip("/")
self.model = model
self.embeddings_url = f"{self.base_url}/api/embeddings"
self.embed_url = f"{self.base_url}/api/embed"
self.generate_url = f"{self.base_url}/api/generate"
self.tags_url = f"{self.base_url}/api/tags"
self.client = httpx.AsyncClient(timeout=120.0) # Embeddings can be slow
@@ -106,8 +107,37 @@ class OllamaClient:
>>> len(embeddings)
3
"""
embeddings = []
if not texts:
return []
# Single batched request via Ollama's /api/embed (the old
# implementation looped one /api/embeddings call per text).
try:
response = await self.client.post(
self.embed_url,
json={"model": self.model, "input": texts}
)
response.raise_for_status()
data = response.json()
embeddings = data.get("embeddings")
if embeddings is not None and len(embeddings) == len(texts):
if show_progress:
logger.info(f"Batched embedding complete: {len(embeddings)}/{len(texts)}")
return embeddings
logger.warning(
f"Batched embed returned {len(embeddings or [])} vectors for "
f"{len(texts)} inputs, falling back to per-text embedding"
)
except Exception as e:
logger.warning(
f"Batched embed failed ({e}), falling back to per-text embedding"
)
# Fallback: per-text embedding preserves partial-success semantics
# (None entries for texts that failed to embed).
embeddings = []
for i, text in enumerate(texts):
if show_progress and i % 10 == 0:
logger.info(f"Embedding progress: {i}/{len(texts)}")
+2
View File
@@ -67,6 +67,8 @@ class VectorUpdateSummary(BaseModel):
chunks_created: int = Field(default=0, description="New chunks created")
chunks_updated: int = Field(default=0, description="Existing chunks updated")
chunks_deleted: int = Field(default=0, description="Old chunks deleted")
chunks_skipped: int = Field(default=0, description="Chunks skipped (embedding failed)")
status: str = Field(default="success", description="'success', 'partial' (some chunks skipped), or 'failed'")
total_chunks: int = Field(default=0, description="Total chunks for this page")
embedding_dim: int = Field(default=768, description="Embedding dimensionality")
processing_time_ms: float = Field(..., description="Processing time in milliseconds")
+68 -33
View File
@@ -154,50 +154,79 @@ class VectorService:
chunks = self._chunk_text(content)
logger.info(f"Split page {page_id} into {len(chunks)} chunks")
# Delete existing chunks for this page
deleted_count = await self.qdrant.delete_by_filter(
collection_name=collection_name,
filter_conditions={"page_id": page_id}
)
# Generate ALL embeddings in one batched /api/embed call
# (previously one sequential Ollama round-trip per chunk)
embeddings = await self.ollama.embed_batch(chunks)
# Generate embeddings and upsert chunks
chunks_created = 0
for idx, chunk_text in enumerate(chunks):
# Generate deterministic UUID from page_id and chunk_index
chunk_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"page_{page_id}_chunk_{idx}"))
# Generate embedding
embedding = await self.ollama.embed(chunk_text)
# Build points; deterministic uuid5 IDs mean re-upserting the
# same page overwrites its previous chunks in place.
points = []
chunks_skipped = 0
embedding_dim = 768
for idx, (chunk_text, embedding) in enumerate(zip(chunks, embeddings)):
if not embedding:
logger.error(f"Failed to generate embedding for chunk {chunk_id}")
chunks_skipped += 1
logger.error(f"Failed to generate embedding for page {page_id} chunk {idx}")
continue
# Prepare metadata
metadata = {
"page_id": page_id,
"page_title": title,
"page_path": path,
"chunk_index": idx,
"chunk_text": chunk_text,
"user": user
}
embedding_dim = len(embedding)
chunk_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"page_{page_id}_chunk_{idx}"))
points.append({
"id": chunk_id,
"vector": embedding,
"payload": {
"page_id": page_id,
"page_title": title,
"page_path": path,
"chunk_index": idx,
"chunk_text": chunk_text,
"user": user
}
})
# Upsert to Qdrant
success = await self.qdrant.upsert_vector(
# Upsert BEFORE deleting stale points. The old order (delete all,
# then embed+upsert one by one) left the page with ZERO vectors if
# anything failed mid-way; now old vectors survive until the new
# ones are safely stored.
chunks_created = 0
if points:
chunks_created = await self.qdrant.upsert_points(
collection_name=collection_name,
vector_id=chunk_id,
vector=embedding,
payload=metadata
points=points
)
if success:
chunks_created += 1
# Prune stale points from a previous version of the page (chunk
# indexes beyond the new count, or indexes whose new embedding
# failed). Only prune if the new upsert actually stored points —
# a fully failed embedding pass must not wipe the old vectors.
deleted_count = 0
if points:
new_ids = {p["id"] for p in points}
existing = await self.qdrant.scroll_all_points(
collection_name=collection_name,
filter_conditions={"page_id": page_id},
with_payload=False
)
stale_ids = [pt["id"] for pt in existing if pt["id"] not in new_ids]
if stale_ids:
deleted_count = await self.qdrant.delete_by_ids(
collection_name=collection_name,
point_ids=stale_ids
)
processing_time_ms = (time.time() - start_time) * 1000
if chunks_created == 0:
status = "failed"
elif chunks_skipped > 0:
status = "partial"
else:
status = "success"
logger.info(
f"Updated vectors for page {page_id}: "
f"{chunks_created} chunks created, {deleted_count} old chunks deleted"
f"{chunks_created} chunks created, {chunks_skipped} skipped, "
f"{deleted_count} stale chunks deleted ({status})"
)
return VectorUpdateSummary(
@@ -205,10 +234,16 @@ class VectorService:
page_title=title,
chunks_created=chunks_created,
chunks_deleted=deleted_count,
chunks_skipped=chunks_skipped,
total_chunks=chunks_created,
embedding_dim=len(embedding) if embedding else 768,
embedding_dim=embedding_dim,
processing_time_ms=processing_time_ms,
success=True
success=chunks_created > 0,
status=status,
error_message=(
f"{chunks_skipped}/{len(chunks)} chunk embeddings failed"
if chunks_skipped else None
)
)
except Exception as e: