Merge pull request #174 from ggozad/fix/optimize-batch-update-chunks
Optimized `rebuild --embed-only` to use batch updates
This commit is contained in:
commit
80ff2fa509
3 changed files with 67 additions and 9 deletions
|
|
@ -1,6 +1,10 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Rebuild Performance**: Optimized `rebuild --embed-only` to use batch updates via LanceDB's `merge_insert` instead of individual chunk updates, and skip chunks with unchanged embeddings
|
||||||
|
|
||||||
## [0.19.4] - 2025-11-28
|
## [0.19.4] - 2025-11-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -748,21 +748,39 @@ class HaikuRAG:
|
||||||
"""Re-embed all chunks without changing chunk boundaries."""
|
"""Re-embed all chunks without changing chunk boundaries."""
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
assert doc.id is not None
|
assert doc.id is not None
|
||||||
chunks = await self.chunk_repository.get_by_document_id(doc.id)
|
|
||||||
if not chunks:
|
# Get raw chunk records directly from LanceDB
|
||||||
|
chunk_records = list(
|
||||||
|
self.store.chunks_table.search()
|
||||||
|
.where(f"document_id = '{doc.id}'")
|
||||||
|
.to_pydantic(self.store.ChunkRecord)
|
||||||
|
)
|
||||||
|
if not chunk_records:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Batch embed all chunk contents
|
# Batch embed all chunk contents
|
||||||
contents = [chunk.content for chunk in chunks]
|
contents = [rec.content for rec in chunk_records]
|
||||||
embeddings = await self.chunk_repository.embedder.embed(contents)
|
embeddings = await self.chunk_repository.embedder.embed(contents)
|
||||||
|
|
||||||
# Update each chunk with new embedding
|
# Build updated records only for chunks with changed embeddings
|
||||||
for chunk, embedding in zip(chunks, embeddings):
|
updated_records = [
|
||||||
assert chunk.id is not None
|
self.store.ChunkRecord(
|
||||||
self.store.chunks_table.update(
|
id=rec.id,
|
||||||
where=f"id = '{chunk.id}'",
|
document_id=rec.document_id,
|
||||||
values={"vector": embedding},
|
content=rec.content,
|
||||||
|
metadata=rec.metadata,
|
||||||
|
order=rec.order,
|
||||||
|
vector=embedding,
|
||||||
)
|
)
|
||||||
|
for rec, embedding in zip(chunk_records, embeddings)
|
||||||
|
if rec.vector != embedding
|
||||||
|
]
|
||||||
|
|
||||||
|
# Batch update chunks with changed embeddings
|
||||||
|
if updated_records:
|
||||||
|
self.store.chunks_table.merge_insert(
|
||||||
|
"id"
|
||||||
|
).when_matched_update_all().execute(updated_records)
|
||||||
|
|
||||||
yield doc.id
|
yield doc.id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,42 @@ async def test_rebuild_embed_only(qa_corpus: Dataset, temp_db_path):
|
||||||
assert chunk.content == chunk_contents_before[chunk.id]
|
assert chunk.content == chunk_contents_before[chunk.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rebuild_embed_only_skips_unchanged(qa_corpus: Dataset, temp_db_path):
|
||||||
|
"""Test embed-only rebuild skips chunks with unchanged embeddings."""
|
||||||
|
async with HaikuRAG(temp_db_path) as client:
|
||||||
|
doc = await client.create_document(content=qa_corpus["document_extracted"][0])
|
||||||
|
assert doc.id is not None
|
||||||
|
|
||||||
|
# Get embeddings before rebuild
|
||||||
|
records_before = list(
|
||||||
|
client.store.chunks_table.search()
|
||||||
|
.where(f"document_id = '{doc.id}'")
|
||||||
|
.to_pydantic(client.store.ChunkRecord)
|
||||||
|
)
|
||||||
|
embeddings_before = {rec.id: rec.vector for rec in records_before}
|
||||||
|
|
||||||
|
# Run embed-only rebuild with same embedder - embeddings should be identical
|
||||||
|
processed_ids = [
|
||||||
|
doc_id
|
||||||
|
async for doc_id in client.rebuild_database(mode=RebuildMode.EMBED_ONLY)
|
||||||
|
]
|
||||||
|
assert doc.id in processed_ids
|
||||||
|
|
||||||
|
# Get embeddings after rebuild
|
||||||
|
records_after = list(
|
||||||
|
client.store.chunks_table.search()
|
||||||
|
.where(f"document_id = '{doc.id}'")
|
||||||
|
.to_pydantic(client.store.ChunkRecord)
|
||||||
|
)
|
||||||
|
embeddings_after = {rec.id: rec.vector for rec in records_after}
|
||||||
|
|
||||||
|
# Embeddings should be identical (same content, same embedder)
|
||||||
|
assert embeddings_before.keys() == embeddings_after.keys()
|
||||||
|
for chunk_id in embeddings_before:
|
||||||
|
assert embeddings_before[chunk_id] == embeddings_after[chunk_id]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rebuild_rechunk(qa_corpus: Dataset, temp_db_path):
|
async def test_rebuild_rechunk(qa_corpus: Dataset, temp_db_path):
|
||||||
"""Test rechunk rebuild: re-chunks from content without accessing source files."""
|
"""Test rechunk rebuild: re-chunks from content without accessing source files."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue