فهرست منبع

Una/feat/mariadb vector store (#5910)

* Add a MariaDB vector store on the site database.

Use the header.inc.php credentials and open the store only when the server is MariaDB 11.7 or newer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use the site database connection for the MariaDB vector store.

The embeddings table is created in install SQL, and the store reuses BxDolDb::getLink() instead of opening its own connection.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix the MariaDB vector store embedding width at 1536.

Store params can no longer choose a different width than the vector column.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Limit MariaDB vector search to the current store.

Rows from every store share one table, so similarity search filters on the store id stored in sourceType.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix

* 384 & 1024 dimentions

* mariadb.sql

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Aleksandr Trofimov 4 روز پیش
والد
کامیت
b9d8f8c527

+ 3 - 0
inc/classes/BxDolAIVectorStoreFactory.php

@@ -120,6 +120,9 @@ class BxDolAIVectorStoreFactory extends BxDolFactory
                     dimension: $aParameters['dimension'] ?? 1024
                 );
                 break;
+            case 'mariadb':
+                $o = BxDolAIVectorStoreMariaDb::open($iId, (int)($a['topk'] ?? 4));
+                break;
             case 'una-proxy':
                 bx_log('sys_agents', 'Remote knowledge is stored on the master', BX_LOG_ERR);
                 throw new Exception('Remote knowledge is stored on the master');

+ 152 - 0
inc/classes/BxDolAIVectorStoreMariaDb.php

