Zero-dependency PHP vector database with Matryoshka search, IVF indexing, and Int8 quantization. Pure PHP 8.1+ — no SQLite, no C extensions, no FFI.
composer require mauricioperera/php-vector-store
Most vector databases require C extensions (sqlite-vec), external services (Pinecone, Weaviate), or specific runtimes (Python). PHP Vector Store runs anywhere PHP runs — shared hosting, WordPress, Laravel, any framework.
| Vectors | Recommended Config | Storage/vec | Total (100K) | Speed |
|---|---|---|---|---|
| <5K | Float32 768d + Matryoshka | 3,072 B | 300 MB | ~3ms |
| 5K-20K | Float32 384d + Matryoshka | 1,536 B | 150 MB | ~1.4ms |
| 20K-100K | Int8 384d + IVF + Matryoshka | 392 B | 38 MB | ~5ms |
| 100K-500K | Int8 384d + IVF + Matryoshka | 392 B | 192 MB | ~15ms |
| >500K | Use sqlite-vec or external service | — | — | — |
Matryoshka embeddings (EmbeddingGemma, etc.) encode information hierarchically. Truncating to 384 dimensions loses <5% of semantic information:
| Config | Storage/vec | Recall@1 | Recall@3 |
|---|---|---|---|
| Float32 768d | 3,072 B | 90% | 100% |
| Float32 384d | 1,536 B | 90% | 100% |
| Int8 768d | 776 B | 90% | 100% |
| Int8 384d | 392 B | 90% | 100% |
Same accuracy, 87% less storage. Benchmarked with real EmbeddingGemma-300m embeddings.
use PHPVectorStore\VectorStore;
use PHPVectorStore\QuantizedStore;
use PHPVectorStore\IVFIndex;
// Option A: Full precision (768d, 3KB/vec)
$store = new VectorStore( __DIR__ . '/vectors', 768 );
// Option B: Reduced dimensions (384d, 1.5KB/vec) — same accuracy
$store = new VectorStore( __DIR__ . '/vectors', 384 );
// Option C: Quantized (384d Int8, 392B/vec) — maximum density
$store = new QuantizedStore( __DIR__ . '/vectors', 384 );
// Store vectors (truncate to 384d if using 384d store)
$embedding_768 = get_embedding_from_api( $text ); // Your embedding API
$embedding_384 = array_slice( $embedding_768, 0, 384 );
$store->set( 'articles', 'art-1', $embedding_384, ['title' => 'My Article'] );
$store->flush();
// Search — Matryoshka stages adapt to your dimension
$results = $store->matryoshkaSearch( 'articles', $query_384, 5, [128, 256, 384] );
// For large datasets, add IVF
$ivf = new IVFIndex( $store, numClusters: 100, numProbes: 20 );
$ivf->build( 'articles' );
$results = $ivf->matryoshkaSearch( 'articles', $query_384, 5, [128, 256, 384] );Full precision. dim × 4 bytes per vector.
$store = new VectorStore( '/path', 768 ); // 3,072 B/vec
$store = new VectorStore( '/path', 384 ); // 1,536 B/vecFiles: {collection}.bin + {collection}.json
Each float mapped to 1 byte + 8-byte header. dim + 8 bytes per vector.
Score drift vs Float32: <0.001. Ranking is identical.
$q8 = new QuantizedStore( '/path', 768 ); // 776 B/vec
$q8 = new QuantizedStore( '/path', 384 ); // 392 B/vecFiles: {collection}.q8.bin + {collection}.q8.json
K-means partitions vectors into clusters. At query time, only searches the closest clusters. Works on top of VectorStore or QuantizedStore.
$ivf = new IVFIndex( $store, numClusters: 100, numProbes: 20 );
$ivf->build( 'collection' );
$ivf->search( 'collection', $query, 5 );File: {collection}.ivf.json
$store->search( 'articles', $query, 5 ); // Full dimension
$store->search( 'articles', $query, 5, 128 ); // Reduced dims (faster)Progressive refinement — each stage narrows candidates before the next.
// For 768d store
$store->matryoshkaSearch( 'col', $query, 5, [128, 384, 768] );
// For 384d store
$store->matryoshkaSearch( 'col', $query, 5, [128, 256, 384] );Speedup: 3-5x over brute-force.
IVF narrows to ~20% of vectors, then Matryoshka stages refine further.
$ivf->matryoshkaSearch( 'col', $query, 5, [128, 256, 384] );Speedup: 10-15x over brute-force.
$store->searchAcross( ['articles', 'comments', 'products'], $query, 10 );| Config | Avg/query |
|---|---|
| Float32 768d brute-force | 2.99ms |
| Float32 768d Matryoshka 128→384→768 | 2.59ms |
| Float32 384d brute-force | 1.40ms |
| Float32 384d Matryoshka 128→256→384 | 1.50ms |
| Int8 384d brute-force | 5.07ms |
| Int8 384d Matryoshka 128→256→384 | 5.42ms |
| Method | Time/query | Speedup |
|---|---|---|
| Brute-force 768d | 796ms | 1x |
| Matryoshka 128→384→768 | 182ms | 4.4x |
| IVF | 100ms | 7.9x |
| IVF + Matryoshka | 54ms | 14.7x |
| Format | Per vector | 10K | 100K | 500K |
|---|---|---|---|---|
| JSON (typical) | ~7,000 B | 70 MB | 700 MB | 3.4 GB |
| Float32 768d | 3,072 B | 30 MB | 300 MB | 1.5 GB |
| Float32 384d | 1,536 B | 15 MB | 150 MB | 750 MB |
| Int8 768d | 776 B | 7.6 MB | 76 MB | 380 MB |
| Int8 384d | 392 B | 3.8 MB | 38 MB | 192 MB |
new VectorStore( string $dir, int $dim = 768, int $maxCollections = 50 );
// Write
->set( $collection, $id, $vector, $metadata = [] )
->remove( $collection, $id ): bool
->drop( $collection )
->flush()
// Read
->get( $collection, $id ): ?array // {id, vector, metadata}
->has( $collection, $id ): bool
->count( $collection ): int
->ids( $collection ): string[]
->collections(): string[]
->stats(): array
// Search
->search( $collection, $query, $limit = 5, $dimSlice = 0 ): array
->matryoshkaSearch( $collection, $query, $limit = 5, $stages = [128,384,768] ): array
->searchAcross( $collections, $query, $limit = 5 ): array
// Import/Export
->import( $collection, $records ): int
->export( $collection ): arraySame interface as VectorStore, with Int8 scalar quantization.
new QuantizedStore( string $dir, int $dim = 768 );
// Same methods: set, remove, get, search, matryoshkaSearch, flush, etc.new IVFIndex( VectorStore|QuantizedStore $store, int $numClusters = 100, int $numProbes = 10 );
->build( $collection, $sampleDims = 128 ): array
->search( $collection, $query, $limit = 5 ): array
->matryoshkaSearch( $collection, $query, $limit, $stages ): array
->hasIndex( $collection ): bool
->indexStats( $collection ): ?array
->dropIndex( $collection )VectorStore::normalize( $vector ): array
VectorStore::cosineSim( $a, $b, $dims ): float
VectorStore::euclideanDist( $a, $b, $dims ): float
VectorStore::dotProduct( $a, $b, $dims ): floatvectors/
├── articles.bin ← Float32: N × dim × 4 bytes
├── articles.json ← Manifest: IDs + metadata
├── articles.q8.bin ← Int8: N × (dim + 8) bytes
├── articles.q8.json ← Quantized manifest
├── articles.ivf.json ← IVF: centroids + cluster assignments
└── .htaccess ← Access protection
Models like EmbeddingGemma encode information hierarchically:
[d0..d127] → coarse (topic, domain)
[d0..d255] → medium (subtopic, entities)
[d0..d383] → detailed (specific meaning) ← 384d sweet spot
[d0..d767] → maximum (fine nuance)
You can truncate at any level. 384d retains ~95% of semantic information at half the storage.
Per-vector min/max linear mapping:
Encode: int8 = round((float - min) / (max - min) × 255) - 128
Decode: float = (int8 + 128) / 255 × (max - min) + min
Dequantized cosine similarity preserves ranking with <0.001 score drift.
Build: all vectors → k-means → K centroids
Query: find P nearest centroids → search only those clusters
Scan: N × (P/K) vectors instead of N
The optimal stack for large datasets:
1. IVF prunes to ~20% of vectors (cluster selection)
2. Matryoshka stage 1 (128d): fast coarse ranking on candidates
3. Matryoshka stage 2 (256d): medium refinement
4. Matryoshka stage 3 (384d): final ranking
All on Int8 quantized data (392 bytes/vector)
Result: 500K vectors in 192 MB, searchable in ~15ms.
The vector store is database-agnostic. It only stores IDs (strings) and vectors (floats). Your primary database keeps the actual content. The ID is the link between them.
Your Database Vector Store
┌──────────────────────────┐ ┌─────────────────────┐
│ record with ID: "42" │◄────────►│ id: "42" │
│ content, metadata, etc. │ │ vector: [0.1, ...] │
└──────────────────────────┘ └─────────────────────┘
// Store: save content to your DB, save vector to store
$id = save_to_your_database( $content );
$store->set( 'articles', (string) $id, $embedding );
// Search: get IDs from vector store, fetch content from your DB
$results = $store->search( 'articles', $query_vector, 5 );
foreach ( $results as $r ) {
$content = fetch_from_your_database( $r['id'] );
}Works with any data source:
| Database | ID example | Works? |
|---|---|---|
| MySQL / MariaDB | "42" (auto-increment) |
Yes |
| PostgreSQL | "7891" (serial) |
Yes |
| MongoDB | "64a7f3b..." (_id) |
Yes |
| Redis | "doc:abc123" (key) |
Yes |
| SQLite | "15" (rowid) |
Yes |
| Files | "note-xyz" (filename) |
Yes |
| S3 / R2 | "uploads/doc.pdf" (key) |
Yes |
| REST API | "ext-resource-99" (external ID) |
Yes |
Use one collection per entity type. This improves search performance by reducing the number of vectors scanned per query.
$store = new QuantizedStore( __DIR__ . '/vectors', 384 );
// Each entity type is its own collection
$store->set( 'posts', '42', $post_vector );
$store->set( 'users', '7', $user_vector );
$store->set( 'products', '128', $product_vector );
$store->set( 'comments', '531', $comment_vector );Storage on disk — each collection is independent:
vectors/
├── posts.q8.bin ← 50K post vectors
├── posts.q8.json
├── users.q8.bin ← 10K user vectors
├── users.q8.json
├── products.q8.bin ← 5K product vectors
├── products.q8.json
├── comments.q8.bin ← 100K comment vectors
└── comments.q8.json
Why this is faster than one big collection:
| Strategy | Search "similar posts" | Vectors scanned |
|---|---|---|
Everything in all |
165K | 165K always |
Per-type: posts |
50K | Only posts |
Per-type: users |
10K | Only users |
Search only what you need, cross-search when you need global recall:
// Targeted: search only posts
$store->search( 'posts', $query, 5 );
// Targeted: search only users
$store->search( 'users', $query, 5 );
// Global: search across everything
$store->searchAcross( ['posts', 'users', 'products', 'comments'], $query, 10 );Each collection also gets its own IVF index, optimized for its size and semantic distribution.
$store = new QuantizedStore( WP_CONTENT_DIR . '/vectors', 384 );
// On post publish: generate embedding and store
add_action( 'wp_after_insert_post', function( $id, $post ) use ( $store ) {
if ( 'publish' !== $post->post_status ) return;
$text = $post->post_title . ' ' . wp_strip_all_tags( $post->post_content );
$vector = array_slice( your_embedding_api( $text ), 0, 384 );
$store->set( 'posts', (string) $id, $vector, ['title' => $post->post_title] );
$store->flush();
}, 10, 2 );
// On post delete: remove vector
add_action( 'deleted_post', function( $id ) use ( $store ) {
$store->remove( 'posts', (string) $id );
$store->flush();
});
// Search
$query = array_slice( your_embedding_api( 'search terms' ), 0, 384 );
$results = $store->matryoshkaSearch( 'posts', $query, 5, [128, 256, 384] );
foreach ( $results as $r ) {
$post = get_post( (int) $r['id'] );
echo $post->post_title . ' — score: ' . $r['score'];
}Works the same with Custom Post Types:
// One collection per CPT
$store->set( 'agent_memory', $memory_id, $vector );
$store->set( 'agent_skill', $skill_id, $vector );
$store->set( 'agent_knowledge', $knowledge_id, $vector );
// Search memories only
$store->search( 'agent_memory', $query, 10 );
// Cross-search for agent recall
$store->searchAcross( ['agent_memory', 'agent_skill', 'agent_knowledge'], $query, 10 );// In a Service Provider
$this->app->singleton( QuantizedStore::class, function () {
return new QuantizedStore( storage_path( 'vectors' ), 384 );
});
// In a Model Observer
public function saved( Article $article ) {
$store = app( QuantizedStore::class );
$vector = array_slice( $this->embed( $article->body ), 0, 384 );
$store->set( 'articles', (string) $article->id, $vector );
$store->flush();
}
// In a Controller
public function search( Request $request ) {
$store = app( QuantizedStore::class );
$query = array_slice( $this->embed( $request->q ), 0, 384 );
$results = $store->matryoshkaSearch( 'articles', $query, 10, [128, 256, 384] );
$ids = array_column( $results, 'id' );
return Article::whereIn( 'id', $ids )->get();
}PHP Vector Store includes a built-in adapter for Neuron AI, the PHP agentic framework. It implements VectorStoreInterface as a zero-dependency local alternative to Pinecone, Qdrant, Chroma, etc.
use NeuronAI\RAG\RAG;
use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\RAG\VectorStore\VectorStoreInterface;
use NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface;
use PHPVectorStore\Integration\NeuronVectorStore;
class MyRAG extends RAG
{
protected function provider(): AIProviderInterface
{
// Your AI provider (Anthropic, OpenAI, Ollama, etc.)
}
protected function embeddings(): EmbeddingsProviderInterface
{
// Your embeddings provider
}
protected function vectorStore(): VectorStoreInterface
{
return new NeuronVectorStore(
directory: __DIR__ . '/vectors',
dimensions: 384,
collection: 'knowledge',
topK: 5,
quantized: true, // Int8 — 4x smaller
matryoshka: true, // Multi-stage search
);
}
}
// Use it
$rag = MyRAG::make();
$rag->addDocuments( $loader->getDocuments() ); // Embeds + stores
$response = $rag->chat( new UserMessage( 'What is...' ) );The adapter:
- Stores documents with content, sourceType, sourceName, and metadata
- Returns
Documentobjects with similarity scores - Supports
deleteBy(sourceType, sourceName)for reindexing - Auto-selects Matryoshka stages based on dimensions
- Uses Int8 quantization by default (392 bytes/vector at 384d)
vs other Neuron vector stores:
| Store | Dependencies | Local | Storage/vec (384d) |
|---|---|---|---|
| NeuronVectorStore | None | Yes | 392 B |
| MemoryVectorStore | None | Yes | ~3 KB (in-memory only, lost on restart) |
| FileVectorStore | None | Yes | ~7 KB (JSON) |
| PineconeVectorStore | API key + HTTP | No | Cloud-hosted |
| QdrantVectorStore | Qdrant server | No | Server-hosted |
| ChromaVectorStore | Chroma server | No | Server-hosted |
require_once 'vendor/autoload.php';
// or: require_once 'src/QuantizedStore.php';
use PHPVectorStore\QuantizedStore;
$store = new QuantizedStore( __DIR__ . '/data/vectors', 384 );
// Store
$store->set( 'documents', 'doc-001', $embedding, ['title' => 'My Doc'] );
$store->flush();
// Search
$results = $store->matryoshkaSearch( 'documents', $query, 5, [128, 256, 384] );| PHP Vector Store | sqlite-vec | |
|---|---|---|
| Dependencies | None | C extension or FFI |
| PHP version | 8.1+ | 8.1+ with extension |
| Deployment | Drop anywhere | Server config needed |
| Best range | <500K vectors | >500K vectors |
| Quantization | Int8 (4x compression) | Varies |
| Matryoshka | Native multi-stage | Manual |
| Storage efficiency | 392 B/vec (Int8 384d) | ~400 B/vec |
| Search algorithm | Brute + IVF + Matryoshka | ANN (HNSW) |
| Crash safety | Atomic rename | SQLite WAL |
GPL-2.0-or-later