feat: optimize database operations with batch upserts and mark inactive functionality
Browse files- common/db.py +60 -22
- common/vector_index.py +1 -1
- pyproject.toml +1 -0
- sync/embeddings_client.py +66 -13
- sync/run_sync.py +19 -6
- tests/sync/test_embeddings_client.py +84 -5
common/db.py
CHANGED
|
@@ -23,40 +23,78 @@ CREATE VIRTUAL TABLE IF NOT EXISTS papers_fts USING fts5(
|
|
| 23 |
"""
|
| 24 |
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
def init_db(path: str) -> sqlite3.Connection:
|
| 27 |
conn = sqlite3.connect(path, check_same_thread=False)
|
| 28 |
conn.row_factory = sqlite3.Row
|
| 29 |
conn.executescript(SCHEMA)
|
|
|
|
|
|
|
| 30 |
conn.commit()
|
| 31 |
return conn
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def upsert_paper(conn: sqlite3.Connection, paper: dict) -> None:
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
""
|
| 46 |
-
|
| 47 |
-
)
|
| 48 |
-
conn.execute("DELETE FROM papers_fts WHERE id = ?", (paper["id"],))
|
| 49 |
-
conn.execute(
|
| 50 |
-
"INSERT INTO papers_fts (id, title, abstract) VALUES (?, ?, ?)",
|
| 51 |
-
(paper["id"], paper["title"], paper["abstract"]),
|
| 52 |
-
)
|
| 53 |
-
conn.commit()
|
| 54 |
|
| 55 |
|
| 56 |
def mark_inactive(conn: sqlite3.Connection, paper_id: str) -> None:
|
| 57 |
-
|
| 58 |
-
conn.execute("DELETE FROM papers_fts WHERE id = ?", (paper_id,))
|
| 59 |
-
conn.commit()
|
| 60 |
|
| 61 |
|
| 62 |
def get_paper_id_by_faiss_id(conn: sqlite3.Connection, faiss_id: int) -> str | None:
|
|
|
|
| 23 |
"""
|
| 24 |
|
| 25 |
|
| 26 |
+
# WAL + synchronous=NORMAL lets writers commit without an fsync per commit
|
| 27 |
+
# (fsync only happens at checkpoint). On slow-fsync filesystems like WSL's
|
| 28 |
+
# /mnt/c, this is the difference between minutes and hours for a full sync.
|
| 29 |
+
_PRAGMAS = [
|
| 30 |
+
"PRAGMA journal_mode=WAL",
|
| 31 |
+
"PRAGMA synchronous=NORMAL",
|
| 32 |
+
"PRAGMA temp_store=MEMORY",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
def init_db(path: str) -> sqlite3.Connection:
|
| 37 |
conn = sqlite3.connect(path, check_same_thread=False)
|
| 38 |
conn.row_factory = sqlite3.Row
|
| 39 |
conn.executescript(SCHEMA)
|
| 40 |
+
for pragma in _PRAGMAS:
|
| 41 |
+
conn.execute(pragma)
|
| 42 |
conn.commit()
|
| 43 |
return conn
|
| 44 |
|
| 45 |
|
| 46 |
+
def upsert_papers(conn: sqlite3.Connection, papers: list[dict]) -> None:
|
| 47 |
+
"""Upsert many papers in a single transaction.
|
| 48 |
+
|
| 49 |
+
Batched so we pay the commit cost once per batch instead of once per
|
| 50 |
+
paper; the per-paper commit (and its fsync) was the dominant cost of a
|
| 51 |
+
full sync. `upsert_paper` delegates here with a one-element list.
|
| 52 |
+
"""
|
| 53 |
+
if not papers:
|
| 54 |
+
return
|
| 55 |
+
rows = []
|
| 56 |
+
fts_rows = []
|
| 57 |
+
for paper in papers:
|
| 58 |
+
params = {**paper, "active": int(paper["active"])}
|
| 59 |
+
params.setdefault("faiss_id", None)
|
| 60 |
+
rows.append(params)
|
| 61 |
+
fts_rows.append((paper["id"], paper["title"], paper["abstract"]))
|
| 62 |
+
with conn: # one transaction: commit on success, rollback on error
|
| 63 |
+
conn.executemany(
|
| 64 |
+
"""
|
| 65 |
+
INSERT INTO papers (id, title, abstract, authors, venue, year, url, active, faiss_id)
|
| 66 |
+
VALUES (:id, :title, :abstract, :authors, :venue, :year, :url, :active, :faiss_id)
|
| 67 |
+
ON CONFLICT(id) DO UPDATE SET
|
| 68 |
+
title=excluded.title, abstract=excluded.abstract, authors=excluded.authors,
|
| 69 |
+
venue=excluded.venue, year=excluded.year, url=excluded.url, active=excluded.active,
|
| 70 |
+
faiss_id=COALESCE(excluded.faiss_id, papers.faiss_id)
|
| 71 |
+
""",
|
| 72 |
+
rows,
|
| 73 |
+
)
|
| 74 |
+
conn.executemany("DELETE FROM papers_fts WHERE id = ?", [(r["id"],) for r in rows])
|
| 75 |
+
conn.executemany(
|
| 76 |
+
"INSERT INTO papers_fts (id, title, abstract) VALUES (?, ?, ?)",
|
| 77 |
+
fts_rows,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
def upsert_paper(conn: sqlite3.Connection, paper: dict) -> None:
|
| 82 |
+
upsert_papers(conn, [paper])
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def mark_inactive_ids(conn: sqlite3.Connection, paper_ids: list[str]) -> None:
|
| 86 |
+
"""Mark many papers inactive in a single transaction (batched for the
|
| 87 |
+
same reason as `upsert_papers`)."""
|
| 88 |
+
if not paper_ids:
|
| 89 |
+
return
|
| 90 |
+
ids = [(pid,) for pid in paper_ids]
|
| 91 |
+
with conn:
|
| 92 |
+
conn.executemany("UPDATE papers SET active = 0 WHERE id = ?", ids)
|
| 93 |
+
conn.executemany("DELETE FROM papers_fts WHERE id = ?", ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
def mark_inactive(conn: sqlite3.Connection, paper_id: str) -> None:
|
| 97 |
+
mark_inactive_ids(conn, [paper_id])
|
|
|
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
def get_paper_id_by_faiss_id(conn: sqlite3.Connection, faiss_id: int) -> str | None:
|
common/vector_index.py
CHANGED
|
@@ -4,7 +4,7 @@ import faiss
|
|
| 4 |
import numpy as np
|
| 5 |
|
| 6 |
|
| 7 |
-
def create_index(dim: int
|
| 8 |
return faiss.IndexFlatIP(dim)
|
| 9 |
|
| 10 |
|
|
|
|
| 4 |
import numpy as np
|
| 5 |
|
| 6 |
|
| 7 |
+
def create_index(dim: int) -> faiss.IndexFlatIP:
|
| 8 |
return faiss.IndexFlatIP(dim)
|
| 9 |
|
| 10 |
|
pyproject.toml
CHANGED
|
@@ -15,6 +15,7 @@ dependencies = [
|
|
| 15 |
"pydantic>=2.6",
|
| 16 |
"pydantic-settings>=2.2",
|
| 17 |
"numpy>=1.26",
|
|
|
|
| 18 |
"acl-anthology>=0.5",
|
| 19 |
]
|
| 20 |
|
|
|
|
| 15 |
"pydantic>=2.6",
|
| 16 |
"pydantic-settings>=2.2",
|
| 17 |
"numpy>=1.26",
|
| 18 |
+
"tqdm>=4.66",
|
| 19 |
"acl-anthology>=0.5",
|
| 20 |
]
|
| 21 |
|
sync/embeddings_client.py
CHANGED
|
@@ -1,20 +1,73 @@
|
|
| 1 |
-
"""Client for the university's OpenAI-compatible Qwen3-Embedding-4B endpoint.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import numpy as np
|
| 3 |
-
from openai import OpenAI, OpenAIError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
class EmbeddingsClient:
|
| 7 |
-
def __init__(self, base_url: str, api_key: str, model: str = "qwen3-embedding-4b",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
self.model = model
|
| 9 |
-
self.
|
| 10 |
-
self._client = OpenAI(base_url=base_url, api_key=api_key, timeout=60.0)
|
| 11 |
|
| 12 |
def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Client for the university's OpenAI-compatible Qwen3-Embedding-4B endpoint.
|
| 2 |
+
|
| 3 |
+
The endpoint runs a 4B model, so a single request carrying an entire weekly
|
| 4 |
+
delta (or a first-run full anthology) easily exceeds the read timeout. We
|
| 5 |
+
therefore chunk the input and retry the transient timeouts the openai SDK
|
| 6 |
+
does not retry on its own (it retries 429/5xx but treats read timeouts as
|
| 7 |
+
non-retryable connection errors).
|
| 8 |
+
"""
|
| 9 |
+
import logging
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
import numpy as np
|
| 13 |
+
from openai import APIConnectionError, OpenAI, OpenAIError
|
| 14 |
+
from tqdm import tqdm
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Max texts per embedding request. Small enough that each call to the 4B
|
| 19 |
+
# model finishes well inside the read timeout, even on a slow week.
|
| 20 |
+
_BATCH_SIZE = 32
|
| 21 |
+
# Number of attempts per chunk before giving up (and propagating before any
|
| 22 |
+
# writes in run_sync, per its "fail before writes" contract).
|
| 23 |
+
_MAX_RETRIES = 3
|
| 24 |
+
_RETRY_BACKOFF_SECONDS = 2.0
|
| 25 |
|
| 26 |
|
| 27 |
class EmbeddingsClient:
|
| 28 |
+
def __init__(self, base_url: str, api_key: str, model: str = "qwen3-embedding-4b",
|
| 29 |
+
timeout: float = 60.0):
|
| 30 |
+
# The hosted qwen3-embedding-4b does NOT support matryoshka representation,
|
| 31 |
+
# so we must not send `dimensions` — sending it 400s. The model returns its
|
| 32 |
+
# fixed native dimensionality; the index dim is derived from the returned
|
| 33 |
+
# vectors in run_sync rather than assumed here.
|
| 34 |
self.model = model
|
| 35 |
+
self._client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
|
|
| 36 |
|
| 37 |
def embed_batch(self, texts: list[str]) -> list[np.ndarray]:
|
| 38 |
+
if not texts:
|
| 39 |
+
return []
|
| 40 |
+
total_chunks = (len(texts) + _BATCH_SIZE - 1) // _BATCH_SIZE
|
| 41 |
+
vectors: list[np.ndarray] = []
|
| 42 |
+
starts = range(0, len(texts), _BATCH_SIZE)
|
| 43 |
+
for start in tqdm(starts, total=total_chunks, desc="embedding", unit="batch"):
|
| 44 |
+
chunk = texts[start:start + _BATCH_SIZE]
|
| 45 |
+
vectors.extend(self._embed_chunk(chunk))
|
| 46 |
+
return vectors
|
| 47 |
+
|
| 48 |
+
def _embed_chunk(self, chunk: list[str]) -> list[np.ndarray]:
|
| 49 |
+
last_error: Exception | None = None
|
| 50 |
+
for attempt in range(1, _MAX_RETRIES + 1):
|
| 51 |
+
try:
|
| 52 |
+
response = self._client.embeddings.create(
|
| 53 |
+
model=self.model, input=chunk
|
| 54 |
+
)
|
| 55 |
+
except APIConnectionError as e:
|
| 56 |
+
# Read timeouts (APITimeoutError is a subclass) and other
|
| 57 |
+
# transient connection errors: the SDK won't retry, so we do.
|
| 58 |
+
last_error = e
|
| 59 |
+
if attempt == _MAX_RETRIES:
|
| 60 |
+
break
|
| 61 |
+
logger.warning(
|
| 62 |
+
"embedding request failed (attempt %d/%d), retrying in %.1fs: %s",
|
| 63 |
+
attempt, _MAX_RETRIES, _RETRY_BACKOFF_SECONDS, e,
|
| 64 |
+
)
|
| 65 |
+
time.sleep(_RETRY_BACKOFF_SECONDS)
|
| 66 |
+
continue
|
| 67 |
+
except OpenAIError as e:
|
| 68 |
+
raise RuntimeError(f"Embedding API request failed: {e}") from e
|
| 69 |
+
data = sorted(response.data, key=lambda d: d.index)
|
| 70 |
+
return [np.array(d.embedding, dtype=np.float32) for d in data]
|
| 71 |
+
raise RuntimeError(
|
| 72 |
+
f"Embedding API request failed after {_MAX_RETRIES} attempts: {last_error}"
|
| 73 |
+
) from last_error
|
sync/run_sync.py
CHANGED
|
@@ -3,7 +3,7 @@ import json
|
|
| 3 |
import os
|
| 4 |
from datetime import datetime, timezone
|
| 5 |
|
| 6 |
-
from common.db import init_db,
|
| 7 |
from common.vector_index import create_index, load_index, save_index, add_vector
|
| 8 |
from sync.anthology_source import iter_papers
|
| 9 |
from sync.delta import compute_delta, build_state
|
|
@@ -29,7 +29,7 @@ def run_sync(anthology_path: str, hf_repo_id: str, hf_token: str,
|
|
| 29 |
try:
|
| 30 |
index = load_index(index_path)
|
| 31 |
except FileNotFoundError:
|
| 32 |
-
index =
|
| 33 |
|
| 34 |
current_papers = list(iter_papers(anthology_path))
|
| 35 |
delta = compute_delta(current_papers, last_state)
|
|
@@ -38,14 +38,27 @@ def run_sync(anthology_path: str, hf_repo_id: str, hf_token: str,
|
|
| 38 |
client = EmbeddingsClient(base_url=embedding_base_url, api_key=embedding_api_key)
|
| 39 |
texts = [_embedding_text(p) for p in delta.new_or_changed]
|
| 40 |
vectors = client.embed_batch(texts) # raises RuntimeError on failure, propagates before any writes below
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
for paper, vector in zip(delta.new_or_changed, vectors):
|
| 42 |
faiss_id = add_vector(index, vector)
|
| 43 |
-
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
mark_inactive(conn, paper_id)
|
| 47 |
|
| 48 |
-
|
|
|
|
| 49 |
state = {
|
| 50 |
"papers": build_state(current_papers),
|
| 51 |
"last_synced_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
| 3 |
import os
|
| 4 |
from datetime import datetime, timezone
|
| 5 |
|
| 6 |
+
from common.db import init_db, upsert_papers, mark_inactive_ids
|
| 7 |
from common.vector_index import create_index, load_index, save_index, add_vector
|
| 8 |
from sync.anthology_source import iter_papers
|
| 9 |
from sync.delta import compute_delta, build_state
|
|
|
|
| 29 |
try:
|
| 30 |
index = load_index(index_path)
|
| 31 |
except FileNotFoundError:
|
| 32 |
+
index = None # created lazily once the embedding dimension is known
|
| 33 |
|
| 34 |
current_papers = list(iter_papers(anthology_path))
|
| 35 |
delta = compute_delta(current_papers, last_state)
|
|
|
|
| 38 |
client = EmbeddingsClient(base_url=embedding_base_url, api_key=embedding_api_key)
|
| 39 |
texts = [_embedding_text(p) for p in delta.new_or_changed]
|
| 40 |
vectors = client.embed_batch(texts) # raises RuntimeError on failure, propagates before any writes below
|
| 41 |
+
# qwen3-embedding-4b returns a fixed native dim (no matryoshka), so the
|
| 42 |
+
# index dimension is whatever the model emits, derived from the first vector.
|
| 43 |
+
embedding_dim = len(vectors[0])
|
| 44 |
+
if index is None:
|
| 45 |
+
index = create_index(dim=embedding_dim)
|
| 46 |
+
elif index.d != embedding_dim:
|
| 47 |
+
raise RuntimeError(
|
| 48 |
+
f"Embedding dimension {embedding_dim} does not match existing index "
|
| 49 |
+
f"dimension {index.d}; delete the stale index.faiss (locally and on "
|
| 50 |
+
f"the HF Hub) and run a fresh full sync."
|
| 51 |
+
)
|
| 52 |
+
upsert_rows = []
|
| 53 |
for paper, vector in zip(delta.new_or_changed, vectors):
|
| 54 |
faiss_id = add_vector(index, vector)
|
| 55 |
+
upsert_rows.append({**paper, "active": True, "faiss_id": faiss_id})
|
| 56 |
+
upsert_papers(conn, upsert_rows)
|
| 57 |
|
| 58 |
+
mark_inactive_ids(conn, list(delta.removed_ids))
|
|
|
|
| 59 |
|
| 60 |
+
if index is not None:
|
| 61 |
+
save_index(index, index_path)
|
| 62 |
state = {
|
| 63 |
"papers": build_state(current_papers),
|
| 64 |
"last_synced_at": datetime.now(timezone.utc).isoformat(),
|
tests/sync/test_embeddings_client.py
CHANGED
|
@@ -18,10 +18,9 @@ class _FakeEmbeddingsResponse:
|
|
| 18 |
def test_embed_batch_parses_response(monkeypatch):
|
| 19 |
captured = {}
|
| 20 |
|
| 21 |
-
def fake_create(self, model, input
|
| 22 |
captured["model"] = model
|
| 23 |
captured["input"] = input
|
| 24 |
-
captured["dimensions"] = dimensions
|
| 25 |
return _FakeEmbeddingsResponse([
|
| 26 |
_FakeEmbeddingDatum([0.2] * 1024, 1),
|
| 27 |
_FakeEmbeddingDatum([0.1] * 1024, 0),
|
|
@@ -29,11 +28,10 @@ def test_embed_batch_parses_response(monkeypatch):
|
|
| 29 |
|
| 30 |
monkeypatch.setattr("openai.resources.embeddings.Embeddings.create", fake_create)
|
| 31 |
|
| 32 |
-
client = EmbeddingsClient(base_url="http://fake", api_key="k"
|
| 33 |
result = client.embed_batch(["a", "b"])
|
| 34 |
|
| 35 |
assert captured["input"] == ["a", "b"]
|
| 36 |
-
assert captured["dimensions"] == 1024
|
| 37 |
assert len(result) == 2
|
| 38 |
assert result[0].shape == (1024,)
|
| 39 |
assert result[0].dtype == np.float32
|
|
@@ -41,10 +39,91 @@ def test_embed_batch_parses_response(monkeypatch):
|
|
| 41 |
assert np.isclose(result[1][0], 0.2)
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
def test_embed_batch_raises_runtime_error_on_api_error(monkeypatch):
|
| 45 |
import httpx
|
| 46 |
|
| 47 |
-
def fake_create(self, model, input
|
| 48 |
request = httpx.Request("POST", "http://fake/embeddings")
|
| 49 |
response = httpx.Response(500, request=request, json={"error": "boom"})
|
| 50 |
raise APIStatusError("boom", response=response, body={"error": "boom"})
|
|
|
|
| 18 |
def test_embed_batch_parses_response(monkeypatch):
|
| 19 |
captured = {}
|
| 20 |
|
| 21 |
+
def fake_create(self, model, input):
|
| 22 |
captured["model"] = model
|
| 23 |
captured["input"] = input
|
|
|
|
| 24 |
return _FakeEmbeddingsResponse([
|
| 25 |
_FakeEmbeddingDatum([0.2] * 1024, 1),
|
| 26 |
_FakeEmbeddingDatum([0.1] * 1024, 0),
|
|
|
|
| 28 |
|
| 29 |
monkeypatch.setattr("openai.resources.embeddings.Embeddings.create", fake_create)
|
| 30 |
|
| 31 |
+
client = EmbeddingsClient(base_url="http://fake", api_key="k")
|
| 32 |
result = client.embed_batch(["a", "b"])
|
| 33 |
|
| 34 |
assert captured["input"] == ["a", "b"]
|
|
|
|
| 35 |
assert len(result) == 2
|
| 36 |
assert result[0].shape == (1024,)
|
| 37 |
assert result[0].dtype == np.float32
|
|
|
|
| 39 |
assert np.isclose(result[1][0], 0.2)
|
| 40 |
|
| 41 |
|
| 42 |
+
def test_embed_batch_empty_input_makes_no_requests(monkeypatch):
|
| 43 |
+
called = {"n": 0}
|
| 44 |
+
|
| 45 |
+
def fake_create(self, model, input):
|
| 46 |
+
called["n"] += 1
|
| 47 |
+
return _FakeEmbeddingsResponse([])
|
| 48 |
+
|
| 49 |
+
monkeypatch.setattr("openai.resources.embeddings.Embeddings.create", fake_create)
|
| 50 |
+
|
| 51 |
+
client = EmbeddingsClient(base_url="http://fake", api_key="k")
|
| 52 |
+
assert client.embed_batch([]) == []
|
| 53 |
+
assert called["n"] == 0
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_embed_batch_chunks_large_input(monkeypatch):
|
| 57 |
+
captured = []
|
| 58 |
+
|
| 59 |
+
def fake_create(self, model, input):
|
| 60 |
+
captured.append(list(input))
|
| 61 |
+
# index resets to 0 within each request, as on the real API
|
| 62 |
+
return _FakeEmbeddingsResponse([
|
| 63 |
+
_FakeEmbeddingDatum([float(i)] * 1024, i) for i in range(len(input))
|
| 64 |
+
])
|
| 65 |
+
|
| 66 |
+
monkeypatch.setattr("openai.resources.embeddings.Embeddings.create", fake_create)
|
| 67 |
+
monkeypatch.setattr("sync.embeddings_client._BATCH_SIZE", 32)
|
| 68 |
+
|
| 69 |
+
client = EmbeddingsClient(base_url="http://fake", api_key="k")
|
| 70 |
+
texts = [f"t{i}" for i in range(80)]
|
| 71 |
+
result = client.embed_batch(texts)
|
| 72 |
+
|
| 73 |
+
assert len(captured) == 3 # 32 + 32 + 16
|
| 74 |
+
assert captured[0] == texts[:32]
|
| 75 |
+
assert captured[1] == texts[32:64]
|
| 76 |
+
assert captured[2] == texts[64:]
|
| 77 |
+
assert len(result) == 80
|
| 78 |
+
# order preserved within and across chunks (each chunk's vectors come back 0..n-1)
|
| 79 |
+
assert [round(v[0]) for v in result] == list(range(32)) + list(range(32)) + list(range(16))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_embed_batch_retries_on_timeout_then_succeeds(monkeypatch):
|
| 83 |
+
import httpx
|
| 84 |
+
from openai import APITimeoutError
|
| 85 |
+
|
| 86 |
+
calls = {"n": 0}
|
| 87 |
+
|
| 88 |
+
def fake_create(self, model, input):
|
| 89 |
+
calls["n"] += 1
|
| 90 |
+
if calls["n"] == 1:
|
| 91 |
+
raise APITimeoutError(request=httpx.Request("POST", "http://fake/embeddings"))
|
| 92 |
+
return _FakeEmbeddingsResponse([_FakeEmbeddingDatum([0.5] * 1024, 0)])
|
| 93 |
+
|
| 94 |
+
monkeypatch.setattr("openai.resources.embeddings.Embeddings.create", fake_create)
|
| 95 |
+
monkeypatch.setattr("sync.embeddings_client.time.sleep", lambda _: None)
|
| 96 |
+
|
| 97 |
+
client = EmbeddingsClient(base_url="http://fake", api_key="k")
|
| 98 |
+
result = client.embed_batch(["a"])
|
| 99 |
+
|
| 100 |
+
assert calls["n"] == 2
|
| 101 |
+
assert np.isclose(result[0][0], 0.5)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_embed_batch_raises_after_exhausted_retries(monkeypatch):
|
| 105 |
+
import httpx
|
| 106 |
+
from openai import APITimeoutError
|
| 107 |
+
|
| 108 |
+
calls = {"n": 0}
|
| 109 |
+
|
| 110 |
+
def fake_create(self, model, input):
|
| 111 |
+
calls["n"] += 1
|
| 112 |
+
raise APITimeoutError(request=httpx.Request("POST", "http://fake/embeddings"))
|
| 113 |
+
|
| 114 |
+
monkeypatch.setattr("openai.resources.embeddings.Embeddings.create", fake_create)
|
| 115 |
+
monkeypatch.setattr("sync.embeddings_client.time.sleep", lambda _: None)
|
| 116 |
+
|
| 117 |
+
client = EmbeddingsClient(base_url="http://fake", api_key="k")
|
| 118 |
+
with pytest.raises(RuntimeError):
|
| 119 |
+
client.embed_batch(["a"])
|
| 120 |
+
assert calls["n"] == 3 # _MAX_RETRIES attempts
|
| 121 |
+
|
| 122 |
+
|
| 123 |
def test_embed_batch_raises_runtime_error_on_api_error(monkeypatch):
|
| 124 |
import httpx
|
| 125 |
|
| 126 |
+
def fake_create(self, model, input):
|
| 127 |
request = httpx.Request("POST", "http://fake/embeddings")
|
| 128 |
response = httpx.Response(500, request=request, json={"error": "boom"})
|
| 129 |
raise APIStatusError("boom", response=response, body={"error": "boom"})
|