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
+10 -4
View File
@@ -41,6 +41,9 @@ def mock_qdrant():
qdrant.search_vectors = AsyncMock(return_value=[])
qdrant.search_with_expiry_filter = AsyncMock(return_value=[])
qdrant.upsert_vector = AsyncMock(return_value=True)
qdrant.upsert_points = AsyncMock(side_effect=lambda collection_name, points: len(points))
qdrant.scroll_all_points = AsyncMock(return_value=[])
qdrant.delete_by_ids = AsyncMock(return_value=0)
qdrant.delete_by_filter = AsyncMock(return_value=0)
return qdrant
@@ -49,6 +52,9 @@ def mock_qdrant():
def mock_ollama():
ollama = MagicMock()
ollama.embed = AsyncMock(return_value=[0.1] * 768)
ollama.embed_batch = AsyncMock(
side_effect=lambda texts, **kw: [[0.1] * 768 for _ in texts]
)
ollama.embed_text = AsyncMock(return_value=[0.1] * 768)
ollama.generate_text = AsyncMock(return_value="{}")
return ollama
@@ -148,8 +154,8 @@ class TestVectorLegScoping:
assert summary.success is False
assert "outside user" in (summary.error_message or "")
mock_qdrant.upsert_vector.assert_not_awaited()
mock_qdrant.delete_by_filter.assert_not_awaited()
mock_qdrant.upsert_points.assert_not_awaited()
mock_qdrant.delete_by_ids.assert_not_awaited()
async def test_update_from_page_rejects_sibling_prefix_namespace(
self, vector_service, mock_qdrant, mock_wiki
@@ -163,7 +169,7 @@ class TestVectorLegScoping:
summary = await vector_service.update_from_page(page_id=43, user=TENANT)
assert summary.success is False
mock_qdrant.upsert_vector.assert_not_awaited()
mock_qdrant.upsert_points.assert_not_awaited()
async def test_update_from_page_accepts_own_namespace(
self, vector_service, mock_qdrant, mock_wiki
@@ -177,7 +183,7 @@ class TestVectorLegScoping:
assert summary.success is True
assert (
mock_qdrant.upsert_vector.await_args.kwargs["collection_name"]
mock_qdrant.upsert_points.await_args.kwargs["collection_name"]
== TENANT_COLLECTION
)