Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ai Click House Store Laravel Package

symfony/ai-click-house-store

ClickHouse vector store integration for Symfony AI Store, letting Symfony AI applications store embeddings and run semantic search using ClickHouse ANN indexes and distance functions. Install with Composer to add a fast, scalable vector database backend.

View on GitHub
Deep Wiki
Context7

Getting Started

The README for this bridge is a stub (two links and a "report issues upstream" note), so "getting started" here means reading the source, not the docs. Budget an hour for that before you promise anything to a product owner.

1. Install and pin.

composer require symfony/ai-click-house-store:^0.14

Pin a minor version. This is a 0.x bridge inside the symfony/ai monorepo split; the public surface has changed in meaningful ways across releases (StoreInterface::clear() arrived in 0.11, count() and VectorDocumentInterface in 0.14, StoreFactory for HTTP-based bridges in 0.14). A composer update without a constraint is how you get a Tuesday-morning fatal error.

2. Confirm what you actually got. Open vendor/symfony/ai-click-house-store/ and read:

  • src/ — the store class and (since 0.14) the StoreFactory. The factory's constructor/create() signature is the thing you need; it is not documented anywhere else.
  • tests/ — this is the de facto documentation. The test fixtures show the expected table schema, column names, and the exact shape of the query() options array.
  • composer.json — note whether it pulls the HTTP client or a native ClickHouse client. As an HTTP-based bridge (per the 0.14 StoreFactory changelog) there is no PHP extension requirement, which is good news for Forge/Vapor-style deploys.

3. Stand up ClickHouse. You need a version that actually supports vector similarity indexes — vector_similarity index type landed in 24.3; anything earlier only has the experimental Annoy index. Check immediately:

SELECT version();
SHOW INDEXES FROM system.tables; -- or just try the DDL below

Locally, a docker-compose service on ports 8123 (HTTP, what the bridge talks to) and 9000 (native, which you will not use here) is fine. The bridge hits the HTTP interface, so your CLICKHOUSE_DSN looks like http://default:password@clickhouse:8123/database.

4. Bind it in Laravel. There is no Laravel bundle. Symfony's ai-bundle autowiring will not help you. Do it manually:

// app/Providers/AiServiceProvider.php
public function register(): void
{
    $this->app->singleton(StoreInterface::class, function () {
        return (new StoreFactory())->create(
            dsn: config('ai.clickhouse.dsn'),
            // table/database options per the factory signature in vendor/
        );
    });
}

Keep the alias (StoreInterface) but also register a named binding (ai.store.clickhouse) so you can run two stores side-by-side during a migration and compare results.

5. First use case — RAG over your own docs. The smallest thing that proves the bridge works end-to-end: take 200 rows from an existing articles table, embed title + body with your embedding provider, add() them as VectorDocuments, then query with a literal string. If that round-trips, the plumbing is sound. Do not start with a 10M-row backfill; you will be debugging ClickHouse part-merge behaviour and PHP memory limits simultaneously.


Implementation Patterns

Schema first, in raw SQL

Eloquent migrations do not cover MergeTree, Array(Float32), or vector indexes. Use DB::statement() inside a migration and accept that this table is not a normal Eloquent model.

public function up(): void
{
    DB::connection('clickhouse')->statement(<<<'SQL'
        CREATE TABLE IF NOT EXISTS documents
        (
            id         UUID,
            tenant_id  UInt32,
            content    String,
            embedding  Array(Float32),
            metadata   String CODEC(ZSTD(3)),
            updated_at DateTime DEFAULT now(),
            INDEX idx_tenant tenant_id TYPE set(1000) GRANULARITY 1,
            INDEX idx_embedding embedding
                TYPE vector_similarity('hnsw', 'cosineDistance', 1536) GRANULARITY 1
        )
        ENGINE = ReplacingMergeTree(updated_at)
        ORDER BY (tenant_id, id)
        SQL);
}

Three things matter here: the vector length (1536) must match your embedding model exactly; the distance function (cosineDistance) must match how you intend to query; and ReplacingMergeTree gives you idempotent upserts for free, which you will want when a queue job retries.

Ingestion: batch, always

ClickHouse dies on single-row inserts. One part per insert, and the merge background will fall over.

Article::query()
    ->whereNull('embedded_at')
    ->chunkById(500, function ($articles) use ($store, $embedder) {
        $vectors = $embedder->embedMany($articles->pluck('body')->all());

        $documents = $articles->zip($vectors)->map(
            fn ($pair) => new VectorDocument(
                id: (string) $pair[0]->uuid,
                vector: $pair[1],
                metadata: [
                    'tenant_id' => $pair[0]->tenant_id,
                    'category'  => $pair[0]->category,
                    'title'     => $pair[0]->title,
                ],
            )
        );

        $store->add(...$documents->all());

        Article::whereIn('id', $articles->modelKeys())->update(['embedded_at' => now()]);
    });

Run this as a queued job (ShouldBeUnique, generous $timeout), never in a request. If your bridge version supports an options argument on add(), pass async_insert => 1 for large backfills — it moves batching into ClickHouse and dramatically reduces part count.

Retrieval: filter before the vector search

The whole reason to pick ClickHouse over a dedicated vector DB is that the same engine does both. Use it that way — filter on indexed metadata columns in the same query rather than post-filtering in PHP (post-filtering silently wrecks recall when top-K is small).

