Skip to content

Commit 4f54326

Browse files
committed
wip:RAG pipeline:general structure #6 - test(ingestion): cover incremental reruns, deletions and config changes against a live Qdrant
1 parent ce97449 commit 4f54326

1 file changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
import os
2+
import subprocess
3+
import urllib.request
4+
from pathlib import Path
5+
from types import SimpleNamespace
6+
7+
import pytest
8+
9+
import ingestion.main as ingestion_main
10+
import utils.vectordb as vectordb
11+
12+
CODE_COLLECTION = "zz_test_ingestion_code"
13+
METADATA_COLLECTION = "zz_test_ingestion_metadata"
14+
VECTOR_SIZE = 4
15+
16+
17+
# True when Qdrant answers at QDRANT_URL. main() talks to Qdrant directly,
18+
# so every test here is skipped when it is unreachable.
19+
def qdrant_reachable():
20+
url = os.environ.get("QDRANT_URL")
21+
if not url:
22+
return False
23+
try:
24+
urllib.request.urlopen(f"{url.rstrip('/')}/healthz", timeout=2)
25+
return True
26+
except Exception:
27+
return False
28+
29+
30+
requires_qdrant = pytest.mark.skipif(
31+
not qdrant_reachable(), reason="needs a running Qdrant (QDRANT_URL)")
32+
33+
34+
# A workspace with k-clone and embeddings stubbed, pointed at throwaway
35+
# collections; embed_calls records how many chunks each run re-embedded, so
36+
# a test can assert on skip behaviour without inspecting point ids by hand.
37+
@pytest.fixture
38+
def workspace(tmp_path, ingestion_env, monkeypatch):
39+
embed_calls = []
40+
41+
def fake_encode_batch(texts):
42+
texts = list(texts)
43+
embed_calls.append(len(texts))
44+
return [[0.1, 0.2, 0.3, 0.4] for _ in texts]
45+
46+
ingestion_env(
47+
DEVELOPMENT_DIR=str(tmp_path),
48+
QDRANT_URL=os.environ["QDRANT_URL"],
49+
QDRANT_COLLECTION_CODE=CODE_COLLECTION,
50+
QDRANT_COLLECTION_METADATA=METADATA_COLLECTION,
51+
QDRANT_VECTOR_SIZE_COLLECTION_CODE=VECTOR_SIZE,
52+
EMBEDDING_MODEL="test-model",
53+
)
54+
real_run = subprocess.run
55+
56+
def run_without_kclone(command, *args, **kwargs):
57+
if command[:2] == ["bash", "k-clone"]:
58+
return None
59+
return real_run(command, *args, **kwargs)
60+
61+
monkeypatch.setattr(ingestion_main.subprocess, "run", run_without_kclone)
62+
monkeypatch.setattr(
63+
ingestion_main.embeddings, "encode_batch", fake_encode_batch)
64+
65+
yield SimpleNamespace(root=tmp_path, embed_calls=embed_calls)
66+
67+
if vectordb.check_collection_exists(CODE_COLLECTION):
68+
vectordb.remove_collection(CODE_COLLECTION)
69+
if vectordb.check_collection_exists(METADATA_COLLECTION):
70+
vectordb.remove_collection(METADATA_COLLECTION)
71+
72+
73+
# --- a first run indexes everything and persists its state -----------------
74+
75+
@requires_qdrant
76+
def test_first_run_creates_the_missing_collections(workspace, knowledge_logs):
77+
# Against a fresh Qdrant neither collection exists yet; a run must create
78+
# both with the configured vector sizes (metadata uses 1-dim dummy
79+
# vectors) and log each creation.
80+
for name in (CODE_COLLECTION, METADATA_COLLECTION):
81+
if vectordb.check_collection_exists(name):
82+
vectordb.remove_collection(name)
83+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
84+
85+
assert ingestion_main.main() == 0
86+
87+
assert vectordb.get_collection_vector_size(CODE_COLLECTION) == VECTOR_SIZE
88+
assert vectordb.get_collection_vector_size(METADATA_COLLECTION) == 1
89+
assert f"collection '{CODE_COLLECTION}' created" in knowledge_logs.text
90+
assert f"collection '{METADATA_COLLECTION}' created" in knowledge_logs.text
91+
92+
93+
@requires_qdrant
94+
def test_first_run_indexes_every_file_and_persists_state(workspace):
95+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
96+
_write(workspace.root, "kdk/docs/guide.md", "# Guide\n\nSome prose.\n")
97+
98+
assert ingestion_main.main() == 0
99+
100+
assert _code_points()
101+
assert vectordb.get_last_ingestion() is not None
102+
assert vectordb.get_indexed_config() == {
103+
"embedding_model": "test-model", "chunking_version": 1}
104+
105+
106+
# --- a second run only touches what actually changed -----------------------
107+
108+
@requires_qdrant
109+
def test_second_run_only_reembeds_the_changed_file(workspace):
110+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
111+
_write(workspace.root, "kdk/docs/guide.md", "# Guide\n\nSome prose.\n")
112+
assert ingestion_main.main() == 0
113+
first_run_chunks = workspace.embed_calls[-1]
114+
115+
_write(workspace.root, "kdk/map/base.js", "export function zoom () {}\n")
116+
assert ingestion_main.main() == 0
117+
second_run_chunks = workspace.embed_calls[-1]
118+
119+
# base.js changed, guide.md did not -- strictly fewer chunks re-embedded.
120+
assert 0 < second_run_chunks < first_run_chunks
121+
122+
123+
@requires_qdrant
124+
def test_second_run_with_nothing_changed_embeds_nothing(workspace):
125+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
126+
assert ingestion_main.main() == 0
127+
calls_after_first_run = len(workspace.embed_calls)
128+
129+
assert ingestion_main.main() == 0
130+
131+
# Nothing to embed -> encode_batch is not called at all, not called with
132+
# an empty list.
133+
assert len(workspace.embed_calls) == calls_after_first_run
134+
135+
136+
# --- a file removed from disk loses its chunks -----------------------------
137+
138+
@requires_qdrant
139+
def test_deleted_file_removes_its_chunks(workspace):
140+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
141+
_write(workspace.root, "kdk/docs/guide.md", "# Guide\n\nSome prose.\n")
142+
assert ingestion_main.main() == 0
143+
144+
(workspace.root / "kdk/map/base.js").unlink()
145+
assert ingestion_main.main() == 0
146+
147+
remaining = {point.payload["source_path"] for point in _code_points()}
148+
assert "map/base.js" not in remaining
149+
assert "docs/guide.md" in remaining
150+
151+
152+
# --- a failing clone aborts the run before it touches the index -------------
153+
154+
@requires_qdrant
155+
def test_a_failed_clone_aborts_the_run_with_exit_code_1(
156+
workspace, monkeypatch, knowledge_logs):
157+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
158+
159+
def failing_kclone(command, *args, **kwargs):
160+
raise subprocess.CalledProcessError(returncode=3, cmd=command)
161+
162+
monkeypatch.setattr(ingestion_main.subprocess, "run", failing_kclone)
163+
164+
assert ingestion_main.main() == 1
165+
166+
assert "k-clone" in knowledge_logs.text
167+
assert "exit code 3" in knowledge_logs.text
168+
assert not _code_points() # nothing was ingested
169+
170+
171+
# --- non-nominal file contents ----------------------------------------------
172+
173+
@requires_qdrant
174+
def test_an_emptied_file_loses_all_its_chunks(workspace):
175+
# A file emptied between two runs yields no chunks on reindex; its old
176+
# chunks must still be dropped.
177+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
178+
_write(workspace.root, "kdk/docs/guide.md", "# Guide\n\nSome prose.\n")
179+
assert ingestion_main.main() == 0
180+
181+
_write(workspace.root, "kdk/map/base.js", "")
182+
assert ingestion_main.main() == 0
183+
184+
remaining = {point.payload["source_path"] for point in _code_points()}
185+
assert "map/base.js" not in remaining
186+
assert "docs/guide.md" in remaining
187+
188+
189+
@requires_qdrant
190+
def test_a_non_utf8_file_does_not_break_the_run(workspace):
191+
# Chunking and hashing read with errors="ignore": a file with invalid
192+
# UTF-8 bytes is ingested with the bad bytes dropped.
193+
path = _write(workspace.root, "kdk/map/base.js", "placeholder\n")
194+
path.write_bytes(b"// caf\xe9 comment\nexport function center () {}\n")
195+
_git(workspace.root / "kdk", "add", "-A")
196+
197+
assert ingestion_main.main() == 0
198+
199+
assert _code_points()
200+
201+
202+
# --- a run never destroys an index it could have reused ---------------------
203+
204+
@requires_qdrant
205+
def test_missing_bookkeeping_does_not_wipe_the_indexed_chunks(workspace):
206+
# A run killed before its last step leaves the metadata collection empty
207+
# while the code collection stays fully populated.
208+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
209+
_write(workspace.root, "kdk/docs/guide.md", "# Guide\n\nSome prose.\n")
210+
assert ingestion_main.main() == 0
211+
indexed_ids = {point.id for point in _code_points()}
212+
vectordb.remove_collection(METADATA_COLLECTION)
213+
214+
assert ingestion_main.main() == 0
215+
216+
# Same points, same ids: nothing was dropped and nothing was re-embedded.
217+
assert {point.id for point in _code_points()} == indexed_ids
218+
assert workspace.embed_calls == [len(indexed_ids)]
219+
220+
221+
@requires_qdrant
222+
def test_an_emptied_code_collection_refills_without_a_reset(workspace):
223+
# The digest comparison reads the code collection itself, so an empty one
224+
# selects the whole corpus.
225+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
226+
assert ingestion_main.main() == 0
227+
vectordb.remove_collection(CODE_COLLECTION)
228+
229+
assert ingestion_main.main() == 0
230+
231+
assert _code_points()
232+
233+
234+
# --- only a vector size change warrants recreating a collection -------------
235+
236+
@requires_qdrant
237+
def test_a_changed_vector_size_recreates_the_code_collection(
238+
workspace, ingestion_env):
239+
# Vectors of another dimension cannot be upserted into the old collection.
240+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
241+
assert ingestion_main.main() == 0
242+
243+
ingestion_env(
244+
DEVELOPMENT_DIR=str(workspace.root),
245+
QDRANT_URL=os.environ["QDRANT_URL"],
246+
QDRANT_COLLECTION_CODE=CODE_COLLECTION,
247+
QDRANT_COLLECTION_METADATA=METADATA_COLLECTION,
248+
QDRANT_VECTOR_SIZE_COLLECTION_CODE=VECTOR_SIZE + 1,
249+
EMBEDDING_MODEL="test-model",
250+
)
251+
monkeypatch_encode = [[0.1] * (VECTOR_SIZE + 1)]
252+
ingestion_main.embeddings.encode_batch = (
253+
lambda texts: monkeypatch_encode * len(list(texts)))
254+
255+
assert ingestion_main.main() == 0
256+
257+
assert (vectordb.get_collection_vector_size(CODE_COLLECTION)
258+
== VECTOR_SIZE + 1)
259+
assert _code_points()
260+
261+
262+
# --- a changed indexing config forces a full rebuild ------------------------
263+
264+
@requires_qdrant
265+
def test_config_change_forces_a_full_reindex(workspace, ingestion_env):
266+
_write(workspace.root, "kdk/map/base.js", "export function center () {}\n")
267+
_write(workspace.root, "kdk/docs/guide.md", "# Guide\n\nSome prose.\n")
268+
assert ingestion_main.main() == 0
269+
first_run_chunks = workspace.embed_calls[-1]
270+
271+
# Nothing on disk changed, but the embedding model did.
272+
ingestion_env(
273+
DEVELOPMENT_DIR=str(workspace.root),
274+
QDRANT_URL=os.environ["QDRANT_URL"],
275+
QDRANT_COLLECTION_CODE=CODE_COLLECTION,
276+
QDRANT_COLLECTION_METADATA=METADATA_COLLECTION,
277+
QDRANT_VECTOR_SIZE_COLLECTION_CODE=VECTOR_SIZE,
278+
EMBEDDING_MODEL="a-different-model",
279+
)
280+
assert ingestion_main.main() == 0
281+
second_run_chunks = workspace.embed_calls[-1]
282+
283+
assert second_run_chunks == first_run_chunks
284+
assert (vectordb.get_indexed_config()["embedding_model"]
285+
== "a-different-model")
286+
287+
288+
# ---------------------------------------------------------------------------
289+
# UTILS
290+
# ---------------------------------------------------------------------------
291+
292+
293+
# Write a workspace-relative file, creating parent directories as needed,
294+
# and track it in its repository -- the scanner only sees tracked files.
295+
def _write(root, relative, text):
296+
path = root / relative
297+
path.parent.mkdir(parents=True, exist_ok=True)
298+
path.write_text(text)
299+
repo_dir = root / Path(relative).parts[0]
300+
if not (repo_dir / ".git").exists():
301+
_git(repo_dir, "init", "-q")
302+
_git(repo_dir, "add", "-A")
303+
return path
304+
305+
306+
# Run a git command in a repository, quietly.
307+
def _git(repo_dir, *args):
308+
subprocess.run(["git", "-C", str(repo_dir), *args],
309+
capture_output=True, check=True)
310+
311+
312+
# Every point currently stored in the throwaway code collection.
313+
def _code_points():
314+
client = vectordb._get_qdrant_client()
315+
records, _ = client.scroll(
316+
collection_name=CODE_COLLECTION, limit=1000, with_payload=True,
317+
with_vectors=False)
318+
return records

0 commit comments

Comments
 (0)