374 lines
12 KiB
Python
374 lines
12 KiB
Python
"""
|
|
Tests for incremental build (Feature #89).
|
|
|
|
When an index already exists and build is run again without --force,
|
|
only new files are indexed and appended to the existing index (HNSW, non-compact only).
|
|
Change detection uses content-hash (merkle tree) via FileSynchronizer.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from leann.cli import _normalize_path
|
|
from leann.sync import FileSynchronizer
|
|
|
|
|
|
def test_normalize_path():
|
|
assert _normalize_path("") == ""
|
|
assert _normalize_path("/a/b") == "/a/b" or "a" in _normalize_path("/a/b")
|
|
rel = "foo/bar"
|
|
out = _normalize_path(rel)
|
|
assert Path(out).is_absolute() or out == rel
|
|
|
|
|
|
def test_file_synchronizer_detect_changes(tmp_path):
|
|
"""FileSynchronizer detect_changes returns all files as added when no snapshot exists."""
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "a.txt").write_text("hello", encoding="utf-8")
|
|
snapshot = str(tmp_path / "test.pickle")
|
|
fs = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
added, removed, modified = fs.detect_changes()
|
|
assert len(added) == 1
|
|
assert len(removed) == 0
|
|
assert len(modified) == 0
|
|
|
|
|
|
def test_file_synchronizer_no_changes_after_commit(tmp_path):
|
|
"""After commit, detect_changes should report no changes if files haven't changed."""
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "a.txt").write_text("hello", encoding="utf-8")
|
|
snapshot = str(tmp_path / "test.pickle")
|
|
fs = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
fs.detect_changes()
|
|
fs.commit()
|
|
|
|
fs2 = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
added, removed, modified = fs2.detect_changes()
|
|
assert not added and not removed and not modified
|
|
|
|
|
|
def test_file_synchronizer_detects_new_file(tmp_path):
|
|
"""Adding a file after commit should be detected as added."""
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "a.txt").write_text("hello", encoding="utf-8")
|
|
snapshot = str(tmp_path / "test.pickle")
|
|
fs = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
fs.detect_changes()
|
|
fs.commit()
|
|
|
|
(docs / "b.txt").write_text("world", encoding="utf-8")
|
|
fs2 = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
added, removed, modified = fs2.detect_changes()
|
|
assert len(added) == 1
|
|
assert len(removed) == 0
|
|
assert len(modified) == 0
|
|
|
|
|
|
def test_file_synchronizer_detects_modification(tmp_path):
|
|
"""Changing file content should be detected as modified."""
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
(docs / "a.txt").write_text("hello", encoding="utf-8")
|
|
snapshot = str(tmp_path / "test.pickle")
|
|
fs = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
fs.detect_changes()
|
|
fs.commit()
|
|
|
|
(docs / "a.txt").write_text("changed", encoding="utf-8")
|
|
fs2 = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
added, removed, modified = fs2.detect_changes()
|
|
assert len(modified) == 1
|
|
assert len(added) == 0
|
|
|
|
|
|
def test_file_synchronizer_touch_no_false_positive(tmp_path):
|
|
"""Touching a file (mtime change, same content) should NOT report as modified."""
|
|
docs = tmp_path / "docs"
|
|
docs.mkdir()
|
|
f = docs / "a.txt"
|
|
f.write_text("hello", encoding="utf-8")
|
|
snapshot = str(tmp_path / "test.pickle")
|
|
fs = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
fs.detect_changes()
|
|
fs.commit()
|
|
|
|
os.utime(f, None)
|
|
fs2 = FileSynchronizer(root_dir=str(docs), snapshot_path=snapshot)
|
|
added, removed, modified = fs2.detect_changes()
|
|
assert not added and not removed and not modified
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.environ.get("CI") == "true",
|
|
reason="Skip in CI to avoid embedding/model load",
|
|
)
|
|
def test_incremental_build_adds_only_new_files(tmp_path):
|
|
"""Build once with one file, add a second file, run build again without --force; index grows."""
|
|
import asyncio
|
|
|
|
from leann.api import LeannSearcher
|
|
from leann.cli import LeannCLI
|
|
|
|
docs_dir = tmp_path / "docs"
|
|
docs_dir.mkdir()
|
|
(docs_dir / "a.txt").write_text("First document content for indexing.", encoding="utf-8")
|
|
|
|
cli = LeannCLI()
|
|
cli.indexes_dir = tmp_path / ".leann" / "indexes"
|
|
cli.indexes_dir.mkdir(parents=True, exist_ok=True)
|
|
index_name = "incr_test"
|
|
index_dir = cli.indexes_dir / index_name
|
|
index_path = cli.get_index_path(index_name)
|
|
|
|
parser = cli.create_parser()
|
|
args = parser.parse_args(
|
|
[
|
|
"build",
|
|
index_name,
|
|
"--docs",
|
|
str(docs_dir),
|
|
"--backend-name",
|
|
"hnsw",
|
|
"--no-compact",
|
|
"--embedding-model",
|
|
"all-MiniLM-L6-v2",
|
|
"--embedding-mode",
|
|
"sentence-transformers",
|
|
"--force",
|
|
]
|
|
)
|
|
asyncio.run(cli.build_index(args))
|
|
assert index_dir.exists()
|
|
assert (index_dir / "documents.leann.meta.json").exists()
|
|
|
|
# Add second file
|
|
(docs_dir / "b.txt").write_text("Second document content.", encoding="utf-8")
|
|
|
|
# Build again without --force (incremental)
|
|
args2 = parser.parse_args(
|
|
[
|
|
"build",
|
|
index_name,
|
|
"--docs",
|
|
str(docs_dir),
|
|
"--backend-name",
|
|
"hnsw",
|
|
"--no-compact",
|
|
"--embedding-model",
|
|
"all-MiniLM-L6-v2",
|
|
"--embedding-mode",
|
|
"sentence-transformers",
|
|
]
|
|
)
|
|
asyncio.run(cli.build_index(args2))
|
|
|
|
# Index should still be searchable and contain both files
|
|
searcher = LeannSearcher(index_path)
|
|
results = searcher.search("Second document", top_k=3)
|
|
searcher.cleanup()
|
|
assert len(results) >= 1
|
|
assert "Second" in results[0].text or "document" in results[0].text
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.environ.get("CI") == "true",
|
|
reason="Skip in CI to avoid embedding/model load",
|
|
)
|
|
def test_ivf_incremental_add_then_remove_searchable(tmp_path):
|
|
"""IVF: add content, search finds it; delete content, incremental build, search no longer finds it."""
|
|
import asyncio
|
|
|
|
from leann.api import LeannSearcher
|
|
from leann.cli import LeannCLI
|
|
|
|
docs_dir = tmp_path / "docs"
|
|
docs_dir.mkdir()
|
|
unique_phrase = "XYZZY_PLUGH_INCR_TEST_12345"
|
|
(docs_dir / "f.txt").write_text(
|
|
f"Initial content.\n\n{unique_phrase}\n\nMore text here.",
|
|
encoding="utf-8",
|
|
)
|
|
# Add extra files so IVF has enough training points (nlist=100)
|
|
for i in range(110):
|
|
(docs_dir / f"filler_{i:03d}.txt").write_text(
|
|
f"Filler document {i} with some content to create enough chunks.",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
cli = LeannCLI()
|
|
cli.indexes_dir = tmp_path / ".leann" / "indexes"
|
|
cli.indexes_dir.mkdir(parents=True, exist_ok=True)
|
|
index_name = "ivf_incr_test"
|
|
index_path = cli.get_index_path(index_name)
|
|
|
|
parser = cli.create_parser()
|
|
build_args = [
|
|
"build",
|
|
index_name,
|
|
"--docs",
|
|
str(docs_dir),
|
|
"--backend-name",
|
|
"ivf",
|
|
"--embedding-model",
|
|
"all-MiniLM-L6-v2",
|
|
"--embedding-mode",
|
|
"sentence-transformers",
|
|
]
|
|
|
|
asyncio.run(cli.build_index(parser.parse_args([*build_args, "--force"])))
|
|
|
|
searcher = LeannSearcher(index_path)
|
|
results = searcher.search(unique_phrase, top_k=5)
|
|
searcher.cleanup()
|
|
assert len(results) >= 1, "Added content should be searchable"
|
|
assert unique_phrase in results[0].text
|
|
|
|
# Remove the unique phrase from the file
|
|
(docs_dir / "f.txt").write_text("Initial content.\n\nMore text here.", encoding="utf-8")
|
|
|
|
# Incremental build (IVF remove+add)
|
|
asyncio.run(cli.build_index(parser.parse_args(build_args)))
|
|
|
|
# Deleted content should no longer be searchable
|
|
searcher = LeannSearcher(index_path)
|
|
results = searcher.search(unique_phrase, top_k=5)
|
|
searcher.cleanup()
|
|
assert all(unique_phrase not in r.text for r in results), (
|
|
"Deleted content should not appear in search results"
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
os.environ.get("CI") == "true",
|
|
reason="Skip in CI to avoid embedding/model load",
|
|
)
|
|
def test_ivf_multiple_incremental_no_duplicates(tmp_path):
|
|
"""IVF: modifying the same file across multiple incremental builds must not create duplicate chunks."""
|
|
import asyncio
|
|
import json
|
|
import pickle
|
|
|
|
from leann.api import LeannSearcher
|
|
from leann.cli import LeannCLI
|
|
|
|
docs_dir = tmp_path / "docs"
|
|
docs_dir.mkdir()
|
|
|
|
target_phrase_v1 = "UNIQUE_TARGET_V1_ALPHA_BRAVO"
|
|
target_phrase_v2 = "UNIQUE_TARGET_V2_CHARLIE_DELTA"
|
|
target_phrase_v3 = "UNIQUE_TARGET_V3_ECHO_FOXTROT"
|
|
|
|
(docs_dir / "target.txt").write_text(
|
|
f"Version one content.\n\n{target_phrase_v1}\n\nEnd of version one.",
|
|
encoding="utf-8",
|
|
)
|
|
# Filler files for IVF training (nlist=100)
|
|
for i in range(110):
|
|
(docs_dir / f"filler_{i:03d}.txt").write_text(
|
|
f"Filler document {i} with unique content for padding the IVF index.",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
cli = LeannCLI()
|
|
cli.indexes_dir = tmp_path / ".leann" / "indexes"
|
|
cli.indexes_dir.mkdir(parents=True, exist_ok=True)
|
|
index_name = "ivf_dup_test"
|
|
index_path = cli.get_index_path(index_name)
|
|
index_dir = cli.indexes_dir / index_name
|
|
|
|
parser = cli.create_parser()
|
|
build_args = [
|
|
"build",
|
|
index_name,
|
|
"--docs",
|
|
str(docs_dir),
|
|
"--backend-name",
|
|
"ivf",
|
|
"--embedding-model",
|
|
"all-MiniLM-L6-v2",
|
|
"--embedding-mode",
|
|
"sentence-transformers",
|
|
]
|
|
|
|
# --- Initial build (--force) ---
|
|
asyncio.run(cli.build_index(parser.parse_args([*build_args, "--force"])))
|
|
|
|
searcher = LeannSearcher(index_path)
|
|
results = searcher.search(target_phrase_v1, top_k=10)
|
|
searcher.cleanup()
|
|
v1_hits = [r for r in results if target_phrase_v1 in r.text]
|
|
assert len(v1_hits) >= 1, "V1 content should be searchable after initial build"
|
|
|
|
# --- Modify target file to V2, incremental build ---
|
|
(docs_dir / "target.txt").write_text(
|
|
f"Version two content.\n\n{target_phrase_v2}\n\nEnd of version two.",
|
|
encoding="utf-8",
|
|
)
|
|
asyncio.run(cli.build_index(parser.parse_args(build_args)))
|
|
|
|
searcher = LeannSearcher(index_path)
|
|
results_v2 = searcher.search(target_phrase_v1, top_k=10)
|
|
searcher.cleanup()
|
|
assert all(target_phrase_v1 not in r.text for r in results_v2), (
|
|
"V1 content should be gone after first incremental update"
|
|
)
|
|
|
|
searcher = LeannSearcher(index_path)
|
|
results_v2b = searcher.search(target_phrase_v2, top_k=10)
|
|
searcher.cleanup()
|
|
v2_hits = [r for r in results_v2b if target_phrase_v2 in r.text]
|
|
assert len(v2_hits) >= 1, "V2 content should be searchable"
|
|
|
|
# --- Modify target file to V3, second incremental build ---
|
|
(docs_dir / "target.txt").write_text(
|
|
f"Version three content.\n\n{target_phrase_v3}\n\nEnd of version three.",
|
|
encoding="utf-8",
|
|
)
|
|
asyncio.run(cli.build_index(parser.parse_args(build_args)))
|
|
|
|
# V1 and V2 should be gone, only V3 present
|
|
searcher = LeannSearcher(index_path)
|
|
results_v3_check_v1 = searcher.search(target_phrase_v1, top_k=10)
|
|
searcher.cleanup()
|
|
assert all(target_phrase_v1 not in r.text for r in results_v3_check_v1), (
|
|
"V1 content should NOT appear after two incremental updates"
|
|
)
|
|
|
|
searcher = LeannSearcher(index_path)
|
|
results_v3_check_v2 = searcher.search(target_phrase_v2, top_k=10)
|
|
searcher.cleanup()
|
|
assert all(target_phrase_v2 not in r.text for r in results_v3_check_v2), (
|
|
"V2 content should NOT appear after second incremental update"
|
|
)
|
|
|
|
searcher = LeannSearcher(index_path)
|
|
results_v3 = searcher.search(target_phrase_v3, top_k=10)
|
|
searcher.cleanup()
|
|
v3_hits = [r for r in results_v3 if target_phrase_v3 in r.text]
|
|
assert len(v3_hits) >= 1, "V3 content should be searchable"
|
|
|
|
# --- Verify passages.jsonl has no stale entries ---
|
|
passages_file = index_dir / "documents.leann.passages.jsonl"
|
|
offset_file = index_dir / "documents.leann.passages.idx"
|
|
with open(offset_file, "rb") as f:
|
|
offset_map = pickle.load(f)
|
|
live_ids = set(offset_map.keys())
|
|
|
|
jsonl_ids = []
|
|
with open(passages_file, encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
data = json.loads(line)
|
|
jsonl_ids.append(data["id"])
|
|
|
|
stale_ids = [pid for pid in jsonl_ids if pid not in live_ids]
|
|
assert len(stale_ids) == 0, (
|
|
f"passages.jsonl has {len(stale_ids)} stale entries not in offset_map: {stale_ids[:5]}"
|
|
)
|