$results = $store->query(
    new VectorDocument(vector: $embedding),
    [
        'limit'  => 20,
        'filter' => ['tenant_id' => $tenantId, 'category' => 'billing'],
    ]
);

The filter abstraction landed in v0.4; check vendor/.../src/ for the exact supported operators (=, IN, ranges) rather than assuming SQL is passed through. Whatever the shape, never interpolate raw user input into the filter — treat it as you would a query builder, and if the bridge builds SQL strings internally, audit that code before shipping it behind a public search box.

Multi-tenancy: one table, tenant_id in the sort key

Putting tenant_id first in ORDER BY (tenant_id, id) keeps per-tenant scans local and lets the set(1000) skip index prune granules. Separate tables per tenant is the alternative, but that fights ClickHouse's model (many small tables = many small parts). If a tenant is genuinely enormous, use a separate database for it and a second named store binding.

Two-store migration / A-B

Because you bound a named store rather than the bare interface, you can run the old store and ClickHouse in parallel:

$this->store->add(...$documents);        // new: ClickHouse
$this->legacyStore->add(...$documents);  // old: whatever you had

Then compare query() results in a command, and switch the StoreInterface binding when recall and latency look right. This is the cheapest way to de-risk an unproven bridge.

Testing

Do not point PHPUnit at a shared ClickHouse instance. Two options:

  • Unit/feature tests: bind a fake or in-memory StoreInterface implementation in TestCase::setUp(). Your RAG code should depend on the interface, not the bridge.
  • Integration tests: a dedicated ClickHouse container (Testcontainers, or a docker-compose service in CI), truncating with TRUNCATE TABLE between tests — not DELETE, which is a mutation and asynchronous.

Decorate the real store with a logging wrapper in your test binding so you can assert which queries were issued, not just which results came back.


Gotchas and Tips

Maturity is the headline risk. 1 star, 0 dependents, a README that is three links. It is maintained by the Symfony AI team and shares their release train, which is reassuring, but "0.x, no docs, no user base" means you are an early adopter and your bug reports may be the first. Treat the package as a component you can read rather than a black box, and be prepared to fork it.

The API is not stable. Between 0.10 and 0.14 the bridge gained count(), clear(), VectorDocumentInterface, and a StoreFactory. Any of those could be reshaped. Pin ^0.14, upgrade deliberately, and read the diff of src/ — the changelog entries are aggregated monorepo summaries and are not a substitute.

remove() is a ClickHouse mutation, not a DELETE. Mutations are asynchronous; they rewrite parts in the background. Consequences: count() right after remove() will be wrong, deleted vectors may still surface in queries for a while, and removing in a tight loop will queue mutations and tank the server. Batch your removals, and if you need fast deletes use ReplacingMergeTree with a deleted flag plus FINAL/argMax at read time rather than mutating.

FINAL is slow. ReplacingMergeTree gives you upserts but duplicates persist until merge. Calling FINAL makes results correct and queries much slower. For a vector store this matters — either accept eventual consistency (usually fine for search) or filter duplicates at the application layer.

ANN indexes are frequently not used. ClickHouse will happily fall back to a brute-force scan. Common causes: a WHERE filter on a non-indexed column, a LIMIT that's too large, a distance function that doesn't match the index definition, or the index not being materialized yet on pre-existing parts. Use EXPLAIN indexes = 1 on your generated SQL and confirm the vector index appears. If it doesn't, you are benchmarking full scans and drawing the wrong conclusions.

Dimension and precision mismatches fail silently. Store as Array(Float32) — that's what the index wants. If you embed with a 3072-dim model and declare 1536 in the index, you get an error at index creation but garbage similarity scores at query time. Assert count($vector) === $expectedDimensions before add(). Also: cosine similarity and L2 give the same ranking for L2-normalised vectors — normalise on ingest if you plan to switch metrics later, or you will get subtly different results and no obvious cause.

No async, no upsert semantics at the interface level. Symfony's StoreInterface is synchronous. For a 10M-vector backfill you are looking at a long-running artisan command, not a fan-out. Chunk it, checkpoint it, make it resumable, and keep it off the request path.

Debugging is ClickHouse-side, not PHP-side. You will not get query logs from the bridge. Enable system.query_log on the server, and if you need visibility, write a decorator that timestamps each add/query/remove call and logs the document count and options. Your first suspicion for a timeout (default HTTP timeouts are short; vector_similarity queries on cold data are slow) or a silent zero-result query should be the SQL that actually ran.

Config quirks worth pinning down early: HTTP port 8123 vs native 9000; whether auth goes in the DSN, in the factory args, or in header options; and whether the bridge manages table creation or expects you to have created it. If it does create tables lazily, verify the index definition it writes — you may be running without an ANN index and never notice until load.

Extension points are your safety valve. Because the store is bound behind StoreInterface, you can: decorate it for logging/metrics/caching, run two stores in parallel during migration, or swap the whole implementation for a different bridge later with a one-line container change. Design your application code against StoreInterface and VectorDocumentInterface and you have bought yourself an exit if the bridge stalls — which, at this maturity level, is a real possibility. That optionality is the main reason to consider the package at all.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console