Skip to content

feat: Add partial reindexing#225

Merged
Pringled merged 7 commits into
mainfrom
partial-reindexing
Jul 21, 2026
Merged

feat: Add partial reindexing#225
Pringled merged 7 commits into
mainfrom
partial-reindexing

Conversation

@Pringled

@Pringled Pringled commented Jul 16, 2026

Copy link
Copy Markdown
Member

This PR adds partial reindexing support. BM25s is dropped in favor of a custom BM25 implementation that supports adding/removing documents without full rebuilds. Results on our benchmarks:

Metric Before After Change
NDCG@10 0.8519 0.8519 No change
Cold index build 836 ms 809 ms 3.2% faster
Warm query p50 1.69 ms 2.33 ms 38% slower
Warm query p90 6.53 ms 7.41 ms 13% slower

The scores are basically the same. Queries are a bit slower but still so fast that I don't think it matters. Now the big improvement, tested on Zig (one of the larger repos in our benchmark):

Operation Before After Change
Rebuild after one changed file 8.47 s 369 ms ~23x faster

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
src/semble/cache.py 100.00% <100.00%> (ø)
src/semble/index/bm25.py 100.00% <100.00%> (ø)
src/semble/index/create.py 100.00% <100.00%> (ø)
src/semble/index/index.py 100.00% <100.00%> (ø)
src/semble/index/types.py 100.00% <100.00%> (ø)
src/semble/search.py 100.00% <100.00%> (ø)
src/semble/version.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

Safe to merge; the incremental reindex logic is well-designed and thoroughly tested, with no observed correctness issues in the happy path.

The core indexing and search paths are correct, the cache validation is thorough, and the test suite covers the main edge cases. All observations are non-blocking: BM25 IDF uses the full document count rather than the scored-document count (an API fragility, not a current bug), the not raw_manifest guard is marginally too broad, and the location field removal from chunks.json is a deliberate format change guarded by the version bump.

src/semble/index/bm25.py — the corpus_size vs doc_order length discrepancy is worth reviewing if the BM25 class is ever used outside the indexing pipeline. src/semble/cache.py — the not raw_manifest guard in load_previous_for_incremental.

Reviews (1): Last reviewed commit: "Optimizations" | Re-trigger Greptile

Comment thread src/semble/index/bm25.py
Comment thread src/semble/cache.py
Comment thread src/semble/index/index.py

@stephantul stephantul left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! Lots of small comments, but nothing major.

Comment thread src/semble/index/create.py Outdated
for index, _, count in embedding_parts:
vector_parts[index] = changed_embeddings[offset : offset + count]
offset += count
same_vector_layout = len(manifest) == len(previous_manifest) and all(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe turn this into a function

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, I turned this into a helper

Comment thread src/semble/index/index.py Outdated
found_version = metadata.get("cache_version")
if found_version != CACHE_FORMAT_VERSION:
raise ValueError(
f"Unsupported index format {found_version!r}; expected {CACHE_FORMAT_VERSION}. Rebuild the index."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make it clear to the user how to rebuild the index? Or do it for them automatically? A possible risk with raising here is that an agent runs into this error and keeps searching and never actually runs semble anymore.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah makes sense, I've updated the error message

Comment thread src/semble/index/index.py Outdated
chunks = []
for chunk_item in chunk_data:
chunks.append(Chunk.from_dict(chunk_item))
if len(chunks) != len(bm25_index.doc_order) or len(chunks) != semantic_index.vectors.shape[0]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can chain this: if not (len(chunks) == x == y)

Comment thread src/semble/cache.py Outdated
vectors = SelectableBasicBackend.load(persistence_path.semantic_index).vectors
bm25_index = BM25.load(persistence_path.bm25_index)
chunk_count = len(chunks)
if vectors.shape[0] != chunk_count or len(bm25_index.doc_order) != chunk_count:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a repetition of a snippet I also commented on, maybe turn it into a function

Comment thread src/semble/cache.py
return None

return PreviousIndex(chunks=chunks, vectors=vectors, manifest=manifest, bm25_index=bm25_index)
except (OSError, orjson.JSONDecodeError, json.JSONDecodeError, KeyError, TypeError, ValueError):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe turn this into a constant? I'm not sure if that's the right way. Also maybe we expect this to log something useful? This seems to point towards an unexpected path, rather than an expected cache invalidation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a debug log with the exception context before falling back, that should be helpful.I'm not sure about a constant though, this is only used once here and I think it's pretty specific for the cache

Comment thread src/semble/index/types.py

mtime: float
start: int
count: int

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this not be length? As in start + length == end?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm it represents the number of chunks so I think count is a bit more intuitive

Comment thread src/semble/cache.py Outdated
for indexed_path, entry in manifest.items():
if (
entry.start != next_start
or entry.count < 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when can entry.count be < 0? Should this not fail when creating the manifest? It seems to me that a manifest item with a negative count can't be valid anyway.

@Pringled Pringled Jul 21, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you're right this is overly defensive, we control the cache so this won't ever happen in practice, I'll get rid of it

Comment thread src/semble/index/create.py Outdated
mtime = file_path.stat().st_mtime
previous_entry = previous_manifest.get(indexed_path)

if previous is not None and previous_entry is not None and previous_entry.mtime == mtime:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous_entry.mtime is a float comparison. I wonder if this ever doesn't work?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yess good catch, will update

Comment thread src/semble/index/create.py Outdated
previous_entry = previous_manifest.get(indexed_path)

if previous is not None and previous_entry is not None and previous_entry.mtime == mtime:
file_chunks = previous.chunks[previous_entry.start : previous_entry.start + previous_entry.count]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe create an end property to be used. e.g., end is defined as start + count.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it, done

Comment thread src/semble/index/create.py Outdated
_reindex_file(bm25_index, indexed_path, file_chunks, previous_entry)

embedding_parts.append((len(vector_parts), len(chunks), len(file_chunks)))
vector_parts.append(np.empty((0, model.dim), dtype=np.float32))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this yet, why do we need an empty thing here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make batching work across different files (you need to preserve the fileorder), but tbh, this is kind of overtuned, I'll just embed per file. The chances of having to re-embed multiple files is small and even if you have to do it it's superfast

@Pringled
Pringled merged commit 204ae4e into main Jul 21, 2026
16 checks passed
@Pringled
Pringled deleted the partial-reindexing branch July 21, 2026 06:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants