dootask/app/Module/Manticore/ManticoreFile.php
2026-01-04 13:30:03 +00:00

583 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Module\Manticore;
use App\Models\File;
use App\Models\FileContent;
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;
use Illuminate\Support\Facades\DB;
/**
* Manticore Search 文件搜索类
*
* 使用方法:
*
* 1. 搜索方法
* - 搜索文件: search($userid, $keyword, $searchType, $from, $size);
*
* 2. 同步方法
* - 单个同步: sync(File $file);
* - 批量同步: batchSync($files);
* - 删除索引: delete($fileId);
*
* 3. 权限更新方法
* - 更新权限: updateAllowedUsers($fileId);
*
* 4. 工具方法
* - 清空索引: clear();
*/
class ManticoreFile
{
/**
* 可搜索的文件类型
*/
public const SEARCHABLE_TYPES = ['document', 'word', 'excel', 'ppt', 'txt', 'md', 'text', 'code'];
/**
* 最大内容长度(字符)- 提取后的文本内容限制
*/
public const MAX_CONTENT_LENGTH = 100000; // 100K 字符
/**
* 不同文件类型的最大大小限制(字节)
*/
public const MAX_FILE_SIZE = [
'office' => 50 * 1024 * 1024, // 50MB - Office 文件图片占空间大但文本少
'text' => 5 * 1024 * 1024, // 5MB - 纯文本文件
'other' => 20 * 1024 * 1024, // 20MB - PDF 等其他文件
];
/**
* Office 文件扩展名
*/
public const OFFICE_EXTENSIONS = [
'doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'rtf',
'xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'ods', 'ots', 'csv', 'tsv',
'ppt', 'pptx', 'pps', 'ppsx', 'odp', 'otp'
];
/**
* 纯文本文件扩展名
*/
public const TEXT_EXTENSIONS = [
'txt', 'md', 'text', 'log', 'json', 'xml', 'html', 'htm', 'css', 'js', 'ts',
'php', 'py', 'java', 'c', 'cpp', 'h', 'go', 'rs', 'rb', 'sh', 'bash', 'sql',
'yaml', 'yml', 'ini', 'conf', 'vue', 'jsx', 'tsx'
];
/**
* 搜索文件(支持全文、向量、混合搜索)
*
* @param int $userid 用户ID
* @param string $keyword 搜索关键词
* @param string $searchType 搜索类型: text/vector/hybrid
* @param int $from 起始位置
* @param int $size 返回数量
* @return array 搜索结果
*/
public static function search(int $userid, string $keyword, string $searchType = 'hybrid', int $from = 0, int $size = 20): array
{
if (empty($keyword)) {
return [];
}
if (!Apps::isInstalled("manticore")) {
// 未安装 Manticore降级到 MySQL LIKE 搜索
return self::searchByMysql($userid, $keyword, $from, $size);
}
try {
switch ($searchType) {
case 'text':
// 纯全文搜索
return self::formatSearchResults(
ManticoreBase::fullTextSearch($keyword, $userid, $size, $from)
);
case 'vector':
// 纯向量搜索(需要先获取 embedding
$embedding = ManticoreBase::getEmbedding($keyword);
if (empty($embedding)) {
// embedding 获取失败,降级到全文搜索
return self::formatSearchResults(
ManticoreBase::fullTextSearch($keyword, $userid, $size, $from)
);
}
return self::formatSearchResults(
ManticoreBase::vectorSearch($embedding, $userid, $size)
);
case 'hybrid':
default:
// 混合搜索
$embedding = ManticoreBase::getEmbedding($keyword);
return self::formatSearchResults(
ManticoreBase::hybridSearch($keyword, $embedding, $userid, $size)
);
}
} catch (\Exception $e) {
Log::error('Manticore search error: ' . $e->getMessage());
return self::searchByMysql($userid, $keyword, $from, $size);
}
}
/**
* 格式化搜索结果
*
* @param array $results Manticore 返回的结果
* @return array 格式化后的结果
*/
private static function formatSearchResults(array $results): array
{
$formatted = [];
foreach ($results as $item) {
$formatted[] = [
'id' => $item['file_id'],
'file_id' => $item['file_id'],
'name' => $item['file_name'],
'type' => $item['file_type'],
'ext' => $item['file_ext'],
'userid' => $item['userid'],
'content_preview' => isset($item['content']) ? mb_substr($item['content'], 0, 500) : null,
'relevance' => $item['relevance'] ?? $item['similarity'] ?? $item['rrf_score'] ?? 0,
];
}
return $formatted;
}
/**
* MySQL 降级搜索(仅搜索文件名)
*
* @param int $userid 用户ID
* @param string $keyword 关键词
* @param int $from 起始位置
* @param int $size 返回数量
* @return array 搜索结果
*/
private static function searchByMysql(int $userid, string $keyword, int $from, int $size): array
{
// 搜索用户自己的文件
$builder = File::where('userid', $userid)
->where('name', 'like', "%{$keyword}%")
->where('type', '!=', 'folder');
$results = $builder->skip($from)->take($size)->get();
return $results->map(function ($file) {
return [
'id' => $file->id,
'file_id' => $file->id,
'name' => $file->name,
'type' => $file->type,
'ext' => $file->ext,
'userid' => $file->userid,
'content_preview' => null,
'relevance' => 0,
];
})->toArray();
}
// ==============================
// 权限计算方法
// ==============================
/**
* 获取文件的 allowed_users 列表
*
* 有权限查看此文件的用户列表:
* - 文件所有者 (userid)
* - 共享用户FileUser 表中的 userid
* - userid=0 表示公开共享
*
* @param File $file 文件模型
* @return array 有权限的用户ID数组
*/
public static function getAllowedUsers(File $file): array
{
$userids = [$file->userid]; // 所有者
// 获取共享用户(包括 userid=0 表示公开)
$shareUsers = FileUser::where('file_id', $file->id)
->pluck('userid')
->toArray();
return array_unique(array_merge($userids, $shareUsers));
}
// ==============================
// 同步方法
// ==============================
/**
* 同步单个文件到 Manticore含 allowed_users
*
* @param File $file 文件模型
* @param bool $withVector 是否同时生成向量(默认 false向量由后台任务生成
* @return bool 是否成功
*/
public static function sync(File $file, bool $withVector = false): bool
{
if (!Apps::isInstalled("manticore")) {
return false;
}
// 不处理文件夹
if ($file->type === 'folder') {
return true;
}
// 根据文件类型检查大小限制
$maxSize = self::getMaxFileSizeByExt($file->ext);
if ($file->size > $maxSize) {
Log::info("Manticore: Skip large file {$file->id} ({$file->size} bytes, max: {$maxSize})");
// 删除可能存在的旧索引(文件更新后可能超限)
self::delete($file->id);
return true;
}
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;
} catch (\Exception $e) {
Log::error('Manticore sync error: ' . $e->getMessage(), [
'file_id' => $file->id,
'file_name' => $file->name,
]);
return false;
}
}
/**
* 根据文件扩展名获取最大文件大小限制
*
* @param string|null $ext 文件扩展名
* @return int 最大文件大小(字节)
*/
private static function getMaxFileSizeByExt(?string $ext): int
{
$ext = strtolower($ext ?? '');
if (in_array($ext, self::OFFICE_EXTENSIONS)) {
return self::MAX_FILE_SIZE['office'];
}
if (in_array($ext, self::TEXT_EXTENSIONS)) {
return self::MAX_FILE_SIZE['text'];
}
return self::MAX_FILE_SIZE['other'];
}
/**
* 获取所有文件类型中的最大文件大小限制
*
* @return int 最大文件大小(字节)
*/
public static function getMaxFileSize(): int
{
return max(self::MAX_FILE_SIZE);
}
/**
* 批量同步文件
*
* @param iterable $files 文件列表
* @param bool $withVector 是否同时生成向量
* @return int 成功同步的数量
*/
public static function batchSync(iterable $files, bool $withVector = false): int
{
if (!Apps::isInstalled("manticore")) {
return 0;
}
$count = 0;
foreach ($files as $file) {
if (self::sync($file, $withVector)) {
$count++;
}
}
return $count;
}
/**
* 删除文件索引
*
* @param int $fileId 文件ID
* @return bool 是否成功
*/
public static function delete(int $fileId): bool
{
if (!Apps::isInstalled("manticore")) {
return false;
}
return ManticoreBase::deleteFileVector($fileId);
}
/**
* 提取文件内容
*
* @param File $file 文件模型
* @return string 文件内容文本
*/
private static function extractFileContent(File $file): string
{
// 1. 先尝试从 FileContent 的 text 字段获取(已提取的文本内容)
$fileContent = FileContent::where('fid', $file->id)->orderByDesc('id')->first();
if ($fileContent && !empty($fileContent->text)) {
return $fileContent->text;
}
// 2. 尝试从 FileContent 的 content 字段获取
if ($fileContent && !empty($fileContent->content)) {
$contentData = Base::json2array($fileContent->content);
// 2.1 某些文件类型直接存储内容
if (!empty($contentData['content'])) {
return is_string($contentData['content']) ? $contentData['content'] : '';
}
// 2.2 尝试使用 TextExtractor 提取文件内容
$filePath = $contentData['url'] ?? null;
if ($filePath && str_starts_with($filePath, 'uploads/')) {
$fullPath = public_path($filePath);
if (file_exists($fullPath)) {
// 根据文件类型设置不同的大小限制
$ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION));
$maxFileSize = self::getMaxFileSizeByExt($ext);
$maxContentSize = self::MAX_CONTENT_LENGTH;
$result = TextExtractor::extractFile(
$fullPath,
(int) ($maxFileSize / 1024), // 转换为 KB
(int) ($maxContentSize / 1024) // 转换为 KB
);
if (Base::isSuccess($result)) {
return $result['data'] ?? '';
}
}
}
}
return '';
}
/**
* 构建用于生成向量的内容
* 包含文件名和文件内容,确保语义搜索能匹配文件名
*
* @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);
}
/**
* 清空所有索引
*
* @return bool 是否成功
*/
public static function clear(): bool
{
if (!Apps::isInstalled("manticore")) {
return false;
}
return ManticoreBase::clearAllFileVectors();
}
/**
* 获取已索引文件数量
*
* @return int 数量
*/
public static function getIndexedCount(): int
{
if (!Apps::isInstalled("manticore")) {
return 0;
}
return ManticoreBase::getIndexedFileCount();
}
// ==============================
// 权限更新方法
// ==============================
/**
* 更新文件的 allowed_users 权限列表
* 从 MySQL 获取最新的共享用户并更新到 Manticore
*
* @param int $fileId 文件ID
* @return bool 是否成功
*/
public static function updateAllowedUsers(int $fileId): bool
{
if (!Apps::isInstalled("manticore") || $fileId <= 0) {
return false;
}
try {
$file = File::find($fileId);
if (!$file) {
return false;
}
$userids = self::getAllowedUsers($file);
return ManticoreBase::updateFileAllowedUsers($fileId, $userids);
} catch (\Exception $e) {
Log::error('Manticore updateAllowedUsers error: ' . $e->getMessage(), ['file_id' => $fileId]);
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("manticore") || !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'])) {
Log::warning('ManticoreFile: Batch embedding failed', ['file_ids' => $ids]);
continue;
}
$embeddings = $result['data'];
$failedIds = [];
// 5. 逐个更新向量到 Manticore
foreach ($ids as $index => $fileId) {
if (!isset($embeddings[$index]) || empty($embeddings[$index])) {
continue;
}
$vectorStr = '[' . implode(',', $embeddings[$index]) . ']';
if (ManticoreBase::updateFileVector($fileId, $vectorStr)) {
$successCount++;
} else {
$failedIds[] = $fileId;
}
}
// 记录更新失败的 ID
if (!empty($failedIds)) {
Log::warning('ManticoreFile: Vector update failed', ['file_ids' => $failedIds]);
}
}
return $successCount;
} catch (\Exception $e) {
Log::error('ManticoreFile generateVectorsBatch error: ' . $e->getMessage());
return 0;
}
}
}