PHP: Эмбеддинги в MySQL без векторной БД (простая реализация)

Решает задачу хранения эмбеддингов (векторов) прямо в MySQL и поиска похожих текстов по cosine similarity — без отдельной векторной БД. Это удобно, когда нужно быстро запуститься и не тащить лишнюю инфраструктуру.

Если данных очень много (сотни тысяч/миллионы чанков) и нужен быстрый “почти мгновенный” поиск ближайших — лучше взять векторное хранилище или Postgres с pgvector. Ещё этот подход не годится, если ты собираешься считать сходство по всем строкам без предварительного отбора — будет дорого по CPU.

Текст режем на небольшие чанки, для каждого получаем эмбеддинг и сохраняем в MySQL как BLOB (float32). При поиске делаем два шага: сначала быстро отбираем кандидатов (например, через FULLTEXT или фильтры), а потом уже точно считаем cosine similarity в PHP и сортируем результаты.

1. SQL-схема (MySQL)

-- Документы (например страницы, статьи, товары) 
CREATE TABLE IF NOT EXISTS AiDoc(
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    SOURCE VARCHAR(64) NOT NULL,
    SourceId VARCHAR(128) NOT NULL,
    Title VARCHAR(255) NOT NULL,
    Url VARCHAR(512) NULL,
    CreatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY(id),
    UNIQUE KEY uq_source(SOURCE, SourceId)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

-- Чанки текста (что будем искать)
CREATE TABLE IF NOT EXISTS AiChunk(
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    DocId BIGINT UNSIGNED NOT NULL,
    ChunkNo INT UNSIGNED NOT NULL,
    Content MEDIUMTEXT NOT NULL,
    ContentHash BINARY(32) NOT NULL, -- SHA-256
    CreatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY(id),
    UNIQUE KEY uq_chunk(DocId, ChunkNo),
    KEY idx_doc(DocId),
    FULLTEXT KEY ft_content(Content),
    CONSTRAINT fk_chunk_doc FOREIGN KEY(DocId) REFERENCES AiDoc(id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

-- Эмбеддинги (вектор хранится в BLOB: float32 packed)
CREATE TABLE IF NOT EXISTS AiEmbedding(
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    ChunkId BIGINT UNSIGNED NOT NULL,
    Model VARCHAR(64) NOT NULL,
    Dims SMALLINT UNSIGNED NOT NULL,
    VecBlob LONGBLOB NOT NULL,
    VecNorm DOUBLE NOT NULL,
    CreatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY(id),
    UNIQUE KEY uq_chunk_model(ChunkId, Model),
    KEY idx_model(Model),
    KEY idx_norm(VecNorm),
    CONSTRAINT fk_emb_chunk FOREIGN KEY(ChunkId) REFERENCES AiChunk(id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

2. PHP: упаковка float32 в BLOB + cosine similarity

final class AiVector
{
	public static function PackFloat32(array $Vector): string
	{
		if (!$Vector) 
		{
			throw new InvalidArgumentException('Vector is empty');
		}
		foreach ($Vector as $v) 
		{
			if (!is_numeric($v)) 
			{
				throw new InvalidArgumentException('Vector contains non-numeric value');
			}
		}

		// 'g' = float 32-bit little-endian (portable between machines)
		$Bin = '';
		foreach ($Vector as $v) 
		{
			$Bin .= pack('g', (float)$v);
		}

		return $Bin;
	}

	public static function UnpackFloat32(string $Blob): array
	{
		$Len = strlen($Blob);
		if ($Len % 4 !== 0) 
		{
			throw new RuntimeException('Invalid float32 blob length');
		}

		$Count = (int)($Len / 4);
		$Out = [];

		for ($i = 0; $i < $Count; $i++) 
		{
			$Out[] = unpack('g', substr($Blob, $i * 4, 4))[1];
		}

		return $Out;
	}

	public static function Norm(array $Vector): float
	{
		$Sum = 0.0;
		foreach ($Vector as $v) 
		{
			$Sum += ((float)$v) * ((float)$v);
		}
		return sqrt($Sum);
	}

	public static function CosineSimilarity(array $A, array $B, float $NormA = 0.0, float $NormB = 0.0): float
	{
		$CountA = count($A);
		if ($CountA !== count($B)) 
		{
			throw new InvalidArgumentException('Vector dims mismatch');
		}

		if ($NormA <= 0.0) {
			$NormA = self::Norm($A);
		}
		if ($NormB <= 0.0) {
			$NormB = self::Norm($B);
		}
		if ($NormA <= 0.0 || $NormB <= 0.0) {
			return 0.0;
		}

		$Dot = 0.0;
		for ($i = 0; $i < $CountA; $i++) {
			$Dot += ((float)$A[$i]) * ((float)$B[$i]);
		}

		return $Dot / ($NormA * $NormB);
	}
}

3. PHP: хранилище + поиск (кандидаты через FULLTEXT, точность через cosine в PHP)


final class AiEmbeddingStore
{
	private PDO $Pdo;
	public function __construct(PDO $Pdo)
	{
		$Pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
		$Pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
		$this->Pdo = $Pdo;
	}

	public function SaveDocument(string $Source, string $SourceId, string $Title, ?string $Url = null): int
	{
		$Source = trim($Source);
		$SourceId = trim($SourceId);
		$Title = trim($Title);

		if ($Source === '' || $SourceId === '' || $Title === '') {
			throw new InvalidArgumentException('Source/SourceId/Title required');
		}

		$Sql = "
			INSERT INTO AiDoc (Source, SourceId, Title, Url)
			VALUES (:Source, :SourceId, :Title, :Url)
			ON DUPLICATE KEY UPDATE
				Title = VALUES(Title),
				Url = VALUES(Url)
		";
		$Stmt = $this->Pdo->prepare($Sql);
		$Stmt->execute([
			':Source' => $Source,
			':SourceId' => $SourceId,
			':Title' => $Title,
			':Url' => $Url,
		]);

		// Если был update, lastInsertId может быть 0 на некоторых конфигурациях.
		$Id = (int)$this->Pdo->lastInsertId();
		if ($Id > 0) {
			return $Id;
		}

		$Stmt = $this->Pdo->prepare("SELECT id FROM AiDoc WHERE Source=:Source AND SourceId=:SourceId LIMIT 1");
		$Stmt->execute([':Source' => $Source, ':SourceId' => $SourceId]);
		$Row = $Stmt->fetch();
		return (int)$Row['id'];
	}

	public function SaveChunk(int $DocId, int $ChunkNo, string $Content): int
	{
		$Content = trim($Content);
		if ($DocId <= 0 || $ChunkNo < 0 || $Content === '') {
			throw new InvalidArgumentException('Invalid chunk data');
		}

		$Hash = hash('sha256', $Content, true); // binary 32 bytes

		$Sql = "
			INSERT INTO AiChunk (DocId, ChunkNo, Content, ContentHash)
			VALUES (:DocId, :ChunkNo, :Content, :ContentHash)
			ON DUPLICATE KEY UPDATE
				Content = VALUES(Content),
				ContentHash = VALUES(ContentHash)
		";
		$Stmt = $this->Pdo->prepare($Sql);
		$Stmt->bindValue(':DocId', $DocId, PDO::PARAM_INT);
		$Stmt->bindValue(':ChunkNo', $ChunkNo, PDO::PARAM_INT);
		$Stmt->bindValue(':Content', $Content, PDO::PARAM_STR);
		$Stmt->bindValue(':ContentHash', $Hash, PDO::PARAM_STR);
		$Stmt->execute();

		$Id = (int)$this->Pdo->lastInsertId();
		if ($Id > 0) {
			return $Id;
		}

		$Stmt = $this->Pdo->prepare("SELECT id FROM AiChunk WHERE DocId=:DocId AND ChunkNo=:ChunkNo LIMIT 1");
		$Stmt->execute([':DocId' => $DocId, ':ChunkNo' => $ChunkNo]);
		$Row = $Stmt->fetch();
		return (int)$Row['id'];
	}

	public function SaveEmbedding(int $ChunkId, string $Model, array $Vector): void
	{
		$Model = trim($Model);
		if ($ChunkId <= 0 || $Model === '') {
			throw new InvalidArgumentException('ChunkId/Model required');
		}

		$Dims = count($Vector);
		if ($Dims < 16 || $Dims > 8192) {
			throw new InvalidArgumentException('Suspicious vector dims');
		}

		$Norm = AiVector::Norm($Vector);
		if ($Norm <= 0.0) {
			throw new InvalidArgumentException('Vector norm is zero');
		}

		$Blob = AiVector::PackFloat32($Vector);

		$Sql = "
			INSERT INTO AiEmbedding (ChunkId, Model, Dims, VecBlob, VecNorm)
			VALUES (:ChunkId, :Model, :Dims, :VecBlob, :VecNorm)
			ON DUPLICATE KEY UPDATE
				Dims = VALUES(Dims),
				VecBlob = VALUES(VecBlob),
				VecNorm = VALUES(VecNorm)
		";
		$Stmt = $this->Pdo->prepare($Sql);
		$Stmt->bindValue(':ChunkId', $ChunkId, PDO::PARAM_INT);
		$Stmt->bindValue(':Model', $Model, PDO::PARAM_STR);
		$Stmt->bindValue(':Dims', $Dims, PDO::PARAM_INT);
		$Stmt->bindValue(':VecBlob', $Blob, PDO::PARAM_LOB);
		$Stmt->bindValue(':VecNorm', $Norm);
		$Stmt->execute();
	}

	public function FindSimilarByFulltextCandidates(
		string $QueryText,
		array $QueryVector,
		string $Model,
		int $Limit = 5,
		int $CandidateLimit = 80
	): array
	{
		$QueryText = trim($QueryText);
		$Model = trim($Model);

		if ($QueryText === '' || $Model === '') {
			throw new InvalidArgumentException('QueryText/Model required');
		}
		if ($Limit < 1) {
			$Limit = 1;
		}
		if ($CandidateLimit < $Limit) {
			$CandidateLimit = $Limit;
		}
		if ($CandidateLimit > 500) {
			$CandidateLimit = 500; // защита от “вытащить всё”
		}

		$QueryNorm = AiVector::Norm($QueryVector);
		if ($QueryNorm <= 0.0) {
			throw new InvalidArgumentException('Query vector norm is zero');
		}

		// 1) Быстрый отбор кандидатов по FULLTEXT
		$Sql = "
			SELECT
				c.id AS ChunkId,
				d.Title,
				d.Url,
				c.Content,
				e.VecBlob,
				e.VecNorm,
				MATCH(c.Content) AGAINST (:Q IN NATURAL LANGUAGE MODE) AS FtScore
			FROM AiChunk c
			INNER JOIN AiDoc d ON d.id = c.DocId
			INNER JOIN AiEmbedding e ON e.ChunkId = c.id AND e.Model = :Model
			WHERE MATCH(c.Content) AGAINST (:Q IN NATURAL LANGUAGE MODE)
			ORDER BY FtScore DESC
			LIMIT {$CandidateLimit}
		";
		$Stmt = $this->Pdo->prepare($Sql);
		$Stmt->execute([':Q' => $QueryText, ':Model' => $Model]);
		$Rows = $Stmt->fetchAll();

		// 2) Точный cosine similarity в PHP
		$Scored = [];
		foreach ($Rows as $Row) {
			$Vec = AiVector::UnpackFloat32($Row['VecBlob']);
			$Sim = AiVector::CosineSimilarity($QueryVector, $Vec, $QueryNorm, (float)$Row['VecNorm']);

			$Scored[] = [
				'ChunkId' => (int)$Row['ChunkId'],
				'Title' => (string)$Row['Title'],
				'Url' => $Row['Url'] !== null ? (string)$Row['Url'] : null,
				'Similarity' => $Sim,
				'FtScore' => (float)$Row['FtScore'],
				'Content' => (string)$Row['Content'],
			];
		}

		usort($Scored, static function(array $A, array $B): int {
			return $B['Similarity'] <=> $A['Similarity'];
		});

		return array_slice($Scored, 0, $Limit);
	}
}

Пример использования

  • Сначала индексируем документ: режем на чанки → получаем эмбеддинги → сохраняем.
  • Потом ищем: эмбеддинг запроса + текст запроса (для FULLTEXT кандидатов) → получаем топ похожих.
require_once __DIR__ . '/AiVector.php'; 
require_once __DIR__ . '/AiEmbeddingStore.php'; 

$Pdo = new PDO('mysql:host=127.0.0.1;dbname=yourdb;charset=utf8mb4', 'user', 'pass', [ PDO::ATTR_EMULATE_PREPARES => false, ]); 
$Store = new AiEmbeddingStore($Pdo); 

// Заглушка: здесь ты подставишь реальный вызов провайдера эмбеддингов 
function GetEmbeddings(string $Text): array 
{ 
	// Верни массив float одного размера (например, 768/1024/1536 — зависит от модели) 
	// В этом сниппете намеренно без привязки к конкретному API. 
	throw new RuntimeException('Implement GetEmbeddings() with your provider'); 
} 

function ChunkText(string $Text, int $ChunkSize = 1000, int $Overlap = 200): array { 
	$Text = trim(preg_replace('~\s+~u', ' ', $Text)); 
	if ($Text === '') { return []; } 
	$Chunks = []; 
	$Len = mb_strlen($Text, 'UTF-8'); 
	$Pos = 0; 
	$No = 0; 
	
	while ($Pos < $Len) 
	{ 
		$Part = mb_substr($Text, $Pos, $ChunkSize, 'UTF-8'); 
		$Part = trim($Part); 
		
		if ($Part !== '') { 
			$Chunks[] = [
				'ChunkNo' => $No, 
				'Content' => $Part
			]; 
			$No++; 
		} 

		$Pos += max(1, $ChunkSize - $Overlap); 
	} 
	return $Chunks; 
} 

// 1. Индексация 
$DocId = $Store->SaveDocument('site', 'page-123', 'Пример страницы', 'https://example.com/page-123'); 
$Text = '...сюда подставь текст страницы...'; 
$Chunks = ChunkText($Text); 
$Model = 'embeddings-v1'; 

foreach ($Chunks as $C) 
{ 
	$ChunkId = $Store->SaveChunk($DocId, (int)$C['ChunkNo'], (string)$C['Content']); 
	$Vec = GetEmbeddings((string)$C['Content']); 
	$Store->SaveEmbedding($ChunkId, $Model, $Vec); 
} 

// 2. Поиск 
$QueryText = 'как настроить nginx и php-fpm'; 
$QueryVector = GetEmbeddings($QueryText); 
$Top = $Store->FindSimilarByFulltextCandidates($QueryText, $QueryVector, $Model, 5, 80); 

foreach ($Top as $Row) 
{ 
	echo "sim={$Row['Similarity']} | {$Row['Title']} | {$Row['Url']}\n"; 
} 

Альтернативы

  • PostgreSQL + pgvector: минимально больно и уже векторно-правильно.
  • Vector DB (Milvus/Qdrant/Pinecone): когда объём большой и нужен быстрый ANN.
  • Elasticsearch/OpenSearch: иногда хватает BM25 + гибридный поиск без эмбеддингов (или hybrid).
Просмотров: 255
Материал помог? Оценка помогает находить устаревшие и спорные решения.

Комментарии

0

Нашли ошибку или хотите дополнить материал?

Комментариев пока нет.Можно первым дополнить материал или сообщить об ошибке.
Telegram