Решает задачу хранения эмбеддингов (векторов) прямо в 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).
Комментарии
0Нашли ошибку или хотите дополнить материал?