ivykopal commited on
Commit
ee7e79e
·
1 Parent(s): d1ccc47

feat: optimize database operations with batch upserts and mark inactive functionality

Browse files
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
- params = {**paper, "active": int(paper["active"])}
36
- params.setdefault("faiss_id", None)
37
- conn.execute(
38
- """
39
- INSERT INTO papers (id, title, abstract, authors, venue, year, url, active, faiss_id)
40
- VALUES (:id, :title, :abstract, :authors, :venue, :year, :url, :active, :faiss_id)
41
- ON CONFLICT(id) DO UPDATE SET
42
- title=excluded.title, abstract=excluded.abstract, authors=excluded.authors,
43
- venue=excluded.venue, year=excluded.year, url=excluded.url, active=excluded.active,
44
- faiss_id=COALESCE(excluded.faiss_id, papers.faiss_id)
45
- """,
46
- params,
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
- conn.execute("UPDATE papers SET active = 0 WHERE id = ?", (paper_id,))
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 = 1024) -> faiss.IndexFlatIP:
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", dimensions: int = 1024):
 
 
 
 
 
8
  self.model = model
9
- self.dimensions = dimensions
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
- try:
14
- response = self._client.embeddings.create(
15
- model=self.model, input=texts, dimensions=self.dimensions
16
- )
17
- except OpenAIError as e:
18
- raise RuntimeError(f"Embedding API request failed: {e}") from e
19
- data = sorted(response.data, key=lambda d: d.index)
20
- return [np.array(d.embedding, dtype=np.float32) for d in data]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, upsert_paper, mark_inactive
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 = create_index(dim=1024)
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
- upsert_paper(conn, {**paper, "active": True, "faiss_id": faiss_id})
 
44
 
45
- for paper_id in delta.removed_ids:
46
- mark_inactive(conn, paper_id)
47
 
48
- save_index(index, index_path)
 
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, dimensions):
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", dimensions=1024)
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, dimensions):
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"})