Files
library-desk/tests/test_vector_reindex.py
jpmschweitzerandClaude Fable 5 8348b4bf92 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
2026-07-14 13:09:37 +02:00

135 lines
4.5 KiB
Python

"""
Offline unit tests for the batched, delete-last vector reindex path.
Pins the Phase D fixes in VectorService.update_from_page:
- embeddings come from ONE batched embed_batch call, not per-chunk embed()
- new points are upserted BEFORE stale points are deleted (deterministic
uuid5 ids make the overwrite safe), so a failure can no longer leave
the page with zero vectors
- the summary reports partial/failed status instead of unconditional
success=True when chunks are skipped
"""
import uuid
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.services.vector_service import VectorService
TENANT = "llm_tester"
TENANT_COLLECTION = "library_desk_llm_tester"
PAGE = {
"id": 44,
"title": "T",
"path": "users/llm_tester/page",
"content": " ".join(f"word{i}" for i in range(1200)), # 3 chunks @ 500/50
"tags": [],
}
def _chunk_id(page_id: int, idx: int) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_DNS, f"page_{page_id}_chunk_{idx}"))
@pytest.fixture
def mock_qdrant():
qdrant = MagicMock()
qdrant.collection_exists = AsyncMock(return_value=True)
qdrant.ensure_collection = AsyncMock()
qdrant.upsert_points = AsyncMock(side_effect=lambda collection_name, points: len(points))
qdrant.scroll_all_points = AsyncMock(return_value=[])
qdrant.delete_by_ids = AsyncMock(side_effect=lambda collection_name, point_ids: len(point_ids))
qdrant.delete_by_filter = AsyncMock(return_value=0)
return qdrant
@pytest.fixture
def mock_ollama():
ollama = MagicMock()
ollama.embed_batch = AsyncMock(
side_effect=lambda texts, **kw: [[0.1] * 768 for _ in texts]
)
return ollama
@pytest.fixture
def mock_wiki():
wiki = MagicMock()
wiki.get_page = AsyncMock(return_value=dict(PAGE))
return wiki
@pytest.fixture
def service(mock_qdrant, mock_wiki, mock_ollama):
return VectorService(mock_qdrant, mock_wiki, mock_ollama)
@pytest.mark.unit
class TestBatchedReindex:
async def test_single_batched_embed_and_single_upsert(
self, service, mock_qdrant, mock_ollama
):
summary = await service.update_from_page(page_id=44, user=TENANT)
assert summary.success is True
assert summary.status == "success"
assert summary.chunks_created == 3
mock_ollama.embed_batch.assert_awaited_once()
mock_qdrant.upsert_points.assert_awaited_once()
async def test_upsert_happens_before_stale_delete(self, service, mock_qdrant):
order = []
mock_qdrant.upsert_points = AsyncMock(
side_effect=lambda collection_name, points: order.append("upsert") or len(points)
)
mock_qdrant.delete_by_ids = AsyncMock(
side_effect=lambda collection_name, point_ids: order.append("delete") or len(point_ids)
)
# One stale point from a previous, longer version of the page
mock_qdrant.scroll_all_points = AsyncMock(return_value=[
{"id": _chunk_id(44, i), "payload": {}} for i in range(4)
])
summary = await service.update_from_page(page_id=44, user=TENANT)
assert order == ["upsert", "delete"]
assert summary.chunks_deleted == 1 # only the stale 4th chunk
deleted = mock_qdrant.delete_by_ids.await_args.kwargs["point_ids"]
assert deleted == [_chunk_id(44, 3)]
async def test_partial_embedding_failure_marks_partial(
self, service, mock_qdrant, mock_ollama
):
mock_ollama.embed_batch = AsyncMock(
side_effect=lambda texts, **kw: [
[0.1] * 768 if i != 1 else None for i in range(len(texts))
]
)
summary = await service.update_from_page(page_id=44, user=TENANT)
assert summary.status == "partial"
assert summary.success is True
assert summary.chunks_created == 2
assert summary.chunks_skipped == 1
assert "failed" in (summary.error_message or "")
async def test_total_embedding_failure_keeps_old_vectors(
self, service, mock_qdrant, mock_ollama
):
mock_ollama.embed_batch = AsyncMock(
side_effect=lambda texts, **kw: [None for _ in texts]
)
mock_qdrant.scroll_all_points = AsyncMock(return_value=[
{"id": _chunk_id(44, 0), "payload": {}}
])
summary = await service.update_from_page(page_id=44, user=TENANT)
assert summary.status == "failed"
assert summary.success is False
# Old vectors are NOT wiped when nothing new was stored
mock_qdrant.upsert_points.assert_not_awaited()
mock_qdrant.delete_by_ids.assert_not_awaited()