Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
* `VectorDatabase::compact()` rebuilds the HNSW graph from the active documents using their stored vectors and returns the number of reclaimed nodes. Soft-deleted nodes, including the ones left behind by `updateDocument()`, no longer accumulate in memory and in `hnsw.bin` for the lifetime of the database. Active documents keep their internal node IDs, so no document file is renamed. On a persistent database the result is saved under the folder lock.
* `VectorDatabase::deletedCount()` reports how many soft-deleted nodes the graph holds, so callers can compact above a threshold.
* `HNSW\Index::compact()` and `HNSW\Index::deletedCount()`.
* `HNSW\Index::insert()` accepts an optional explicit node ID, which must be greater than every ID in the graph.

### Fixed
* The README claimed that document files are removed from disk as soon as a document is deleted. Removal is deferred to the next `save()`.

## [0.4.1] - 2026-09-02

Documentation only. No code changes, no behaviour changes.
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ $results = $db->vectorSearch($queryVector, k: 10);

```php
$db->count(); // Active (non-deleted) documents currently indexed.
$db->deletedCount(); // Soft-deleted documents still held in the HNSW graph.
$db->isPersistent(); // false for in-memory databases, where save() would throw.
```

Expand Down Expand Up @@ -401,7 +402,20 @@ $updated = $db->updateDocument(new Document(
$db->save();
```

Deleted documents are soft-deleted from the HNSW graph (kept for connectivity but excluded from results) and fully removed from the BM25 index. Document files are deleted from disk immediately.
Deleted documents are soft-deleted from the HNSW graph (kept for connectivity but excluded from results) and fully removed from the BM25 index. On a persistent database a tombstone is written immediately and the document file is removed on the next `save()`.

### Reclaiming deleted documents

A soft-deleted node keeps its vector and its edges, in memory and in `hnsw.bin`. Since `updateDocument()` is a delete plus an insert, the graph grows with the total number of edits rather than with the number of live documents. `compact()` rebuilds the HNSW graph from the active documents using their stored vectors, so nothing needs to be embedded again:

```php
// Compact once deleted nodes exceed 20% of the live documents.
if ($db->deletedCount() > $db->count() * 0.2) {
$reclaimed = $db->compact(); // number of nodes removed from the graph
}
```

On a persistent database `compact()` saves the result itself, under the same lock as `save()`. When nothing is deleted it returns 0 and writes nothing. The rebuild costs about as much as inserting every active document again, so run it from a maintenance job rather than on every request.

## Metadata filtering

Expand Down
80 changes: 76 additions & 4 deletions src/HNSW/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,28 @@ public function __construct(private readonly Config $config = new Config())
/**
* Insert a document into the index.
*
* When `$nodeId` is null the next ID after the highest one currently in the
* graph is assigned. VectorDatabase passes its own ID instead, so the two
* stay aligned after compact() leaves gaps in the ID space.
*
* @param int|null $nodeId Explicit node ID, greater than every ID in the graph.
*
* @throws DimensionMismatchException if the vector dimension differs from previously inserted vectors.
* @throws \InvalidArgumentException if `$nodeId` is not greater than every ID in the graph.
*/
public function insert(Document $document): void
public function insert(Document $document, ?int $nodeId = null): void
{
$lastId = array_key_last($this->nodes);
if ($nodeId === null) {
$nodeId = $lastId === null ? 0 : $lastId + 1;
} elseif ($nodeId < 0 || ($lastId !== null && $nodeId <= $lastId)) {
throw new \InvalidArgumentException(sprintf(
'Node ID %d must be greater than the highest node ID in the graph (%s).',
$nodeId,
$lastId === null ? 'none' : (string) $lastId,
));
}

$vector = $document->vector;
$dim = count($vector);

Expand All @@ -167,8 +185,9 @@ public function insert(Document $document): void
throw DimensionMismatchException::forVectors($this->dimension, $dim);
}

$nodeId = count($this->nodes);
$maxLayer = $this->randomLevel();
// Graph size before this insert; drives the adaptive efConstruction below.
$graphSize = count($this->nodes);
$maxLayer = $this->randomLevel();
$node = new Node($nodeId, $vector, $maxLayer);

$this->nodes[$nodeId] = $node;
Expand Down Expand Up @@ -213,7 +232,7 @@ public function insert(Document $document): void
// base layer and M on upper layers, even during early inserts.
$efC = max(
$mMax,
min($this->config->efConstruction, (int) ($nodeId / 10)),
min($this->config->efConstruction, (int) ($graphSize / 10)),
);
// Find efConstruction nearest neighbours at this layer.
$W = $this->searchLayer($dv, [[$epDist, $ep]], $efC, $lc);
Expand Down Expand Up @@ -432,6 +451,59 @@ public function delete(int $nodeId): bool
return true;
}

/**
* Number of soft-deleted nodes still held in the graph.
*/
public function deletedCount(): int
{
return count($this->deleted);
}

/**
* Physically remove every soft-deleted node by rebuilding the graph from
* the active nodes and their stored vectors.
*
* Active nodes keep their node IDs, so callers holding node IDs (such as
* VectorDatabase) need no remapping. The rebuild re-inserts every active
* node, costing about as much as building the index from scratch, but it
* needs no re-embedding.
*
* @return int Number of nodes reclaimed.
*/
public function compact(): int
{
$reclaimed = count($this->deleted);
if ($reclaimed === 0) {
return 0;
}

$active = [];
foreach ($this->nodes as $nodeId => $node) {
if (!isset($this->deleted[$nodeId])) {
$doc = $this->documents[$nodeId];
$active[$nodeId] = new Document(
id: $doc->id,
vector: $node->vector,
text: $doc->text,
metadata: $doc->metadata,
);
}
}

$this->nodes = [];
$this->documents = [];
$this->distVectors = [];
$this->deleted = [];
$this->entryPoint = null;
$this->maxLayer = 0;

foreach ($active as $nodeId => $doc) {
$this->insert($doc, $nodeId);
}

return $reclaimed;
}

/**
* Check if a node has been soft-deleted.
*/
Expand Down
46 changes: 44 additions & 2 deletions src/VectorDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public function addDocument(Document $document): void
$this->nodeIdToDoc[$nodeId] = $document;
$this->docIdToNodeId[$document->id] = $nodeId;

$this->hnswIndex->insert($document);
$this->hnswIndex->insert($document, $nodeId);
$this->bm25Index->addDocument($nodeId, $document);

// Persist doc file asynchronously when a path is configured.
Expand Down Expand Up @@ -215,7 +215,9 @@ public function addDocuments(array $documents): void
* Delete a document by its user-visible ID.
*
* The document is soft-deleted from HNSW (excluded from results but kept
* for graph connectivity) and fully removed from the BM25 index.
* for graph connectivity) and fully removed from the BM25 index. The HNSW
* node, vector included, stays in memory and in `hnsw.bin` until
* `compact()` is called.
*
* When persistence is enabled, a tombstone marker is written immediately so
* the deletion survives a crash. The physical doc file is removed during
Expand Down Expand Up @@ -863,6 +865,46 @@ public function count(): int
return $this->hnswIndex->count();
}

/**
* Number of soft-deleted documents still held in the HNSW graph.
*
* Every `deleteDocument()` and `updateDocument()` adds one. Use it to
* decide when to call `compact()`, for example once it exceeds a share
* of `count()`.
*/
public function deletedCount(): int
{
return $this->hnswIndex->deletedCount();
}

/**
* Reclaim the space held by soft-deleted documents.
*
* Rebuilds the HNSW graph from the active documents using their stored
* vectors, so nothing is re-embedded. The BM25 index needs no rebuild
* because deletions already remove documents from it. Active documents
* keep their internal node IDs, so no document file is renamed.
*
* The rebuild costs about as much as inserting every active document
* again. When persistence is enabled and something was reclaimed, the
* database is saved afterwards, with the same locking as `save()`.
*
* @return int Number of reclaimed documents (0 when there was nothing to do).
*
* @throws LockTimeoutException if the folder lock is held by another process.
* @throws \RuntimeException on I/O failure while saving.
*/
public function compact(): int
{
$reclaimed = $this->hnswIndex->compact();

if ($reclaimed > 0 && $this->path !== null) {
$this->save();
}

return $reclaimed;
}

// ------------------------------------------------------------------
// Fusion strategies
// ------------------------------------------------------------------
Expand Down
117 changes: 117 additions & 0 deletions tests/HNSW/IndexDeleteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,121 @@ public function testChurnPreservesRecall(): void
sprintf('Churn recall too low: %d/%d', $recalled, $k),
);
}

public function testDeletedCount(): void
{
$index = $this->makeIndex();
$index->insert(new Document(id: 'a', vector: [1.0, 0.0]));
$index->insert(new Document(id: 'b', vector: [0.0, 1.0]));
self::assertSame(0, $index->deletedCount());

$index->delete(0);
self::assertSame(1, $index->deletedCount());

$index->delete(0);
self::assertSame(1, $index->deletedCount(), 'Deleting twice counts once.');
}

public function testCompactRemovesDeletedNodesAndKeepsIds(): void
{
$index = $this->makeIndex();
$index->insert(new Document(id: 'a', vector: [1.0, 0.0])); // node 0
$index->insert(new Document(id: 'b', vector: [0.0, 1.0])); // node 1
$index->insert(new Document(id: 'c', vector: [-1.0, 0.0])); // node 2
$index->delete(1);

self::assertSame(1, $index->compact());
self::assertSame(0, $index->deletedCount());
self::assertSame(2, $index->count());

// Survivors keep their node IDs; the reclaimed node is gone for good.
self::assertSame([1.0, 0.0], $index->getVector(0));
self::assertSame([-1.0, 0.0], $index->getVector(2));
self::assertFalse($index->delete(1));

$state = $index->exportState();
self::assertSame([0, 2], array_keys($state['nodes']));
self::assertSame([], $state['deleted']);
foreach ($state['nodes'] as $node) {
foreach ($node['connections'] as $conns) {
self::assertNotContains(1, $conns, 'No edge may point at a reclaimed node.');
}
}
}

public function testCompactWithoutDeletionsIsNoop(): void
{
$index = $this->makeIndex();
$index->insert(new Document(id: 'a', vector: [1.0, 0.0]));
$before = $index->exportState();

self::assertSame(0, $index->compact());
self::assertSame($before, $index->exportState());
}

public function testInsertAfterCompactUsesNextFreeId(): void
{
$index = $this->makeIndex();
$index->insert(new Document(id: 'a', vector: [1.0, 0.0])); // node 0
$index->insert(new Document(id: 'b', vector: [0.0, 1.0])); // node 1
$index->delete(0);
$index->compact();

$index->insert(new Document(id: 'c', vector: [-1.0, 0.0]));
self::assertSame([1, 2], array_keys($index->exportState()['nodes']));
}

public function testInsertAcceptsExplicitIdWithGap(): void
{
$index = $this->makeIndex();
$index->insert(new Document(id: 'a', vector: [1.0, 0.0]), 5);
$index->insert(new Document(id: 'b', vector: [0.0, 1.0]), 9);

self::assertSame([5, 9], array_keys($index->exportState()['nodes']));
self::assertSame('b', $index->search([0.0, 1.0], 1)[0]->document->id);
}

public function testInsertRejectsExplicitIdNotAboveHighest(): void
{
$index = $this->makeIndex();
$index->insert(new Document(id: 'a', vector: [1.0, 0.0]), 3);

$this->expectException(\InvalidArgumentException::class);
$index->insert(new Document(id: 'b', vector: [0.0, 1.0]), 3);
}

public function testCompactPreservesRecall(): void
{
mt_srand(42);
$index = $this->makeIndex();
$dim = 8;
$k = 10;

$vectors = [];
for ($i = 0; $i < 300; $i++) {
$vectors[$i] = $this->randomVector($dim);
$index->insert(new Document(id: $i, vector: $vectors[$i]));
}
for ($i = 0; $i < 300; $i += 3) {
$index->delete($i);
unset($vectors[$i]);
}

self::assertSame(100, $index->compact());

$query = $this->randomVector($dim);
$hnswIds = array_map(fn($sr) => $sr->document->id, $index->search($query, $k));

$dists = [];
foreach ($vectors as $id => $v) {
$dists[$id] = sqrt(array_sum(array_map(fn($a, $b) => ($a - $b) ** 2, $query, $v)));
}
asort($dists);
$groundTruth = array_slice(array_keys($dists), 0, $k);

self::assertGreaterThanOrEqual(
(int) ceil($k * 0.7),
count(array_intersect($hnswIds, $groundTruth)),
);
}
}
Loading
Loading