feat(vector-db): integrate vector database for image search

- Replace pgvector with Microsoft Vector DB for image vector storage and search
- Update Picture model to use float[] instead of Vector type
- Modify PictureService to use VectorDbService for vector search
- Remove vector-related code from MyDbContext
- Add PictureVector model for Vector DB integration
This commit is contained in:
ShiYu
2025-05-31 00:33:37 +08:00
parent 5886eb8690
commit 32af3720e3
8 changed files with 98 additions and 120 deletions
+1 -3
View File
@@ -4,8 +4,6 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using System.Text; using System.Text;
using Foxel.Services.Attributes;
using System.Reflection;
using Foxel.Services.AI; using Foxel.Services.AI;
using Foxel.Services.Auth; using Foxel.Services.Auth;
using Foxel.Services.Background; using Foxel.Services.Background;
@@ -48,7 +46,7 @@ public static class ServiceCollectionExtensions
Console.WriteLine($"数据库连接: {connectionString}"); Console.WriteLine($"数据库连接: {connectionString}");
services.AddDbContextFactory<MyDbContext>(options => services.AddDbContextFactory<MyDbContext>(options =>
options.UseNpgsql(connectionString, o => o.UseVector())); options.UseNpgsql(connectionString));
} }
public static void AddApplicationOpenApi(this IServiceCollection services) public static void AddApplicationOpenApi(this IServiceCollection services)
+2 -3
View File
@@ -16,10 +16,9 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.54.0-preview" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Pgvector" Version="0.3.1" /> <PackageReference Include="Scalar.AspNetCore" Version="2.4.6" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.2" />
<PackageReference Include="Scalar.AspNetCore" Version="2.3.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.46" /> <PackageReference Include="Tencent.QCloud.Cos.Sdk" Version="5.4.46" />
</ItemGroup> </ItemGroup>
+5 -6
View File
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json; using System.Text.Json;
using Foxel.Services.Attributes; using Foxel.Services.Attributes;
using Vector = Pgvector.Vector;
namespace Foxel.Models.DataBase; namespace Foxel.Models.DataBase;
@@ -15,7 +14,7 @@ public class Picture : BaseModel
[StringLength(1024)] public string? ThumbnailPath { get; set; } = string.Empty; [StringLength(1024)] public string? ThumbnailPath { get; set; } = string.Empty;
[StringLength(2000)] public string Description { get; set; } = string.Empty; [StringLength(2000)] public string Description { get; set; } = string.Empty;
[Column(TypeName = "vector(1024)")] public Vector? Embedding { get; set; } public float[]? Embedding { get; set; }
public DateTime? TakenAt { get; set; } public DateTime? TakenAt { get; set; }
@@ -57,8 +56,8 @@ public enum PermissionType
public enum ProcessingStatus public enum ProcessingStatus
{ {
Pending, // 等待处理 Pending, // 等待处理
Processing, // 处理中 Processing, // 处理中
Completed, // 处理完成 Completed, // 处理完成
Failed // 处理失败 Failed // 处理失败
} }
+12
View File
@@ -0,0 +1,12 @@
namespace Foxel.Models.Vector;
using Microsoft.Extensions.VectorData;
public class PictureVector
{
[VectorStoreKey] public int Id { get; set; }
[VectorStoreData] public string? Name { get; set; }
[VectorStoreVector(Dimensions: 1024, DistanceFunction = DistanceFunction.CosineSimilarity)]
public ReadOnlyMemory<float>? Embedding { get; set; }
}
-11
View File
@@ -5,17 +5,6 @@ namespace Foxel;
public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options) public class MyDbContext(DbContextOptions<MyDbContext> options) : DbContext(options)
{ {
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<Picture>()
.HasIndex(p => p.Embedding)
.HasMethod("ivfflat")
.HasOperators("vector_cosine_ops")
.HasStorageParameter("lists", 100);
}
public DbSet<Picture> Pictures { get; set; } = null!; public DbSet<Picture> Pictures { get; set; } = null!;
public DbSet<User> Users { get; set; } = null!; public DbSet<User> Users { get; set; } = null!;
public DbSet<Tag> Tags { get; set; } = null!; public DbSet<Tag> Tags { get; set; } = null!;
+2 -2
View File
@@ -1,8 +1,7 @@
using Foxel;
using Foxel.Extensions; using Foxel.Extensions;
using Foxel.Services.Initializer; using Foxel.Services.Initializer;
using Foxel.Services.VectorDB;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var environment = builder.Environment; var environment = builder.Environment;
@@ -17,6 +16,7 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddApplicationAuthentication(); builder.Services.AddApplicationAuthentication();
builder.Services.AddApplicationAuthorization(); builder.Services.AddApplicationAuthorization();
builder.Services.AddApplicationCors(); builder.Services.AddApplicationCors();
builder.Services.AddSingleton<VectorDbService>();
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
+19 -7
View File
@@ -209,7 +209,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
// 1. 获取图片信息 // 1. 获取图片信息
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 10); await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 10);
if (picture == null) if (picture == null)
{ {
throw new Exception($"找不到ID为{task.PictureId}的图片"); throw new Exception($"找不到ID为{task.PictureId}的图片");
@@ -225,7 +225,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
{ {
// 非本地存储需要先下载文件 // 非本地存储需要先下载文件
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 15); await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 15);
localFilePath = await storageService.ExecuteAsync(picture.StorageType, localFilePath = await storageService.ExecuteAsync(picture.StorageType,
provider => provider.DownloadFileAsync(picture.Path)); provider => provider.DownloadFileAsync(picture.Path));
isTempFile = true; isTempFile = true;
} }
@@ -245,11 +245,12 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
// 更新缩略图路径到数据库 // 更新缩略图路径到数据库
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 25); await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 25);
if (picture.StorageType == StorageType.Local) if (picture.StorageType == StorageType.Local)
{ {
// 本地存储缩略图 // 本地存储缩略图
var relativeThumbnailPath = $"/Uploads/{Path.GetRelativePath("Uploads", Path.GetDirectoryName(thumbnailPath)!)}/{Path.GetFileName(thumbnailPath)}"; var relativeThumbnailPath =
$"/Uploads/{Path.GetRelativePath("Uploads", Path.GetDirectoryName(thumbnailPath)!)}/{Path.GetFileName(thumbnailPath)}";
picture.ThumbnailPath = relativeThumbnailPath.Replace('\\', '/'); picture.ThumbnailPath = relativeThumbnailPath.Replace('\\', '/');
} }
else else
@@ -275,7 +276,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
// 4. 从EXIF中提取拍摄时间并确保是UTC格式 // 4. 从EXIF中提取拍摄时间并确保是UTC格式
picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal); picture.TakenAt = ImageHelper.ParseExifDateTime(exifInfo.DateTimeOriginal);
// 保存缩略图和EXIF信息的更改,确保这些基本信息即使在后续步骤失败时也能保存 // 保存缩略图和EXIF信息的更改,确保这些基本信息即使在后续步骤失败时也能保存
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
@@ -300,7 +301,18 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 60); await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 60);
var combinedText = $"{finalTitle}. {finalDescription}"; var combinedText = $"{finalTitle}. {finalDescription}";
var embedding = await aiService.GetEmbeddingAsync(combinedText); var embedding = await aiService.GetEmbeddingAsync(combinedText);
picture.Embedding = new Pgvector.Vector(embedding); picture.Embedding = embedding;
if (picture.UserId.HasValue && embedding.Length > 0)
{
var vectorDbService = scope.ServiceProvider.GetRequiredService<VectorDB.VectorDbService>();
var pictureVector = new Foxel.Models.Vector.PictureVector
{
Id = picture.Id,
Name = picture.Name,
Embedding = embedding
};
await vectorDbService.AddPictureToUserCollectionAsync(picture.UserId.Value, pictureVector);
}
// 8. 获取所有可用标签名称 // 8. 获取所有可用标签名称
await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 70); await UpdatePictureStatus(task.PictureId, ProcessingStatus.Processing, 70);
@@ -367,7 +379,7 @@ public sealed class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable
{ {
picture.ProcessingStatus = ProcessingStatus.Failed; picture.ProcessingStatus = ProcessingStatus.Failed;
picture.ProcessingError = ex.Message; picture.ProcessingError = ex.Message;
try try
{ {
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
+57 -88
View File
@@ -8,10 +8,9 @@ using Foxel.Services.Attributes;
using Foxel.Services.Background; using Foxel.Services.Background;
using Foxel.Services.Configuration; using Foxel.Services.Configuration;
using Foxel.Services.Storage; using Foxel.Services.Storage;
using Foxel.Services.VectorDB;
using Foxel.Utils; using Foxel.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Pgvector;
using Pgvector.EntityFrameworkCore;
namespace Foxel.Services.Media; namespace Foxel.Services.Media;
@@ -20,6 +19,7 @@ public class PictureService(
IAiService embeddingService, IAiService embeddingService,
IConfigService configuration, IConfigService configuration,
IBackgroundTaskQueue backgroundTaskQueue, IBackgroundTaskQueue backgroundTaskQueue,
VectorDbService vectorDbService,
IStorageService storageService) IStorageService storageService)
: IPictureService : IPictureService
{ {
@@ -61,7 +61,7 @@ public class PictureService(
{ {
// 如果向量搜索失败,记录错误并回退到标准搜索 // 如果向量搜索失败,记录错误并回退到标准搜索
Console.WriteLine($"向量搜索失败,回退到标准搜索: {ex.Message}"); Console.WriteLine($"向量搜索失败,回退到标准搜索: {ex.Message}");
// 如果是明确的配置错误,则向上抛出异常 // 如果是明确的配置错误,则向上抛出异常
if (ex.Message.Contains("请检查嵌入模型配置")) if (ex.Message.Contains("请检查嵌入模型配置"))
{ {
@@ -69,7 +69,7 @@ public class PictureService(
} }
} }
} }
// 执行标准搜索(作为默认方法或向量搜索的回退选项) // 执行标准搜索(作为默认方法或向量搜索的回退选项)
return await PerformStandardSearchAsync( return await PerformStandardSearchAsync(
dbContext, page, pageSize, searchQuery, tags, dbContext, page, pageSize, searchQuery, tags,
@@ -95,78 +95,41 @@ public class PictureService(
int? ownerId, int? ownerId,
bool includeAllPublic) bool includeAllPublic)
{ {
try var queryEmbedding = await embeddingService.GetEmbeddingAsync(searchQuery);
var res = await vectorDbService.SearchAsync(queryEmbedding, userId);
var ids = res.Select(r => r.Id).ToList();
var picturesData = await dbContext.Pictures
.Include(p => p.Tags)
.Include(p => p.User)
.Where(p => ids.Contains(p.Id))
.ToListAsync();
var picturesOrdered = ids
.Select(id => picturesData.FirstOrDefault(p => p.Id == id))
.Where(p => p != null)
.ToList();
var paginatedResults = picturesOrdered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => MapPictureToResponse(p!))
.ToList();
var totalCount = picturesOrdered.Count;
await PopulateFavoriteInfo(dbContext, paginatedResults, userId);
if (userId.HasValue)
{ {
float[]? queryEmbedding = null; await PopulateAlbumInfo(dbContext, paginatedResults, userId.Value);
try
{
queryEmbedding = await embeddingService.GetEmbeddingAsync(searchQuery);
// 检查嵌入向量是否有效
if (queryEmbedding == null || queryEmbedding.Length == 0)
{
throw new InvalidOperationException("嵌入模型返回了空向量");
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"向量搜索失败,请检查嵌入模型配置: {ex.Message}", ex);
}
var queryVector = new Vector(queryEmbedding);
// 构建基础查询
var query = dbContext.Pictures
.Include(p => p.Tags)
.Include(p => p.User)
.Where(p => p.Embedding != null);
// 应用共通的查询条件
query = ApplyCommonFilters(query, tags, startDate, endDate, userId, onlyWithGps,
excludeAlbumId, albumId, onlyFavorites, ownerId, includeAllPublic);
// 执行向量搜索
var allResults = await query
.Select(p => new
{
Picture = p,
Similarity = 1.0 - p.Embedding!.CosineDistance(queryVector)
})
.Where(p => p.Similarity >= similarityThreshold)
.OrderByDescending(p => p.Similarity)
.ToListAsync();
// 计算总数并分页
var totalCount = allResults.Count;
var paginatedResults = allResults
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(r => MapPictureToResponse(r.Picture))
.ToList();
// 处理收藏信息
await PopulateFavoriteInfo(dbContext, paginatedResults, userId);
// 为当前用户的图片添加相册信息
if (userId.HasValue)
{
await PopulateAlbumInfo(dbContext, paginatedResults, userId.Value);
}
return new PaginatedResult<PictureResponse>
{
Data = paginatedResults,
Page = page,
PageSize = pageSize,
TotalCount = totalCount
};
} }
catch (Exception ex)
return new PaginatedResult<PictureResponse>
{ {
Console.WriteLine($"向量搜索失败: {ex.Message}"); Data = paginatedResults,
throw new InvalidOperationException($"向量搜索失败: {ex.Message}", ex); Page = page,
} PageSize = pageSize,
TotalCount = totalCount
};
} }
// 执行标准搜索 // 执行标准搜索
@@ -377,9 +340,9 @@ public class PictureService(
{ {
Id = picture.Id, Id = picture.Id,
Name = picture.Name, Name = picture.Name,
Path = storageService.ExecuteAsync(picture.StorageType, provider => Path = storageService.ExecuteAsync(picture.StorageType, provider =>
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result, Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))).Result,
ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider => ThumbnailPath = storageService.ExecuteAsync(picture.StorageType, provider =>
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result, Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))).Result,
Description = picture.Description, Description = picture.Description,
CreatedAt = picture.CreatedAt, CreatedAt = picture.CreatedAt,
@@ -482,8 +445,8 @@ public class PictureService(
string? configValue = configuration[configKey]; string? configValue = configuration[configKey];
return !string.IsNullOrEmpty(configValue) && return !string.IsNullOrEmpty(configValue) &&
Enum.TryParse<StorageType>(configValue, out var configStorageType) Enum.TryParse<StorageType>(configValue, out var configStorageType)
? configStorageType ? configStorageType
: StorageType.Local; : StorageType.Local;
} }
if (userId == null) if (userId == null)
@@ -494,6 +457,7 @@ public class PictureService(
{ {
storageType = GetConfigStorageType("Storage:DefaultStorage"); storageType = GetConfigStorageType("Storage:DefaultStorage");
} }
ImageFormat convertToFormat = ImageFormat.Original; ImageFormat convertToFormat = ImageFormat.Original;
string defaultFormatConfig = configuration["Upload:DefaultImageFormat"]; string defaultFormatConfig = configuration["Upload:DefaultImageFormat"];
if (!string.IsNullOrEmpty(defaultFormatConfig)) if (!string.IsNullOrEmpty(defaultFormatConfig))
@@ -503,12 +467,14 @@ public class PictureService(
convertToFormat = parsedFormat; convertToFormat = parsedFormat;
} }
} }
int quality = 100; int quality = 100;
string defaultQualityConfig = configuration["Upload:DefaultImageQuality"]; string defaultQualityConfig = configuration["Upload:DefaultImageQuality"];
if (!string.IsNullOrEmpty(defaultQualityConfig)) if (!string.IsNullOrEmpty(defaultQualityConfig))
{ {
quality = int.Parse(defaultQualityConfig); quality = int.Parse(defaultQualityConfig);
} }
string originalFileName = fileName; string originalFileName = fileName;
string finalFileName = fileName; string finalFileName = fileName;
string finalContentType = contentType; string finalContentType = contentType;
@@ -553,7 +519,7 @@ public class PictureService(
try try
{ {
// 使用存储服务保存文件 // 使用存储服务保存文件
string relativePath = await storageService.ExecuteAsync(storageType.Value, string relativePath = await storageService.ExecuteAsync(storageType.Value,
provider => provider.SaveAsync(finalStream, finalFileName, finalContentType)); provider => provider.SaveAsync(finalStream, finalFileName, finalContentType));
// 创建基本的Picture对象,使用文件名作为标题和描述 // 创建基本的Picture对象,使用文件名作为标题和描述
@@ -620,10 +586,12 @@ public class PictureService(
{ {
Id = picture.Id, Id = picture.Id,
Name = picture.Name, Name = picture.Name,
Path = await storageService.ExecuteAsync(picture.StorageType, provider => Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
Task.FromResult(provider.GetUrl(relativePath))), Task.FromResult(provider.GetUrl(relativePath))),
ThumbnailPath = isAnonymous ? await storageService.ExecuteAsync(picture.StorageType, provider => ThumbnailPath = isAnonymous
Task.FromResult(provider.GetUrl(relativePath))) : null, ? await storageService.ExecuteAsync(picture.StorageType, provider =>
Task.FromResult(provider.GetUrl(relativePath)))
: null,
Description = picture.Description, Description = picture.Description,
CreatedAt = picture.CreatedAt, CreatedAt = picture.CreatedAt,
Tags = new List<string>(), Tags = new List<string>(),
@@ -700,7 +668,8 @@ public class PictureService(
new List<(int PictureId, string Path, string ThumbnailPath, int? UserId, StorageType StorageType)>(); new List<(int PictureId, string Path, string ThumbnailPath, int? UserId, StorageType StorageType)>();
foreach (var picture in picturesToDelete) foreach (var picture in picturesToDelete)
{ {
filesToDelete.Add((picture.Id, picture.Path, picture.ThumbnailPath ?? string.Empty, picture.User?.Id, picture.StorageType)); filesToDelete.Add((picture.Id, picture.Path, picture.ThumbnailPath ?? string.Empty, picture.User?.Id,
picture.StorageType));
} }
if (picturesToDelete.Any()) if (picturesToDelete.Any())
@@ -718,13 +687,13 @@ public class PictureService(
try try
{ {
// 使用存储服务删除文件 // 使用存储服务删除文件
await storageService.ExecuteAsync(storageType, await storageService.ExecuteAsync(storageType,
provider => provider.DeleteAsync(path)); provider => provider.DeleteAsync(path));
// 删除缩略图 // 删除缩略图
if (!string.IsNullOrEmpty(thumbnailPath)) if (!string.IsNullOrEmpty(thumbnailPath))
{ {
await storageService.ExecuteAsync(storageType, await storageService.ExecuteAsync(storageType,
provider => provider.DeleteAsync(thumbnailPath)); provider => provider.DeleteAsync(thumbnailPath));
} }
} }
@@ -780,11 +749,11 @@ public class PictureService(
{ {
var combinedText = $"{picture.Name}. {picture.Description}"; var combinedText = $"{picture.Name}. {picture.Description}";
var embedding = await embeddingService.GetEmbeddingAsync(combinedText); var embedding = await embeddingService.GetEmbeddingAsync(combinedText);
// 只有在成功获取到非空嵌入向量时才更新 // 只有在成功获取到非空嵌入向量时才更新
if (embedding != null && embedding.Length > 0) if (embedding != null && embedding.Length > 0)
{ {
picture.Embedding = new Vector(embedding); picture.Embedding = embedding;
} }
else else
{ {
@@ -826,9 +795,9 @@ public class PictureService(
{ {
Id = picture.Id, Id = picture.Id,
Name = picture.Name, Name = picture.Name,
Path = await storageService.ExecuteAsync(picture.StorageType, provider => Path = await storageService.ExecuteAsync(picture.StorageType, provider =>
Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))), Task.FromResult(provider.GetUrl(picture.Path ?? string.Empty))),
ThumbnailPath = await storageService.ExecuteAsync(picture.StorageType, provider => ThumbnailPath = await storageService.ExecuteAsync(picture.StorageType, provider =>
Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))), Task.FromResult(provider.GetUrl(picture.ThumbnailPath ?? string.Empty))),
Description = picture.Description, Description = picture.Description,
CreatedAt = picture.CreatedAt, CreatedAt = picture.CreatedAt,