ReMe/test/reme2/test_bm25_lite.py
jinli.yl 602622c9f6 up
2026-05-12 20:32:59 +08:00

561 lines
No EOL
15 KiB
Python

"""Tests for BM25Lite search engine."""
import asyncio
import tempfile
import warnings
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Filter jieba/pkg_resources deprecation warnings
warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
from reme2.component.file_store.bm25_lite import BM25Lite
async def create_bm25(index_dir: Path, k1: float = 1.5, b: float = 0.75) -> BM25Lite:
"""Create and start a BM25Lite instance."""
bm25 = BM25Lite(index_dir=index_dir, k1=k1, b=b)
await bm25.start()
return bm25
def test_basic_init():
"""Test BM25Lite initialization."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = BM25Lite(index_dir=tmpdir)
assert bm25.k1 == 1.5
assert bm25.b == 0.75
assert bm25.vocab == {}
assert bm25.inverted_index == {}
assert bm25.doc_meta == {}
assert bm25.n_docs == 0
assert bm25.avg_len == 0.0
print("✓ test_basic_init passed")
asyncio.run(run())
def test_start_with_tokenizer():
"""Test BM25Lite starts and initializes tokenizer."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
assert bm25._tokenizer is not None
assert bm25.is_started
await bm25.close()
assert not bm25.is_started
print("✓ test_start_with_tokenizer passed")
asyncio.run(run())
def test_add_single_doc():
"""Test adding a single document."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
bm25.add_docs({"doc1": "hello world"})
assert bm25.n_docs == 1
assert bm25.total_len > 0
assert "doc1" in bm25.doc_meta
await bm25.close()
print("✓ test_add_single_doc passed")
asyncio.run(run())
def test_add_multiple_docs():
"""Test adding multiple documents."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
docs = {
"doc1": "hello world",
"doc2": "hello python",
"doc3": "world python",
}
bm25.add_docs(docs)
assert bm25.n_docs == 3
assert len(bm25.vocab) > 0
assert len(bm25.inverted_index) > 0
await bm25.close()
print("✓ test_add_multiple_docs passed")
asyncio.run(run())
def test_retrieve_basic():
"""Test basic retrieval functionality."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
docs = {
"doc1": "python programming language",
"doc2": "java programming language",
"doc3": "python data analysis",
}
bm25.add_docs(docs)
results = bm25.retrieve("python", k=3)
assert len(results) <= 3
assert "doc1" in results or "doc3" in results
await bm25.close()
print("✓ test_retrieve_basic passed")
asyncio.run(run())
def test_retrieve_with_limit():
"""Test retrieval with result limit."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
docs = {
f"doc{i}": f"python programming {i}" for i in range(10)
}
bm25.add_docs(docs)
results = bm25.retrieve("python", k=3)
assert len(results) == 3
results = bm25.retrieve("python", k=5)
assert len(results) == 5
await bm25.close()
print("✓ test_retrieve_with_limit passed")
asyncio.run(run())
def test_retrieve_empty_query():
"""Test retrieval with empty or unknown query."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
docs = {"doc1": "hello world"}
bm25.add_docs(docs)
results = bm25.retrieve("", k=3)
assert results == {}
results = bm25.retrieve("unknownxyz", k=3)
assert results == {}
await bm25.close()
print("✓ test_retrieve_empty_query passed")
asyncio.run(run())
def test_retrieve_empty_index():
"""Test retrieval from empty index."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
results = bm25.retrieve("python", k=3)
assert results == {}
await bm25.close()
print("✓ test_retrieve_empty_index passed")
asyncio.run(run())
def test_update_doc():
"""Test updating an existing document."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
bm25.add_docs({"doc1": "hello world python"})
old_len = bm25.total_len
bm25.add_docs({"doc1": "java"})
assert bm25.n_docs == 1
assert bm25.total_len != old_len
results = bm25.retrieve("java", k=1)
assert "doc1" in results
await bm25.close()
print("✓ test_update_doc passed")
asyncio.run(run())
def test_remove_doc():
"""Test removing a document."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
docs = {
"doc1": "hello world",
"doc2": "hello python",
}
bm25.add_docs(docs)
assert bm25.n_docs == 2
bm25._remove_doc("doc1")
assert bm25.n_docs == 1
assert "doc1" not in bm25.doc_meta
results = bm25.retrieve("hello", k=2)
assert "doc1" not in results
assert "doc2" in results
await bm25.close()
print("✓ test_remove_doc passed")
asyncio.run(run())
def test_remove_nonexistent_doc():
"""Test removing a nonexistent document (should be no-op)."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
bm25.add_docs({"doc1": "hello world"})
bm25._remove_doc("nonexistent")
assert bm25.n_docs == 1
await bm25.close()
print("✓ test_remove_nonexistent_doc passed")
asyncio.run(run())
def test_clear():
"""Test clearing the index."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
bm25.add_docs({
"doc1": "hello world",
"doc2": "hello python",
})
assert bm25.n_docs == 2
bm25.clear()
assert bm25.n_docs == 0
assert bm25.vocab == {}
assert bm25.inverted_index == {}
assert bm25.doc_meta == {}
assert bm25.total_len == 0
assert bm25._idf_cache == {}
await bm25.close()
print("✓ test_clear passed")
asyncio.run(run())
def test_reindex():
"""Test reindex functionality to compact vocab."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
bm25.add_docs({"doc1": "hello world"})
bm25._remove_doc("doc1")
assert bm25.n_docs == 0
assert len(bm25.vocab) > 0
bm25.reindex()
assert bm25.vocab == {}
assert bm25.inverted_index == {}
await bm25.close()
print("✓ test_reindex passed")
asyncio.run(run())
def test_reindex_with_docs():
"""Test reindex with remaining documents."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
bm25.add_docs({
"doc1": "hello world",
"doc2": "hello python",
})
old_vocab = bm25.vocab.copy()
bm25._remove_doc("doc1")
bm25.reindex()
assert bm25.n_docs == 1
assert "doc2" in bm25.doc_meta
assert len(bm25.vocab) < len(old_vocab)
results = bm25.retrieve("hello", k=1)
assert "doc2" in results
await bm25.close()
print("✓ test_reindex_with_docs passed")
asyncio.run(run())
def test_persistence():
"""Test dump and load persistence."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
bm25 = await create_bm25(tmpdir_path)
docs = {
"doc1": "hello world",
"doc2": "hello python",
"doc3": "programming language",
}
bm25.add_docs(docs)
old_vocab = bm25.vocab.copy()
old_doc_meta = {k: dict(v) for k, v in bm25.doc_meta.items()}
await bm25.dump()
await bm25.close()
bm25_new = await create_bm25(tmpdir_path)
assert bm25_new.vocab == old_vocab
assert bm25_new.n_docs == 3
for doc_id, meta in old_doc_meta.items():
assert doc_id in bm25_new.doc_meta
results = bm25_new.retrieve("hello", k=2)
assert "doc1" in results or "doc2" in results
await bm25_new.close()
print("✓ test_persistence passed")
asyncio.run(run())
def test_custom_params():
"""Test custom k1 and b parameters."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir), k1=2.0, b=0.5)
assert bm25.k1 == 2.0
assert bm25.b == 0.5
bm25.add_docs({"doc1": "test document"})
results = bm25.retrieve("test", k=1)
assert "doc1" in results
await bm25.close()
print("✓ test_custom_params passed")
asyncio.run(run())
def test_chinese_text():
"""Test with Chinese text."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
docs = {
"doc1": "我爱北京天安门",
"doc2": "北京是中国的首都",
"doc3": "上海的天气很好",
}
bm25.add_docs(docs)
results = bm25.retrieve("北京", k=2)
assert len(results) <= 2
assert "doc1" in results or "doc2" in results
await bm25.close()
print("✓ test_chinese_text passed")
asyncio.run(run())
def test_mixed_chinese_english():
"""Test with mixed Chinese and English text."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
docs = {
"doc1": "Python 是一种编程语言",
"doc2": "Java 编程语言",
"doc3": "Python 数据分析",
}
bm25.add_docs(docs)
results = bm25.retrieve("Python", k=3)
assert len(results) > 0
results = bm25.retrieve("编程", k=2)
assert len(results) > 0
await bm25.close()
print("✓ test_mixed_chinese_english passed")
asyncio.run(run())
def test_idf_cache():
"""Test IDF cache functionality."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
bm25.add_docs({
"doc1": "hello world",
"doc2": "hello python",
})
token = "hello"
if token in bm25.vocab:
tid = bm25.vocab[token]
idf1 = bm25._get_idf(tid)
assert tid in bm25._idf_cache
idf2 = bm25._get_idf(tid)
assert idf1 == idf2
await bm25.close()
print("✓ test_idf_cache passed")
asyncio.run(run())
def test_avg_len():
"""Test average document length calculation."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
assert bm25.avg_len == 0.0
bm25.add_docs({"doc1": "hello world python"})
assert bm25.avg_len > 0
bm25.add_docs({"doc2": "test"})
new_avg = bm25.avg_len
assert new_avg > 0
await bm25.close()
print("✓ test_avg_len passed")
asyncio.run(run())
def test_score_ordering():
"""Test that results are ordered by score descending."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
docs = {
"doc1": "python python python",
"doc2": "python python",
"doc3": "python",
}
bm25.add_docs(docs)
results = bm25.retrieve("python", k=3)
scores = list(results.values())
for i in range(len(scores) - 1):
assert scores[i] >= scores[i + 1]
await bm25.close()
print("✓ test_score_ordering passed")
asyncio.run(run())
def test_empty_doc():
"""Test adding empty document."""
async def run():
with tempfile.TemporaryDirectory() as tmpdir:
bm25 = await create_bm25(Path(tmpdir))
bm25.add_docs({"doc1": ""})
assert bm25.n_docs == 0
bm25.add_docs({"doc2": " "})
assert bm25.n_docs == 0
await bm25.close()
print("✓ test_empty_doc passed")
asyncio.run(run())
if __name__ == "__main__":
print("\n=== BM25Lite Tests ===")
test_basic_init()
test_start_with_tokenizer()
test_add_single_doc()
test_add_multiple_docs()
test_retrieve_basic()
test_retrieve_with_limit()
test_retrieve_empty_query()
test_retrieve_empty_index()
test_update_doc()
test_remove_doc()
test_remove_nonexistent_doc()
test_clear()
test_reindex()
test_reindex_with_docs()
test_persistence()
test_custom_params()
test_chinese_text()
test_mixed_chinese_english()
test_idf_cache()
test_avg_len()
test_score_ordering()
test_empty_doc()
print("\n所有测试通过!")