mirror of
https://github.com/kuaifan/dootask.git
synced 2026-07-22 05:59:58 +00:00
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:
parent
e4fbf9693a
commit
746337c2f6
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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分钟
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 Embeddings(MODEL_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_KEY(Laravel 主密钥),避免其落入搜索引擎元数据/数据卷;
|
||||
* 派生值不可反推,且仅授予 /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);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 通用工具方法
|
||||
// ==============================
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
{
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user