feat: add AI chat, RAG Q&A, knowledge base, embeddings, document processing

- AI chat with SSE streaming (Microsoft Agent Framework + Qwen)
- RAG Q&A with hybrid retrieval (vector + keyword RRF fusion)
- Knowledge base CRUD with semantic text chunking
- Embedding generation via Azure.AI.OpenAI / LM Studio
- Document upload with chunked upload support
- Redis caching for chat messages
- Chunk/vector preview endpoints
- gRPC auth service improvements
- Removed demo menus, cleaned up seed data
This commit is contained in:
向宁 2026-05-20 20:28:15 +08:00
parent 9a403634b2
commit 67b030c3c5
84 changed files with 3803 additions and 157 deletions

141
CLAUDE.md Normal file
View File

@ -0,0 +1,141 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **Cross-repo rules** — see `/Users/wen/project/rag/CLAUDE.md` for full workspace conventions. Key shared rules:
> - File-scoped namespaces, primary constructor DI, `record` for DTOs/Commands/Queries
> - Entity: `BaseEntity + IAuditable/ISoftDelete/IFullAudit` marker interfaces
> - EF Config: snake_case table, `ValueGeneratedNever()`, `IEntityTypeConfiguration<T>`
> - Endpoint: `FastEndpoint<TReq, TRes>` + `Permissions("resource:action")`
> - Middleware: `Cors → GlobalException → ApiResponse → Auth → AuthZ → FastEndpoints`
> - Response: `{ code: 0, data, message: "ok" }` (auto-wrapped)
> - JWT shared key: `RagJwtSecretKey2026MustBeAtLeast32CharsLong!`
> - Other repos: `rag-backend` (5211), `im-system` (5212), `work-flow`, `file-system` (8080 Go), `rag-frontend` (5666 Vue)
## Build & Run
```bash
dotnet build
cd src/RAG.Api && dotnet run # HTTP :5211, gRPC :50051
cd src/RAG.Api && dotnet run -- --seed # Migrate + seed, then exit
cd src/RAG.Infrastructure && dotnet ef migrations add <Name> --startup-project ../RAG.Api
cd src/RAG.Infrastructure && dotnet ef database update --startup-project ../RAG.Api
docker compose up -d # PostgreSQL 17 + pgvector, Redis 7, RabbitMQ 3
```
## Architecture
.NET 10, ABP modular. Four projects: `Api → Application → Infrastructure → Domain`.
### ABP Module Chain
```
RAGApiModule [DependsOn(RAGApplicationModule, RAGInfrastructureModule)]
→ RAGApplicationModule [DependsOn(RAGInfrastructureModule)]
→ RAGInfrastructureModule [DependsOn(RAGDomainModule)]
```
DI in `ConfigureServices()`, middleware in `OnApplicationInitialization()`. `Program.cs` bootstraps via `builder.AddApplicationAsync<RAGApiModule>()`.
### Middleware Order
```
Cors → GlobalExceptionMiddleware → ApiResponseMiddleware → Authentication → Authorization → FastEndpoints → SwaggerGen → MapGrpcService
```
### Feature Folder Convention
```
Application/{Feature}/
Commands/{Action}{Entity}Command.cs — record + Handler(DbContext) in same file
Queries/{Action}{Entity}Query.cs — record + Handler(DbContext) in same file
DTOs/{Entity}DTOs.cs — all DTOs for feature in one file
Validators/{Entity}CommandValidators.cs — FluentValidation rules
Api/Endpoints/{Feature}/
{Action}{Entity}Endpoint.cs — FastEndpoint<TReq, TRes>
```
### gRPC Auth Service
`AuthGrpcService` (code-first via protobuf-net.Grpc) exposes `ValidateToken` and `CheckPermission`. Other services (file-system, work-flow, im-system) call these RPCs.
### Seed Data
`SeedData.cs` creates: 11 permissions, 3 roles (SuperAdmin/Admin/User), admin user (`admin/admin123`), ~13 demo menus.
## Code Patterns
### Entity
```csharp
public class User : BaseEntity, IFullAudit
{
public string Username { get; set; } = default!;
public ICollection<UserRole> UserRoles { get; set; } = [];
// IAuditable, ISoftDelete, IHasOperatorIP fields with = default!
}
```
Join tables: composite key + `IAuditable` only (no BaseEntity, no soft delete).
### EF Configuration
```csharp
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users"); // snake_case
builder.HasKey(e => e.Id);
builder.Property(e => e.Id).ValueGeneratedNever(); // Client-generated Guid
builder.HasIndex(e => e.Username).IsUnique();
}
}
```
### Command + Handler (same file)
```csharp
public record CreateUserCommand(string Username, string Email, string Password) : IRequest<UserDto>;
public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler<CreateUserCommand, UserDto>
{
public async Task<UserDto> Handle(CreateUserCommand request, CancellationToken ct) { ... }
}
```
### Endpoint
```csharp
public class CreateUserEndpoint(IMediator mediator) : Endpoint<CreateUserRequest, UserDto>
{
public override void Configure() { Post("/users"); Permissions("user:create"); }
public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct) { ... }
}
```
### Validation
```csharp
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserCommandValidator()
{
RuleFor(x => x.Username).NotEmpty().Length(3, 50);
}
}
```
Auto-registered + `ValidationBehavior` pipeline. Chinese error messages.
## Conventions
- File-scoped namespaces everywhere
- Primary constructors for DI (endpoints, handlers, middleware)
- `record` for DTOs/Commands/Queries, `class` for entities/handlers/endpoints
- Nullable reference types ON, `= default!` for required strings
- `TreatWarningsAsErrors` ON
- Permission codes: `{resource}:{action}` (e.g., `user:create`)
- Response: `{ code: 0, data, message: "ok" }` (auto-wrapped)
- Route prefix: `/api/`

View File

@ -1,5 +1,4 @@
using RAG.Domain.Common;
using RAG.Domain.Exceptions;
using FastEndpoints;
using MediatR;
using RAG.Application.Auth.Queries;

View File

@ -1,5 +1,4 @@
using RAG.Domain.Common;
using RAG.Domain.Exceptions;
using FastEndpoints;
using MediatR;
using RAG.Application.Auth.Queries;

View File

@ -1,9 +1,7 @@
using RAG.Domain.Exceptions;
using FastEndpoints;
using MediatR;
using Microsoft.Extensions.Configuration;
using RAG.Application.Auth.Commands;
using RAG.Application.Auth.DTOs;
namespace RAG.Api.Endpoints.Auth;

View File

@ -0,0 +1,23 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Chat.Commands;
using RAG.Application.Chat.DTOs;
namespace RAG.Api.Endpoints.Chat;
public class CreateConversationEndpoint(IMediator mediator) : Endpoint<CreateConversationRequest, ConversationDto>
{
public override void Configure()
{
Post("/chat/conversations");
AllowAnonymous();
}
public override async Task HandleAsync(CreateConversationRequest req, CancellationToken ct)
{
var result = await mediator.Send(new CreateConversationCommand(req.Title), ct);
await Send.ResponseAsync(result, 201, ct);
}
}
public record CreateConversationRequest(string Title);

View File

@ -0,0 +1,22 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Chat.Commands;
namespace RAG.Api.Endpoints.Chat;
public class DeleteConversationEndpoint(IMediator mediator) : Endpoint<DeleteConversationEndpointRequest>
{
public override void Configure()
{
Delete("/chat/conversations/{Id}");
AllowAnonymous();
}
public override async Task HandleAsync(DeleteConversationEndpointRequest req, CancellationToken ct)
{
await mediator.Send(new DeleteConversationCommand(req.Id), ct);
HttpContext.Response.StatusCode = 204;
}
}
public record DeleteConversationEndpointRequest(Guid Id);

View File

@ -0,0 +1,23 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Chat.DTOs;
using RAG.Application.Chat.Queries;
namespace RAG.Api.Endpoints.Chat;
public class GetConversationDetailEndpoint(IMediator mediator) : Endpoint<GetConversationDetailRequest, ConversationDetailDto>
{
public override void Configure()
{
Get("/chat/conversations/{Id}");
AllowAnonymous();
}
public override async Task HandleAsync(GetConversationDetailRequest req, CancellationToken ct)
{
var result = await mediator.Send(new GetConversationDetailQuery(req.Id), ct);
await Send.OkAsync(result, ct);
}
}
public record GetConversationDetailRequest(Guid Id);

View File