@@ -0,0 +1,152 @@
+<?php defined('BX_DOL') or defined('BX_DOL_INSTALL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * MariaDB vector store on the site database connection.
+ * Embeddings of width 1024 and 384 live in separate tables, created from install/sql/mariadb.sql.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAIVectorStoreMariaDb
+{
+    public const MARIADB_MIN_VERSION = '11.7.0';
+    public const DIMENSIONS = [1024, 384];
+
+    /**
+     * @return array{engine:string, version:string, supported:bool}
+     */
+    public static function describe(string $sVersion): array
+    {
+        $sVersion = trim($sVersion);
+        if ($sVersion === '')
+            return ['engine' => '', 'version' => '', 'supported' => false];
+
+        $bMaria = stripos($sVersion, 'mariadb') !== false;
+        $sEngine = $bMaria ? 'mariadb' : 'mysql';
+        $sNumber = self::versionNumber($sVersion, $bMaria);
+        $bSupported = $bMaria && $sNumber !== '' && self::atLeast($sNumber, self::MARIADB_MIN_VERSION);
+
+        return [
+            'engine' => $sEngine,
+            'version' => $sNumber,
+            'supported' => $bSupported,
+        ];
+    }
+
+    public static function compatibilityMessage(array $aServer): string
+    {
+        if (($aServer['engine'] ?? '') === 'mariadb')
+            $sEngine = 'MariaDB';
+        elseif (($aServer['engine'] ?? '') === 'mysql')
+            $sEngine = 'MySQL';
+        else
+            $sEngine = 'unknown';
+
+        $sVersion = (string)($aServer['version'] ?? '');
+        if ($sVersion === '')
+            $sVersion = 'unknown';
+
+        return 'MariaDB vector store needs MariaDB ' . self::MARIADB_MIN_VERSION . ' or newer (connected: ' . $sEngine . ' ' . $sVersion . ')';
+    }
+
+    public static function tableName(string $sTable): string
+    {
+        if (!preg_match('/^[A-Za-z0-9_]+$/', $sTable))
+            throw new Exception('MariaDB vector store table name is invalid');
+
+        return $sTable;
+    }
+
+    public static function tableForDimension(int $iDimension): string
+    {
+        if (!in_array($iDimension, self::DIMENSIONS, true))
+            throw new Exception('MariaDB vector store expects embedding width 1024 or 384, got ' . $iDimension);
+
+        return self::tableName('sys_agents_vector_mariadb_' . $iDimension);
+    }
+
+    public static function pdo(): PDO
+    {
+        BxDolDb::getInstance();
+        $oPdo = BxDolDb::getLink();
+        if (!$oPdo instanceof PDO)
+            throw new Exception('Database is not connected');
+
+        return $oPdo;
+    }
+
+    public static function open(int $iStoreId, int $iTopK): BxDolAIVectorStoreMariaDbStore
+    {
+        if ($iStoreId < 1)
+            throw new Exception('MariaDB vector store id is missing');
+        if ($iTopK < 1)
+            $iTopK = 4;
+
+        $oDb = BxDolDb::getInstance();
+        $sVersion = $oDb ? (string)$oDb->getOne('SELECT VERSION()') : '';
+        $aServer = self::describe($sVersion);
+        if (empty($aServer['supported'])) {
+            $sMessage = self::compatibilityMessage($aServer);
+            bx_log('sys_agents', $sMessage, BX_LOG_ERR);
+            throw new Exception($sMessage);
+        }
+
+        foreach (self::DIMENSIONS as $iDimension)
+            self::assertTable($iDimension);
+
+        return new BxDolAIVectorStoreMariaDbStore(self::pdo(), $iTopK, (string)$iStoreId);
+    }
+
+    protected static function versionNumber(string $sVersion, bool $bMaria): string
+    {
+        if ($bMaria && preg_match('/(\d+\.\d+(?:\.\d+)?)-MariaDB/i', $sVersion, $aMatch))
+            return $aMatch[1];
+        if (preg_match('/(\d+\.\d+(?:\.\d+)?)/', $sVersion, $aMatch))
+            return $aMatch[1];
+
+        return '';
+    }
+
+    protected static function atLeast(string $sVersion, string $sMinimum): bool
+    {
+        return version_compare(self::normalizeVersion($sVersion), self::normalizeVersion($sMinimum), '>=');
+    }
+
+    protected static function normalizeVersion(string $sVersion): string
+    {
+        $aParts = explode('.', $sVersion);
+        $aParts = array_slice(array_pad($aParts, 3, '0'), 0, 3);
+        return implode('.', $aParts);
+    }
+
+    protected static function assertTable(int $iDimension): void
+    {
+        $sTable = self::tableForDimension($iDimension);
+        $iExisting = self::embeddingDimensions($sTable);
+        if ($iExisting === null)
+            throw new Exception('MariaDB vector store table ' . $sTable . ' is missing');
+        if ($iExisting !== $iDimension)
+            throw new Exception('MariaDB vector store ' . $sTable . ' must use dimension ' . $iDimension);
+    }
+
+    protected static function embeddingDimensions(string $sTable): ?int
+    {
+        $oDb = BxDolDb::getInstance();
+        if (!$oDb)
+            return null;
+
+        $sType = $oDb->getOne(
+            'SELECT `COLUMN_TYPE` FROM `information_schema`.`COLUMNS` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = ? AND `COLUMN_NAME` = ?',
+            [$sTable, 'embedding']
+        );
+        if (!is_string($sType) || !preg_match('/vector\((\d+)\)/i', $sType, $aMatch))
+            return null;
+
+        return (int)$aMatch[1];
+    }
+}
+
+/** @} */

+ 97 - 0
inc/classes/BxDolAIVectorStoreMariaDbStore.php

@@ -0,0 +1,97 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * MariaDB vector search limited to one Studio store.
+ * Rows from every store share one table per embedding width and are marked with sourceType.
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAIVectorStoreMariaDbStore extends NeuronAI\RAG\VectorStore\MariaDBVectorStore
+{
+    public function __construct(
+        PDO $oPdo,
+        int $iTopK,
+        protected string $sStoreId,
+    ) {
+        parent::__construct($oPdo, BxDolAIVectorStoreMariaDb::tableForDimension(1024), $iTopK);
+    }
+
+    public function addDocuments(array $documents): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        $aGrouped = [];
+        foreach ($documents as $oDocument) {
+            if (!$oDocument instanceof NeuronAI\RAG\Document)
+                continue;
+
+            $sTable = BxDolAIVectorStoreMariaDb::tableForDimension(count($oDocument->getEmbedding()));
+            $aGrouped[$sTable][] = $oDocument;
+        }
+
+        foreach ($aGrouped as $sTable => $aDocuments) {
+            $this->tableName = $sTable;
+            parent::addDocuments($aDocuments);
+        }
+
+        return $this;
+    }
+
+    public function deleteBy(string $sourceType, ?string $sourceName = null): NeuronAI\RAG\VectorStore\VectorStoreInterface
+    {
+        foreach (BxDolAIVectorStoreMariaDb::DIMENSIONS as $iDimension) {
+            $this->tableName = BxDolAIVectorStoreMariaDb::tableForDimension($iDimension);
+            parent::deleteBy($sourceType, $sourceName);
+        }
+
+        return $this;
+    }
+
+    public function similaritySearch(array $embedding): iterable
+    {
+        $this->tableName = BxDolAIVectorStoreMariaDb::tableForDimension(count($embedding));
+
+        $oStatement = $this->pdo->prepare(sprintf(
+            <<<'SQL'
+                SELECT id, content, sourceType, sourceName, metadata,
+                       VEC_DISTANCE_COSINE(embedding, VEC_FromText(:embedding)) AS distance
+                FROM %s
+                WHERE sourceType = :storeId
+                ORDER BY distance ASC
+                LIMIT %d
+                SQL,
+            $this->tableName,
+            $this->topK,
+        ));
+
+        $oStatement->execute([
+            ':embedding' => json_encode($embedding),
+            ':storeId' => $this->sStoreId,
+        ]);
+
+        $aDocuments = [];
+        while ($aRow = $oStatement->fetch(PDO::FETCH_ASSOC)) {
+            $oDocument = new NeuronAI\RAG\Document($aRow['content']);
+            $oDocument->id = $aRow['id'];
+            $oDocument->sourceType = $aRow['sourceType'];
+            $oDocument->sourceName = $aRow['sourceName'];
+            $oDocument->score = NeuronAI\RAG\VectorSimilarity::similarityFromDistance((float)$aRow['distance']);
+
+            $aMetadata = json_decode($aRow['metadata'] ?? '{}', true);
+            if (is_array($aMetadata)) {
+                foreach ($aMetadata as $sKey => $mixedValue) {
+                    if (!in_array($sKey, ['content', 'sourceType', 'sourceName', 'score', 'embedding', 'id'], true))
+                        $oDocument->addMetadata($sKey, $mixedValue);
+                }
+            }
+
+            $aDocuments[] = $oDocument;
+        }
+
+        return $aDocuments;
+    }
+}
+
+/** @} */

