feat(search): 语义搜索迁移至 Manticore Auto Embeddings,向量走 ai 插件免费模型

- embedding 统一改走 ai 插件 /embeddings(免费向量模型,1024 维),删除第三方 key 分支
- 5 张向量表启用引擎 Auto Embeddings(MODEL_NAME/FROM/API_URL),写入/更新时引擎自动生成向量
- 表定义使用派生密钥 sha256(APP_KEY:embeddings),避免主密钥进入引擎元数据
- 写入改为 REPLACE(单行原子)与多行批量 REPLACE(整语句一次向量化调用,失败回退逐行)
- 结构指纹 vector:schema 标记:版本/模型/端点/APP_KEY 变化时自动清空重建并重置同步指针
- 退役 PHP 向量管道:删除 generate-vectors 命令、双指针、withVector 分支与批量向量生成方法
- 根治缺陷:全文重同步不再抹掉向量;内容编辑后向量由引擎自动重算,不再过期
This commit is contained in:
kuaifan 2026-07-15 10:45:20 +00:00
parent e4fbf9693a
commit 746337c2f6
12 changed files with 579 additions and 1379 deletions

View File

@ -1,205 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Traits\ManticoreSyncLock;
use App\Models\File;
use App\Models\Project;
use App\Models\ProjectTask;
use App\Models\User;
use App\Models\WebSocketDialogMsg;
use App\Module\Apps;
use App\Module\Manticore\ManticoreFile;
use App\Module\Manticore\ManticoreKeyValue;
use App\Module\Manticore\ManticoreMsg;
use App\Module\Manticore\ManticoreProject;
use App\Module\Manticore\ManticoreTask;
use App\Module\Manticore\ManticoreUser;
use Illuminate\Console\Command;
/**
* 异步向量生成命令
*
* 用于后台批量生成已索引数据的向量,与全文索引解耦
* 使用双指针追踪sync:xxxLastId全文已同步 vector:xxxLastId向量已生成
*
* 运行模式:
* - 持续处理直到所有待处理数据完成
* - 每批处理完成后休眠几秒,避免 API 过载
* - 定时器只作为兜底触发机制
*/
class GenerateManticoreVectors extends Command
{
use ManticoreSyncLock;
protected $signature = 'manticore:generate-vectors
{--type=all : 类型 (msg/file/task/project/user/all)}
{--batch=50 : 每批 embedding 数量}
{--sleep=3 : 每批处理后休眠秒数}
{--reset : 重置向量进度指针}';
protected $description = '批量生成 Manticore 已索引数据的向量';
/**
* 类型配置
*/
private const TYPE_CONFIG = [
'msg' => [
'syncKey' => 'sync:manticoreMsgLastId',
'vectorKey' => 'vector:manticoreMsgLastId',
'class' => ManticoreMsg::class,
'model' => WebSocketDialogMsg::class,
'idField' => 'id',
],
'file' => [
'syncKey' => 'sync:manticoreFileLastId',
'vectorKey' => 'vector:manticoreFileLastId',
'class' => ManticoreFile::class,
'model' => File::class,
'idField' => 'id',
],
'task' => [
'syncKey' => 'sync:manticoreTaskLastId',
'vectorKey' => 'vector:manticoreTaskLastId',
'class' => ManticoreTask::class,
'model' => ProjectTask::class,
'idField' => 'id',
],
'project' => [
'syncKey' => 'sync:manticoreProjectLastId',
'vectorKey' => 'vector:manticoreProjectLastId',
'class' => ManticoreProject::class,
'model' => Project::class,
'idField' => 'id',
],
'user' => [
'syncKey' => 'sync:manticoreUserLastId',
'vectorKey' => 'vector:manticoreUserLastId',
'class' => ManticoreUser::class,
'model' => User::class,
'idField' => 'userid',
],
];
public function handle(): int
{
if (!Apps::isInstalled("search")) {
$this->error("应用「Manticore Search」未安装");
return 1;
}
if (!Apps::isInstalled("ai")) {
$this->error("应用「AI」未安装无法生成向量");
return 1;
}
$this->registerSignalHandlers();
if (!$this->acquireLock()) {
return 1;
}
$type = $this->option('type');
$batchSize = intval($this->option('batch'));
$sleepSeconds = intval($this->option('sleep'));
$reset = $this->option('reset');
if ($type === 'all') {
$types = array_keys(self::TYPE_CONFIG);
} else {
if (!isset(self::TYPE_CONFIG[$type])) {
$this->error("未知类型: {$type}。可用类型: msg, file, task, project, user, all");
$this->releaseLock();
return 1;
}
$types = [$type];
}
// 持续处理直到所有类型都没有待处理数据
$round = 0;
do {
$round++;
$totalPending = 0;
foreach ($types as $t) {
if ($this->shouldStop) {
break;
}
$pending = $this->processType($t, $batchSize, $reset && $round === 1);
$totalPending += $pending;
}
// 如果还有待处理数据,休眠后继续
if ($totalPending > 0 && !$this->shouldStop) {
$this->info("\n--- 第 {$round} 轮完成,剩余 {$totalPending} 条待处理,{$sleepSeconds} 秒后继续 ---\n");
sleep($sleepSeconds);
$this->setLock(); // 刷新锁
}
} while ($totalPending > 0 && !$this->shouldStop);
$this->info("\n向量生成完成(共 {$round} 轮)");
$this->releaseLock();
return 0;
}
/**
* 处理单个类型的向量生成(每次处理一批)
*
* @param string $type 类型
* @param int $batchSize 每批数量
* @param bool $reset 是否重置进度
* @return int 剩余待处理数量
*/
private function processType(string $type, int $batchSize, bool $reset): int
{
$config = self::TYPE_CONFIG[$type];
// 获取进度指针
$syncLastId = intval(ManticoreKeyValue::get($config['syncKey'], 0));
$vectorLastId = $reset ? 0 : intval(ManticoreKeyValue::get($config['vectorKey'], 0));
if ($reset) {
ManticoreKeyValue::set($config['vectorKey'], 0);
$this->info("[{$type}] 已重置向量进度指针");
}
// 计算待处理范围
$pendingCount = $syncLastId - $vectorLastId;
if ($pendingCount <= 0) {
return 0;
}
// 获取待处理的 ID 列表(每次处理 batchSize * 5 条,让 generateVectorsBatch 内部再分批调用 API
$modelClass = $config['model'];
$idField = $config['idField'];
$fetchCount = $batchSize * 5;
$ids = $modelClass::where($idField, '>', $vectorLastId)
->where($idField, '<=', $syncLastId)
->orderBy($idField)
->limit($fetchCount)
->pluck($idField)
->toArray();
if (empty($ids)) {
return 0;
}
// 批量生成向量
$manticoreClass = $config['class'];
$successCount = $manticoreClass::generateVectorsBatch($ids, $batchSize);
$currentLastId = end($ids);
// 更新向量进度指针
ManticoreKeyValue::set($config['vectorKey'], $currentLastId);
$remaining = $pendingCount - count($ids);
$this->info("[{$type}] 处理 " . count($ids) . " 条,成功 {$successCount}ID: {$vectorLastId} -> {$currentLastId},剩余 {$remaining}");
// 刷新锁
$this->setLock();
return max(0, $remaining);
}
}

View File