@ -0,0 +1,21 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Chat.DTOs;
using RAG.Application.Chat.Queries;
namespace RAG.Api.Endpoints.Chat;
public class GetConversationsEndpoint(IMediator mediator) : EndpointWithoutRequest<List<ConversationDto>>
{
public override void Configure()
{
Get("/chat/conversations");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var result = await mediator.Send(new GetConversationsQuery(), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,24 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Chat.Commands;
using RAG.Application.Chat.DTOs;
namespace RAG.Api.Endpoints.Chat;
public class SendMessageEndpoint(IMediator mediator) : Endpoint<SendMessageRequest, SendMessageResponse>
{
public override void Configure()
{
Post("/chat/conversations/{ConversationId}/messages");
AllowAnonymous();
}
public override async Task HandleAsync(SendMessageRequest req, CancellationToken ct)
{
var conversationId = Route<Guid>("ConversationId");
var result = await mediator.Send(new SendMessageCommand(conversationId, req.Content), ct);
await Send.OkAsync(result, ct);
}
}
public record SendMessageRequest(string Content);

View File

@ -0,0 +1,79 @@
using System.Text;
using System.Text.Json;
using FastEndpoints;
using RAG.Domain.Entities;
using RAG.Domain.Enums;
using RAG.Domain.Interfaces;
using RAG.Infrastructure.Persistence;
namespace RAG.Api.Endpoints.Chat;
public class StreamMessageEndpoint(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache)
: Endpoint<StreamMessageRequest>
{
public override void Configure()
{
Post("/chat/conversations/{ConversationId}/stream");
AllowAnonymous();
}
public override async Task HandleAsync(StreamMessageRequest req, CancellationToken ct)
{
var conversationId = Route<Guid>("ConversationId");
var conversation = await db.Conversations.FindAsync([conversationId], ct);
if (conversation is null)
{
HttpContext.Response.StatusCode = 404;
return;
}
// 保存用户消息到 DB + Redis
var userMessage = new ChatMessage
{
ConversationId = conversationId,
Role = ChatRole.User,
Content = req.Content
};
await db.ChatMessages.AddAsync(userMessage, ct);
await db.SaveChangesAsync(ct);
await cache.AppendMessageAsync(conversationId,
new CachedChatMessage(userMessage.Id, ChatRole.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct);
// SSE 响应
HttpContext.Response.ContentType = "text/event-stream";
HttpContext.Response.Headers.CacheControl = "no-cache";
HttpContext.Response.Headers.Connection = "keep-alive";
var fullReply = new StringBuilder();
await foreach (var chunk in chatAgent.RunStreamingAsync(req.Content, ct))
{
fullReply.Append(chunk);
var sseData = JsonSerializer.Serialize(new { content = chunk });
await HttpContext.Response.WriteAsync($"data: {sseData}\n\n", ct);
await HttpContext.Response.Body.FlushAsync(ct);
}
var replyText = fullReply.ToString();
// 保存助手回复到 DB + Redis
var assistantMessage = new ChatMessage
{
ConversationId = conversationId,
Role = ChatRole.Assistant,
Content = replyText
};
await db.ChatMessages.AddAsync(assistantMessage, ct);
await db.SaveChangesAsync(ct);
await cache.AppendMessageAsync(conversationId,
new CachedChatMessage(assistantMessage.Id, ChatRole.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct);
// 发送结束标记(含完整消息 ID
var doneData = JsonSerializer.Serialize(new { messageId = assistantMessage.Id });
await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct);
await HttpContext.Response.Body.FlushAsync(ct);
}
}
public record StreamMessageRequest(string Content);

View File

@ -0,0 +1,156 @@
using System.Collections.Concurrent;
using FastEndpoints;
using MediatR;
using RAG.Application.Document.Commands;
using RAG.Application.Document.DTOs;
namespace RAG.Api.Endpoints.Document;
// ===== 数据模型 =====
public class ChunkUploadSession(string sessionId, Guid knowledgeBaseId, string fileName, long fileSize, int chunkCount, string tempDir)
{
public string SessionId { get; } = sessionId;
public Guid KnowledgeBaseId { get; } = knowledgeBaseId;
public string FileName { get; } = fileName;
public long FileSize { get; } = fileSize;
public int ChunkCount { get; } = chunkCount;
public string TempDir { get; } = tempDir;
public ConcurrentDictionary<int, string> UploadedChunks { get; } = [];
}
public static class ChunkUploadStore
{
public static readonly ConcurrentDictionary<string, ChunkUploadSession> Sessions = [];
}
// ===== 初始化 =====
public class InitChunkUploadRequest
{
public string FileName { get; set; } = default!;
public long FileSize { get; set; }
public int ChunkCount { get; set; }
}
public class InitChunkUploadEndpoint : Endpoint<InitChunkUploadRequest, ChunkUploadSession>
{
public override void Configure()
{
Post("/knowledge-bases/{KnowledgeBaseId}/documents/chunk-upload/init");
AllowAnonymous();
}
public override async Task HandleAsync(InitChunkUploadRequest req, CancellationToken ct)
{
var kbId = Route<Guid>("KnowledgeBaseId");
var sessionId = Guid.NewGuid().ToString("N");
var tempDir = Path.Combine(Path.GetTempPath(), "rag_chunks", sessionId);
Directory.CreateDirectory(tempDir);
var session = new ChunkUploadSession(sessionId, kbId, req.FileName, req.FileSize, req.ChunkCount, tempDir);
ChunkUploadStore.Sessions[sessionId] = session;
await Send.OkAsync(session, ct);
}
}
// ===== 上传分片 =====
public class UploadChunkEndpoint : EndpointWithoutRequest<EmptyResponse>
{
public override void Configure()
{
Post("/documents/chunk-upload/{SessionId}/chunks/{ChunkIndex}");
AllowAnonymous();
AllowFileUploads();
}
public override async Task HandleAsync(CancellationToken ct)
{
var sessionId = Route<string>("SessionId");
var chunkIndex = Route<int>("ChunkIndex");
if (!ChunkUploadStore.Sessions.TryGetValue(sessionId!, out var session))
{
HttpContext.Response.StatusCode = 404;
return;
}
var file = Files.FirstOrDefault();
if (file is null)
{
ThrowError("请上传分片文件");
return;
}
var chunkPath = Path.Combine(session.TempDir, $"chunk_{chunkIndex}");
await using (var stream = System.IO.File.Create(chunkPath))
{
await file.CopyToAsync(stream, ct);
}
session.UploadedChunks.TryAdd(chunkIndex, chunkPath);
HttpContext.Response.StatusCode = 200;
await HttpContext.Response.WriteAsJsonAsync(new { uploaded = session.UploadedChunks.Count, total = session.ChunkCount }, ct);
}
}
// ===== 合并完成 =====
public class CompleteChunkUploadRequest
{
public string? Title { get; set; }
}
public class CompleteChunkUploadEndpoint(IMediator mediator) : Endpoint<CompleteChunkUploadRequest, DocumentDto>
{
public override void Configure()
{
Post("/documents/chunk-upload/{SessionId}/complete");
AllowAnonymous();
}
public override async Task HandleAsync(CompleteChunkUploadRequest req, CancellationToken ct)
{
var sessionId = Route<string>("SessionId");
if (!ChunkUploadStore.Sessions.TryGetValue(sessionId!, out var session))
{
HttpContext.Response.StatusCode = 404;
return;
}
if (session.UploadedChunks.Count != session.ChunkCount)
{
ThrowError($"分片不完整:已上传 {session.UploadedChunks.Count}/{session.ChunkCount}");
return;
}
var ext = Path.GetExtension(session.FileName);
var mergedPath = Path.Combine(Path.GetTempPath(), $"rag_{Guid.NewGuid()}{ext}");
await using (var mergedStream = System.IO.File.Create(mergedPath))
{
for (var i = 0; i < session.ChunkCount; i++)
{
if (!session.UploadedChunks.TryGetValue(i, out var chunkPath)) continue;
await using var chunkStream = System.IO.File.OpenRead(chunkPath);
await chunkStream.CopyToAsync(mergedStream, ct);
}
}
var result = await mediator.Send(new UploadDocumentCommand(
session.KnowledgeBaseId,
req.Title ?? session.FileName,
session.FileName,
mergedPath,
session.FileSize,
"application/octet-stream"
), ct);
try { Directory.Delete(session.TempDir, true); } catch { /* ignore */ }
ChunkUploadStore.Sessions.TryRemove(sessionId!, out _);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,22 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Document.DTOs;
using RAG.Application.Document.Queries;
namespace RAG.Api.Endpoints.Document;
public class GetDocumentChunksEndpoint(IMediator mediator) : EndpointWithoutRequest<List<ChunkPreviewDto>>
{
public override void Configure()
{
Get("/documents/{DocumentId}/chunks");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var docId = Route<Guid>("DocumentId");
var result = await mediator.Send(new GetDocumentChunksQuery(docId), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,22 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Document.DTOs;
using RAG.Application.Document.Queries;
namespace RAG.Api.Endpoints.Document;
public class ListDocumentsEndpoint(IMediator mediator) : EndpointWithoutRequest<List<DocumentDto>>
{
public override void Configure()
{
Get("/knowledge-bases/{KnowledgeBaseId}/documents");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var kbId = Route<Guid>("KnowledgeBaseId");
var result = await mediator.Send(new GetDocumentsQuery(kbId), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,22 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Document.Commands;
using RAG.Application.Document.DTOs;
namespace RAG.Api.Endpoints.Document;
public class ProcessDocumentEndpoint(IMediator mediator) : Endpoint<EmptyRequest, ProcessDocumentResponse>
{
public override void Configure()
{
Post("/documents/{DocumentId}/process");
AllowAnonymous();
}
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
{
var docId = Route<Guid>("DocumentId");
var result = await mediator.Send(new ProcessDocumentCommand(docId), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,38 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Document.Commands;
using RAG.Application.Document.DTOs;
using DomainExceptions = RAG.Domain.Exceptions;
namespace RAG.Api.Endpoints.Document;
public class UploadDocumentEndpoint(IMediator mediator) : Endpoint<UploadDocumentRequest, DocumentDto>
{
public override void Configure()
{
Post("/knowledge-bases/{KnowledgeBaseId}/documents");
AllowAnonymous();
AllowFileUploads();
}
public override async Task HandleAsync(UploadDocumentRequest req, CancellationToken ct)
{
var kbId = Route<Guid>("KnowledgeBaseId");
var file = Files.FirstOrDefault();
if (file is null)
throw new DomainExceptions.BusinessException("请上传文件");
var tempPath = Path.Combine(Path.GetTempPath(), $"rag_{Guid.NewGuid()}{Path.GetExtension(file.FileName)}");
await using (var stream = System.IO.File.Create(tempPath))
{
await file.CopyToAsync(stream, ct);
}
var result = await mediator.Send(new UploadDocumentCommand(kbId, req.Title ?? file.FileName,
file.FileName, tempPath, new FileInfo(tempPath).Length, file.ContentType), ct);
await Send.ResponseAsync(result, 201, ct);
}
}
public record UploadDocumentRequest(string? Title);

View File

@ -0,0 +1,23 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Embedding.Commands;
using RAG.Application.Embedding.DTOs;
namespace RAG.Api.Endpoints.Embedding;
public class EmbedBatchEndpoint(IMediator mediator) : Endpoint<EmbedBatchRequest, EmbeddingBatchResponse>
{
public override void Configure()
{
Post("/embeddings/batch");
AllowAnonymous();
}
public override async Task HandleAsync(EmbedBatchRequest req, CancellationToken ct)
{
var result = await mediator.Send(new EmbedBatchCommand(req.Texts), ct);
await Send.OkAsync(result, ct);
}
}
public record EmbedBatchRequest(List<string> Texts);

View File

@ -0,0 +1,23 @@
using FastEndpoints;
using MediatR;
using RAG.Application.Embedding.Commands;
using RAG.Application.Embedding.DTOs;
namespace RAG.Api.Endpoints.Embedding;
public class EmbedTextEndpoint(IMediator mediator) : Endpoint<EmbedTextRequest, EmbeddingResponse>
{
public override void Configure()
{
Post("/embeddings");
AllowAnonymous();
}
public override async Task HandleAsync(EmbedTextRequest req, CancellationToken ct)
{
var result = await mediator.Send(new EmbedTextCommand(req.Text), ct);
await Send.OkAsync(result, ct);
}
}
public record EmbedTextRequest(string Text);

View File

@ -0,0 +1,22 @@
using FastEndpoints;
using MediatR;
using RAG.Application.KnowledgeBase.Commands;
using RAG.Application.KnowledgeBase.DTOs;
namespace RAG.Api.Endpoints.KnowledgeBase;
public class CreateKBEndpoint(IMediator mediator) : Endpoint<CreateKnowledgeBaseRequest, KnowledgeBaseDto>
{
public override void Configure()
{
Post("/knowledge-bases");
AllowAnonymous();
}
public override async Task HandleAsync(CreateKnowledgeBaseRequest req, CancellationToken ct)
{
var result = await mediator.Send(
new CreateKnowledgeBaseCommand(req.Name, req.Description, req.EmbeddingModel, req.ChunkSize, req.ChunkOverlap), ct);
await Send.ResponseAsync(result, 201, ct);
}
}

View File

@ -0,0 +1,22 @@
using FastEndpoints;
using MediatR;
using RAG.Application.KnowledgeBase.Commands;
namespace RAG.Api.Endpoints.KnowledgeBase;
public class DeleteKBEndpoint(IMediator mediator) : Endpoint<DeleteKBEndpointRequest>
{
public override void Configure()
{
Delete("/knowledge-bases/{Id}");
AllowAnonymous();
}
public override async Task HandleAsync(DeleteKBEndpointRequest req, CancellationToken ct)
{
await mediator.Send(new DeleteKnowledgeBaseCommand(req.Id), ct);
HttpContext.Response.StatusCode = 204;
}
}
public record DeleteKBEndpointRequest(Guid Id);

View File

@ -0,0 +1,21 @@
using FastEndpoints;
using MediatR;
using RAG.Application.KnowledgeBase.DTOs;
using RAG.Application.KnowledgeBase.Queries;
namespace RAG.Api.Endpoints.KnowledgeBase;
public class GetKBsEndpoint(IMediator mediator) : EndpointWithoutRequest<List<KnowledgeBaseDto>>
{
public override void Configure()
{
Get("/knowledge-bases");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken ct)
{
var result = await mediator.Send(new GetKnowledgeBasesQuery(), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -1,5 +1,4 @@
using RAG.Domain.Common;
using RAG.Domain.Exceptions;
using FastEndpoints;
using MediatR;
using RAG.Application.Menus.DTOs;

View File

@ -0,0 +1,21 @@
using FastEndpoints;
using MediatR;
using RAG.Application.RagQA.Commands;
using RAG.Application.RagQA.DTOs;
namespace RAG.Api.Endpoints.RAG;
public class RAGQueryEndpoint(IMediator mediator) : Endpoint<RAGQueryRequest, RAGQueryResponse>
{
public override void Configure()
{
Post("/rag/query");
AllowAnonymous();
}
public override async Task HandleAsync(RAGQueryRequest req, CancellationToken ct)
{
var result = await mediator.Send(new RAGQueryCommand(req.KnowledgeBaseId, req.Question), ct);
await Send.OkAsync(result, ct);
}
}

View File

@ -0,0 +1,169 @@
using System.Text;
using System.Text.Json;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using RAG.Application.RagQA.DTOs;
using RAG.Domain.Interfaces;
using RAG.Infrastructure.Persistence;
namespace RAG.Api.Endpoints.RAG;
public class RAGStreamEndpoint(RagDbContext db, IEmbeddingService embeddingService, IAIChatAgent chatAgent)
: Endpoint<RAGQueryRequest>
{
private const int VectorTopK = 20;
private const int FinalTopK = 5;
private const double MinSimilarity = 0.3;
public override void Configure()
{
Post("/rag/stream");
AllowAnonymous();
}
public override async Task HandleAsync(RAGQueryRequest req, CancellationToken ct)
{
var queryVector = await embeddingService.EmbedAsync(req.Question, ct);
var vectorStr = $"[{string.Join(",", queryVector)}]";
var vectorSql = $"""
SELECT dc."Content", d."Title" as document_title, dc."DocumentId",
1 - (dc.embedding <=> '{vectorStr}'::vector) AS similarity
FROM document_chunks dc
JOIN documents d ON dc."DocumentId" = d."Id"
WHERE d."KnowledgeBaseId" = '{req.KnowledgeBaseId}'
AND dc.embedding IS NOT NULL
ORDER BY dc.embedding <=> '{vectorStr}'::vector
LIMIT {VectorTopK}
""";
var candidates = new List<(Guid DocId, string DocTitle, string Content, double VectorScore)>();
var conn = db.Database.GetDbConnection();
await conn.OpenAsync(ct);
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = vectorSql;
using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var similarity = reader["similarity"] is double d ? d : 0.0;
if (similarity < MinSimilarity) continue;
candidates.Add((
reader["DocumentId"] is Guid g ? g : Guid.Empty,
reader["document_title"]?.ToString() ?? "",
reader["Content"]?.ToString() ?? "",
similarity
));
}
}
finally
{
await conn.CloseAsync();
}
// SSE
HttpContext.Response.ContentType = "text/event-stream";
HttpContext.Response.Headers.CacheControl = "no-cache";
HttpContext.Response.Headers.Connection = "keep-alive";
if (candidates.Count == 0)
{
var noData = JsonSerializer.Serialize(new { content = "未在知识库中找到相关内容。" });
await HttpContext.Response.WriteAsync($"data: {noData}\n\n", ct);
await HttpContext.Response.WriteAsync("data: [DONE]\n\n", ct);
await HttpContext.Response.Body.FlushAsync(ct);
return;
}
// 关键词重排序
var keywords = ExtractKeywords(req.Question);
var ranked = candidates.Select(c =>
{
var keywordScore = KeywordMatchScore(c.Content, keywords);
var finalScore = c.VectorScore * 0.7 + keywordScore * 0.3;
return (c.DocId, c.DocTitle, c.Content, FinalScore: finalScore);
})
.OrderByDescending(x => x.FinalScore)
.Take(FinalTopK)
.ToList();
var sources = ranked.Select(r => new SourceChunk(
r.DocId,
r.DocTitle,
r.Content.Length > 200 ? r.Content[..200] + "..." : r.Content,
Math.Round(r.FinalScore, 4)
)).ToList();
var sourcesData = JsonSerializer.Serialize(new { sources });
await HttpContext.Response.WriteAsync($"event: sources\ndata: {sourcesData}\n\n", ct);
await HttpContext.Response.Body.FlushAsync(ct);
var contextBuilder = new StringBuilder();
contextBuilder.AppendLine("以下是检索到的相关上下文:");
for (var i = 0; i < ranked.Count; i++)
contextBuilder.AppendLine($"\n--- 上下文 {i + 1} ---\n{ranked[i].Content}");
var prompt = $"""
{contextBuilder}
{req.Question}
""";
await foreach (var chunk in chatAgent.RunStreamingAsync(prompt, ct))
{
var sseData = JsonSerializer.Serialize(new { content = chunk });
await HttpContext.Response.WriteAsync($"data: {sseData}\n\n", ct);
await HttpContext.Response.Body.FlushAsync(ct);
}
await HttpContext.Response.WriteAsync("data: [DONE]\n\n", ct);
await HttpContext.Response.Body.FlushAsync(ct);
}
private static List<string> ExtractKeywords(string question)
{
var stopWords = new HashSet<string>
{
"的", "了", "是", "在", "有", "和", "与", "或", "不", "也", "都",
"这", "那", "个", "一", "什么", "怎么", "如何", "为什么", "哪",
"哪些", "吗", "呢", "吧", "啊", "请", "能", "可以", "会",
"what", "how", "why", "is", "the", "a", "an", "of", "to", "in"
};
var words = new List<string>();
var englishWords = System.Text.RegularExpressions.Regex.Matches(question, @"[a-zA-Z]+")
.Select(m => m.Value.ToLowerInvariant())
.Where(w => w.Length >= 2 && !stopWords.Contains(w));
words.AddRange(englishWords);
var chineseSegments = System.Text.RegularExpressions.Regex.Matches(question, @"[一-鿿]+");
foreach (System.Text.RegularExpressions.Match seg in chineseSegments)
{
var text = seg.Value;
if (text.Length <= 4)
{
if (!stopWords.Contains(text)) words.Add(text);
}
else
{
for (var len = 2; len <= Math.Min(4, text.Length); len++)
for (var i = 0; i <= text.Length - len; i++)
{
var sub = text[i..(i + len)];
if (!stopWords.Contains(sub)) words.Add(sub);
}
}
}
return words.Distinct().ToList();
}
private static double KeywordMatchScore(string content, List<string> keywords)
{
if (keywords.Count == 0) return 0;
var matched = keywords.Count(kw => content.Contains(kw, StringComparison.OrdinalIgnoreCase));
return (double)matched / keywords.Count;
}
}

View File

@ -1,13 +1,15 @@
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;
using RAG.Infrastructure.Persistence;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
namespace RAG.Api.Grpc;
public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBase
public class AuthGrpcService(IConfiguration config, IServiceScopeFactory scopeFactory) : AuthService.AuthServiceBase
{
private readonly TokenValidationParameters _validationParams = new()
{
@ -38,7 +40,7 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? "";
response.Username = principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue(JwtRegisteredClaimNames.UniqueName) ?? "";
response.Email = principal.FindFirstValue(JwtRegisteredClaimNames.Email)
response.Email = principal.FindFirstValue(ClaimTypes.Email)
?? principal.FindFirstValue(ClaimTypes.Email) ?? "";
response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value));
response.Permissions.AddRange(principal.FindAll("permissions").Select(c => c.Value));
@ -70,4 +72,40 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas
return Task.FromResult(response);
}
public override async Task<GetUsersResponse> GetUsers(GetUsersRequest request, ServerCallContext context)
{
var response = new GetUsersResponse();
if (request.UserIds.Count == 0)
return response;
var userIds = request.UserIds
.Select(id => Guid.TryParse(id, out var guid) ? guid : Guid.Empty)
.Where(id => id != Guid.Empty)
.ToList();
if (userIds.Count == 0)
return response;
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<RagDbContext>();
var users = await db.Users
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.Username })
.ToListAsync(context.CancellationToken);
foreach (var user in users)
{
response.Users.Add(new UserInfo
{
UserId = user.Id.ToString(),
Username = user.Username,
Avatar = ""
});
}
return response;
}
}

View File

@ -16,7 +16,9 @@ public class ApiResponseMiddleware(RequestDelegate next)
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.ContentType?.StartsWith("application/grpc") == true || IsExcludedPath(context.Request.Path))
if (context.Request.ContentType?.StartsWith("application/grpc") == true
|| IsExcludedPath(context.Request.Path)
|| IsStreamEndpoint(context.Request.Path))
{
await next(context);
return;
@ -77,5 +79,10 @@ public class ApiResponseMiddleware(RequestDelegate next)
private static bool IsExcludedPath(PathString path) =>
ExcludedPaths.Any(p => path.Value?.StartsWith(p, StringComparison.OrdinalIgnoreCase) == true);
private static readonly string[] StreamPaths = ["/api/rag/stream", "/api/chat/"];
private static bool IsStreamEndpoint(PathString path) =>
StreamPaths.Any(p => path.Value?.StartsWith(p, StringComparison.OrdinalIgnoreCase) == true
&& path.Value?.EndsWith("/stream", StringComparison.OrdinalIgnoreCase) == true);
private record ApiResponse(JsonElement Data, int Code = 0, string Message = "ok");
}

View File

@ -6,10 +6,6 @@ using RAG.Domain.Exceptions;
namespace RAG.Api.Middleware;
/// <summary>
/// 全局异常中间件,捕获所有未处理异常并统一返回 { code, message, data } 格式。
/// 必须注册在 ApiResponseMiddleware 之前,确保异常不会穿透到 ASP.NET Core 默认错误页。
/// </summary>
public class GlobalExceptionMiddleware(RequestDelegate next)
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
@ -28,6 +24,7 @@ public class GlobalExceptionMiddleware(RequestDelegate next)
}
catch (Exception ex)
{
if (context.Response.HasStarted) return;
await HandleExceptionAsync(context, ex);
}
}

View File

@ -7,6 +7,7 @@ option csharp_namespace = "RAG.Api.Grpc";
service AuthService {
rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse);
rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse);
rpc GetUsers (GetUsersRequest) returns (GetUsersResponse);
}
message ValidateTokenRequest {
@ -33,3 +34,17 @@ message CheckPermissionResponse {
string user_id = 2;
repeated string roles = 3;
}
message GetUsersRequest {
repeated string user_ids = 1;
}
message GetUsersResponse {
repeated UserInfo users = 1;
}
message UserInfo {
string user_id = 1;
string username = 2;
string avatar = 3;
}

View File

@ -19,14 +19,14 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Host=192.168.1.154;Port=5432;Database=rag;Username=auto_agent;Password=auto_agent",
"Redis": "192.168.1.154:31040,password=xn001624."
"Default": "Host=localhost;Port=5432;Database=rag;Username=rag;Password=rag123",
"Redis": "localhost:6379,abortConnect=false"
},
"RabbitMq": {
"Host": "192.168.1.154",
"Port": 31020,
"Host": "localhost",
"Port": 5672,
"Username": "guest",
"Password": "xn001624."
"Password": "guest"
},
"Jwt": {
"SigningKey": "RagJwtSecretKey2026MustBeAtLeast32CharsLong!",
@ -35,5 +35,14 @@
},
"Cookie": {
"Secure": false
},
"Ai": {
"BaseUrl": "http://localhost:1234/v1",
"ApiKey": "lm-studio",
"ChatModel": "qwen/qwen3.6-27b",
"EmbeddingModel": "text-embedding-embeddinggemma-300m",
"DefaultInstructions": "你是一个RAG知识库助手基于提供的上下文准确回答用户问题。如果上下文中没有相关信息请如实说明。",
"MaxTokens": 2000,
"Temperature": 0.7
}
}

View File

@ -0,0 +1,28 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Chat.DTOs;
using RAG.Domain.Common;
using RAG.Domain.Entities;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Chat.Commands;
public record CreateConversationCommand(string Title) : IRequest<ConversationDto>;
public class CreateConversationCommandHandler(RagDbContext db, ICurrentUserContext userContext) : IRequestHandler<CreateConversationCommand, ConversationDto>
{
public async Task<ConversationDto> Handle(CreateConversationCommand request, CancellationToken ct)
{
var conversation = new Conversation
{
Title = request.Title,
UserId = Guid.Parse(userContext.GetRequiredUserId())
};
await db.Conversations.AddAsync(conversation, ct);
await db.SaveChangesAsync(ct);
return new ConversationDto(conversation.Id, conversation.Title, conversation.UserId,
conversation.KnowledgeBaseId, conversation.CreatedAt);
}
}

View File

@ -0,0 +1,24 @@
using MediatR;
using RAG.Domain.Exceptions;
using RAG.Domain.Interfaces;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Chat.Commands;
public record DeleteConversationCommand(Guid Id) : IRequest<Unit>;
public class DeleteConversationCommandHandler(RagDbContext db, IChatMessageCache cache)
: IRequestHandler<DeleteConversationCommand, Unit>
{
public async Task<Unit> Handle(DeleteConversationCommand request, CancellationToken ct)
{
var conversation = await db.Conversations.FindAsync([request.Id], ct)
?? throw new NotFoundException("会话不存在");
db.Conversations.Remove(conversation);
await db.SaveChangesAsync(ct);
await cache.RemoveAsync(request.Id, ct);
return Unit.Value;
}
}

View File

@ -0,0 +1,62 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Chat.DTOs;
using RAG.Domain.Entities;
using RAG.Domain.Enums;
using RAG.Domain.Exceptions;
using RAG.Domain.Interfaces;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Chat.Commands;
public record SendMessageCommand(Guid ConversationId, string Content) : IRequest<SendMessageResponse>;
public class SendMessageCommandHandler(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache)
: IRequestHandler<SendMessageCommand, SendMessageResponse>
{
public async Task<SendMessageResponse> Handle(SendMessageCommand request, CancellationToken ct)
{
var conversation = await db.Conversations.FindAsync([request.ConversationId], ct)
?? throw new NotFoundException("会话不存在");
// 保存用户消息
var userMessage = new ChatMessage
{
ConversationId = request.ConversationId,
Role = ChatRole.User,
Content = request.Content
};
await db.ChatMessages.AddAsync(userMessage, ct);
await db.SaveChangesAsync(ct);
// 追加到 Redis
await cache.AppendMessageAsync(request.ConversationId,
new CachedChatMessage(userMessage.Id, ChatRole.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct);
// 获取历史消息构建上下文
var history = await db.ChatMessages
.Where(m => m.ConversationId == request.ConversationId)
.OrderBy(m => m.CreatedAt)
.Select(m => $"{m.Role}: {m.Content}")
.ToListAsync(ct);
var prompt = string.Join("\n", history);
var reply = await chatAgent.RunAsync(prompt, ct);
// 保存助手回复
var assistantMessage = new ChatMessage
{
ConversationId = request.ConversationId,
Role = ChatRole.Assistant,
Content = reply
};
await db.ChatMessages.AddAsync(assistantMessage, ct);
await db.SaveChangesAsync(ct);
// 追加到 Redis
await cache.AppendMessageAsync(request.ConversationId,
new CachedChatMessage(assistantMessage.Id, ChatRole.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct);
return new SendMessageResponse(assistantMessage.Id, reply);
}
}

View File

@ -0,0 +1,10 @@
namespace RAG.Application.Chat.DTOs;
public record ConversationDto(Guid Id, string Title, Guid UserId, Guid? KnowledgeBaseId, DateTime CreatedAt);
public record ConversationDetailDto(Guid Id, string Title, Guid UserId, Guid? KnowledgeBaseId,
DateTime CreatedAt, List<ChatMessageDto> Messages);
public record ChatMessageDto(Guid Id, string Role, string Content, int? TokenUsage, DateTime CreatedAt);
public record SendMessageResponse(Guid MessageId, string Content);

View File

@ -0,0 +1,43 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Chat.DTOs;
using RAG.Domain.Exceptions;
using RAG.Domain.Interfaces;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Chat.Queries;
public record GetConversationDetailQuery(Guid Id) : IRequest<ConversationDetailDto>;
public class GetConversationDetailQueryHandler(RagDbContext db, IChatMessageCache cache)
: IRequestHandler<GetConversationDetailQuery, ConversationDetailDto>
{
public async Task<ConversationDetailDto> Handle(GetConversationDetailQuery request, CancellationToken ct)
{
var conversation = await db.Conversations.FindAsync([request.Id], ct)
?? throw new NotFoundException("会话不存在");
// 先查 Redis 缓存
var cached = await cache.GetMessagesAsync(request.Id, ct);
if (cached is not null)
{
var messages = cached.Select(m => new ChatMessageDto(m.Id, m.Role, m.Content, m.TokenUsage, m.CreatedAt)).ToList();
return new ConversationDetailDto(conversation.Id, conversation.Title, conversation.UserId,
conversation.KnowledgeBaseId, conversation.CreatedAt, messages);
}
// 缓存 miss查数据库
var dbMessages = await db.ChatMessages
.Where(m => m.ConversationId == request.Id)
.OrderBy(m => m.CreatedAt)
.Select(m => new ChatMessageDto(m.Id, m.Role.ToString(), m.Content, m.TokenUsage, m.CreatedAt))
.ToListAsync(ct);
// 回填 Redis
var cachedMessages = dbMessages.Select(m => new CachedChatMessage(m.Id, m.Role, m.Content, m.TokenUsage, m.CreatedAt)).ToList();
await cache.SetMessagesAsync(request.Id, cachedMessages, ct);
return new ConversationDetailDto(conversation.Id, conversation.Title, conversation.UserId,
conversation.KnowledgeBaseId, conversation.CreatedAt, dbMessages);
}
}

View File

@ -0,0 +1,22 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Chat.DTOs;
using RAG.Domain.Common;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Chat.Queries;
public record GetConversationsQuery : IRequest<List<ConversationDto>>;
public class GetConversationsQueryHandler(RagDbContext db, ICurrentUserContext userContext) : IRequestHandler<GetConversationsQuery, List<ConversationDto>>
{
public async Task<List<ConversationDto>> Handle(GetConversationsQuery request, CancellationToken ct)
{
var userId = Guid.Parse(userContext.GetRequiredUserId());
return await db.Conversations
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.Select(c => new ConversationDto(c.Id, c.Title, c.UserId, c.KnowledgeBaseId, c.CreatedAt))
.ToListAsync(ct);
}
}

View File

@ -0,0 +1,22 @@
using FluentValidation;
namespace RAG.Application.Chat.Validators;
public class CreateConversationCommandValidator : AbstractValidator<Commands.CreateConversationCommand>
{
public CreateConversationCommandValidator()
{
RuleFor(x => x.Title).NotEmpty().WithMessage("会话标题不能为空")
.MaximumLength(200).WithMessage("会话标题不能超过200个字符");
}
}
public class SendMessageCommandValidator : AbstractValidator<Commands.SendMessageCommand>
{
public SendMessageCommandValidator()
{
RuleFor(x => x.ConversationId).NotEmpty().WithMessage("会话ID不能为空");
RuleFor(x => x.Content).NotEmpty().WithMessage("消息内容不能为空")
.MaximumLength(10000).WithMessage("消息内容不能超过10000个字符");
}
}

View File

@ -0,0 +1,88 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Document.DTOs;
using RAG.Domain.Entities;
using RAG.Domain.Enums;
using RAG.Domain.Exceptions;
using RAG.Domain.Interfaces;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Document.Commands;
public record ProcessDocumentCommand(Guid DocumentId) : IRequest<ProcessDocumentResponse>;
public class ProcessDocumentCommandHandler(RagDbContext db, ITextChunker chunker, IEmbeddingService embeddingService)
: IRequestHandler<ProcessDocumentCommand, ProcessDocumentResponse>
{
public async Task<ProcessDocumentResponse> Handle(ProcessDocumentCommand request, CancellationToken ct)
{
var doc = await db.Documents.FindAsync([request.DocumentId], ct)
?? throw new NotFoundException("文档不存在");
if (doc.Status == DocumentStatus.Processing)
throw new RAG.Domain.Exceptions.BusinessException("文档正在处理中");
var kb = await db.KnowledgeBases.FindAsync([doc.KnowledgeBaseId], ct)
?? throw new NotFoundException("知识库不存在");
doc.Status = DocumentStatus.Processing;
await db.SaveChangesAsync(ct);
try
{
// 读取文件内容
var content = await File.ReadAllTextAsync(doc.FilePath, ct);
// 分块
var chunks = chunker.Chunk(content, kb.ChunkSize, kb.ChunkOverlap);
// 批量向量化
var texts = chunks.Select(c => c.Content).ToList();
var vectors = await embeddingService.EmbedBatchAsync(texts, ct);
// 删除旧分块
var existingChunks = await db.DocumentChunks
.Where(c => c.DocumentId == doc.Id)
.ToListAsync(ct);
db.DocumentChunks.RemoveRange(existingChunks);
// 保存新分块
var chunkEntities = new List<DocumentChunk>();
for (var i = 0; i < chunks.Count; i++)
{
var chunk = new DocumentChunk
{
DocumentId = doc.Id,
Content = chunks[i].Content,
ChunkIndex = chunks[i].Index,
TokenCount = chunks[i].Content.Length / 4, // 粗略估算
Embedding = vectors[i]
};
chunkEntities.Add(chunk);
}
await db.DocumentChunks.AddRangeAsync(chunkEntities, ct);
doc.ChunkCount = chunks.Count;
doc.Status = DocumentStatus.Completed;
await db.SaveChangesAsync(ct);
// EF Ignore 了 Embedding 属性,用 raw SQL 写入向量
for (var i = 0; i < chunkEntities.Count; i++)
{
var vectorStr = $"[{string.Join(",", vectors[i])}]";
var chunkId = chunkEntities[i].Id;
await db.Database.ExecuteSqlAsync(
$"UPDATE document_chunks SET embedding = {vectorStr}::vector WHERE \"Id\" = {chunkId}", ct);
}
return new ProcessDocumentResponse(doc.Id, doc.ChunkCount, doc.Status.ToString());
}
catch
{
doc.Status = DocumentStatus.Failed;
await db.SaveChangesAsync(ct);
throw;
}
}
}

View File

@ -0,0 +1,38 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Document.DTOs;
using RAG.Domain.Entities;
using RAG.Domain.Enums;
using RAG.Domain.Exceptions;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Document.Commands;
public record UploadDocumentCommand(Guid KnowledgeBaseId, string Title, string FileName, string FilePath,
long FileSize, string ContentType) : IRequest<DocumentDto>;
public class UploadDocumentCommandHandler(RagDbContext db) : IRequestHandler<UploadDocumentCommand, DocumentDto>
{
public async Task<DocumentDto> Handle(UploadDocumentCommand request, CancellationToken ct)
{
var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct)
?? throw new NotFoundException("知识库不存在");
var doc = new RAG.Domain.Entities.Document
{
KnowledgeBaseId = request.KnowledgeBaseId,
Title = request.Title,
FileName = request.FileName,
FilePath = request.FilePath,
FileSize = request.FileSize,
ContentType = request.ContentType,
Status = DocumentStatus.Pending
};
await db.Documents.AddAsync(doc, ct);
await db.SaveChangesAsync(ct);
return new DocumentDto(doc.Id, doc.KnowledgeBaseId, doc.Title, doc.FileName,
doc.FileSize, doc.ContentType, doc.ChunkCount, doc.Status.ToString(), doc.CreatedAt);
}
}

View File

@ -0,0 +1,16 @@
namespace RAG.Application.Document.DTOs;
public record DocumentDto(Guid Id, Guid KnowledgeBaseId, string Title, string FileName, long FileSize,
string ContentType, int ChunkCount, string Status, DateTime CreatedAt);
public record ProcessDocumentResponse(Guid DocumentId, int ChunkCount, string Status);
public record ChunkPreviewDto(
Guid Id,
int ChunkIndex,
string Content,
int TokenCount,
bool HasEmbedding,
int? EmbeddingDimension,
List<float>? EmbeddingPreview
);

View File

@ -0,0 +1,75 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Document.DTOs;
using RAG.Domain.Exceptions;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Document.Queries;
public record GetDocumentChunksQuery(Guid DocumentId) : IRequest<List<ChunkPreviewDto>>;
public class GetDocumentChunksQueryHandler(RagDbContext db)
: IRequestHandler<GetDocumentChunksQuery, List<ChunkPreviewDto>>
{
public async Task<List<ChunkPreviewDto>> Handle(GetDocumentChunksQuery request, CancellationToken ct)
{
var doc = await db.Documents.FindAsync([request.DocumentId], ct)
?? throw new NotFoundException("文档不存在");
var chunks = await db.DocumentChunks
.Where(c => c.DocumentId == request.DocumentId)
.OrderBy(c => c.ChunkIndex)
.Select(c => new { c.Id, c.ChunkIndex, c.Content, c.TokenCount })
.ToListAsync(ct);
// 查询 embedding 状态和预览EF Ignore 了 Embedding用 raw SQL
var conn = db.Database.GetDbConnection();
await conn.OpenAsync(ct);
var embeddingInfo = new Dictionary<Guid, (bool Has, int? Dim, List<float>? Preview)>();
try
{
var ids = string.Join("','", chunks.Select(c => c.Id.ToString()));
using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
SELECT "Id",
embedding IS NOT NULL as has_embedding,
CASE WHEN embedding IS NOT NULL THEN vector_dims(embedding) ELSE NULL END as dim,
embedding::text as embedding_text
FROM document_chunks
WHERE "DocumentId" = '{request.DocumentId}'
ORDER BY "ChunkIndex"
""";
using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var id = (Guid)reader["Id"];
var hasEmbedding = reader["has_embedding"] is bool b && b;
var dimObj = reader["dim"];
int? dim = dimObj is int d ? d : (dimObj is string s && int.TryParse(s, out var parsed) ? parsed : (int?)null);
List<float>? preview = null;
if (hasEmbedding)
{
var text = reader["embedding_text"]?.ToString() ?? "";
// 解析 [0.1, 0.2, ...] 取前10个
var cleaned = text.Trim('[', ']');
var parts = cleaned.Split(',', StringSplitOptions.RemoveEmptyEntries);
preview = parts.Take(10).Select(p => float.TryParse(p.Trim(), out var v) ? v : 0f).ToList();
}
embeddingInfo[id] = (hasEmbedding, dim, preview);
}
}
finally
{
await conn.CloseAsync();
}
return chunks.Select(c =>
{
var info = embeddingInfo.GetValueOrDefault(c.Id);
return new ChunkPreviewDto(c.Id, c.ChunkIndex, c.Content, c.TokenCount, info.Has, info.Dim, info.Preview);
}).ToList();
}
}

View File

@ -0,0 +1,21 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.Document.DTOs;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.Document.Queries;
public record GetDocumentsQuery(Guid KnowledgeBaseId) : IRequest<List<DocumentDto>>;
public class GetDocumentsQueryHandler(RagDbContext db) : IRequestHandler<GetDocumentsQuery, List<DocumentDto>>
{
public async Task<List<DocumentDto>> Handle(GetDocumentsQuery request, CancellationToken ct)
{
return await db.Documents
.Where(d => d.KnowledgeBaseId == request.KnowledgeBaseId)
.OrderByDescending(d => d.CreatedAt)
.Select(d => new DocumentDto(d.Id, d.KnowledgeBaseId, d.Title, d.FileName,
d.FileSize, d.ContentType, d.ChunkCount, d.Status.ToString(), d.CreatedAt))
.ToListAsync(ct);
}
}

View File

@ -0,0 +1,22 @@
using FluentValidation;
namespace RAG.Application.Document.Validators;
public class UploadDocumentCommandValidator : AbstractValidator<Commands.UploadDocumentCommand>
{
public UploadDocumentCommandValidator()
{
RuleFor(x => x.KnowledgeBaseId).NotEmpty().WithMessage("知识库ID不能为空");
RuleFor(x => x.Title).NotEmpty().WithMessage("文档标题不能为空").MaximumLength(500);
RuleFor(x => x.FileName).NotEmpty().MaximumLength(500);
RuleFor(x => x.FilePath).NotEmpty().MaximumLength(1000);
}
}
public class ProcessDocumentCommandValidator : AbstractValidator<Commands.ProcessDocumentCommand>
{
public ProcessDocumentCommandValidator()
{
RuleFor(x => x.DocumentId).NotEmpty().WithMessage("文档ID不能为空");
}
}

View File

@ -0,0 +1,18 @@
using MediatR;
using RAG.Application.Embedding.DTOs;
using RAG.Domain.Interfaces;
namespace RAG.Application.Embedding.Commands;
public record EmbedBatchCommand(List<string> Texts) : IRequest<EmbeddingBatchResponse>;
public class EmbedBatchCommandHandler(IEmbeddingService embeddingService)
: IRequestHandler<EmbedBatchCommand, EmbeddingBatchResponse>
{
public async Task<EmbeddingBatchResponse> Handle(EmbedBatchCommand request, CancellationToken ct)
{
var vectors = await embeddingService.EmbedBatchAsync(request.Texts, ct);
var dimensions = vectors.FirstOrDefault()?.Length ?? 0;
return new EmbeddingBatchResponse(vectors.Select(v => v.ToList()).ToList(), dimensions);
}
}

View File

@ -0,0 +1,17 @@
using MediatR;
using RAG.Application.Embedding.DTOs;
using RAG.Domain.Interfaces;
namespace RAG.Application.Embedding.Commands;
public record EmbedTextCommand(string Text) : IRequest<EmbeddingResponse>;
public class EmbedTextCommandHandler(IEmbeddingService embeddingService)
: IRequestHandler<EmbedTextCommand, EmbeddingResponse>
{
public async Task<EmbeddingResponse> Handle(EmbedTextCommand request, CancellationToken ct)
{
var vector = await embeddingService.EmbedAsync(request.Text, ct);
return new EmbeddingResponse(vector.ToList(), vector.Length);
}
}

View File

@ -0,0 +1,5 @@
namespace RAG.Application.Embedding.DTOs;
public record EmbeddingResponse(List<float> Vector, int Dimensions);
public record EmbeddingBatchResponse(List<List<float>> Vectors, int Dimensions);

View File

@ -0,0 +1,21 @@
using FluentValidation;
namespace RAG.Application.Embedding.Validators;
public class EmbedTextCommandValidator : AbstractValidator<Commands.EmbedTextCommand>
{
public EmbedTextCommandValidator()
{
RuleFor(x => x.Text).NotEmpty().WithMessage("文本内容不能为空")
.MaximumLength(10000).WithMessage("单条文本不能超过10000个字符");
}
}
public class EmbedBatchCommandValidator : AbstractValidator<Commands.EmbedBatchCommand>
{
public EmbedBatchCommandValidator()
{
RuleFor(x => x.Texts).NotEmpty().WithMessage("文本列表不能为空")
.Must(t => t.Count <= 100).WithMessage("批量文本不能超过100条");
}
}

View File

@ -0,0 +1,29 @@
using MediatR;
using RAG.Application.KnowledgeBase.DTOs;
using RAG.Domain.Entities;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.KnowledgeBase.Commands;
public record CreateKnowledgeBaseCommand(string Name, string? Description, string? EmbeddingModel, int? ChunkSize, int? ChunkOverlap) : IRequest<KnowledgeBaseDto>;
public class CreateKnowledgeBaseCommandHandler(RagDbContext db) : IRequestHandler<CreateKnowledgeBaseCommand, KnowledgeBaseDto>
{
public async Task<KnowledgeBaseDto> Handle(CreateKnowledgeBaseCommand request, CancellationToken ct)
{
var kb = new RAG.Domain.Entities.KnowledgeBase
{
Name = request.Name,
Description = request.Description,
EmbeddingModel = request.EmbeddingModel ?? "text-embedding-embeddinggemma-300m",
ChunkSize = request.ChunkSize ?? 500,
ChunkOverlap = request.ChunkOverlap ?? 50
};
await db.KnowledgeBases.AddAsync(kb, ct);
await db.SaveChangesAsync(ct);
return new KnowledgeBaseDto(kb.Id, kb.Name, kb.Description, kb.EmbeddingModel,
kb.ChunkSize, kb.ChunkOverlap, kb.Status.ToString(), kb.CreatedAt);
}
}

View File

@ -0,0 +1,20 @@
using MediatR;
using RAG.Domain.Exceptions;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.KnowledgeBase.Commands;
public record DeleteKnowledgeBaseCommand(Guid Id) : IRequest<Unit>;
public class DeleteKnowledgeBaseCommandHandler(RagDbContext db) : IRequestHandler<DeleteKnowledgeBaseCommand, Unit>
{
public async Task<Unit> Handle(DeleteKnowledgeBaseCommand request, CancellationToken ct)
{
var kb = await db.KnowledgeBases.FindAsync([request.Id], ct)
?? throw new NotFoundException("知识库不存在");
db.KnowledgeBases.Remove(kb);
await db.SaveChangesAsync(ct);
return Unit.Value;
}
}

View File

@ -0,0 +1,9 @@
namespace RAG.Application.KnowledgeBase.DTOs;
public record KnowledgeBaseDto(Guid Id, string Name, string? Description, string EmbeddingModel,
int ChunkSize, int ChunkOverlap, string Status, DateTime CreatedAt);
public record CreateKnowledgeBaseRequest(string Name, string? Description, string? EmbeddingModel,
int? ChunkSize, int? ChunkOverlap);
public record UpdateKnowledgeBaseRequest(string? Name, string? Description, int? ChunkSize, int? ChunkOverlap);

View File

@ -0,0 +1,20 @@
using MediatR;
using RAG.Application.KnowledgeBase.DTOs;
using RAG.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace RAG.Application.KnowledgeBase.Queries;
public record GetKnowledgeBasesQuery : IRequest<List<KnowledgeBaseDto>>;
public class GetKnowledgeBasesQueryHandler(RagDbContext db) : IRequestHandler<GetKnowledgeBasesQuery, List<KnowledgeBaseDto>>
{
public async Task<List<KnowledgeBaseDto>> Handle(GetKnowledgeBasesQuery request, CancellationToken ct)
{
return await db.KnowledgeBases
.OrderByDescending(k => k.CreatedAt)
.Select(k => new KnowledgeBaseDto(k.Id, k.Name, k.Description, k.EmbeddingModel,
k.ChunkSize, k.ChunkOverlap, k.Status.ToString(), k.CreatedAt))
.ToListAsync(ct);
}
}

View File

@ -0,0 +1,13 @@
using FluentValidation;
namespace RAG.Application.KnowledgeBase.Validators;
public class CreateKnowledgeBaseCommandValidator : AbstractValidator<Commands.CreateKnowledgeBaseCommand>
{
public CreateKnowledgeBaseCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("知识库名称不能为空")
.MaximumLength(200).WithMessage("名称不能超过200个字符");
RuleFor(x => x.Description).MaximumLength(1000).When(x => x.Description != null);
}
}

View File

@ -0,0 +1,165 @@
using System.Text;
using MediatR;
using Microsoft.EntityFrameworkCore;
using RAG.Application.RagQA.DTOs;
using RAG.Domain.Entities;
using RAG.Domain.Enums;
using RAG.Domain.Exceptions;
using RAG.Domain.Interfaces;
using RAG.Infrastructure.Persistence;
namespace RAG.Application.RagQA.Commands;
public record RAGQueryCommand(Guid KnowledgeBaseId, string Question) : IRequest<RAGQueryResponse>;
public class RAGQueryCommandHandler(RagDbContext db, IEmbeddingService embeddingService, IAIChatAgent chatAgent)
: IRequestHandler<RAGQueryCommand, RAGQueryResponse>
{
private const int VectorTopK = 20;
private const int FinalTopK = 5;
private const double MinSimilarity = 0.3;
public async Task<RAGQueryResponse> Handle(RAGQueryCommand request, CancellationToken ct)
{
var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct)
?? throw new NotFoundException("知识库不存在");
// 向量化问题
var queryVector = await embeddingService.EmbedAsync(request.Question, ct);
var vectorStr = $"[{string.Join(",", queryVector)}]";
// 第一阶段:向量粗召回 Top-20
var vectorSql = $"""
SELECT dc."Content", d."Title" as document_title, dc."DocumentId",
1 - (dc.embedding <=> '{vectorStr}'::vector) AS similarity
FROM document_chunks dc
JOIN documents d ON dc."DocumentId" = d."Id"
WHERE d."KnowledgeBaseId" = '{request.KnowledgeBaseId}'
AND dc.embedding IS NOT NULL
ORDER BY dc.embedding <=> '{vectorStr}'::vector
LIMIT {VectorTopK}
""";
var candidates = new List<(Guid DocId, string DocTitle, string Content, double VectorScore)>();
var conn = db.Database.GetDbConnection();
await conn.OpenAsync(ct);
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = vectorSql;
using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var similarity = reader["similarity"] is double d ? d : 0.0;
if (similarity < MinSimilarity) continue;
candidates.Add((
reader["DocumentId"] is Guid g ? g : Guid.Empty,
reader["document_title"]?.ToString() ?? "",
reader["Content"]?.ToString() ?? "",
similarity
));
}
}
finally
{
await conn.CloseAsync();
}
if (candidates.Count == 0)
return new RAGQueryResponse("未在知识库中找到相关内容,无法回答该问题。", []);
// 第二阶段:关键词匹配重排序
var keywords = ExtractKeywords(request.Question);
var ranked = candidates.Select(c =>
{
var keywordScore = KeywordMatchScore(c.Content, keywords);
// RRF 融合:向量分权重 0.7 + 关键词分权重 0.3
var finalScore = c.VectorScore * 0.7 + keywordScore * 0.3;
return (c.DocId, c.DocTitle, c.Content, FinalScore: finalScore);
})
.OrderByDescending(x => x.FinalScore)
.Take(FinalTopK)
.ToList();
var sources = ranked.Select(r => new SourceChunk(
r.DocId,
r.DocTitle,
r.Content.Length > 200 ? r.Content[..200] + "..." : r.Content,
Math.Round(r.FinalScore, 4)
)).ToList();
// 构建 RAG prompt
var contextBuilder = new StringBuilder();
contextBuilder.AppendLine("以下是检索到的相关上下文:");
for (var i = 0; i < ranked.Count; i++)
contextBuilder.AppendLine($"\n--- 上下文 {i + 1} ---\n{ranked[i].Content}");
var prompt = $"""
{contextBuilder}
{request.Question}
""";
var answer = await chatAgent.RunAsync(prompt, ct);
return new RAGQueryResponse(answer, sources);
}
/// <summary>提取中文/英文关键词(简单分词)</summary>
private static List<string> ExtractKeywords(string question)
{
// 去除常见疑问词
var stopWords = new HashSet<string>
{
"的", "了", "是", "在", "有", "和", "与", "或", "不", "也", "都",
"这", "那", "个", "一", "什么", "怎么", "如何", "为什么", "哪",
"哪些", "吗", "呢", "吧", "啊", "请", "能", "可以", "会",
"what", "how", "why", "is", "the", "a", "an", "of", "to", "in"
};
var words = new List<string>();
// 提取中文词组2-4字和英文单词
var englishWords = System.Text.RegularExpressions.Regex.Matches(question, @"[a-zA-Z]+")
.Select(m => m.Value.ToLowerInvariant())
.Where(w => w.Length >= 2 && !stopWords.Contains(w));
words.AddRange(englishWords);
// 提取中文连续字符段按2-4字滑窗
var chineseSegments = System.Text.RegularExpressions.Regex.Matches(question, @"[一-鿿]+");
foreach (System.Text.RegularExpressions.Match seg in chineseSegments)
{
var text = seg.Value;
if (text.Length <= 4)
{
if (!stopWords.Contains(text))
words.Add(text);
}
else
{
// 滑窗提取 2-4 字子串
for (var len = 2; len <= Math.Min(4, text.Length); len++)
{
for (var i = 0; i <= text.Length - len; i++)
{
var sub = text[i..(i + len)];
if (!stopWords.Contains(sub))
words.Add(sub);
}
}
}
}
return words.Distinct().ToList();
}
/// <summary>计算关键词匹配得分0-1</summary>
private static double KeywordMatchScore(string content, List<string> keywords)
{
if (keywords.Count == 0) return 0;
var matched = keywords.Count(kw => content.Contains(kw, StringComparison.OrdinalIgnoreCase));
return (double)matched / keywords.Count;
}
}

View File

@ -0,0 +1,7 @@
namespace RAG.Application.RagQA.DTOs;
public record RAGQueryRequest(Guid KnowledgeBaseId, string Question);
public record RAGQueryResponse(string Answer, List<SourceChunk> Sources);
public record SourceChunk(Guid DocumentId, string DocumentTitle, string Content, double Similarity);

View File

@ -0,0 +1,13 @@
using FluentValidation;
namespace RAG.Application.RagQA.Validators;
public class RAGQueryCommandValidator : AbstractValidator<Commands.RAGQueryCommand>
{
public RAGQueryCommandValidator()
{
RuleFor(x => x.KnowledgeBaseId).NotEmpty().WithMessage("知识库ID不能为空");
RuleFor(x => x.Question).NotEmpty().WithMessage("问题不能为空")
.MaximumLength(5000).WithMessage("问题不能超过5000个字符");
}
}

View File

@ -12,4 +12,7 @@ public abstract class BaseEntity<TId> where TId : struct
/// <summary>
/// 实体基类,默认使用 Guid 作为主键。项目中大部分实体继承此类。
/// </summary>
public abstract class BaseEntity : BaseEntity<Guid> { }
public abstract class BaseEntity : BaseEntity<Guid>
{
public BaseEntity() => Id = Guid.NewGuid();
}

View File

@ -0,0 +1,21 @@
using RAG.Domain.Common;
using RAG.Domain.Enums;
namespace RAG.Domain.Entities;
public class ChatMessage : BaseEntity, IAuditable
{
public Guid ConversationId { get; set; }
public ChatRole Role { get; set; }
public string Content { get; set; } = default!;
public int? TokenUsage { get; set; }
// IAuditable
public string CreatedBy { get; set; } = default!;
public DateTime CreatedAt { get; set; }
public string UpdatedBy { get; set; } = default!;
public DateTime UpdatedAt { get; set; }
// Navigation
public Conversation Conversation { get; set; } = default!;
}

View File

@ -0,0 +1,27 @@
using RAG.Domain.Common;
namespace RAG.Domain.Entities;
public class Conversation : BaseEntity, IFullAudit
{
public string Title { get; set; } = default!;
public Guid UserId { get; set; }
public Guid? KnowledgeBaseId { get; set; }
// IAuditable
public string CreatedBy { get; set; } = default!;
public DateTime CreatedAt { get; set; }
public string UpdatedBy { get; set; } = default!;
public DateTime UpdatedAt { get; set; }
// ISoftDelete
public bool IsDeleted { get; set; }
// IHasOperatorIP
public string OperatorIP { get; set; } = default!;
// Navigation
public User User { get; set; } = default!;
public KnowledgeBase? KnowledgeBase { get; set; }
public ICollection<ChatMessage> Messages { get; set; } = [];
}

View File

@ -0,0 +1,32 @@
using RAG.Domain.Common;
using RAG.Domain.Enums;
namespace RAG.Domain.Entities;
public class Document : BaseEntity, IFullAudit
{
public Guid KnowledgeBaseId { get; set; }
public string Title { get; set; } = default!;
public string FileName { get; set; } = default!;
public string FilePath { get; set; } = default!;
public long FileSize { get; set; }
public string ContentType { get; set; } = default!;
public int ChunkCount { get; set; }
public DocumentStatus Status { get; set; } = DocumentStatus.Pending;
// IAuditable
public string CreatedBy { get; set; } = default!;
public DateTime CreatedAt { get; set; }
public string UpdatedBy { get; set; } = default!;
public DateTime UpdatedAt { get; set; }
// ISoftDelete
public bool IsDeleted { get; set; }
// IHasOperatorIP
public string OperatorIP { get; set; } = default!;
// Navigation
public KnowledgeBase KnowledgeBase { get; set; } = default!;
public ICollection<DocumentChunk> Chunks { get; set; } = [];
}

View File

@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations.Schema;
using RAG.Domain.Common;
namespace RAG.Domain.Entities;
public class DocumentChunk : BaseEntity, IAuditable
{
public Guid DocumentId { get; set; }
public string Content { get; set; } = default!;
public int ChunkIndex { get; set; }
public int TokenCount { get; set; }
public float[]? Embedding { get; set; }
// IAuditable
public string CreatedBy { get; set; } = default!;
public DateTime CreatedAt { get; set; }
public string UpdatedBy { get; set; } = default!;
public DateTime UpdatedAt { get; set; }
// Navigation
public Document Document { get; set; } = default!;
}

View File

@ -0,0 +1,29 @@
using RAG.Domain.Common;
using RAG.Domain.Enums;
namespace RAG.Domain.Entities;
public class KnowledgeBase : BaseEntity, IFullAudit
{
public string Name { get; set; } = default!;
public string? Description { get; set; }
public string EmbeddingModel { get; set; } = "text-embedding-embeddinggemma-300m";
public int ChunkSize { get; set; } = 500;
public int ChunkOverlap { get; set; } = 50;
public KnowledgeBaseStatus Status { get; set; } = KnowledgeBaseStatus.Active;
// IAuditable
public string CreatedBy { get; set; } = default!;
public DateTime CreatedAt { get; set; }
public string UpdatedBy { get; set; } = default!;
public DateTime UpdatedAt { get; set; }
// ISoftDelete
public bool IsDeleted { get; set; }
// IHasOperatorIP
public string OperatorIP { get; set; } = default!;
// Navigation
public ICollection<Document> Documents { get; set; } = [];
}

View File

@ -0,0 +1,8 @@
namespace RAG.Domain.Enums;
public enum ChatRole
{
System = 0,
User = 1,
Assistant = 2
}

View File

@ -0,0 +1,9 @@
namespace RAG.Domain.Enums;
public enum DocumentStatus
{
Pending = 0,
Processing = 1,
Completed = 2,
Failed = 3
}

View File

@ -0,0 +1,7 @@
namespace RAG.Domain.Enums;
public enum KnowledgeBaseStatus
{
Active = 0,
Inactive = 1
}

View File

@ -0,0 +1,7 @@
namespace RAG.Domain.Interfaces;
public interface IAIChatAgent
{
Task<string> RunAsync(string prompt, CancellationToken ct = default);
IAsyncEnumerable<string> RunStreamingAsync(string prompt, CancellationToken ct = default);
}

View File

@ -0,0 +1,18 @@
namespace RAG.Domain.Interfaces;
public interface IChatMessageCache
{
/// <summary>获取会话的消息列表(缓存 miss 时返回 null</summary>
Task<List<CachedChatMessage>?> GetMessagesAsync(Guid conversationId, CancellationToken ct = default);
/// <summary>设置会话消息缓存</summary>
Task SetMessagesAsync(Guid conversationId, List<CachedChatMessage> messages, CancellationToken ct = default);
/// <summary>追加一条消息到缓存</summary>
Task AppendMessageAsync(Guid conversationId, CachedChatMessage message, CancellationToken ct = default);
/// <summary>删除会话缓存</summary>
Task RemoveAsync(Guid conversationId, CancellationToken ct = default);
}
public record CachedChatMessage(Guid Id, string Role, string Content, int? TokenUsage, DateTime CreatedAt);

View File

@ -0,0 +1,7 @@
namespace RAG.Domain.Interfaces;
public interface IEmbeddingService
{
Task<float[]> EmbedAsync(string text, CancellationToken ct = default);
Task<List<float[]>> EmbedBatchAsync(List<string> texts, CancellationToken ct = default);
}

View File

@ -0,0 +1,8 @@
namespace RAG.Domain.Interfaces;
public record TextChunk(string Content, int Index, int StartPosition, int EndPosition);
public interface ITextChunker
{
List<TextChunk> Chunk(string content, int chunkSize = 500, int overlap = 50);
}

View File

@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Pgvector" Version="0.3.2" />
<PackageReference Include="Volo.Abp.Core" Version="10.3.0" />
</ItemGroup>

View File

@ -0,0 +1,14 @@
namespace RAG.Infrastructure.AI;
public class AiOptions
{
public const string SectionName = "Ai";
public string BaseUrl { get; set; } = "http://localhost:1234/v1";
public string ApiKey { get; set; } = "lm-studio";
public string ChatModel { get; set; } = "";
public string EmbeddingModel { get; set; } = "";
public string DefaultInstructions { get; set; } = "";
public int MaxTokens { get; set; } = 2000;
public double Temperature { get; set; } = 0.7;
}

View File

@ -0,0 +1,43 @@
using System.Runtime.CompilerServices;
using System.ClientModel;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Options;
using OpenAI;
using OpenAI.Chat;
using RAG.Domain.Interfaces;
namespace RAG.Infrastructure.AI;
public class ChatAgentService : IAIChatAgent
{
private readonly AIAgent _agent;
public ChatAgentService(IOptions<AiOptions> options)
{
var opt = options.Value;
var client = new OpenAIClient(
new ApiKeyCredential(opt.ApiKey),
new OpenAIClientOptions { Endpoint = new Uri(opt.BaseUrl) });
var chatClient = client.GetChatClient(opt.ChatModel);
_agent = chatClient.AsAIAgent(
instructions: opt.DefaultInstructions,
name: "RagAssistant");
}
public async Task<string> RunAsync(string prompt, CancellationToken ct)
{
var response = await _agent.RunAsync(prompt, null, null, ct);
return response.Text;
}
public async IAsyncEnumerable<string> RunStreamingAsync(
string prompt, [EnumeratorCancellation] CancellationToken ct)
{
await foreach (var update in _agent.RunStreamingAsync(prompt, null, null, ct))
{
if (!string.IsNullOrEmpty(update.Text))
yield return update.Text;
}
}
}

View File

@ -0,0 +1,34 @@
using System.ClientModel;
using Microsoft.Extensions.Options;
using OpenAI;
using OpenAI.Embeddings;
using RAG.Domain.Interfaces;
namespace RAG.Infrastructure.AI;
public class EmbeddingService : IEmbeddingService
{
private readonly EmbeddingClient _client;
public EmbeddingService(IOptions<AiOptions> options)
{
var opt = options.Value;
var openaiClient = new OpenAIClient(
new ApiKeyCredential(opt.ApiKey),
new OpenAIClientOptions { Endpoint = new Uri(opt.BaseUrl) });
_client = openaiClient.GetEmbeddingClient(opt.EmbeddingModel);
}
public async Task<float[]> EmbedAsync(string text, CancellationToken ct)
{
var response = await _client.GenerateEmbeddingAsync(text, cancellationToken: ct);
return response.Value.ToFloats().ToArray();
}
public async Task<List<float[]>> EmbedBatchAsync(List<string> texts, CancellationToken ct)
{
var response = await _client.GenerateEmbeddingsAsync(texts, cancellationToken: ct);
return response.Value.Select(e => e.ToFloats().ToArray()).ToList();
}
}

View File

@ -0,0 +1,127 @@
using RAG.Domain.Interfaces;
namespace RAG.Infrastructure.AI;
public class TextChunker : ITextChunker
{
public List<TextChunk> Chunk(string content, int chunkSize = 500, int overlap = 50)
{
var chunks = new List<TextChunk>();
if (string.IsNullOrEmpty(content))
return chunks;
// 先按段落分割
var paragraphs = content.Split(["\r\n\r\n", "\n\n"], StringSplitOptions.RemoveEmptyEntries)
.Select(p => p.Trim())
.Where(p => p.Length > 0)
.ToList();
// 如果段落很少且内容很长,按单行再拆
if (paragraphs.Count <= 1 && content.Length > chunkSize)
{
paragraphs = content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(p => p.Trim())
.Where(p => p.Length > 0)
.ToList();
}
var index = 0;
var currentChunk = new List<string>();
var currentLength = 0;
var chunkStartPosition = 0;
foreach (var para in paragraphs)
{
// 单个段落超过 chunkSize需要再拆分
if (para.Length > chunkSize)
{
// 先把当前积攒的内容保存
if (currentChunk.Count > 0)
{
chunks.Add(BuildChunk(currentChunk, index++, ref chunkStartPosition));
currentChunk = [];
currentLength = 0;
}
// 按句子拆分超长段落
var sentences = SplitSentences(para);
var sentenceChunk = new List<string>();
var sentenceLength = 0;
foreach (var sentence in sentences)
{
if (sentenceLength + sentence.Length > chunkSize && sentenceChunk.Count > 0)
{
chunks.Add(BuildChunk(sentenceChunk, index++, ref chunkStartPosition));
// 保留 overlap
var overlapText = string.Join("", sentenceChunk.TakeLast(2));
sentenceChunk = overlapText.Length < chunkSize / 2 ? [overlapText] : [];
sentenceLength = sentenceChunk.Sum(s => s.Length);
}
sentenceChunk.Add(sentence);
sentenceLength += sentence.Length;
}
if (sentenceChunk.Count > 0)
{
chunks.Add(BuildChunk(sentenceChunk, index++, ref chunkStartPosition));
}
}
else if (currentLength + para.Length > chunkSize && currentChunk.Count > 0)
{
// 当前段落加入会超限,先保存当前 chunk
chunks.Add(BuildChunk(currentChunk, index++, ref chunkStartPosition));
// 保留 overlap取最后一个段落作为下一个 chunk 的开头
currentChunk = [..currentChunk.TakeLast(1)];
currentLength = currentChunk.Sum(s => s.Length);
currentChunk.Add(para);
currentLength += para.Length;
}
else
{
currentChunk.Add(para);
currentLength += para.Length;
}
}
if (currentChunk.Count > 0)
{
chunks.Add(BuildChunk(currentChunk, index++, ref chunkStartPosition));
}
return chunks;
}
private static TextChunk BuildChunk(List<string> parts, int index, ref int startPosition)
{
var text = string.Join("\n\n", parts);
var chunk = new TextChunk(text, index, startPosition, startPosition + text.Length);
startPosition += text.Length;
return chunk;
}
private static List<string> SplitSentences(string text)
{
var sentences = new List<string>();
var start = 0;
for (var i = 0; i < text.Length; i++)
{
if (text[i] is '。' or '' or '' or '.' or '!' or '?' or '' or ';')
{
var end = i + 1;
if (end < text.Length) end++;
sentences.Add(text[start..Math.Min(end, text.Length)]);
start = end;
}
}
if (start < text.Length)
sentences.Add(text[start..]);
return sentences.Where(s => s.Trim().Length > 0).ToList();
}
}

View File

@ -0,0 +1,38 @@
using System.Text.Json;
using RAG.Domain.Interfaces;
using StackExchange.Redis;
namespace RAG.Infrastructure.Cache;
public class ChatMessageCache(IConnectionMultiplexer redis) : IChatMessageCache
{
private readonly IDatabase _db = redis.GetDatabase();
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private static string Key(Guid conversationId) => $"chat:messages:{conversationId}";
public async Task<List<CachedChatMessage>?> GetMessagesAsync(Guid conversationId, CancellationToken ct)
{
var value = await _db.StringGetAsync(Key(conversationId));
if (value.IsNullOrEmpty) return null;
return JsonSerializer.Deserialize<List<CachedChatMessage>>((string)value!, JsonOptions);
}
public async Task SetMessagesAsync(Guid conversationId, List<CachedChatMessage> messages, CancellationToken ct)
{
var json = JsonSerializer.Serialize(messages, JsonOptions);
await _db.StringSetAsync(Key(conversationId), json, TimeSpan.FromHours(1));
}
public async Task AppendMessageAsync(Guid conversationId, CachedChatMessage message, CancellationToken ct)
{
var messages = await GetMessagesAsync(conversationId, ct) ?? [];
messages.Add(message);
await SetMessagesAsync(conversationId, messages, ct);
}
public async Task RemoveAsync(Guid conversationId, CancellationToken ct)
{
await _db.KeyDeleteAsync(Key(conversationId));
}
}

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RAG.Domain.Entities;
namespace RAG.Infrastructure.Persistence.Configurations;
public class ChatMessageConfiguration : IEntityTypeConfiguration<ChatMessage>
{
public void Configure(EntityTypeBuilder<ChatMessage> builder)
{
builder.ToTable("chat_messages");
builder.HasKey(m => m.Id);
builder.Property(m => m.Id).ValueGeneratedNever();
builder.Property(m => m.Content).IsRequired();
builder.Property(m => m.Role).IsRequired();
builder.Property(m => m.CreatedBy).HasMaxLength(100);
builder.Property(m => m.UpdatedBy).HasMaxLength(100);
}
}

View File

@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RAG.Domain.Entities;
namespace RAG.Infrastructure.Persistence.Configurations;
public class ConversationConfiguration : IEntityTypeConfiguration<Conversation>
{
public void Configure(EntityTypeBuilder<Conversation> builder)
{
builder.ToTable("conversations");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id).ValueGeneratedNever();
builder.Property(c => c.Title).HasMaxLength(200).IsRequired();
builder.Property(c => c.KnowledgeBaseId).IsRequired(false);
builder.Property(c => c.CreatedBy).HasMaxLength(100);
builder.Property(c => c.UpdatedBy).HasMaxLength(100);
builder.Property(c => c.IsDeleted).HasDefaultValue(false);
builder.Property(c => c.OperatorIP).HasMaxLength(50);
builder.HasOne(c => c.User)
.WithMany()
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(c => c.Messages)
.WithOne(m => m.Conversation)
.HasForeignKey(m => m.ConversationId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RAG.Domain.Entities;
namespace RAG.Infrastructure.Persistence.Configurations;
public class DocumentChunkConfiguration : IEntityTypeConfiguration<DocumentChunk>
{
public void Configure(EntityTypeBuilder<DocumentChunk> builder)
{
builder.ToTable("document_chunks");
builder.HasKey(c => c.Id);
builder.Property(c => c.Id).ValueGeneratedNever();
builder.Property(c => c.Content).IsRequired();
builder.Ignore(c => c.Embedding);
builder.Property(c => c.CreatedBy).HasMaxLength(100);
builder.Property(c => c.UpdatedBy).HasMaxLength(100);
builder.HasIndex(c => c.DocumentId);
}
}

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RAG.Domain.Entities;
namespace RAG.Infrastructure.Persistence.Configurations;
public class DocumentConfiguration : IEntityTypeConfiguration<Document>
{
public void Configure(EntityTypeBuilder<Document> builder)
{
builder.ToTable("documents");
builder.HasKey(d => d.Id);
builder.Property(d => d.Id).ValueGeneratedNever();
builder.Property(d => d.Title).HasMaxLength(500).IsRequired();
builder.Property(d => d.FileName).HasMaxLength(500).IsRequired();
builder.Property(d => d.FilePath).HasMaxLength(1000).IsRequired();
builder.Property(d => d.ContentType).HasMaxLength(100).IsRequired();
builder.Property(d => d.CreatedBy).HasMaxLength(100);
builder.Property(d => d.UpdatedBy).HasMaxLength(100);
builder.Property(d => d.IsDeleted).HasDefaultValue(false);
builder.Property(d => d.OperatorIP).HasMaxLength(50);
builder.HasMany(d => d.Chunks)
.WithOne(c => c.Document)
.HasForeignKey(c => c.DocumentId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RAG.Domain.Entities;
namespace RAG.Infrastructure.Persistence.Configurations;
public class KnowledgeBaseConfiguration : IEntityTypeConfiguration<KnowledgeBase>
{
public void Configure(EntityTypeBuilder<KnowledgeBase> builder)
{
builder.ToTable("knowledge_bases");
builder.HasKey(k => k.Id);
builder.Property(k => k.Id).ValueGeneratedNever();
builder.Property(k => k.Name).HasMaxLength(200).IsRequired();
builder.Property(k => k.Description).HasMaxLength(1000);
builder.Property(k => k.EmbeddingModel).HasMaxLength(100).IsRequired();
builder.Property(k => k.CreatedBy).HasMaxLength(100);
builder.Property(k => k.UpdatedBy).HasMaxLength(100);
builder.Property(k => k.IsDeleted).HasDefaultValue(false);
builder.Property(k => k.OperatorIP).HasMaxLength(50);
builder.HasMany(k => k.Documents)
.WithOne(d => d.KnowledgeBase)
.HasForeignKey(d => d.KnowledgeBaseId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@ -0,0 +1,811 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using RAG.Infrastructure.Persistence;
#nullable disable
namespace RAG.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(RagDbContext))]
[Migration("20260519082511_AddAIEntities")]
partial class AddAIEntities
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RAG.Domain.Entities.ChatMessage", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ConversationId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<int?>("TokenUsage")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("ConversationId");
b.ToTable("chat_messages", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.Conversation", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<Guid?>("KnowledgeBaseId")
.HasColumnType("uuid");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KnowledgeBaseId");
b.HasIndex("UserId");
b.ToTable("conversations", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.Document", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ChunkCount")
.HasColumnType("integer");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<Guid>("KnowledgeBaseId")
.HasColumnType("uuid");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("KnowledgeBaseId");
b.ToTable("documents", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.DocumentChunk", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ChunkIndex")
.HasColumnType("integer");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("DocumentId")
.HasColumnType("uuid");
b.Property<int>("TokenCount")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("DocumentId");
b.ToTable("document_chunks", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.KnowledgeBase", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ChunkOverlap")
.HasColumnType("integer");
b.Property<int>("ChunkSize")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("EmbeddingModel")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.ToTable("knowledge_bases", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.Menu", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("ActiveIcon")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool?>("AffixTab")
.HasColumnType("boolean");
b.Property<string>("Authority")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Component")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("HideInMenu")
.HasColumnType("boolean");
b.Property<string>("Icon")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool?>("KeepAlive")
.HasColumnType("boolean");
b.Property<string>("Link")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("MenuVisibleWithForbidden")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("NoBasicLayout")
.HasColumnType("boolean");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Redirect")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("menus", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.Permission", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Description")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Group")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("permissions", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.RefreshToken", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("refresh_tokens", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.Role", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Description")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("roles", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.RolePermission", b =>
{
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<Guid>("PermissionId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("RoleId", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("role_permissions", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.User", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.HasIndex("Username")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.UserRole", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("user_roles", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.ChatMessage", b =>
{
b.HasOne("RAG.Domain.Entities.Conversation", "Conversation")
.WithMany("Messages")
.HasForeignKey("ConversationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Conversation");
});
modelBuilder.Entity("RAG.Domain.Entities.Conversation", b =>
{
b.HasOne("RAG.Domain.Entities.KnowledgeBase", "KnowledgeBase")
.WithMany()
.HasForeignKey("KnowledgeBaseId");
b.HasOne("RAG.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("KnowledgeBase");
b.Navigation("User");
});
modelBuilder.Entity("RAG.Domain.Entities.Document", b =>
{
b.HasOne("RAG.Domain.Entities.KnowledgeBase", "KnowledgeBase")
.WithMany("Documents")
.HasForeignKey("KnowledgeBaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("KnowledgeBase");
});
modelBuilder.Entity("RAG.Domain.Entities.DocumentChunk", b =>
{
b.HasOne("RAG.Domain.Entities.Document", "Document")
.WithMany("Chunks")
.HasForeignKey("DocumentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Document");
});
modelBuilder.Entity("RAG.Domain.Entities.Menu", b =>
{
b.HasOne("RAG.Domain.Entities.Menu", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Parent");
});
modelBuilder.Entity("RAG.Domain.Entities.RefreshToken", b =>
{
b.HasOne("RAG.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("RAG.Domain.Entities.RolePermission", b =>
{
b.HasOne("RAG.Domain.Entities.Permission", "Permission")
.WithMany("RolePermissions")
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RAG.Domain.Entities.Role", "Role")
.WithMany("RolePermissions")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Permission");
b.Navigation("Role");
});
modelBuilder.Entity("RAG.Domain.Entities.UserRole", b =>
{
b.HasOne("RAG.Domain.Entities.Role", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RAG.Domain.Entities.User", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("RAG.Domain.Entities.Conversation", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("RAG.Domain.Entities.Document", b =>
{
b.Navigation("Chunks");
});
modelBuilder.Entity("RAG.Domain.Entities.KnowledgeBase", b =>
{
b.Navigation("Documents");
});
modelBuilder.Entity("RAG.Domain.Entities.Menu", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("RAG.Domain.Entities.Permission", b =>
{
b.Navigation("RolePermissions");
});
modelBuilder.Entity("RAG.Domain.Entities.Role", b =>
{
b.Navigation("RolePermissions");
b.Navigation("UserRoles");
});
modelBuilder.Entity("RAG.Domain.Entities.User", b =>
{
b.Navigation("UserRoles");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,204 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RAG.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAIEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.CreateTable(
name: "knowledge_bases",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
EmbeddingModel = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
ChunkSize = table.Column<int>(type: "integer", nullable: false),
ChunkOverlap = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
OperatorIP = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_knowledge_bases", x => x.Id);
});
migrationBuilder.CreateTable(
name: "conversations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
KnowledgeBaseId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
OperatorIP = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_conversations", x => x.Id);
table.ForeignKey(
name: "FK_conversations_knowledge_bases_KnowledgeBaseId",
column: x => x.KnowledgeBaseId,
principalTable: "knowledge_bases",
principalColumn: "Id");
table.ForeignKey(
name: "FK_conversations_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "documents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
KnowledgeBaseId = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
FileName = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
FilePath = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
FileSize = table.Column<long>(type: "bigint", nullable: false),
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
ChunkCount = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
OperatorIP = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_documents", x => x.Id);
table.ForeignKey(
name: "FK_documents_knowledge_bases_KnowledgeBaseId",
column: x => x.KnowledgeBaseId,
principalTable: "knowledge_bases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "chat_messages",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<int>(type: "integer", nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
TokenUsage = table.Column<int>(type: "integer", nullable: true),
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_chat_messages", x => x.Id);
table.ForeignKey(
name: "FK_chat_messages_conversations_ConversationId",
column: x => x.ConversationId,
principalTable: "conversations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "document_chunks",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DocumentId = table.Column<Guid>(type: "uuid", nullable: false),
Content = table.Column<string>(type: "text", nullable: false),
ChunkIndex = table.Column<int>(type: "integer", nullable: false),
TokenCount = table.Column<int>(type: "integer", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_document_chunks", x => x.Id);
table.ForeignKey(
name: "FK_document_chunks_documents_DocumentId",
column: x => x.DocumentId,
principalTable: "documents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_chat_messages_ConversationId",
table: "chat_messages",
column: "ConversationId");
migrationBuilder.CreateIndex(
name: "IX_conversations_KnowledgeBaseId",
table: "conversations",
column: "KnowledgeBaseId");
migrationBuilder.CreateIndex(
name: "IX_conversations_UserId",
table: "conversations",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_document_chunks_DocumentId",
table: "document_chunks",
column: "DocumentId");
migrationBuilder.CreateIndex(
name: "IX_documents_KnowledgeBaseId",
table: "documents",
column: "KnowledgeBaseId");
migrationBuilder.Sql("ALTER TABLE document_chunks ADD COLUMN embedding vector(768)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("ALTER TABLE document_chunks DROP COLUMN IF EXISTS embedding");
migrationBuilder.DropTable(
name: "chat_messages");
migrationBuilder.DropTable(
name: "document_chunks");
migrationBuilder.DropTable(
name: "conversations");
migrationBuilder.DropTable(
name: "documents");
migrationBuilder.DropTable(
name: "knowledge_bases");
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
}
}
}

View File

@ -20,8 +20,271 @@ namespace RAG.Infrastructure.Persistence.Migrations
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("RAG.Domain.Entities.ChatMessage", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ConversationId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<int?>("TokenUsage")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("ConversationId");
b.ToTable("chat_messages", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.Conversation", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<Guid?>("KnowledgeBaseId")
.HasColumnType("uuid");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("KnowledgeBaseId");
b.HasIndex("UserId");
b.ToTable("conversations", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.Document", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ChunkCount")
.HasColumnType("integer");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("FilePath")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<Guid>("KnowledgeBaseId")
.HasColumnType("uuid");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("KnowledgeBaseId");
b.ToTable("documents", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.DocumentChunk", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ChunkIndex")
.HasColumnType("integer");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("DocumentId")
.HasColumnType("uuid");
b.Property<int>("TokenCount")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("DocumentId");
b.ToTable("document_chunks", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.KnowledgeBase", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ChunkOverlap")
.HasColumnType("integer");
b.Property<int>("ChunkSize")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Description")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("EmbeddingModel")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsDeleted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("OperatorIP")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.ToTable("knowledge_bases", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.Menu", b =>
{
b.Property<Guid>("Id")
@ -394,6 +657,56 @@ namespace RAG.Infrastructure.Persistence.Migrations
b.ToTable("user_roles", (string)null);
});
modelBuilder.Entity("RAG.Domain.Entities.ChatMessage", b =>
{
b.HasOne("RAG.Domain.Entities.Conversation", "Conversation")
.WithMany("Messages")
.HasForeignKey("ConversationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Conversation");
});
modelBuilder.Entity("RAG.Domain.Entities.Conversation", b =>
{
b.HasOne("RAG.Domain.Entities.KnowledgeBase", "KnowledgeBase")
.WithMany()
.HasForeignKey("KnowledgeBaseId");
b.HasOne("RAG.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("KnowledgeBase");
b.Navigation("User");
});
modelBuilder.Entity("RAG.Domain.Entities.Document", b =>
{
b.HasOne("RAG.Domain.Entities.KnowledgeBase", "KnowledgeBase")
.WithMany("Documents")
.HasForeignKey("KnowledgeBaseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("KnowledgeBase");
});
modelBuilder.Entity("RAG.Domain.Entities.DocumentChunk", b =>
{
b.HasOne("RAG.Domain.Entities.Document", "Document")
.WithMany("Chunks")
.HasForeignKey("DocumentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Document");
});
modelBuilder.Entity("RAG.Domain.Entities.Menu", b =>
{
b.HasOne("RAG.Domain.Entities.Menu", "Parent")
@ -453,6 +766,21 @@ namespace RAG.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("RAG.Domain.Entities.Conversation", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("RAG.Domain.Entities.Document", b =>
{
b.Navigation("Chunks");
});
modelBuilder.Entity("RAG.Domain.Entities.KnowledgeBase", b =>
{
b.Navigation("Documents");
});
modelBuilder.Entity("RAG.Domain.Entities.Menu", b =>
{
b.Navigation("Children");

View File

@ -1,5 +1,6 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Pgvector.EntityFrameworkCore;
using RAG.Domain.Common;
using RAG.Domain.Entities;
@ -16,9 +17,15 @@ public class RagDbContext : DbContext
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<Menu> Menus => Set<Menu>();
public DbSet<Conversation> Conversations => Set<Conversation>();
public DbSet<ChatMessage> ChatMessages => Set<ChatMessage>();
public DbSet<KnowledgeBase> KnowledgeBases => Set<KnowledgeBase>();
public DbSet<Document> Documents => Set<Document>();
public DbSet<DocumentChunk> DocumentChunks => Set<DocumentChunk>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("vector");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(RagDbContext).Assembly);
// 为所有实现 ISoftDelete 的实体自动注册全局查询过滤器e => !e.IsDeleted

View File

@ -1,4 +1,5 @@
using RAG.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace RAG.Infrastructure.Persistence;
@ -6,71 +7,83 @@ public static class SeedData
{
public static async Task SeedAsync(RagDbContext db)
{
if (db.Roles.Any()) return;
var permissions = new[]
// 权限
if (!await db.Permissions.AnyAsync())
{
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Name = "查看用户", Code = "user:read", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Name = "创建用户", Code = "user:create", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), Name = "更新用户", Code = "user:update", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000004"), Name = "删除用户", Code = "user:delete", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000005"), Name = "分配角色", Code = "user:assign-role", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000006"), Name = "查看角色", Code = "role:read", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000007"), Name = "创建角色", Code = "role:create", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000008"), Name = "更新角色", Code = "role:update", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000009"), Name = "删除角色", Code = "role:delete", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000010"), Name = "分配权限", Code = "role:assign-permission", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000011"), Name = "查看权限", Code = "permission:read", Group = "SystemManagement" },
};
var permissions = new[]
{
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Name = "查看用户", Code = "user:read", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Name = "创建用户", Code = "user:create", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), Name = "更新用户", Code = "user:update", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000004"), Name = "删除用户", Code = "user:delete", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000005"), Name = "分配角色", Code = "user:assign-role", Group = "UserManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000006"), Name = "查看角色", Code = "role:read", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000007"), Name = "创建角色", Code = "role:create", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000008"), Name = "更新角色", Code = "role:update", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000009"), Name = "删除角色", Code = "role:delete", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000010"), Name = "分配权限", Code = "role:assign-permission", Group = "RoleManagement" },
new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000011"), Name = "查看权限", Code = "permission:read", Group = "SystemManagement" },
};
await db.Permissions.AddRangeAsync(permissions);
await db.SaveChangesAsync();
}
await db.Permissions.AddRangeAsync(permissions);
var superAdminRole = new Role
// 角色
if (!await db.Roles.AnyAsync())
{
Id = Guid.Parse("20000000-0000-0000-0000-000000000001"),
Name = "SuperAdmin",
Description = "超级管理员"
};
var adminRole = new Role
var superAdminRole = new Role { Id = Guid.Parse("20000000-0000-0000-0000-000000000001"), Name = "SuperAdmin", Description = "超级管理员" };
var adminRole = new Role { Id = Guid.Parse("20000000-0000-0000-0000-000000000002"), Name = "Admin", Description = "管理员" };
var userRole = new Role { Id = Guid.Parse("20000000-0000-0000-0000-000000000003"), Name = "User", Description = "普通用户" };
await db.Roles.AddRangeAsync(superAdminRole, adminRole, userRole);
await db.SaveChangesAsync();
}
// 角色-权限关联
if (!await db.RolePermissions.AnyAsync())
{
Id = Guid.Parse("20000000-0000-0000-0000-000000000002"),
Name = "Admin",
Description = "管理员"
};
var userRole = new Role
var superAdminRoleId = Guid.Parse("20000000-0000-0000-0000-000000000001");
var adminRoleId = Guid.Parse("20000000-0000-0000-0000-000000000002");
var userRoleId = Guid.Parse("20000000-0000-0000-0000-000000000003");
var allPerms = await db.Permissions.ToListAsync();
await db.RolePermissions.AddRangeAsync(allPerms.Select(p => new RolePermission { RoleId = superAdminRoleId, PermissionId = p.Id }));
await db.RolePermissions.AddRangeAsync(allPerms.Where(p => p.Code is "user:read" or "user:create" or "user:update" or "role:read" or "permission:read").Select(p => new RolePermission { RoleId = adminRoleId, PermissionId = p.Id }));
await db.RolePermissions.AddRangeAsync(allPerms.Where(p => p.Code is "user:read" or "role:read" or "permission:read").Select(p => new RolePermission { RoleId = userRoleId, PermissionId = p.Id }));
await db.SaveChangesAsync();
}
// 管理员用户
var adminUserId = Guid.Parse("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
var existingAdmin = await db.Users.FirstOrDefaultAsync(u => u.Id == adminUserId);
if (existingAdmin == null)
{
Id = Guid.Parse("20000000-0000-0000-0000-000000000003"),
Name = "User",
Description = "普通用户"
};
var seededAdminId = Guid.Parse("30000000-0000-0000-0000-000000000001");
existingAdmin = await db.Users.FirstOrDefaultAsync(u => u.Username == "admin");
if (existingAdmin == null)
{
existingAdmin = new User
{
Id = seededAdminId,
Username = "admin",
Email = "admin@rag.local",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin123")
};
await db.Users.AddAsync(existingAdmin);
await db.SaveChangesAsync();
}
}
await db.Roles.AddRangeAsync(superAdminRole, adminRole, userRole);
var allRolePerms = permissions.Select(p => new RolePermission { RoleId = superAdminRole.Id, PermissionId = p.Id }).ToList();
await db.RolePermissions.AddRangeAsync(allRolePerms);
var adminPerms = permissions.Where(p => p.Code is "user:read" or "user:create" or "user:update" or "role:read" or "permission:read")
.Select(p => new RolePermission { RoleId = adminRole.Id, PermissionId = p.Id }).ToList();
await db.RolePermissions.AddRangeAsync(adminPerms);
var userPerms = permissions.Where(p => p.Code is "user:read" or "role:read" or "permission:read")
.Select(p => new RolePermission { RoleId = userRole.Id, PermissionId = p.Id }).ToList();
await db.RolePermissions.AddRangeAsync(userPerms);
var adminUser = new User
// 确保管理员有 SuperAdmin 角色
var superAdminId = Guid.Parse("20000000-0000-0000-0000-000000000001");
var hasRole = await db.UserRoles.AnyAsync(ur => ur.UserId == existingAdmin.Id && ur.RoleId == superAdminId);
if (!hasRole)
{
Id = Guid.Parse("30000000-0000-0000-0000-000000000001"),
Username = "admin",
Email = "admin@rag.local",
PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin123")
};
await db.Users.AddAsync(adminUser);
await db.UserRoles.AddAsync(new UserRole { UserId = adminUser.Id, RoleId = superAdminRole.Id });
await db.UserRoles.AddAsync(new UserRole { UserId = existingAdmin.Id, RoleId = superAdminId });
await db.SaveChangesAsync();
}
// ===== 菜单种子数据 =====
await SeedMenusAsync(db);
await db.SaveChangesAsync();
}
private static async Task SeedMenusAsync(RagDbContext db)
@ -102,89 +115,6 @@ public static class SeedData
Title = "page.dashboard.workspace", Order = 2,
ParentId = Guid.Parse("40000000-0000-0000-0000-000000000001")
},
// ── Demos ──
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000010"),
Name = "Demos", Path = "/demos", Redirect = "/demos/access",
Title = "demos.title", Icon = "ic:baseline-view-in-ar",
Order = 1000, Component = null, KeepAlive = true
},
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000011"),
Name = "AccessDemos", Path = "/demosaccess", Redirect = "/demos/access/page-control",
Title = "demos.access.backendPermissions", Icon = "mdi:cloud-key-outline",
Order = 1, Component = null,
ParentId = Guid.Parse("40000000-0000-0000-0000-000000000010")
},
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000012"),
Name = "AccessPageControlDemo", Path = "/demos/access/page-control",
Component = "/demos/access/index",
Title = "demos.access.pageAccess", Icon = "mdi:page-previous-outline",
Order = 1,
ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011")
},
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000013"),
Name = "AccessButtonControlDemo", Path = "/demos/access/button-control",
Component = "/demos/access/button-control",
Title = "demos.access.buttonControl", Icon = "mdi:button-cursor",
Order = 2,
ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011")
},
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000014"),
Name = "AccessMenuVisible403Demo", Path = "/demos/access/menu-visible-403",
Component = "/demos/access/menu-visible-403",
Title = "demos.access.menuVisible403", Icon = "mdi:button-cursor",
Order = 3, MenuVisibleWithForbidden = true, Authority = "no-body",
ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011")
},
// SuperAdmin 专属页面
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000015"),
Name = "AccessSuperVisibleDemo", Path = "/demos/access/super-visible",
Component = "/demos/access/super-visible",
Title = "demos.access.superVisible", Icon = "mdi:button-cursor",
Order = 4, Authority = "SuperAdmin",
ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011")
},
// Admin 专属页面
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000016"),
Name = "AccessAdminVisibleDemo", Path = "/demos/access/admin-visible",
Component = "/demos/access/admin-visible",
Title = "demos.access.adminVisible", Icon = "mdi:button-cursor",
Order = 4, Authority = "Admin",
ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011")
},
// User 专属页面
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000017"),
Name = "AccessUserVisibleDemo", Path = "/demos/access/user-visible",
Component = "/demos/access/user-visible",
Title = "demos.access.userVisible", Icon = "mdi:button-cursor",
Order = 4, Authority = "User",
ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011")
},
// ── About ──
new()
{
Id = Guid.Parse("40000000-0000-0000-0000-000000000020"),
Name = "About", Path = "/about", Component = "_core/about/index",
Title = "demos.vben.about", Icon = "lucide:copyright",
Order = 9999
},
};
await db.Menus.AddRangeAsync(menus);

View File

@ -6,6 +6,7 @@
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -14,6 +15,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
<PackageReference Include="StackExchange.Redis" Version="2.12.14" />
<PackageReference Include="Volo.Abp.Core" Version="10.3.0" />

View File

@ -1,7 +1,10 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Pgvector.EntityFrameworkCore;
using RAG.Domain;
using RAG.Domain.Interfaces;
using RAG.Infrastructure.AI;
using RAG.Infrastructure.Cache;
using RAG.Infrastructure.Messaging;
using RAG.Infrastructure.Persistence;
@ -24,7 +27,7 @@ public class RAGInfrastructureModule : AbpModule
// DbContext 挂载审计拦截器,拦截器从 DI 容器解析 ICurrentUserContext
services.AddDbContext<RagDbContext>((sp, options) =>
options.UseNpgsql(config.GetConnectionString("Default"))
options.UseNpgsql(config.GetConnectionString("Default"), o => o.UseVector())
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
// Scoped 生命周期:每个 HTTP 请求创建一个拦截器实例,确保 ICurrentUserContext 正确获取当前用户
@ -32,5 +35,12 @@ public class RAGInfrastructureModule : AbpModule
services.AddRedisCache(config);
services.AddRabbitMq(config);
// AI 服务注册
services.Configure<AiOptions>(config.GetSection(AiOptions.SectionName));
services.AddScoped<IAIChatAgent, ChatAgentService>();
services.AddScoped<IEmbeddingService, EmbeddingService>();
services.AddScoped<ITextChunker, TextChunker>();
services.AddSingleton<IChatMessageCache, ChatMessageCache>();
}
}