+ 24 - 1
install/classes/BxDolInstallSiteConfig.php

@@ -12,6 +12,7 @@ define('BX_INSTALL_ERR_GENERAL', 'general');
 class BxDolInstallSiteConfig
 {
     protected $_sSqlDb;
+    protected $_sSqlMariaDb;
     protected $_sSqlAddon;
     protected $_sPatternHeader;
 
@@ -33,6 +34,7 @@ class BxDolInstallSiteConfig
         $this->_isAutologin = $isAutologin;
 
         $this->_sSqlDb = BX_INSTALL_DIR . 'sql/system.sql';
+        $this->_sSqlMariaDb = BX_INSTALL_DIR . 'sql/mariadb.sql';
         $this->_sSqlAddon = BX_INSTALL_DIR . 'sql/addon.sql';
         $this->_sPatternHeader = BX_INSTALL_DIR . 'patterns/header.inc.php';
 
@@ -349,6 +351,13 @@ EOF;
         if (true !== $mixedRes)
             return $this->dbErrors2ErrorFields($mixedRes);
 
+        // UUID and VECTOR. MariaDB 11.7 and newer only. MySQL and older MariaDB cannot create these tables.
+        if ($this->shouldRunMariaDbSql($oDb)) {
+            $mixedRes = $oDb->executeSQL($this->_sSqlMariaDb);
+            if (true !== $mixedRes)
+                return $this->dbErrors2ErrorFields($mixedRes);
+        }
+
         $mixedRes = $oDb->executeSQL($this->_sSqlAddon, $this->getMarkersForDb($a, $oDb));
         if (true !== $mixedRes)
             return $this->dbErrors2ErrorFields($mixedRes);
@@ -363,7 +372,7 @@ EOF;
 
             $aFiles = scandir($sSqlDir);
             foreach ($aFiles as $sFile) {
-                if ($sFile[0] === '.' || $sFile === 'system.sql' || $sFile === 'addon.sql') {
+                if ($sFile[0] === '.' || $sFile === 'system.sql' || $sFile === 'addon.sql' || $sFile === 'mariadb.sql') {
                     continue;
                 }
                 $sFilePath = $sSqlDir . $sFile;
@@ -379,6 +388,20 @@ EOF;
         return array();
     }
 
+    /**
+     * mariadb.sql uses UUID and VECTOR, available on MariaDB 11.7 and newer.
+     */
+    protected function shouldRunMariaDbSql($oDb)
+    {
+        if (!class_exists('BxDolAIVectorStoreMariaDb', false))
+            require_once(BX_INSTALL_DIR . '../inc/classes/BxDolAIVectorStoreMariaDb.php');
+
+        $sVersion = (string)$oDb->getOne('SELECT VERSION()');
+        $aServer = BxDolAIVectorStoreMariaDb::describe($sVersion);
+
+        return ($aServer['engine'] ?? '') === 'mariadb' && !empty($aServer['supported']);
+    }
+
     public function processConfigDataHeader ($a)
     {
         $aMarkers = $this->getMarkersForPhp($a);

+ 32 - 0
install/sql/mariadb.sql

@@ -0,0 +1,32 @@
+
+--
+-- MariaDB vector tables. The installer runs this file after system.sql and before addon.sql
+-- when the server is MariaDB 11.7 or newer. UUID and VECTOR are not available before that.
+-- One table per embedding width. A VECTOR column has a fixed width, so 1024 and 384 cannot share a table.
+--
+
+DROP TABLE IF EXISTS `sys_agents_vector_mariadb_1024`, `sys_agents_vector_mariadb_384`;
+
+CREATE TABLE IF NOT EXISTS `sys_agents_vector_mariadb_1024` (
+  `id` uuid NOT NULL,
+  `content` text,
+  `sourceType` varchar(255),
+  `sourceName` varchar(255),
+  `metadata` json,
+  `embedding` vector(1024) NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `sourceType` (`sourceType`),
+  VECTOR INDEX (`embedding`)
+) ENGINE=InnoDB;
+
+CREATE TABLE IF NOT EXISTS `sys_agents_vector_mariadb_384` (
+  `id` uuid NOT NULL,
+  `content` text,
+  `sourceType` varchar(255),
+  `sourceName` varchar(255),
+  `metadata` json,
+  `embedding` vector(384) NOT NULL,
+  PRIMARY KEY (`id`),
+  KEY `sourceType` (`sourceType`),
+  VECTOR INDEX (`embedding`)
+) ENGINE=InnoDB;

+ 5 - 0
install/sql/system.sql

@@ -7110,6 +7110,11 @@ SET @j = JSON_OBJECT(
 INSERT INTO sys_agents_vector_store (type, title, docs, topk, params, duplicate, changed, active)
 VALUES ('meilisearch', 'Meilisearch', 'https://www.meilisearch.com/', 5, CAST(@j AS CHAR), 0, 0, 0);
 
+SET @j = JSON_OBJECT();
+INSERT INTO sys_agents_vector_store (type, title, docs, topk, params, duplicate, changed, active)
+VALUES ('mariadb', 'MariaDB', 'Embeddings in the site database. Supported on MariaDB 11.7 and newer. Rows are stored in sys_agents_vector_mariadb_1024 or sys_agents_vector_mariadb_384, matching the embedding width.', 4, CAST(@j AS CHAR), 0, 0, 0);
+
+-- Vector tables are in mariadb.sql. The installer runs that file after this one, on MariaDB 11.7 and newer.
 
 CREATE TABLE IF NOT EXISTS `sys_agents_tools` (
   `id` int(11) NOT NULL AUTO_INCREMENT,

+ 268 - 0
tests/units/inc/classes/BxDolAIVectorStoreMariaDbTest.php

@@ -0,0 +1,268 @@
+<?php
+
+use PHPUnit\Framework\Attributes\DataProvider;
+
+class BxDolAIVectorStoreMariaDbTest extends BxDolTestCase
+{
+    #[DataProvider('providerForDescribe')]
+    public function testDescribe(string $sVersion, string $sEngine, string $sNumber, bool $bSupported)
+    {
+        $a = BxDolAIVectorStoreMariaDb::describe($sVersion);
+
+        $this->assertSame($sEngine, $a['engine']);
+        $this->assertSame($sNumber, $a['version']);
+        $this->assertSame($bSupported, $a['supported']);
+    }
+
+    public static function providerForDescribe(): array
+    {
+        return [
+            ['11.8.2-MariaDB-ubu2404', 'mariadb', '11.8.2', true],
+            ['12.1.2-MariaDB-ubu2404', 'mariadb', '12.1.2', true],
+            ['11.7.0-MariaDB', 'mariadb', '11.7.0', true],
+            ['11.7-MariaDB', 'mariadb', '11.7', true],
+            ['11.6.2-MariaDB', 'mariadb', '11.6.2', false],
+            ['10.11.6-MariaDB-1:10.11.6+maria~ubu2204', 'mariadb', '10.11.6', false],
+            ['5.5.5-10.11.6-MariaDB', 'mariadb', '10.11.6', false],
+            ['5.5.5-11.7.2-MariaDB', 'mariadb', '11.7.2', true],
+            ['8.0.36', 'mysql', '8.0.36', false],
+            ['8.0.36-0ubuntu0.22.04.1', 'mysql', '8.0.36', false],
+            ['8.4.3', 'mysql', '8.4.3', false],
+            ['9.0.0', 'mysql', '9.0.0', false],
+            ['9.2.0-commercial', 'mysql', '9.2.0', false],
+            ['', '', '', false],
+        ];
+    }
+
+    public function testCompatibilityMessageNamesEngineAndVersion()
+    {
+        $sMaria = BxDolAIVectorStoreMariaDb::compatibilityMessage(
+            BxDolAIVectorStoreMariaDb::describe('10.11.6-MariaDB')
+        );
+        $sMysql = BxDolAIVectorStoreMariaDb::compatibilityMessage(
+            BxDolAIVectorStoreMariaDb::describe('9.0.0')
+        );
+
+        $this->assertStringContainsString('MariaDB 11.7.0', $sMaria);
+        $this->assertStringContainsString('MariaDB 10.11.6', $sMaria);
+        $this->assertStringContainsString('MySQL 9.0.0', $sMysql);
+    }
+
+    public function testTableName()
+    {
+        $this->assertSame('sys_agents_vector_mariadb_1024', BxDolAIVectorStoreMariaDb::tableForDimension(1024));
+        $this->assertSame('sys_agents_vector_mariadb_384', BxDolAIVectorStoreMariaDb::tableForDimension(384));
+
+        try {
+            BxDolAIVectorStoreMariaDb::tableForDimension(1536);
+            $this->fail('An embedding width must be 1024 or 384');
+        }
+        catch (Exception $oException) {
+            $this->assertSame('MariaDB vector store expects embedding width 1024 or 384, got 1536', $oException->getMessage());
+        }
+
+        try {
+            BxDolAIVectorStoreMariaDb::tableName('sys_agents_vs_1; DROP TABLE sys_accounts');
+            $this->fail('A table name must be an identifier');
+        }
+        catch (Exception $oException) {
+            $this->assertSame('MariaDB vector store table name is invalid', $oException->getMessage());
+        }
+    }
+
+    public function testPdoIsBxDolDbLink()
+    {
+        BxDolDb::getInstance();
+        $this->assertSame(BxDolDb::getLink(), BxDolAIVectorStoreMariaDb::pdo());
+    }
+
+    public function testWrongEmbeddingWidthIsRejected()
+    {
+        $this->skipUnlessMariaDb();
+
+        $oStore = BxDolAIVectorStoreMariaDb::open(903, 4);
+        $oDocument = new NeuronAI\RAG\Document('width-mismatch');
+        $oDocument->embedding = [0.1, 0.2, 0.3];
+        $oDocument->sourceType = '903';
+        $oDocument->sourceName = 'width-mismatch';
+
+        try {
+            $oStore->addDocument($oDocument);
+            $this->fail('A short embedding must not be stored');
+        }
+        catch (Exception $oException) {
+            $this->assertSame(
+                'MariaDB vector store expects embedding width 1024 or 384, got 3',
+                $oException->getMessage()
+            );
+        }
+
+        foreach (BxDolAIVectorStoreMariaDb::DIMENSIONS as $iDimension) {
+            $sTable = BxDolAIVectorStoreMariaDb::tableForDimension($iDimension);
+            $iLeft = (int)BxDolDb::getInstance()->getOne(
+                "SELECT COUNT(*) FROM `{$sTable}` WHERE `sourceName` = 'width-mismatch'"
+            );
+            $this->assertSame(0, $iLeft, $sTable);
+        }
+    }
+
+    public static function providerForDimension(): array
+    {
+        return [
+            '1024' => [1024],
+            '384' => [384],
+        ];
+    }
+
+    #[DataProvider('providerForDimension')]
+    public function testClosestDocumentRanksFirst(int $iDimension)
+    {
+        $this->skipUnlessMariaDb();
+
+        $aNear = array_fill(0, $iDimension, 0.0);
+        $aNear[0] = 1.0;
+        $aFar = array_fill(0, $iDimension, 0.0);
+        $aFar[1] = 1.0;
+
+        $oStore = BxDolAIVectorStoreMariaDb::open(904, 4);
+        $oFar = new NeuronAI\RAG\Document('far-chunk');
+        $oFar->embedding = $aFar;
+        $oFar->sourceType = '904';
+        $oFar->sourceName = 'rank';
+        $oNear = new NeuronAI\RAG\Document('near-chunk');
+        $oNear->embedding = $aNear;
+        $oNear->sourceType = '904';
+        $oNear->sourceName = 'rank';
+
+        try {
+            $oStore->addDocument($oFar);
+            $oStore->addDocument($oNear);
+
+            $aFound = iterator_to_array($oStore->similaritySearch($aNear));
+
+            $this->assertSame('near-chunk', $aFound[0]->getContent());
+            $this->assertGreaterThan(0.9, $aFound[0]->getScore());
+            $this->assertGreaterThanOrEqual(0.0, $aFound[1]->getScore());
+            $this->assertGreaterThan($aFound[1]->getScore(), $aFound[0]->getScore());
+        }
+        finally {
+            $oStore->deleteBy('904', 'rank');
+        }
+    }
+
+    public function testSearchStaysOnTheMatchingDimensionTable()
+    {
+        $this->skipUnlessMariaDb();
+
+        $aWide = array_fill(0, 1024, 0.0);
+        $aWide[0] = 1.0;
+        $aNarrow = array_fill(0, 384, 0.0);
+        $aNarrow[0] = 1.0;
+
+        $oStore = BxDolAIVectorStoreMariaDb::open(905, 4);
+        $oWide = new NeuronAI\RAG\Document('wide-chunk');
+        $oWide->embedding = $aWide;
+        $oWide->sourceType = '905';
+        $oWide->sourceName = 'cross-dim';
+        $oNarrow = new NeuronAI\RAG\Document('narrow-chunk');
+        $oNarrow->embedding = $aNarrow;
+        $oNarrow->sourceType = '905';
+        $oNarrow->sourceName = 'cross-dim';
+
+        try {
+            $oStore->addDocument($oWide);
+            $oStore->addDocument($oNarrow);
+
+            $aFromWide = iterator_to_array($oStore->similaritySearch($aWide));
+            $aFromNarrow = iterator_to_array($oStore->similaritySearch($aNarrow));
+
+            $this->assertCount(1, $aFromWide);
+            $this->assertSame('wide-chunk', $aFromWide[0]->getContent());
+            $this->assertCount(1, $aFromNarrow);
+            $this->assertSame('narrow-chunk', $aFromNarrow[0]->getContent());
+            $this->assertSame(1, $this->countBySourceName(1024, 'cross-dim'));
+            $this->assertSame(1, $this->countBySourceName(384, 'cross-dim'));
+        }
+        finally {
+            $oStore->deleteBy('905', 'cross-dim');
+        }
+
+        $this->assertSame(0, $this->countBySourceName(1024, 'cross-dim'));
+        $this->assertSame(0, $this->countBySourceName(384, 'cross-dim'));
+    }
+
+    #[DataProvider('providerForDimension')]
+    public function testSearchIsLimitedToStore(int $iDimension)
+    {
+        $this->skipUnlessMariaDb();
+
+        $aNearA = array_fill(0, $iDimension, 0.0);
+        $aNearA[0] = 1.0;
+        $aNearB = array_fill(0, $iDimension, 0.0);
+        $aNearB[1] = 1.0;
+
+        $oStoreA = BxDolAIVectorStoreMariaDb::open(901, 4);
+        $oStoreB = BxDolAIVectorStoreMariaDb::open(902, 4);
+        $oDocA = new NeuronAI\RAG\Document('store-a-only');
+        $oDocA->embedding = $aNearA;
+        $oDocA->sourceType = '901';
+        $oDocA->sourceName = 'search-filter';
+        $oDocB = new NeuronAI\RAG\Document('store-b-only');
+        $oDocB->embedding = $aNearB;
+        $oDocB->sourceType = '902';
+        $oDocB->sourceName = 'search-filter';
+
+        try {
+            $oStoreA->addDocument($oDocA);
+            $oStoreB->addDocument($oDocB);
+
+            $aFromA = iterator_to_array($oStoreA->similaritySearch($aNearB));
+            $aFromB = iterator_to_array($oStoreB->similaritySearch($aNearB));
+
+            $this->assertSame('store-a-only', $aFromA[0]->getContent());
+            $this->assertSame('901', (string)$aFromA[0]->getSourceType());
+            $this->assertSame('store-b-only', $aFromB[0]->getContent());
+            $this->assertSame('902', (string)$aFromB[0]->getSourceType());
+        }
+        finally {
+            $oStoreA->deleteBy('901', 'search-filter');
+            $oStoreB->deleteBy('902', 'search-filter');
+        }
+    }
+
+    public function testInstalledServerVersionIsRecognized()
+    {
+        $sVersion = (string)BxDolDb::getInstance()->getOne('SELECT VERSION()');
+        $a = BxDolAIVectorStoreMariaDb::describe($sVersion);
+
+        $this->assertContains($a['engine'], ['mariadb', 'mysql', '']);
+        if ($a['version'] !== '')
+            $this->assertMatchesRegularExpression('/^\d+\.\d+/', $a['version']);
+        $bSupported = $a['engine'] === 'mariadb' && $a['version'] !== ''
+            && version_compare(self::normalize($a['version']), BxDolAIVectorStoreMariaDb::MARIADB_MIN_VERSION, '>=');
+        $this->assertSame($bSupported, $a['supported']);
+    }
+
+    protected function skipUnlessMariaDb(): void
+    {
+        $sVersion = (string)BxDolDb::getInstance()->getOne('SELECT VERSION()');
+        if (!BxDolAIVectorStoreMariaDb::describe($sVersion)['supported'])
+            $this->markTestSkipped('MariaDB vector store is not supported on this server');
+    }
+
+    protected function countBySourceName(int $iDimension, string $sSourceName): int
+    {
+        $sTable = BxDolAIVectorStoreMariaDb::tableForDimension($iDimension);
+        return (int)BxDolDb::getInstance()->getOne(
+            "SELECT COUNT(*) FROM `{$sTable}` WHERE `sourceName` = ?",
+            [$sSourceName]
+        );
+    }
+
+    protected static function normalize(string $sVersion): string
+    {
+        $aParts = explode('.', $sVersion);
+        $aParts = array_slice(array_pad($aParts, 3, '0'), 0, 3);
+        return implode('.', $aParts);
+    }
+}

+ 55 - 0
tests/units/install/BxDolInstallMariaDbSqlTest.php

@@ -0,0 +1,55 @@
+<?php
+
+class BxDolInstallMariaDbSqlTest extends \PHPUnit\Framework\TestCase
+{
+    public function testVectorTablesLiveInMariaDbSql(): void
+    {
+        $sSystem = file_get_contents(__DIR__ . '/../../../install/sql/system.sql');
+        $sMaria = file_get_contents(__DIR__ . '/../../../install/sql/mariadb.sql');
+
+        $this->assertIsString($sSystem);
+        $this->assertIsString($sMaria);
+        $this->assertDoesNotMatchRegularExpression('/CREATE TABLE[^;]*sys_agents_vector_mariadb_(1024|384)/is', $sSystem);
+        $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS `sys_agents_vector_mariadb_1024`', $sMaria);
+        $this->assertStringContainsString('CREATE TABLE IF NOT EXISTS `sys_agents_vector_mariadb_384`', $sMaria);
+    }
+
+    public function testMariaDbSqlRunsOnlyOnSupportedMariaDb(): void
+    {
+        $oConfig = $this->installer();
+        $oMethod = new ReflectionMethod(BxDolInstallSiteConfig::class, 'shouldRunMariaDbSql');
+
+        $this->assertTrue($oMethod->invoke($oConfig, $this->version('11.8.2-MariaDB-ubu2404')));
+        $this->assertTrue($oMethod->invoke($oConfig, $this->version('5.5.5-11.7.2-MariaDB')));
+        $this->assertFalse($oMethod->invoke($oConfig, $this->version('10.11.6-MariaDB-1:10.11.6+maria~ubu2204')));
+        $this->assertFalse($oMethod->invoke($oConfig, $this->version('11.6.2-MariaDB')));
+        $this->assertFalse($oMethod->invoke($oConfig, $this->version('8.0.36')));
+        $this->assertFalse($oMethod->invoke($oConfig, $this->version('9.0.0')));
+    }
+
+    private function installer(): BxDolInstallSiteConfig
+    {
+        if (!defined('BX_DOL_INSTALL'))
+            define('BX_DOL_INSTALL', true);
+        if (!defined('BX_INSTALL_DIR'))
+            define('BX_INSTALL_DIR', dirname(__DIR__, 3) . '/install/');
+
+        require_once dirname(__DIR__, 3) . '/install/classes/BxDolInstallSiteConfig.php';
+
+        return (new ReflectionClass(BxDolInstallSiteConfig::class))->newInstanceWithoutConstructor();
+    }
+
+    private function version(string $sVersion): object
+    {
+        return new class($sVersion) {
+            public function __construct(private string $sVersion)
+            {
+            }
+
+            public function getOne($sQuery)
+            {
+                return $this->sVersion;
+            }
+        };
+    }
+}