@ -90,6 +90,24 @@ class ManticoreSyncFailure extends AbstractModel
->delete();
}
/**
* 批量清除同步成功记录(供批量写入路径使用,避免逐条删除)
*
* @param string $dataType 数据类型
* @param array $dataIds 数据ID列表
* @param string $action 操作类型
*/
public static function removeSuccessBatch(string $dataType, array $dataIds, string $action): void
{
if (empty($dataIds)) {
return;
}
self::where('data_type', $dataType)
->whereIn('data_id', $dataIds)
->where('action', $action)
->delete();
}
/**
* 获取待重试的记录
* 根据重试次数决定间隔1次=1分钟2次=5分钟3次=15分钟4次+=30分钟

View File

@ -900,7 +900,61 @@ class AI
}
/**
* 通过 OpenAI 兼容接口获取文本的 Embedding 向量
* 调用 ai 插件的 /embeddings 端点批量向量化(免费向量模型,零配置)。
*
* 走主程序 ai 插件的内网调用,共享主程序 APP_KEY 鉴权;
* 向量维度由 ai 插件的模型决定,主程序不再传 dimensions。
*
* @param array $texts 文本数组
* @return array retSuccess(data=向量数组的数组,与输入同序,缺失位置为 []) / retError
*/
protected static function requestPluginEmbeddings(array $texts)
{
$texts = array_values($texts);
$count = count($texts);
if ($count === 0) {
return Base::retSuccess("success", []);
}
$host = config('dootask.ai_host', 'ai');
$port = (int) config('dootask.ai_port', 5001);
$url = "http://{$host}:{$port}/embeddings";
$post = json_encode(["input" => $texts]);
$headers = [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . (string) config('app.key'),
];
$timeout = $count > 1 ? 120 : 30;
$res = Ihttp::ihttp_request($url, $post, $headers, $timeout);
if (Base::isError($res)) {
return Base::retError("Embedding 接口请求失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['data']) || !is_array($resData['data'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
// 按 index 回填,保证与输入顺序对齐,缺失位置留 []
$vectors = array_fill(0, $count, []);
foreach ($resData['data'] as $item) {
$idx = $item['index'] ?? null;
if ($idx === null || !isset($vectors[$idx])) {
continue;
}
$embedding = $item['embedding'] ?? [];
if (is_array($embedding) && !empty($embedding)) {
$vectors[$idx] = $embedding;
}
}
return Base::retSuccess("success", $vectors);
}
/**
* 通过 ai 插件的免费向量模型获取文本的 Embedding 向量
*
* @param string $text 需要转换的文本
* @param bool $noCache 是否禁用缓存
@ -916,50 +970,22 @@ class AI
return Base::retError('文本内容不能为空');
}
// 截断过长的文本(OpenAI 限制 8191 tokens约 32K 字符)
// 截断过长的文本(约 32K 字符)
$text = mb_substr($text, 0, 30000);
$cacheKey = "openAIEmbedding::" . md5($text);
// 缓存键换命名空间embeddingV2避免历史 1536 维缓存污染新的向量维度
$cacheKey = "embeddingV2::" . md5($text);
if ($noCache) {
Cache::forget($cacheKey);
}
$provider = self::resolveEmbeddingProvider();
if (!$provider) {
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
}
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text, $provider) {
$payload = [
"model" => $provider['model'],
"input" => $text,
];
// 统一向量维度为 1536与 Manticore 配置一致)
// OpenAI、智谱等支持 dimensions 参数的厂商需要显式指定
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
if ($supportsDimensions) {
$payload['dimensions'] = 1536;
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setUrlPath('/embeddings');
$ai->setTimeout(30);
$res = $ai->request(true);
$result = Cache::remember($cacheKey, Carbon::now()->addDays(7), function () use ($text) {
$res = self::requestPluginEmbeddings([$text]);
if (Base::isError($res)) {
return Base::retError("Embedding 请求失败", $res);
return $res;
}
$resData = Base::json2array($res['data']);
if (empty($resData['data'][0]['embedding'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
$embedding = $resData['data'][0]['embedding'];
$embedding = $res['data'][0] ?? [];
if (!is_array($embedding) || empty($embedding)) {
return Base::retError("Embedding 向量为空");
}
@ -973,193 +999,4 @@ class AI
return $result;
}
/**
* 批量获取文本的 Embedding 向量
* OpenAI API 原生支持批量输入,一次请求处理多个文本
*
* @param array $texts 文本数组(最多 100 条)
* @param bool $noCache 是否禁用缓存
* @return array 返回结果,成功时 data 为向量数组的数组(与输入顺序对应)
*/
public static function getBatchEmbeddings(array $texts, $noCache = false)
{
if (!Apps::isInstalled('ai')) {
return Base::retError('应用「AI Assistant」未安装');
}
if (empty($texts)) {
return Base::retSuccess("success", []);
}
// 限制批量大小
// OpenAI 限制:最多 2048 条,单次请求合计最多 300,000 tokens
// 这里限制 500 条,假设平均每条 500 tokens合计 250,000 tokens
$texts = array_slice($texts, 0, 500);
// 准备结果数组,并检查缓存
$results = [];
$uncachedTexts = [];
$uncachedIndices = [];
foreach ($texts as $index => $text) {
if (empty($text)) {
$results[$index] = [];
continue;
}
// 截断过长的文本
$text = mb_substr($text, 0, 30000);
$texts[$index] = $text; // 更新截断后的文本
$cacheKey = "openAIEmbedding::" . md5($text);
if ($noCache) {
Cache::forget($cacheKey);
}
// 检查缓存
if (!$noCache && Cache::has($cacheKey)) {
$cached = Cache::get($cacheKey);
if (Base::isSuccess($cached)) {
$results[$index] = $cached['data'];
continue;
}
}
// 未命中缓存,加入待请求列表
$uncachedTexts[] = $text;
$uncachedIndices[] = $index;
}
// 如果所有文本都在缓存中
if (empty($uncachedTexts)) {
// 按原始顺序返回
ksort($results);
return Base::retSuccess("success", array_values($results));
}
// 获取 provider
$provider = self::resolveEmbeddingProvider();
if (!$provider) {
return Base::retError("请先在「AI 助手」设置中配置支持 Embedding 的 AI 服务");
}
// 构建批量请求
$payload = [
"model" => $provider['model'],
"input" => $uncachedTexts,
];
$supportsDimensions = in_array($provider['vendor'], ['openai', 'zhipu']);
if ($supportsDimensions) {
$payload['dimensions'] = 1536;
}
$post = json_encode($payload);
$ai = new self($post);
$ai->setProvider($provider);
$ai->setUrlPath('/embeddings');
$ai->setTimeout(120); // 批量请求需要更长超时
$res = $ai->request(true);
if (Base::isError($res)) {
return Base::retError("批量 Embedding 请求失败", $res);
}
$resData = Base::json2array($res['data']);
if (empty($resData['data'])) {
return Base::retError("Embedding 接口返回数据格式错误", $resData);
}
// 处理返回的向量并写入缓存
foreach ($resData['data'] as $item) {
$itemIndex = $item['index'] ?? null;
if ($itemIndex === null || !isset($uncachedIndices[$itemIndex])) {
continue;
}
$originalIndex = $uncachedIndices[$itemIndex];
$embedding = $item['embedding'] ?? [];
if (!empty($embedding) && is_array($embedding)) {
$results[$originalIndex] = $embedding;
} else {
$results[$originalIndex] = [];
}
}
// 填充未获取到向量的位置
foreach ($uncachedIndices as $originalIndex) {
if (!isset($results[$originalIndex])) {
$results[$originalIndex] = [];
}
}
// 按原始顺序返回
ksort($results);
return Base::retSuccess("success", array_values($results));
}
/**
* 获取 Embedding 模型配置
*
* @return array|null
*/
protected static function resolveEmbeddingProvider()
{
$setting = Base::setting('aibotSetting');
if (!is_array($setting)) {
$setting = [];
}
// 优先使用 OpenAI支持 embedding 接口)
$key = trim((string)($setting['openai_key'] ?? ''));
if ($key !== '') {
$baseUrl = trim((string)($setting['openai_base_url'] ?? ''));
$baseUrl = $baseUrl ?: 'https://api.openai.com/v1';
$agency = trim((string)($setting['openai_agency'] ?? ''));
return [
'vendor' => 'openai',
'model' => 'text-embedding-3-small',
'api_key' => $key,
'base_url' => rtrim($baseUrl, '/'),
'agency' => $agency,
];
}
$vendorDefaults = [
'deepseek' => [
'base_url' => 'https://api.deepseek.com',
'model' => 'deepseek-embedding',
],
'zhipu' => [
'base_url' => 'https://open.bigmodel.cn/api/paas/v4',
'model' => 'embedding-3',
],
];
// 尝试其他支持 embedding 的服务(如 deepseek、zhipu、qianwen 等)
foreach ($vendorDefaults as $vendor => $defaults) {
$key = trim((string)($setting[$vendor . '_key'] ?? ''));
if ($key !== '') {
$baseUrl = trim((string)($setting[$vendor . '_base_url'] ?? ''));
$baseUrl = $baseUrl ?: $defaults['base_url']; // 使用配置或默认值
$agency = trim((string)($setting[$vendor . '_agency'] ?? ''));
return [
'vendor' => $vendor,
'model' => $defaults['model'],
'api_key' => $key,
'base_url' => rtrim($baseUrl, '/'),
'agency' => $agency,
];
}
}
return null;
}
}

View File

@ -8,6 +8,7 @@ use App\Module\Base;
use App\Module\AI;
use PDO;
use PDOException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
@ -21,6 +22,30 @@ class ManticoreBase
private static ?PDO $pdo = null;
private static bool $initialized = false;
/**
* 向量表结构版本;修改表结构/向量列参数时递增,触发已部署实例自动重建
*/
private const SCHEMA_VERSION = 1;
/**
* Auto Embeddings MODEL_NAME。必须用 Manticore 不认识的名字:
* 已知名字(如 text-embedding-ada-002)会按引擎硬编码维度校验,
* 未知名字才会在建表时向 API_URL 探测真实维度(免费模型为 1024)。
* 实际模型由 ai 插件的 EMBEDDING_MODEL 决定,此处仅为路由标签。
*/
private const EMBEDDING_MODEL_NAME = 'openai/qwen3-embedding';
/**
* 批量写入分块上限行数与字节预算Manticore max_allowed_packet 默认 128MB取保守值
*/
private const BATCH_CHUNK_ROWS = 30;
private const BATCH_CHUNK_BYTES = 8388608;
/**
* 5 张向量表名(键值即 VECTOR_TABLE_CONFIG type
*/
private const VECTOR_TABLES = ['msg', 'file', 'task', 'project', 'user'];
private string $host;
private int $port;
@ -70,13 +95,120 @@ class ManticoreBase
/**
* 初始化表结构
*
* 向量列使用 Manticore Auto EmbeddingsMODEL_NAME/API_URL 指向 ai 插件 /embeddings
* 引擎在写入/更新行时自动按 FROM 字段生成向量,无需 PHP 侧生成。
* key_values 中的 vector:schema 标记记录当前结构指纹(结构版本/模型/端点/APP_KEY 哈希),
* 不匹配首次安装、老版本升级、APP_KEY 轮换)即整体重建并重置同步指针,触发全量重灌。
*/
private function initializeTables(PDO $pdo): void
{
try {
// 创建文件向量表
// charset_table='non_cjk, cjk' 同时支持英文和中日韩文字
// 键值表必须最先建(用于读取/持久化结构标记与同步指针)
$pdo->exec("
CREATE TABLE IF NOT EXISTS key_values (
id BIGINT,
k STRING,
v TEXT
)
");
$expected = self::schemaMarker();
if (self::kvGetPdo($pdo, 'vector:schema') === $expected && self::allVectorTablesExist($pdo)) {
return;
}
// 并发进程只允许一个执行重建;抢锁失败的进程本轮写入失败会进重试队列,无碍
$lock = Cache::lock('manticore:schema-rebuild', 300);
if (!$lock->get()) {
return;
}
try {
foreach (self::vectorTableDDLs() as $table => $ddl) {
$pdo->exec("DROP TABLE IF EXISTS {$table}");
// CREATE 时引擎会调用 ai 端点探测向量维度ai 未就绪则抛错
$pdo->exec($ddl);
}
} catch (\Throwable $e) {
// 建表失败(如 ai 插件未就绪/未升级):不写 marker下个进程重试可自愈
Log::error('Manticore schema rebuild failed: ' . $e->getMessage());
return;
} finally {
$lock->release();
}
self::resetSyncPointersPdo($pdo);
// 清理旧向量管道遗留键vector:dim 与 vector:*LastId 指针)
$legacy = ["'vector:dim'"];
foreach (self::VECTOR_TABLES as $t) {
$legacy[] = "'vector:manticore" . ucfirst($t) . "LastId'";
}
$pdo->exec("DELETE FROM key_values WHERE k IN (" . implode(',', $legacy) . ")");
self::kvSetPdo($pdo, 'vector:schema', $expected);
Log::info("Manticore vector tables rebuilt for auto-embeddings schema {$expected}");
} catch (\Throwable $e) {
Log::error('Manticore initializeTables failed: ' . $e->getMessage());
}
}
/**
* ai 插件向量化端点地址(引擎 Auto Embeddings 与主程序查询侧共用)
*/
private static function embeddingsApiUrl(): string
{
$host = config('dootask.ai_host', 'ai');
$port = (int) config('dootask.ai_port', 5001);
return "http://{$host}:{$port}/embeddings";
}
/**
* 写入表定义的派生密钥sha256(APP_KEY:embeddings),与 ai 插件约定一致。
* 不直接写 APP_KEYLaravel 主密钥),避免其落入搜索引擎元数据/数据卷;
* 派生值不可反推,且仅授予 /embeddings 调用权限。
*/
private static function embeddingsApiKey(): string
{
return hash('sha256', config('app.key') . ':embeddings');
}
/**
* 当前向量表结构指纹:结构版本/模型名/端点/APP_KEY 哈希任一变化都会触发整体重建
*/
private static function schemaMarker(): string
{
return md5(self::SCHEMA_VERSION . '|' . self::EMBEDDING_MODEL_NAME . '|'
. self::embeddingsApiUrl() . '|' . hash('sha256', (string) config('app.key')));
}
/**
* 生成 Auto Embeddings 向量列定义(引擎按 FROM 字段自动生成/更新向量)
*
* 注意不写 KNN_DIMS MODEL_NAME 互斥),维度由引擎建表时向端点探测。
*
* @param string $from 参与向量化的字段(镜像旧 PHP 管道的拼接字段,逗号分隔)
*/
private static function vectorColumnDDL(string $from): string
{
return "content_vector float_vector knn_type='hnsw' hnsw_similarity='cosine'"
. " MODEL_NAME='" . self::EMBEDDING_MODEL_NAME . "'"
. " FROM='{$from}'"
. " API_KEY='" . self::embeddingsApiKey() . "'"
. " API_URL='" . self::embeddingsApiUrl() . "'"
. " API_TIMEOUT='60'";
}
/**
* 生成 5 张向量表的建表语句
*
* charset_table='non_cjk, cjk' 同时支持英文和中日韩文字
*
* @return array [table => DDL]
*/
private static function vectorTableDDLs(): array
{
$tail = "\n ) charset_table='non_cjk, cjk' morphology='icu_chinese'";
return [
'file_vectors' => "
CREATE TABLE IF NOT EXISTS file_vectors (
id BIGINT,
file_id BIGINT,
@ -87,21 +219,8 @@ class ManticoreBase
file_ext STRING,
content TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建键值存储表
$pdo->exec("
CREATE TABLE IF NOT EXISTS key_values (
id BIGINT,
k STRING,
v TEXT
)
");
// 创建用户向量表
$pdo->exec("
" . self::vectorColumnDDL('file_name,content') . $tail,
'user_vectors' => "
CREATE TABLE IF NOT EXISTS user_vectors (
id BIGINT,
userid BIGINT,
@ -110,12 +229,8 @@ class ManticoreBase
profession TEXT,
tags TEXT,
introduction TEXT,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建项目向量表
$pdo->exec("
" . self::vectorColumnDDL('nickname,email,profession,tags,introduction') . $tail,
'project_vectors' => "
CREATE TABLE IF NOT EXISTS project_vectors (
id BIGINT,
project_id BIGINT,
@ -124,12 +239,8 @@ class ManticoreBase
project_name TEXT,
project_desc TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建任务向量表
$pdo->exec("
" . self::vectorColumnDDL('project_name,project_desc') . $tail,
'task_vectors' => "
CREATE TABLE IF NOT EXISTS task_vectors (
id BIGINT,
task_id BIGINT,
@ -140,12 +251,8 @@ class ManticoreBase
task_desc TEXT,
task_content TEXT,
allowed_users MULTI,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
// 创建消息向量表
$pdo->exec("
" . self::vectorColumnDDL('task_name,task_desc,task_content') . $tail,
'msg_vectors' => "
CREATE TABLE IF NOT EXISTS msg_vectors (
id BIGINT,
msg_id BIGINT,
@ -155,13 +262,69 @@ class ManticoreBase
content TEXT,
allowed_users MULTI,
created_at BIGINT,
content_vector float_vector knn_type='hnsw' knn_dims='1536' hnsw_similarity='cosine'
) charset_table='non_cjk, cjk' morphology='icu_chinese'
");
" . self::vectorColumnDDL('content') . $tail,
];
}
// Tables initialized successfully
} catch (PDOException $e) {
// 表可能已存在,忽略初始化错误
/**
* 检查 5 张向量表是否都已存在
*/
private static function allVectorTablesExist(PDO $pdo): bool
{
try {
$stmt = $pdo->query("SHOW TABLES");
$existing = [];
foreach ($stmt->fetchAll(PDO::FETCH_NUM) as $row) {
$existing[$row[0]] = true;
}
foreach (array_keys(self::vectorTableDDLs()) as $table) {
if (!isset($existing[$table])) {
return false;
}
}
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* 直接用 PDO 读取 key_values避免初始化期通过 ManticoreKeyValue 造成递归)
*/
private static function kvGetPdo(PDO $pdo, string $key): ?string
{
try {
$stmt = $pdo->prepare("SELECT v FROM key_values WHERE k = ?");
$stmt->execute([$key]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return ($row && isset($row['v'])) ? (string)$row['v'] : null;
} catch (\Throwable $e) {
return null;
}
}
/**
* 直接用 PDO 写入 key_values ManticoreKeyValue::set id 规则)
*/
private static function kvSetPdo(PDO $pdo, string $key, string $value): void
{
try {
$del = $pdo->prepare("DELETE FROM key_values WHERE k = ?");
$del->execute([$key]);
$ins = $pdo->prepare("INSERT INTO key_values (id, k, v) VALUES (?, ?, ?)");
$ins->execute([abs(crc32($key)), $key, $value]);
} catch (\Throwable $e) {
// 忽略
}
}
/**
* 重置全文同步进度指针sync:*),触发全量重灌(向量由引擎随行自动生成)
*/
private static function resetSyncPointersPdo(PDO $pdo): void
{
foreach (self::VECTOR_TABLES as $t) {
self::kvSetPdo($pdo, "sync:manticore" . ucfirst($t) . "LastId", '0');
}
}
@ -1810,10 +1973,10 @@ class ManticoreBase
];
/**
* 通用向量插入方法
* 通用单行写入方法REPLACE向量由引擎 Auto Embeddings 自动生成)
*
* 使用 executeRaw 直接执行 SQL避免 Manticore prepared statement
* 无法解析 MVA 和向量字段括号语法的问题。
* 无法解析 MVA 字段括号语法的问题。
*
* @param string $type 类型: msg/file/task/project/user
* @param array $data 数据,键名对应字段名
@ -1828,8 +1991,6 @@ class ManticoreBase
$config = self::VECTOR_TABLE_CONFIG[$type];
$table = $config['table'];
$pk = $config['pk'];
$fields = $config['fields'];
$mvaFields = $config['mva_fields'];
// 检查主键
$pkValue = $data[$pk] ?? 0;
@ -1838,44 +1999,11 @@ class ManticoreBase
}
$instance = new self();
[$fieldList, $valueList] = $instance->buildRowValues($config, $data);
// 先删除已存在的记录
$instance->execute("DELETE FROM {$table} WHERE {$pk} = ?", [$pkValue]);
// 构建字段列表和值
$fieldList = [];
$valueList = [];
// 处理普通字段
foreach ($fields as $field) {
$fieldList[] = $field;
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
if (in_array($field, self::NUMERIC_FIELDS)) {
$valueList[] = (int)$value;
} else {
$valueList[] = $instance->quoteValue((string)$value);
}
}
// 处理 MVA 字段
foreach ($mvaFields as $mvaField) {
$fieldList[] = $mvaField;
$mvaData = $data[$mvaField] ?? [];
$valueList[] = !empty($mvaData)
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
: '()';
}
// 处理向量字段
$vectorValue = $data['content_vector'] ?? null;
if ($vectorValue) {
$fieldList[] = 'content_vector';
$valueList[] = str_replace(['[', ']'], ['(', ')'], $vectorValue);
}
// 构建并执行 SQL
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
// REPLACE 按 id 原子替换整行,向量列由引擎按 FROM 字段自动重新生成。
// 前提:所有 upsertXxxVector 均强制 id = 主键值,故 REPLACE(按 id) 与按主键去重等价
$sql = "REPLACE INTO {$table} (" . implode(', ', $fieldList) . ") VALUES (" . implode(', ', $valueList) . ")";
$result = $instance->executeRaw($sql);
@ -1885,12 +2013,46 @@ class ManticoreBase
ManticoreSyncFailure::removeSuccess($type, $pkValue, 'sync');
} else {
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "INSERT failed for {$table}");
ManticoreSyncFailure::recordFailure($type, $pkValue, 'sync', "REPLACE failed for {$table}");
}
return $result;
}
/**
* 构建一行数据的字段列表与内联值(普通字段 + MVA 字段,向量列由引擎自动生成,不在此列)
*
* @param array $config VECTOR_TABLE_CONFIG 中的类型配置
* @param array $data 行数据
* @return array [fieldList, valueList]
*/
private function buildRowValues(array $config, array $data): array
{
$fieldList = [];
$valueList = [];
foreach ($config['fields'] as $field) {
$fieldList[] = $field;
$value = $data[$field] ?? ($field === 'created_at' ? time() : (in_array($field, self::NUMERIC_FIELDS) ? 0 : ''));
if (in_array($field, self::NUMERIC_FIELDS)) {
$valueList[] = (int)$value;
} else {
$valueList[] = $this->quoteValue((string)$value);
}
}
foreach ($config['mva_fields'] as $mvaField) {
$fieldList[] = $mvaField;
$mvaData = $data[$mvaField] ?? [];
$valueList[] = !empty($mvaData)
? '(' . implode(',', array_map('intval', $mvaData)) . ')'
: '()';
}
return [$fieldList, $valueList];
}
/**
* 通用向量删除方法
*
@ -1924,179 +2086,81 @@ class ManticoreBase
}
/**
* 通用批量更新向量方法(高性能版本)
* 通用批量写入方法:每块一条多行 REPLACE向量由引擎 Auto Embeddings 自动生成
*
* 优化:将 N 条记录的 3N 次操作减少为 N+2 次操作
* 1. 批量 SELECT 获取现有记录 (1)
* 2. 预构建所有 INSERT SQL验证数据完整性
* 3. 批量 DELETE 删除旧记录 (1)
* 4. 逐条 INSERT 新记录带向量 (N次因向量字段无法批量绑定)
* 引擎对一条多行语句只调用一次向量化接口(实测 30 0.9s
* 多行语句失败是原子的;整块失败时回退逐行 upsertVector
* 使单条坏行不毒化整批、且失败按真实主键记入重试表。
*
* @param string $type 类型: msg/file/task/project/user
* @param array $vectorData 向量数据 [pk_value => vectorStr, ...]
* @return int 成功更新的数量
* @param array $rows 行数据数组(与 upsertVector $data 同构,需含 id 与主键)
* @return int 成功写入的数量
*/
public static function batchUpdateVectors(string $type, array $vectorData): int
public static function batchUpsertVectors(string $type, array $rows): int
{
if (empty($vectorData) || !isset(self::VECTOR_TABLE_CONFIG[$type])) {
if (empty($rows) || !isset(self::VECTOR_TABLE_CONFIG[$type])) {
return 0;
}
$config = self::VECTOR_TABLE_CONFIG[$type];
$table = $config['table'];
$pk = $config['pk'];
$fields = $config['fields'];
$mvaFields = $config['mva_fields'];
$instance = new self();
$ids = array_keys($vectorData);
// 1. 批量查询现有记录
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$existingRows = $instance->query(
"SELECT * FROM {$table} WHERE {$pk} IN ({$placeholders})",
$ids
);
if (empty($existingRows)) {
return 0;
}
// 建立 pk => row 的映射
$existingMap = [];
foreach ($existingRows as $row) {
$existingMap[$row[$pk]] = $row;
}
$idsToUpdate = array_keys($existingMap);
if (empty($idsToUpdate)) {
return 0;
}
// 2. 预构建所有 INSERT 语句(在删除前验证数据完整性)
$insertStatements = [];
foreach ($idsToUpdate as $pkValue) {
$existing = $existingMap[$pkValue];
$vectorStr = $vectorData[$pkValue] ?? null;
if (empty($vectorStr)) {
// 预构建每行的内联值,剔除无主键行
$pending = [];
$fieldListRef = null;
foreach ($rows as $row) {
if (($row[$pk] ?? 0) <= 0) {
continue;
}
// Manticore 向量使用 () 格式
$vectorStr = str_replace(['[', ']'], ['(', ')'], $vectorStr);
// 构建字段列表和值(直接内联值,不使用参数绑定)
$fieldList = $fields;
$quotedValues = [];
foreach ($fields as $field) {
$value = $existing[$field] ?? null;
// 处理默认值:数值字段用 0时间戳字段用当前时间其他用空字符串
if ($value === null) {
if ($field === 'created_at') {
$value = time();
} elseif (in_array($field, self::NUMERIC_FIELDS)) {
$value = 0;
} else {
$value = '';
}
}
// 根据字段类型处理值
if (in_array($field, self::NUMERIC_FIELDS)) {
$quotedValues[] = (int)$value;
} else {
$quotedValues[] = $instance->quoteValue((string)$value);
}
}
// 构建 MVA 字段
$mvaValuesStr = [];
foreach ($mvaFields as $mvaField) {
$fieldList[] = $mvaField;
$mvaValuesStr[] = !empty($existing[$mvaField])
? '(' . $existing[$mvaField] . ')'
: '()';
}
// 添加向量字段
$fieldList[] = 'content_vector';
// 构建 SQL所有值直接内联使用 executeRaw 避免 prepared statement 解析问题)
$allValues = implode(', ', array_merge($quotedValues, $mvaValuesStr, [$vectorStr]));
$sql = "INSERT INTO {$table} (" . implode(', ', $fieldList) . ") VALUES ({$allValues})";
$insertStatements[] = ['sql' => $sql, 'pk' => $pkValue];
[$fieldList, $valueList] = $instance->buildRowValues($config, $row);
$fieldListRef = $fieldList;
$valuesSql = '(' . implode(', ', $valueList) . ')';
$pending[] = ['pk' => $row[$pk], 'sql' => $valuesSql, 'bytes' => strlen($valuesSql), 'row' => $row];
}
// 如果没有有效的插入语句,直接返回
if (empty($insertStatements)) {
if (empty($pending)) {
return 0;
}
// 3. 批量删除旧记录(只删除有有效向量的记录)
$validPks = array_column($insertStatements, 'pk');
$deletePlaceholders = implode(',', array_fill(0, count($validPks), '?'));
$instance->execute(
"DELETE FROM {$table} WHERE {$pk} IN ({$deletePlaceholders})",
$validPks
);
// 分块:行数上限 + 字节预算(文件内容可达 10 万字符/行)
$chunks = [];
$current = [];
$currentBytes = 0;
foreach ($pending as $item) {
if (!empty($current)
&& (count($current) >= self::BATCH_CHUNK_ROWS || $currentBytes + $item['bytes'] > self::BATCH_CHUNK_BYTES)) {
$chunks[] = $current;
$current = [];
$currentBytes = 0;
}
$current[] = $item;
$currentBytes += $item['bytes'];
}
if (!empty($current)) {
$chunks[] = $current;
}
// 4. 逐条插入新记录(使用 executeRaw 避免 prepared statement 解析问题)
$successCount = 0;
foreach ($insertStatements as $stmt) {
if ($instance->executeRaw($stmt['sql'])) {
$successCount++;
// 成功则删除失败记录(如果有)
ManticoreSyncFailure::removeSuccess($type, $stmt['pk'], 'sync');
foreach ($chunks as $chunk) {
$sql = "REPLACE INTO {$table} (" . implode(', ', $fieldListRef) . ") VALUES "
. implode(', ', array_column($chunk, 'sql'));
if ($instance->executeRaw($sql)) {
$successCount += count($chunk);
ManticoreSyncFailure::removeSuccessBatch($type, array_column($chunk, 'pk'), 'sync');
} else {
// 失败则记录
ManticoreSyncFailure::recordFailure($type, $stmt['pk'], 'sync', "Batch INSERT failed for {$table}");
// 整块失败回退逐行REPLACE 幂等,重复写已生效行无害)
foreach ($chunk as $item) {
if (self::upsertVector($type, $item['row'])) {
$successCount++;
}
}
}
}
return $successCount;
}
/**
* 批量更新消息向量(兼容方法)
*/
public static function batchUpdateMsgVectors(array $vectorData): int
{
return self::batchUpdateVectors('msg', $vectorData);
}
/**
* 批量更新文件向量
*/
public static function batchUpdateFileVectors(array $vectorData): int
{
return self::batchUpdateVectors('file', $vectorData);
}
/**
* 批量更新任务向量
*/
public static function batchUpdateTaskVectors(array $vectorData): int
{
return self::batchUpdateVectors('task', $vectorData);
}
/**
* 批量更新项目向量
*/
public static function batchUpdateProjectVectors(array $vectorData): int
{
return self::batchUpdateVectors('project', $vectorData);
}
/**
* 批量更新用户向量
*/
public static function batchUpdateUserVectors(array $vectorData): int
{
return self::batchUpdateVectors('user', $vectorData);
}
// ==============================
// 通用工具方法
// ==============================

View File

@ -8,7 +8,6 @@ use App\Models\FileUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\TextExtractor;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
@ -214,13 +213,12 @@ class ManticoreFile
// ==============================
/**
* 同步单个文件到 Manticore allowed_users
* 同步单个文件到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param File $file 文件模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(File $file, bool $withVector = false): bool
public static function sync(File $file): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -240,42 +238,7 @@ class ManticoreFile
}
try {
// 提取文件内容
$content = self::extractFileContent($file);
// 限制提取后的内容长度
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && Apps::isInstalled('ai')) {
// 向量内容包含文件名和文件内容
$vectorContent = self::buildVectorContent($file->name, $content);
if (!empty($vectorContent)) {
$embeddingResult = ManticoreBase::getEmbedding($vectorContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
}
// 获取文件的 allowed_users
$allowedUsers = self::getAllowedUsers($file);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertFileVector([
'file_id' => $file->id,
'userid' => $file->userid,
'pshare' => $file->pshare ?? 0,
'file_name' => $file->name,
'file_type' => $file->type,
'file_ext' => $file->ext,
'content' => $content,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
return ManticoreBase::upsertFileVector(self::buildRow($file));
} catch (\Exception $e) {
Log::error('Manticore sync error: ' . $e->getMessage(), [
'file_id' => $file->id,
@ -285,6 +248,30 @@ class ManticoreFile
}
}
/**
* 构建文件索引行数据(含提取的文件内容与 allowed_users
*
* @param File $file 文件模型
* @return array 行数据
*/
private static function buildRow(File $file): array
{
// 提取文件内容并限制长度
$content = mb_substr(self::extractFileContent($file), 0, self::MAX_CONTENT_LENGTH);
return [
'id' => $file->id,
'file_id' => $file->id,
'userid' => $file->userid,
'pshare' => $file->pshare ?? 0,
'file_name' => $file->name,
'file_type' => $file->type,
'file_ext' => $file->ext,
'content' => $content,
'allowed_users' => self::getAllowedUsers($file),
];
}
/**
* 根据文件扩展名获取最大文件大小限制
*
@ -317,25 +304,41 @@ class ManticoreFile
}
/**
* 批量同步文件
* 批量同步文件(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $files 文件列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $files, bool $withVector = false): int
public static function batchSync(iterable $files): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($files as $file) {
if (self::sync($file, $withVector)) {
// 文件夹不索引
if ($file->type === 'folder') {
$count++;
continue;
}
// 超限文件删除旧索引
if ($file->size > self::getMaxFileSizeByExt($file->ext)) {
self::delete($file->id);
$count++;
continue;
}
try {
$rows[] = self::buildRow($file);
} catch (\Exception $e) {
Log::error('Manticore file batchSync build error: ' . $e->getMessage(), [
'file_id' => $file->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('file', $rows);
}
/**
@ -497,27 +500,6 @@ class ManticoreFile
return $result['data'] ?? '';
}
/**
* 构建用于生成向量的内容
* 包含文件名和文件内容,确保语义搜索能匹配文件名
*
* @param string $fileName 文件名
* @param string $content 文件内容
* @return string 用于生成向量的文本
*/
private static function buildVectorContent(string $fileName, string $content): string
{
$parts = [];
if (!empty($fileName)) {
$parts[] = $fileName;
}
if (!empty($content)) {
$parts[] = $content;
}
return implode(' ', $parts);
}
/**
* 清空所有索引
@ -577,94 +559,4 @@ class ManticoreFile
return false;
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成文件向量
* 用于后台异步处理,将已索引文件的向量批量生成
*
* @param array $fileIds 文件ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $fileIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($fileIds)) {
return 0;
}
try {
// 1. 查询文件信息
$files = File::whereIn('id', $fileIds)
->where('type', '!=', 'folder')
->get();
if ($files->isEmpty()) {
return 0;
}
// 2. 提取每个文件的内容(包含文件名)
$fileContents = [];
foreach ($files as $file) {
// 检查文件大小限制
$maxSize = self::getMaxFileSizeByExt($file->ext);
if ($file->size > $maxSize) {
continue;
}
$content = self::extractFileContent($file);
// 向量内容包含文件名和文件内容
$vectorContent = self::buildVectorContent($file->name, $content);
if (!empty($vectorContent)) {
// 限制内容长度
$vectorContent = mb_substr($vectorContent, 0, self::MAX_CONTENT_LENGTH);
$fileContents[$file->id] = $vectorContent;
}
}
if (empty($fileContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($fileContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $fileId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$fileId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateFileVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreFile generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -5,8 +5,6 @@ namespace App\Module\Manticore;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Carbon\Carbon;
use DB;
use Illuminate\Support\Facades\Log;
@ -328,13 +326,12 @@ class ManticoreMsg
// ==============================
/**
* 同步单个消息到 Manticore allowed_users
* 同步单个消息到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param WebSocketDialogMsg $msg 消息模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(WebSocketDialogMsg $msg, bool $withVector = false): bool
public static function sync(WebSocketDialogMsg $msg): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -347,37 +344,7 @@ class ManticoreMsg
}
try {
// 提取消息内容(使用 key 字段)
$content = $msg->key ?? '';
// 限制内容长度
$content = mb_substr($content, 0, self::MAX_CONTENT_LENGTH);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($content) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($content);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取消息的 allowed_users
$allowedUsers = self::getAllowedUsers($msg);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertMsgVector([
'msg_id' => $msg->id,
'dialog_id' => $msg->dialog_id,
'userid' => $msg->userid,
'msg_type' => $msg->type,
'content' => $content,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
]);
return $result;
return ManticoreBase::upsertMsgVector(self::buildRow($msg));
} catch (\Exception $e) {
Log::error('Manticore msg sync error: ' . $e->getMessage(), [
'msg_id' => $msg->id,
@ -388,106 +355,59 @@ class ManticoreMsg
}
/**
* 批量同步消息
* 构建消息索引行数据(含 allowed_users
*
* @param WebSocketDialogMsg $msg 消息模型
* @return array 行数据
*/
private static function buildRow(WebSocketDialogMsg $msg): array
{
// 提取消息内容(使用 key 字段)并限制长度
$content = mb_substr($msg->key ?? '', 0, self::MAX_CONTENT_LENGTH);
return [
'id' => $msg->id,
'msg_id' => $msg->id,
'dialog_id' => $msg->dialog_id,
'userid' => $msg->userid,
'msg_type' => $msg->type,
'content' => $content,
'allowed_users' => self::getAllowedUsers($msg),
'created_at' => $msg->created_at ? $msg->created_at->timestamp : time(),
];
}
/**
* 批量同步消息(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $msgs 消息列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $msgs, bool $withVector = false): int
public static function batchSync(iterable $msgs): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($msgs as $msg) {
if (self::sync($msg, $withVector)) {
$count++;
}
}
return $count;
}
/**
* 批量生成向量(供后台任务调用)
*
* @param array $msgIds 消息ID数组
* @param int $batchSize 每批 embedding 数量
* @return int 成功生成向量的数量
*/
public static function generateVectorsBatch(array $msgIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled('ai') || empty($msgIds)) {
return 0;
}
$count = 0;
// 分批处理
foreach (array_chunk($msgIds, $batchSize) as $batchIds) {
// 获取消息
$msgs = WebSocketDialogMsg::whereIn('id', $batchIds)
->whereIn('type', self::INDEXABLE_TYPES)
->where('bot', '!=', 1)
->whereNotNull('key')
->where('key', '!=', '')
->get()
->keyBy('id');
if ($msgs->isEmpty()) {
if (!self::shouldIndex($msg)) {
if (ManticoreBase::deleteMsgVector($msg->id)) {
$count++;
}
continue;
}
// 准备文本
$texts = [];
$idsArray = [];
foreach ($batchIds as $id) {
if (isset($msgs[$id])) {
$content = mb_substr($msgs[$id]->key ?? '', 0, self::MAX_CONTENT_LENGTH);
if (!empty($content)) {
$texts[] = $content;
$idsArray[] = $id;
}
}
}
if (empty($texts)) {
continue;
}
// 批量获取 embeddings
$result = AI::getBatchEmbeddings($texts);
if (Base::isError($result)) {
continue;
}
$embeddings = $result['data'] ?? [];
// 构建批量更新数据 [msg_id => vectorStr]
$vectorData = [];
foreach ($embeddings as $index => $embedding) {
if (empty($embedding) || !is_array($embedding)) {
continue;
}
$msgId = $idsArray[$index] ?? null;
if (!$msgId) {
continue;
}
$vectorData[$msgId] = '[' . implode(',', $embedding) . ']';
}
// 批量更新向量(优化:减少数据库操作次数)
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateMsgVectors($vectorData);
$count += $batchCount;
try {
$rows[] = self::buildRow($msg);
} catch (\Exception $e) {
Log::error('Manticore msg batchSync build error: ' . $e->getMessage(), [
'msg_id' => $msg->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('msg', $rows);
}
/**

View File

@ -5,8 +5,6 @@ namespace App\Module\Manticore;
use App\Models\Project;
use App\Models\ProjectUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
@ -122,13 +120,12 @@ class ManticoreProject
}
/**
* 同步单个项目到 Manticore allowed_users
* 同步单个项目到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param Project $project 项目模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(Project $project, bool $withVector = false): bool
public static function sync(Project $project): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -140,33 +137,7 @@ class ManticoreProject
}
try {
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($project);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取项目成员列表(作为 allowed_users
$allowedUsers = self::getAllowedUsers($project->id);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertProjectVector([
'project_id' => $project->id,
'userid' => $project->userid ?? 0,
'personal' => $project->personal ?? 0,
'project_name' => $project->name ?? '',
'project_desc' => $project->desc ?? '',
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
return ManticoreBase::upsertProjectVector(self::buildRow($project));
} catch (\Exception $e) {
Log::error('Manticore project sync error: ' . $e->getMessage(), [
'project_id' => $project->id,
@ -177,45 +148,55 @@ class ManticoreProject
}
/**
* 构建可搜索的文本内容
* 构建项目索引行数据(含 allowed_users
*
* @param Project $project 项目模型
* @return string 可搜索的文本
* @return array 行数据
*/
private static function buildSearchableContent(Project $project): string
private static function buildRow(Project $project): array
{
$parts = [];
if (!empty($project->name)) {
$parts[] = $project->name;
}
if (!empty($project->desc)) {
$parts[] = $project->desc;
}
return implode(' ', $parts);
return [
'id' => $project->id,
'project_id' => $project->id,
'userid' => $project->userid ?? 0,
'personal' => $project->personal ?? 0,
'project_name' => $project->name ?? '',
'project_desc' => $project->desc ?? '',
'allowed_users' => self::getAllowedUsers($project->id),
];
}
/**
* 批量同步项目
* 批量同步项目(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $projects 项目列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $projects, bool $withVector = false): int
public static function batchSync(iterable $projects): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($projects as $project) {
if (self::sync($project, $withVector)) {
$count++;
if ($project->archived_at) {
if (self::delete($project->id)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($project);
} catch (\Exception $e) {
Log::error('Manticore project batchSync build error: ' . $e->getMessage(), [
'project_id' => $project->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('project', $rows);
}
/**
@ -286,84 +267,4 @@ class ManticoreProject
return false;
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成项目向量
* 用于后台异步处理,将已索引项目的向量批量生成
*
* @param array $projectIds 项目ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $projectIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($projectIds)) {
return 0;
}
try {
// 1. 查询项目信息
$projects = Project::whereIn('id', $projectIds)
->whereNull('archived_at')
->get();
if ($projects->isEmpty()) {
return 0;
}
// 2. 提取每个项目的内容
$projectContents = [];
foreach ($projects as $project) {
$searchableContent = self::buildSearchableContent($project);
if (!empty($searchableContent)) {
$projectContents[$project->id] = $searchableContent;
}
}
if (empty($projectContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($projectContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $projectId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$projectId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateProjectVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreProject generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -9,7 +9,6 @@ use App\Models\ProjectTaskVisibilityUser;
use App\Models\ProjectUser;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
@ -189,13 +188,12 @@ class ManticoreTask
// ==============================
/**
* 同步单个任务到 Manticore allowed_users
* 同步单个任务到 Manticore allowed_users,向量由引擎 Auto Embeddings 自动生成
*
* @param ProjectTask $task 任务模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(ProjectTask $task, bool $withVector = false): bool
public static function sync(ProjectTask $task): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -207,38 +205,7 @@ class ManticoreTask
}
try {
// 获取任务详细内容
$taskContent = self::getTaskContent($task);
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($task, $taskContent);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 获取任务的 allowed_users
$allowedUsers = self::getAllowedUsers($task);
// 写入 Manticore含 allowed_users
$result = ManticoreBase::upsertTaskVector([
'task_id' => $task->id,
'project_id' => $task->project_id ?? 0,
'userid' => $task->userid ?? 0,
'visibility' => $task->visibility ?? 1,
'task_name' => $task->name ?? '',
'task_desc' => $task->desc ?? '',
'task_content' => $taskContent,
'content_vector' => $embedding,
'allowed_users' => $allowedUsers,
]);
return $result;
return ManticoreBase::upsertTaskVector(self::buildRow($task));
} catch (\Exception $e) {
Log::error('Manticore task sync error: ' . $e->getMessage(), [
'task_id' => $task->id,
@ -248,6 +215,27 @@ class ManticoreTask
}
}
/**
* 构建任务索引行数据(含详细内容与 allowed_users
*
* @param ProjectTask $task 任务模型
* @return array 行数据
*/
private static function buildRow(ProjectTask $task): array
{
return [
'id' => $task->id,
'task_id' => $task->id,
'project_id' => $task->project_id ?? 0,
'userid' => $task->userid ?? 0,
'visibility' => $task->visibility ?? 1,
'task_name' => $task->name ?? '',
'task_desc' => $task->desc ?? '',
'task_content' => self::getTaskContent($task),
'allowed_users' => self::getAllowedUsers($task),
];
}
/**
* 获取任务详细内容
*
@ -311,49 +299,36 @@ class ManticoreTask
}
/**
* 构建可搜索的文本内容
*
* @param ProjectTask $task 任务模型
* @param string $taskContent 任务详细内容
* @return string 可搜索的文本
*/
private static function buildSearchableContent(ProjectTask $task, string $taskContent): string
{
$parts = [];
if (!empty($task->name)) {
$parts[] = $task->name;
}
if (!empty($task->desc)) {
$parts[] = $task->desc;
}
if (!empty($taskContent)) {
$parts[] = $taskContent;
}
return implode(' ', $parts);
}
/**
* 批量同步任务
* 批量同步任务(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $tasks 任务列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $tasks, bool $withVector = false): int
public static function batchSync(iterable $tasks): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($tasks as $task) {
if (self::sync($task, $withVector)) {
$count++;
if ($task->archived_at || $task->deleted_at) {
if (self::delete($task->id)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($task);
} catch (\Exception $e) {
Log::error('Manticore task batchSync build error: ' . $e->getMessage(), [
'task_id' => $task->id,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('task', $rows);
}
/**
@ -506,88 +481,4 @@ class ManticoreTask
Log::error('Manticore cascadeToChildren error: ' . $e->getMessage(), ['task_id' => $taskId]);
}
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成任务向量
* 用于后台异步处理,将已索引任务的向量批量生成
*
* @param array $taskIds 任务ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $taskIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($taskIds)) {
return 0;
}
try {
// 1. 查询任务信息
$tasks = ProjectTask::whereIn('id', $taskIds)
->whereNull('deleted_at')
->whereNull('archived_at')
->get();
if ($tasks->isEmpty()) {
return 0;
}
// 2. 提取每个任务的内容
$taskContents = [];
foreach ($tasks as $task) {
$taskContent = self::getTaskContent($task);
$searchableContent = self::buildSearchableContent($task, $taskContent);
if (!empty($searchableContent)) {
// 限制内容长度
$searchableContent = mb_substr($searchableContent, 0, self::MAX_CONTENT_LENGTH);
$taskContents[$task->id] = $searchableContent;
}
}
if (empty($taskContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($taskContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $taskId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$taskId] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateTaskVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreTask generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -5,8 +5,6 @@ namespace App\Module\Manticore;
use App\Models\User;
use App\Models\UserTag;
use App\Module\Apps;
use App\Module\Base;
use App\Module\AI;
use Illuminate\Support\Facades\Log;
/**
@ -123,13 +121,12 @@ class ManticoreUser
}
/**
* 同步单个用户到 Manticore
* 同步单个用户到 Manticore(向量由引擎 Auto Embeddings 按行内文本自动生成)
*
* @param User $user 用户模型
* @param bool $withVector 是否同时生成向量(默认 false,向量由后台任务生成)
* @return bool 是否成功
*/
public static function sync(User $user, bool $withVector = false): bool
public static function sync(User $user): bool
{
if (!Apps::isInstalled("search")) {
return false;
@ -146,33 +143,7 @@ class ManticoreUser
}
try {
// 获取用户标签Top 10
$tags = self::getUserTags($user->userid);
// 构建用于搜索的文本内容
$searchableContent = self::buildSearchableContent($user, $tags);
// 只有明确要求时才生成向量(默认不生成,由后台任务处理)
$embedding = null;
if ($withVector && !empty($searchableContent) && Apps::isInstalled('ai')) {
$embeddingResult = ManticoreBase::getEmbedding($searchableContent);
if (!empty($embeddingResult)) {
$embedding = '[' . implode(',', $embeddingResult) . ']';
}
}
// 写入 Manticore
$result = ManticoreBase::upsertUserVector([
'userid' => $user->userid,
'nickname' => $user->nickname ?? '',
'email' => $user->email ?? '',
'profession' => $user->profession ?? '',
'tags' => $tags,
'introduction' => $user->introduction ?? '',
'content_vector' => $embedding,
]);
return $result;
return ManticoreBase::upsertUserVector(self::buildRow($user));
} catch (\Exception $e) {
Log::error('Manticore user sync error: ' . $e->getMessage(), [
'userid' => $user->userid,
@ -183,55 +154,61 @@ class ManticoreUser
}
/**
* 构建可搜索的文本内容
* 构建用户索引行数据(含标签 Top 10
*
* @param User $user 用户模型
* @param string $tags 用户标签(空格分隔)
* @return string 可搜索的文本
* @return array 行数据
*/
private static function buildSearchableContent(User $user, string $tags = ''): string
private static function buildRow(User $user): array
{
$parts = [];
$tags = self::getUserTags($user->userid);
if (!empty($user->nickname)) {
$parts[] = $user->nickname;
}
if (!empty($user->email)) {
$parts[] = $user->email;
}
if (!empty($user->profession)) {
$parts[] = $user->profession;
}
if (!empty($tags)) {
$parts[] = $tags;
}
if (!empty($user->introduction)) {
$parts[] = $user->introduction;
}
return implode(' ', $parts);
return [
'id' => $user->userid,
'userid' => $user->userid,
'nickname' => $user->nickname ?? '',
'email' => $user->email ?? '',
'profession' => $user->profession ?? '',
'tags' => $tags,
'introduction' => $user->introduction ?? '',
];
}
/**
* 批量同步用户
* 批量同步用户(每块一条多行 REPLACE向量由引擎自动生成
*
* @param iterable $users 用户列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $users, bool $withVector = false): int
public static function batchSync(iterable $users): int
{
if (!Apps::isInstalled("search")) {
return 0;
}
$count = 0;
$rows = [];
foreach ($users as $user) {
if (self::sync($user, $withVector)) {
if ($user->bot) {
$count++;
continue;
}
if ($user->disable_at) {
if (self::delete($user->userid)) {
$count++;
}
continue;
}
try {
$rows[] = self::buildRow($user);
} catch (\Exception $e) {
Log::error('Manticore user batchSync build error: ' . $e->getMessage(), [
'userid' => $user->userid,
]);
}
}
return $count;
return $count + ManticoreBase::batchUpsertVectors('user', $rows);
}
/**
@ -276,87 +253,4 @@ class ManticoreUser
return ManticoreBase::getIndexedUserCount();
}
// ==============================
// 批量向量生成方法
// ==============================
/**
* 批量生成用户向量
* 用于后台异步处理,将已索引用户的向量批量生成
*
* @param array $userIds 用户ID数组
* @param int $batchSize 每批 embedding 数量默认20
* @return int 成功处理的数量
*/
public static function generateVectorsBatch(array $userIds, int $batchSize = 20): int
{
if (!Apps::isInstalled("search") || !Apps::isInstalled("ai") || empty($userIds)) {
return 0;
}
try {
// 1. 查询用户信息
$users = User::whereIn('userid', $userIds)
->where('bot', 0)
->whereNull('disable_at')
->get();
if ($users->isEmpty()) {
return 0;
}
// 2. 提取每个用户的内容(包含标签)
$userContents = [];
foreach ($users as $user) {
$tags = self::getUserTags($user->userid);
$searchableContent = self::buildSearchableContent($user, $tags);
if (!empty($searchableContent)) {
$userContents[$user->userid] = $searchableContent;
}
}
if (empty($userContents)) {
return 0;
}
// 3. 分批处理
$successCount = 0;
$chunks = array_chunk($userContents, $batchSize, true);
foreach ($chunks as $chunk) {
$texts = array_values($chunk);
$ids = array_keys($chunk);
// 4. 批量获取 embedding
$result = AI::getBatchEmbeddings($texts);
if (!Base::isSuccess($result) || empty($result['data'])) {
continue;
}
$embeddings = $result['data'];
// 5. 构建批量更新数据
$vectorData = [];
foreach ($ids as $index => $userid) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorData[$userid] = '[' . implode(',', $embeddings[$index]) . ']';
}
// 6. 批量更新向量
if (!empty($vectorData)) {
$batchCount = ManticoreBase::batchUpdateUserVectors($vectorData);
$successCount += $batchCount;
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreUser generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}

View File

@ -235,11 +235,8 @@ class ManticoreSyncTask extends AbstractTask
}
Cache::put("ManticoreSyncTask:CheckTime", time(), Carbon::now()->addMinutes(5));
// 执行增量全文索引同步
// 执行增量全文索引同步(向量由 Manticore Auto Embeddings 随行自动生成)
$this->runIncrementalSync();
// 执行向量生成
$this->runVectorGeneration();
}
/**
@ -261,22 +258,6 @@ class ManticoreSyncTask extends AbstractTask
@shell_exec("php /var/www/artisan manticore:retry-failures 2>&1 &");
}
/**
* 执行向量生成(兜底触发)
*
* 命令内部有锁机制,如果已在运行会自动跳过
* 命令会持续处理直到无待处理数据,然后自动退出
*/
private function runVectorGeneration(): void
{
if (!Apps::isInstalled("ai")) {
return;
}
// 启动向量生成命令
@shell_exec("php /var/www/artisan manticore:generate-vectors --type=all --batch=50 2>&1 &");
}
public function end()
{
}

View File

@ -26,6 +26,12 @@ return [
// Manticore 全文搜索服务端口ManticoreBase
'search_port' => env('SEARCH_PORT', 9306),
// AI 插件服务主机AI::getEmbedding 走 ai 插件 /embeddings 免费向量模型)
'ai_host' => env('AI_HOST', 'ai'),
// AI 插件服务端口AI::getEmbedding
'ai_port' => env('AI_PORT', 5001),
// 文件回收站自动清空天数DeleteTmpTask
'auto_empty_file_recycle' => env('AUTO_EMPTY_FILE_RECYCLE', 365),

View File

@ -90,14 +90,15 @@
display: flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
padding: 4px 14px;
line-height: 24px;
border-radius: 16px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: #666;
background-color: #f0f0f0;
transition: all 0.2s ease;
cursor: pointer;
> i {
font-size: 14px;