This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Rag.Models.Settings;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using CommonHelpers;
|
||||
|
||||
namespace Api.Clients.Ai;
|
||||
|
||||
public sealed class CachedRagAiClient : IAiClient
|
||||
{
|
||||
private readonly RagAiClient _client;
|
||||
private readonly IRagRepository _repository;
|
||||
private readonly AiSettings _settings;
|
||||
|
||||
public CachedRagAiClient(RagAiClient client, IRagRepository repository, IOptions<AiSettings> options)
|
||||
{
|
||||
_client = client;
|
||||
_repository = repository;
|
||||
_settings = options.Value;
|
||||
}
|
||||
|
||||
public async Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct)
|
||||
{
|
||||
var model = GetEmbeddingModel();
|
||||
var textHash = HashHelper.Compute(input);
|
||||
var cacheKey = HashHelper.Compute($"embedding:{_settings.Provider}:{model}:{textHash}");
|
||||
var cached = await _repository.GetEmbeddingAsync(cacheKey, ct);
|
||||
if (cached is not null) return cached;
|
||||
|
||||
var vector = await _client.CreateEmbeddingAsync(input, ct);
|
||||
await _repository.SaveEmbeddingAsync(cacheKey, model, textHash, vector, ct);
|
||||
return vector;
|
||||
}
|
||||
|
||||
public async Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
|
||||
{
|
||||
var model = GetChatModel();
|
||||
var cacheKey = HashHelper.Compute($"chat:{_settings.Provider}:{model}:{temperature:0.00}:{systemPrompt}:{userPrompt}");
|
||||
var cached = await _repository.GetChatCompletionAsync(cacheKey, ct);
|
||||
if (cached is not null) return cached;
|
||||
|
||||
var response = await _client.CreateChatCompletionAsync(systemPrompt, userPrompt, temperature, ct);
|
||||
await _repository.SaveChatCompletionAsync(cacheKey, model, temperature, response, ct);
|
||||
return response;
|
||||
}
|
||||
|
||||
private string GetEmbeddingModel() => string.Equals(_settings.Provider, "Ollama", StringComparison.OrdinalIgnoreCase)
|
||||
? _settings.Ollama.EmbeddingModel
|
||||
: _settings.OpenAI.EmbeddingModel;
|
||||
|
||||
private string GetChatModel() => string.Equals(_settings.Provider, "Ollama", StringComparison.OrdinalIgnoreCase)
|
||||
? _settings.Ollama.ChatModel
|
||||
: _settings.OpenAI.ChatModel;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Api.Clients.Ai.Contracts;
|
||||
|
||||
public interface IAiClient
|
||||
{
|
||||
Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct);
|
||||
Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Rag.Models.Settings;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
|
||||
namespace Api.Clients.Ai;
|
||||
|
||||
public sealed class RagAiClient : IAiClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly AiSettings _settings;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public RagAiClient(HttpClient http, IOptions<AiSettings> options)
|
||||
{
|
||||
_http = http;
|
||||
_settings = options.Value;
|
||||
}
|
||||
|
||||
public async Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return IsOllama() ? await CreateOllamaEmbeddingAsync(input, ct) : await CreateOpenAiEmbeddingAsync(input, ct);
|
||||
}
|
||||
|
||||
public async Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
|
||||
{
|
||||
return IsOllama()
|
||||
? await CreateOllamaChatCompletionAsync(systemPrompt, userPrompt, temperature, ct)
|
||||
: await CreateOpenAiChatCompletionAsync(systemPrompt, userPrompt, temperature, ct);
|
||||
}
|
||||
|
||||
private bool IsOllama() => string.Equals(_settings.Provider, "Ollama", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<float[]> CreateOpenAiEmbeddingAsync(string input, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.OpenAI.ApiKey)) throw new InvalidOperationException("OpenAI API key is missing.");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/embeddings");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _settings.OpenAI.ApiKey);
|
||||
request.Content = ToJson(new { model = _settings.OpenAI.EmbeddingModel, input });
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(15, _settings.OpenAI.TimeoutSeconds)));
|
||||
using var response = await _http.SendAsync(request, cts.Token);
|
||||
var json = await response.Content.ReadAsStringAsync(cts.Token);
|
||||
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"OpenAI embeddings failed: {(int)response.StatusCode} {json}");
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.GetProperty("data")[0].GetProperty("embedding").EnumerateArray().Select(x => x.GetSingle()).ToArray();
|
||||
}
|
||||
|
||||
private async Task<string> CreateOpenAiChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.OpenAI.ApiKey)) throw new InvalidOperationException("OpenAI API key is missing.");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _settings.OpenAI.ApiKey);
|
||||
request.Content = ToJson(new
|
||||
{
|
||||
model = _settings.OpenAI.ChatModel,
|
||||
temperature,
|
||||
response_format = new { type = "json_object" },
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
new { role = "user", content = userPrompt }
|
||||
}
|
||||
});
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(15, _settings.OpenAI.TimeoutSeconds)));
|
||||
using var response = await _http.SendAsync(request, cts.Token);
|
||||
var json = await response.Content.ReadAsStringAsync(cts.Token);
|
||||
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"OpenAI chat failed: {(int)response.StatusCode} {json}");
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? "{}";
|
||||
}
|
||||
|
||||
private async Task<float[]> CreateOllamaEmbeddingAsync(string input, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = _settings.Ollama.BaseUrl.TrimEnd('/');
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(30, _settings.Ollama.TimeoutSeconds)));
|
||||
using var response = await _http.PostAsync($"{baseUrl}/api/embeddings", ToJson(new { model = _settings.Ollama.EmbeddingModel, prompt = input }), cts.Token);
|
||||
var json = await response.Content.ReadAsStringAsync(cts.Token);
|
||||
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Ollama embeddings failed: {(int)response.StatusCode} {json}");
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.GetProperty("embedding").EnumerateArray().Select(x => x.GetSingle()).ToArray();
|
||||
}
|
||||
|
||||
private async Task<string> CreateOllamaChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = _settings.Ollama.BaseUrl.TrimEnd('/');
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(30, _settings.Ollama.TimeoutSeconds)));
|
||||
using var response = await _http.PostAsync($"{baseUrl}/api/chat", ToJson(new
|
||||
{
|
||||
model = _settings.Ollama.ChatModel,
|
||||
stream = false,
|
||||
format = "json",
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
new { role = "user", content = userPrompt }
|
||||
},
|
||||
options = new { temperature = (float)temperature }
|
||||
}), cts.Token);
|
||||
var json = await response.Content.ReadAsStringAsync(cts.Token);
|
||||
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Ollama chat failed: {(int)response.StatusCode} {json}");
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.GetProperty("message").GetProperty("content").GetString() ?? "{}";
|
||||
}
|
||||
|
||||
private static StringContent ToJson<T>(T payload) => new(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Controller that exposes simple health and readiness endpoints for the API.
|
||||
/// Routes are prefixed with "api/health".
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class HealthController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Liveness probe.
|
||||
/// Indicates whether the process is running. Used by orchestration systems to confirm the process is alive.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// 200 OK with JSON payload: { "status": "alive" } when the process is running.
|
||||
/// </returns>
|
||||
// GET api/health/live
|
||||
[HttpGet("live")]
|
||||
[SwaggerOperation(Summary = "Liveness probe", Description = "Returns whether the API process is alive.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Service is alive")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult Live() => Ok(new { status = "alive" });
|
||||
|
||||
/// <summary>
|
||||
/// Basic health check endpoint.
|
||||
/// Returns overall status and the current server time in UTC.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// 200 OK with JSON payload: { "status": "ok", "time": <UTC time> }.
|
||||
/// </returns>
|
||||
// GET api/health
|
||||
[HttpGet]
|
||||
[SwaggerOperation(Summary = "Health check", Description = "Returns overall health status and current UTC time.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Health check succeeded")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult Health() => Ok(new { status = "ok", time = DateTimeOffset.UtcNow });
|
||||
|
||||
/// <summary>
|
||||
/// Echo endpoint.
|
||||
/// Returns the received JSON payload unchanged. Useful for testing request/response plumbing.
|
||||
/// </summary>
|
||||
/// <param name="payload">Arbitrary JSON from the request body. The endpoint returns the same object.</param>
|
||||
/// <returns>200 OK with the same JSON payload provided in the request body.</returns>
|
||||
// POST api/health/echo
|
||||
[HttpPost("echo")]
|
||||
[SwaggerOperation(Summary = "Echo payload", Description = "Returns the same JSON payload received in the request body.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Payload echoed successfully")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult Echo(object payload) => Ok(payload);
|
||||
|
||||
/// <summary>
|
||||
/// Readiness probe.
|
||||
/// Indicates whether the service is ready to accept traffic. Typically checks downstream dependencies.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// 200 OK with JSON { "status": "ready" } when ready;
|
||||
/// 503 Service Unavailable with JSON { "status": "not_ready" } when not ready.
|
||||
/// </returns>
|
||||
// GET api/health/ready
|
||||
[HttpGet("ready")]
|
||||
[SwaggerOperation(Summary = "Readiness probe", Description = "Returns whether the service is ready to accept traffic.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Service is ready")]
|
||||
[SwaggerResponse(StatusCodes.Status503ServiceUnavailable, "Service is not ready")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public IActionResult Ready()
|
||||
{
|
||||
var ready = true;
|
||||
|
||||
return ready
|
||||
? Ok(new { status = "ready" })
|
||||
: StatusCode(503, new { status = "not_ready" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Api.Services.Contracts;
|
||||
using Rag.Models.Requests;
|
||||
using Rag.Models.Responses;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using Shared.Models.Responses;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/rag")]
|
||||
public sealed class RagController : ControllerBase
|
||||
{
|
||||
private readonly IRagService _ragService;
|
||||
private readonly ILogger<RagController> _logger;
|
||||
|
||||
public RagController(IRagService ragService, ILogger<RagController> logger)
|
||||
{
|
||||
_ragService = ragService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("documents")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
[SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF file or raw text document using multipart/form-data payload.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully")]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid indexing request")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<IndexDocumentResponse>> IndexDocument(
|
||||
[FromForm] IndexDocumentUploadRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Index document request received. HasFile={HasFile}, DocumentType={DocumentType}, Title={Title}, SourceUrl={SourceUrl}",
|
||||
request.File is not null, request.DocumentType, request.Title, request.SourceUrl);
|
||||
|
||||
if (request.File is not null)
|
||||
{
|
||||
var result = await _ragService.IndexPdfAsync(request.File, request.DocumentType, request.Title, request.SourceUrl, ct);
|
||||
_logger.LogInformation("Indexed PDF document. DocumentId={DocumentId}, DocumentType={DocumentType}, Chunks={Chunks}, Cached={Cached}",
|
||||
result.DocumentId, result.DocumentType, result.Chunks, result.Cached);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
var textResult = await _ragService.IndexTextAsync(new IndexDocumentRequest
|
||||
{
|
||||
Text = request.Text,
|
||||
DocumentType = request.DocumentType,
|
||||
Title = request.Title,
|
||||
SourceUrl = request.SourceUrl
|
||||
}, ct);
|
||||
_logger.LogInformation("Indexed text document. DocumentId={DocumentId}, DocumentType={DocumentType}, Chunks={Chunks}, Cached={Cached}",
|
||||
textResult.DocumentId, textResult.DocumentType, textResult.Chunks, textResult.Cached);
|
||||
return Ok(textResult);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid document indexing request.");
|
||||
return BadRequest(new ErrorResponse { Error = ex.Message, Code = "invalid_request" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("documents/json")]
|
||||
[SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a text document sent as JSON.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "JSON document indexed successfully")]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid JSON indexing request")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<IndexDocumentResponse>> IndexJsonDocument([FromBody] IndexDocumentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("JSON document indexing request received. DocumentType={DocumentType}, Title={Title}, SourceUrl={SourceUrl}",
|
||||
request.DocumentType, request.Title, request.SourceUrl);
|
||||
var result = await _ragService.IndexTextAsync(request, ct);
|
||||
_logger.LogInformation("Indexed JSON document. DocumentId={DocumentId}, DocumentType={DocumentType}, Chunks={Chunks}, Cached={Cached}",
|
||||
result.DocumentId, result.DocumentType, result.Chunks, result.Cached);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid JSON document indexing request.");
|
||||
return BadRequest(new ErrorResponse { Error = ex.Message, Code = "invalid_request" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("search")]
|
||||
[SwaggerOperation(Summary = "Semantic search", Description = "Performs semantic retrieval over indexed documents.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Search results returned")]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid search request")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<SearchResponse>> Search([FromBody] SearchRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Semantic search request received. TargetTypes={TargetTypes}, TopK={TopK}",
|
||||
string.Join(',', request.TargetDocumentTypes ?? System.Array.Empty<string>()), request.TopK);
|
||||
var result = await _ragService.SearchAsync(request, ct);
|
||||
_logger.LogInformation("Semantic search completed. ResultCount={ResultCount}", result.Results.Count);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid semantic search request.");
|
||||
return BadRequest(new ErrorResponse { Error = ex.Message, Code = "invalid_request" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("documents/{id}")]
|
||||
[SwaggerOperation(Summary = "Get document details", Description = "Returns indexed document details for the provided document id.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Document details returned")]
|
||||
[SwaggerResponse(StatusCodes.Status404NotFound, "Document was not found")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<RagDocumentDetailsResponse>> GetDocument(string id, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Get document request received. DocumentId={DocumentId}", id);
|
||||
var document = await _ragService.GetDocumentAsync(id, ct);
|
||||
if (document is null)
|
||||
{
|
||||
_logger.LogWarning("Document not found. DocumentId={DocumentId}", id);
|
||||
return NotFound(new ErrorResponse { Error = "Document not found.", Code = "document_not_found" });
|
||||
}
|
||||
return Ok(document);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Api.Data.Entities;
|
||||
|
||||
public sealed class RagChatCompletionCacheEntity
|
||||
{
|
||||
public string CacheKey { get; set; } = string.Empty;
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public decimal Temperature { get; set; }
|
||||
public string ResponseText { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Api.Data.Entities;
|
||||
|
||||
public sealed class RagChunkEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string DocumentId { get; set; } = string.Empty;
|
||||
public int ChunkIndex { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public byte[] Embedding { get; set; } = [];
|
||||
|
||||
public RagDocumentEntity? Document { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Api.Data.Entities;
|
||||
|
||||
public sealed class RagDocumentEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string DocumentType { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? SourceUrl { get; set; }
|
||||
public string RawText { get; set; } = string.Empty;
|
||||
public string TextHash { get; set; } = string.Empty;
|
||||
public double TypeConfidence { get; set; }
|
||||
public string MetadataJson { get; set; } = "{}";
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public ICollection<RagChunkEntity> Chunks { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Api.Data.Entities;
|
||||
|
||||
public sealed class RagEmbeddingCacheEntity
|
||||
{
|
||||
public string CacheKey { get; set; } = string.Empty;
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public string TextHash { get; set; } = string.Empty;
|
||||
public byte[] Vector { get; set; } = [];
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Api.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.Data;
|
||||
|
||||
public sealed class RagDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "rag";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
|
||||
public RagDbContext(DbContextOptions<RagDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<RagDocumentEntity> RagDocuments => Set<RagDocumentEntity>();
|
||||
public DbSet<RagChunkEntity> RagChunks => Set<RagChunkEntity>();
|
||||
public DbSet<RagEmbeddingCacheEntity> RagEmbeddingCache => Set<RagEmbeddingCacheEntity>();
|
||||
public DbSet<RagChatCompletionCacheEntity> RagChatCompletionCache => Set<RagChatCompletionCacheEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<RagDocumentEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("Documents");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.DocumentType).HasMaxLength(80).IsRequired();
|
||||
entity.Property(x => x.Title).HasMaxLength(300).IsRequired();
|
||||
entity.Property(x => x.SourceUrl).HasMaxLength(1200);
|
||||
entity.Property(x => x.RawText).IsRequired();
|
||||
entity.Property(x => x.TextHash).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.MetadataJson).HasDefaultValue("{}").IsRequired();
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => x.TextHash);
|
||||
entity.HasIndex(x => x.DocumentType);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RagChunkEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("Chunks");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.DocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Text).IsRequired();
|
||||
entity.Property(x => x.Embedding).IsRequired();
|
||||
entity.HasOne(x => x.Document)
|
||||
.WithMany(x => x.Chunks)
|
||||
.HasForeignKey(x => x.DocumentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RagEmbeddingCacheEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("EmbeddingCache");
|
||||
entity.HasKey(x => x.CacheKey);
|
||||
entity.Property(x => x.CacheKey).HasMaxLength(64);
|
||||
entity.Property(x => x.Model).HasMaxLength(120).IsRequired();
|
||||
entity.Property(x => x.TextHash).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.Vector).IsRequired();
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => x.TextHash);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RagChatCompletionCacheEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("ChatCompletionCache");
|
||||
entity.HasKey(x => x.CacheKey);
|
||||
entity.Property(x => x.CacheKey).HasMaxLength(64);
|
||||
entity.Property(x => x.Model).HasMaxLength(120).IsRequired();
|
||||
entity.Property(x => x.Temperature).HasColumnType("decimal(4,2)");
|
||||
entity.Property(x => x.ResponseText).IsRequired();
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Rag.Models;
|
||||
|
||||
namespace Api.Data.Repositories.Contracts;
|
||||
|
||||
public interface IRagRepository
|
||||
{
|
||||
Task InitializeAsync(CancellationToken ct);
|
||||
Task<RagDocumentRecord?> GetDocumentByTextHashAsync(string textHash, string? sourceUrl, CancellationToken ct);
|
||||
Task<RagDocumentRecord?> GetDocumentByIdAsync(string id, CancellationToken ct);
|
||||
Task SaveDocumentAsync(RagDocumentRecord document, IReadOnlyList<RagChunkRecord> chunks, CancellationToken ct);
|
||||
Task<IReadOnlyList<SearchCandidateChunk>> SearchChunksAsync(float[] queryEmbedding, IReadOnlyList<string>? targetTypes, int topK, CancellationToken ct);
|
||||
Task<float[]?> GetEmbeddingAsync(string cacheKey, CancellationToken ct);
|
||||
Task SaveEmbeddingAsync(string cacheKey, string model, string textHash, float[] vector, CancellationToken ct);
|
||||
Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct);
|
||||
Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using Api.Data;
|
||||
using Api.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using Rag.Models;
|
||||
|
||||
namespace Api.Data.Repositories;
|
||||
|
||||
public sealed class EfRagRepository : IRagRepository
|
||||
{
|
||||
private readonly RagDbContext _db;
|
||||
private readonly ILogger<EfRagRepository> _logger;
|
||||
|
||||
public EfRagRepository(RagDbContext db, ILogger<EfRagRepository> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Ensuring RAG database schema exists using EF Core");
|
||||
//await _db.Database.EnsureCreatedAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<RagDocumentRecord?> GetDocumentByTextHashAsync(string textHash, string? sourceUrl, CancellationToken ct)
|
||||
{
|
||||
var query = _db.RagDocuments
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TextHash == textHash);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourceUrl))
|
||||
{
|
||||
query = query.Where(x => x.SourceUrl == sourceUrl);
|
||||
}
|
||||
|
||||
var entity = await query
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return entity is null ? null : ToRecord(entity);
|
||||
}
|
||||
|
||||
public async Task<RagDocumentRecord?> GetDocumentByIdAsync(string id, CancellationToken ct)
|
||||
{
|
||||
var entity = await _db.RagDocuments
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||
|
||||
return entity is null ? null : ToRecord(entity);
|
||||
}
|
||||
|
||||
public async Task SaveDocumentAsync(RagDocumentRecord document, IReadOnlyList<RagChunkRecord> chunks, CancellationToken ct)
|
||||
{
|
||||
var exists = await _db.RagDocuments.AnyAsync(x => x.Id == document.Id, ct);
|
||||
if (exists)
|
||||
{
|
||||
_logger.LogInformation("RAG document already exists. DocumentId={DocumentId}", document.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var entity = new RagDocumentEntity
|
||||
{
|
||||
Id = document.Id,
|
||||
DocumentType = document.DocumentType,
|
||||
Title = document.Title,
|
||||
SourceUrl = document.SourceUrl,
|
||||
RawText = document.Text,
|
||||
TextHash = document.TextHash,
|
||||
TypeConfidence = document.TypeConfidence,
|
||||
MetadataJson = document.MetadataJson,
|
||||
CreatedAt = document.CreatedAt.UtcDateTime,
|
||||
Chunks = chunks.Select(chunk => new RagChunkEntity
|
||||
{
|
||||
Id = chunk.Id,
|
||||
DocumentId = chunk.DocumentId,
|
||||
ChunkIndex = chunk.ChunkIndex,
|
||||
Text = chunk.Text,
|
||||
Embedding = VectorSerializer.ToBytes(chunk.Embedding)
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
_db.RagDocuments.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SearchCandidateChunk>> SearchChunksAsync(
|
||||
float[] queryEmbedding,
|
||||
IReadOnlyList<string>? targetTypes,
|
||||
int topK,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var types = targetTypes?
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(x => x.Trim().ToLowerInvariant())
|
||||
.Distinct()
|
||||
.ToArray() ?? System.Array.Empty<string>();
|
||||
|
||||
var query = _db.RagChunks
|
||||
.AsNoTracking()
|
||||
.Include(x => x.Document)
|
||||
.AsQueryable();
|
||||
|
||||
if (types.Length > 0)
|
||||
{
|
||||
query = query.Where(x => x.Document != null && types.Contains(x.Document.DocumentType.ToLower()));
|
||||
}
|
||||
|
||||
var rows = await query.ToListAsync(ct);
|
||||
|
||||
return rows
|
||||
.Where(x => x.Document is not null)
|
||||
.Select(x => new SearchCandidateChunk
|
||||
{
|
||||
Document = ToRecord(x.Document!),
|
||||
Chunk = new RagChunkRecord
|
||||
{
|
||||
Id = x.Id,
|
||||
DocumentId = x.DocumentId,
|
||||
ChunkIndex = x.ChunkIndex,
|
||||
Text = x.Text,
|
||||
Embedding = VectorSerializer.FromBytes(x.Embedding)
|
||||
},
|
||||
Score = VectorSerializer.CosineSimilarity(queryEmbedding, VectorSerializer.FromBytes(x.Embedding))
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Take(Math.Max(topK * 4, topK))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<float[]?> GetEmbeddingAsync(string cacheKey, CancellationToken ct)
|
||||
{
|
||||
var entry = await _db.RagEmbeddingCache
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.CacheKey == cacheKey, ct);
|
||||
|
||||
return entry is null ? null : VectorSerializer.FromBytes(entry.Vector);
|
||||
}
|
||||
|
||||
public async Task SaveEmbeddingAsync(string cacheKey, string model, string textHash, float[] vector, CancellationToken ct)
|
||||
{
|
||||
var exists = await _db.RagEmbeddingCache.AnyAsync(x => x.CacheKey == cacheKey, ct);
|
||||
if (exists) return;
|
||||
|
||||
_db.RagEmbeddingCache.Add(new RagEmbeddingCacheEntity
|
||||
{
|
||||
CacheKey = cacheKey,
|
||||
Model = model,
|
||||
TextHash = textHash,
|
||||
Vector = VectorSerializer.ToBytes(vector),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)
|
||||
{
|
||||
return await _db.RagChatCompletionCache
|
||||
.AsNoTracking()
|
||||
.Where(x => x.CacheKey == cacheKey)
|
||||
.Select(x => x.ResponseText)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
public async Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct)
|
||||
{
|
||||
var exists = await _db.RagChatCompletionCache.AnyAsync(x => x.CacheKey == cacheKey, ct);
|
||||
if (exists) return;
|
||||
|
||||
_db.RagChatCompletionCache.Add(new RagChatCompletionCacheEntity
|
||||
{
|
||||
CacheKey = cacheKey,
|
||||
Model = model,
|
||||
Temperature = temperature,
|
||||
ResponseText = responseText,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static RagDocumentRecord ToRecord(RagDocumentEntity entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
DocumentType = entity.DocumentType,
|
||||
Title = entity.Title,
|
||||
SourceUrl = entity.SourceUrl,
|
||||
Text = entity.RawText,
|
||||
TextHash = entity.TextHash,
|
||||
TypeConfidence = entity.TypeConfidence,
|
||||
MetadataJson = entity.MetadataJson,
|
||||
CreatedAt = new DateTimeOffset(DateTime.SpecifyKind(entity.CreatedAt, DateTimeKind.Utc))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Api.Data.Repositories;
|
||||
|
||||
public static class VectorSerializer
|
||||
{
|
||||
public static byte[] ToBytes(float[] vector)
|
||||
{
|
||||
var bytes = new byte[vector.Length * sizeof(float)];
|
||||
Buffer.BlockCopy(vector, 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public static float[] FromBytes(byte[] bytes)
|
||||
{
|
||||
var vector = new float[bytes.Length / sizeof(float)];
|
||||
Buffer.BlockCopy(bytes, 0, vector, 0, bytes.Length);
|
||||
return vector;
|
||||
}
|
||||
|
||||
public static double CosineSimilarity(float[] a, float[] b)
|
||||
{
|
||||
if (a.Length == 0 || a.Length != b.Length) return 0;
|
||||
double dot = 0, magA = 0, magB = 0;
|
||||
for (var i = 0; i < a.Length; i++)
|
||||
{
|
||||
dot += a[i] * b[i];
|
||||
magA += a[i] * a[i];
|
||||
magB += b[i] * b[i];
|
||||
}
|
||||
return magA == 0 || magB == 0 ? 0 : dot / (Math.Sqrt(magA) * Math.Sqrt(magB));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(RagDbContext))]
|
||||
[Migration("20260507140305_InitialRagSchema")]
|
||||
partial class InitialRagSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("rag")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagChatCompletionCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("ResponseText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Temperature")
|
||||
.HasColumnType("decimal(4,2)");
|
||||
|
||||
b.HasKey("CacheKey");
|
||||
|
||||
b.ToTable("ChatCompletionCache", "rag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<int>("ChunkIndex")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("DocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<byte[]>("Embedding")
|
||||
.IsRequired()
|
||||
.HasColumnType("varbinary(max)");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentId");
|
||||
|
||||
b.ToTable("Chunks", "rag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("DocumentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("nvarchar(max)")
|
||||
.HasDefaultValue("{}");
|
||||
|
||||
b.Property<string>("RawText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(1200)
|
||||
.HasColumnType("nvarchar(1200)");
|
||||
|
||||
b.Property<string>("TextHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<double>("TypeConfidence")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentType");
|
||||
|
||||
b.HasIndex("TextHash");
|
||||
|
||||
b.ToTable("Documents", "rag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagEmbeddingCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("TextHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<byte[]>("Vector")
|
||||
.IsRequired()
|
||||
.HasColumnType("varbinary(max)");
|
||||
|
||||
b.HasKey("CacheKey");
|
||||
|
||||
b.HasIndex("TextHash");
|
||||
|
||||
b.ToTable("EmbeddingCache", "rag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b =>
|
||||
{
|
||||
b.HasOne("Api.Data.Entities.RagDocumentEntity", "Document")
|
||||
.WithMany("Chunks")
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Document");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b =>
|
||||
{
|
||||
b.Navigation("Chunks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialRagSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "rag");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChatCompletionCache",
|
||||
schema: "rag",
|
||||
columns: table => new
|
||||
{
|
||||
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
|
||||
Temperature = table.Column<decimal>(type: "decimal(4,2)", nullable: false),
|
||||
ResponseText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChatCompletionCache", x => x.CacheKey);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Documents",
|
||||
schema: "rag",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
DocumentType = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
|
||||
Title = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
|
||||
SourceUrl = table.Column<string>(type: "nvarchar(1200)", maxLength: 1200, nullable: true),
|
||||
RawText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
TextHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
TypeConfidence = table.Column<double>(type: "float", nullable: false),
|
||||
MetadataJson = table.Column<string>(type: "nvarchar(max)", nullable: false, defaultValue: "{}"),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Documents", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmbeddingCache",
|
||||
schema: "rag",
|
||||
columns: table => new
|
||||
{
|
||||
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
|
||||
TextHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Vector = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmbeddingCache", x => x.CacheKey);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Chunks",
|
||||
schema: "rag",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
DocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ChunkIndex = table.Column<int>(type: "int", nullable: false),
|
||||
Text = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Embedding = table.Column<byte[]>(type: "varbinary(max)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Chunks", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Chunks_Documents_DocumentId",
|
||||
column: x => x.DocumentId,
|
||||
principalSchema: "rag",
|
||||
principalTable: "Documents",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Chunks_DocumentId",
|
||||
schema: "rag",
|
||||
table: "Chunks",
|
||||
column: "DocumentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Documents_DocumentType",
|
||||
schema: "rag",
|
||||
table: "Documents",
|
||||
column: "DocumentType");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Documents_TextHash",
|
||||
schema: "rag",
|
||||
table: "Documents",
|
||||
column: "TextHash");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmbeddingCache_TextHash",
|
||||
schema: "rag",
|
||||
table: "EmbeddingCache",
|
||||
column: "TextHash");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChatCompletionCache",
|
||||
schema: "rag");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Chunks",
|
||||
schema: "rag");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmbeddingCache",
|
||||
schema: "rag");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Documents",
|
||||
schema: "rag");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(RagDbContext))]
|
||||
partial class RagDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("rag")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagChatCompletionCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("ResponseText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Temperature")
|
||||
.HasColumnType("decimal(4,2)");
|
||||
|
||||
b.HasKey("CacheKey");
|
||||
|
||||
b.ToTable("ChatCompletionCache", "rag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<int>("ChunkIndex")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("DocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<byte[]>("Embedding")
|
||||
.IsRequired()
|
||||
.HasColumnType("varbinary(max)");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentId");
|
||||
|
||||
b.ToTable("Chunks", "rag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("DocumentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(80)
|
||||
.HasColumnType("nvarchar(80)");
|
||||
|
||||
b.Property<string>("MetadataJson")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("nvarchar(max)")
|
||||
.HasDefaultValue("{}");
|
||||
|
||||
b.Property<string>("RawText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(1200)
|
||||
.HasColumnType("nvarchar(1200)");
|
||||
|
||||
b.Property<string>("TextHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("nvarchar(300)");
|
||||
|
||||
b.Property<double>("TypeConfidence")
|
||||
.HasColumnType("float");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DocumentType");
|
||||
|
||||
b.HasIndex("TextHash");
|
||||
|
||||
b.ToTable("Documents", "rag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagEmbeddingCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("TextHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<byte[]>("Vector")
|
||||
.IsRequired()
|
||||
.HasColumnType("varbinary(max)");
|
||||
|
||||
b.HasKey("CacheKey");
|
||||
|
||||
b.HasIndex("TextHash");
|
||||
|
||||
b.ToTable("EmbeddingCache", "rag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b =>
|
||||
{
|
||||
b.HasOne("Api.Data.Entities.RagDocumentEntity", "Document")
|
||||
.WithMany("Chunks")
|
||||
.HasForeignKey("DocumentId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Document");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b =>
|
||||
{
|
||||
b.Navigation("Chunks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Reflection;
|
||||
using Api.Clients.Ai;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using Api.Data;
|
||||
using Api.Data.Repositories;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Rag.Models.Settings;
|
||||
using Serilog;
|
||||
using Shared.Models.Settings;
|
||||
using StartupHelpers;
|
||||
|
||||
StartupExtensions.LoadDotEnvFile();
|
||||
|
||||
const string ServiceName = "rag-api";
|
||||
var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly());
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.ConfigureJsonSerilog(ServiceName, appVersion);
|
||||
Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion);
|
||||
|
||||
builder.AddAzureKeyVaultIfConfigured();
|
||||
|
||||
builder.Services.Configure<DatabaseSettings>(builder.Configuration.GetSection("Database"));
|
||||
builder.Services.Configure<RagSettings>(builder.Configuration.GetSection("Rag"));
|
||||
builder.Services.Configure<Rag.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
|
||||
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
|
||||
|
||||
builder.Services.AddDbContext<RagDbContext>(options =>
|
||||
{
|
||||
var configuration = builder.Configuration;
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(configuration);
|
||||
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsHistoryTable(RagDbContext.MigrationTableName, RagDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<RagAiClient>();
|
||||
builder.Services.AddScoped<IRagRepository, EfRagRepository>();
|
||||
builder.Services.AddHttpClient<RagAiClient>();
|
||||
builder.Services.AddScoped<IRagRepository, EfRagRepository>();
|
||||
builder.Services.AddScoped<IAiClient, CachedRagAiClient>();
|
||||
builder.Services.AddSingleton<ITextExtractor, TextExtractor>();
|
||||
builder.Services.AddSingleton<ITextChunker, TextChunker>();
|
||||
builder.Services.AddSingleton<IDocumentClassifier, DocumentClassifier>();
|
||||
builder.Services.AddScoped<IRagService, RagService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.LogStartupDiagnostics(ServiceName);
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IRagRepository>();
|
||||
await repository.InitializeAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
app.UseDefaultSerilogRequestLogging();
|
||||
app.UseJsonExceptionHandler(ServiceName);
|
||||
app.UseInternalApiKeyProtection();
|
||||
app.UseSwaggerInDevelopment(ServiceName, ServiceName);
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
Log.Information("Running EF Core migrations if any");
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<RagDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete", ServiceName);
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.Information("Shutting down {Service}", ServiceName);
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"rag-api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:58424;http://localhost:58426"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Rag.Models;
|
||||
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
public interface IDocumentClassifier
|
||||
{
|
||||
Task<DocumentClassification> ClassifyAsync(string text, string? providedType, string? providedTitle, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Rag.Models.Requests;
|
||||
using Rag.Models.Responses;
|
||||
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
public interface IRagService
|
||||
{
|
||||
Task<IndexDocumentResponse> IndexTextAsync(IndexDocumentRequest request, CancellationToken ct);
|
||||
Task<IndexDocumentResponse> IndexPdfAsync(IFormFile file, string? documentType, string? title, string? sourceUrl, CancellationToken ct);
|
||||
Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken ct);
|
||||
Task<RagDocumentDetailsResponse?> GetDocumentAsync(string documentId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
public interface ITextChunker
|
||||
{
|
||||
IReadOnlyList<string> Chunk(string text, int chunkSize, int overlap);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
public interface ITextExtractor
|
||||
{
|
||||
Task<string> ExtractPdfAsync(Stream stream, CancellationToken ct);
|
||||
string Normalize(string value);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.Services.Contracts;
|
||||
using Rag.Models;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
public sealed class DocumentClassifier : IDocumentClassifier
|
||||
{
|
||||
private static readonly HashSet<string> KnownTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"cv", "job", "article", "contract", "invoice", "product", "documentation", "unknown"
|
||||
};
|
||||
|
||||
public Task<DocumentClassification> ClassifyAsync(string text, string? providedType, string? providedTitle, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(providedType))
|
||||
{
|
||||
var normalized = NormalizeType(providedType);
|
||||
return Task.FromResult(new DocumentClassification
|
||||
{
|
||||
DocumentType = normalized,
|
||||
Confidence = KnownTypes.Contains(normalized) && normalized != "unknown" ? 1.0 : 0.6,
|
||||
Title = BuildTitle(providedTitle, text, normalized)
|
||||
});
|
||||
}
|
||||
|
||||
var lower = text.ToLowerInvariant();
|
||||
var scores = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["cv"] = Count(lower, "curriculum vitae", "resume", "work experience", "professional experience", "education", "skills", "technologies", "linkedin", "github"),
|
||||
["job"] = Count(lower, "job description", "requirements", "responsibilities", "qualifications", "apply", "we are looking", "salary", "benefits", "remote", "hybrid"),
|
||||
["contract"] = Count(lower, "agreement", "contract", "party", "parties", "liability", "termination", "confidentiality", "governing law"),
|
||||
["invoice"] = Count(lower, "invoice", "vat", "subtotal", "total", "amount due", "due date", "billing"),
|
||||
["documentation"] = Count(lower, "api", "endpoint", "configuration", "install", "usage", "parameters", "response", "request"),
|
||||
["product"] = Count(lower, "features", "pricing", "sku", "product", "specification", "warranty")
|
||||
};
|
||||
|
||||
var best = scores.OrderByDescending(x => x.Value).First();
|
||||
var type = best.Value <= 0 ? "unknown" : best.Key;
|
||||
var confidence = best.Value <= 0 ? 0.25 : Math.Min(0.95, 0.45 + best.Value * 0.08);
|
||||
|
||||
return Task.FromResult(new DocumentClassification
|
||||
{
|
||||
DocumentType = type,
|
||||
Confidence = confidence,
|
||||
Title = BuildTitle(providedTitle, text, type)
|
||||
});
|
||||
}
|
||||
|
||||
private static int Count(string lower, params string[] terms) => terms.Count(term => lower.Contains(term));
|
||||
|
||||
private static string NormalizeType(string value)
|
||||
{
|
||||
var cleaned = Regex.Replace(value.Trim().ToLowerInvariant(), "[^a-z0-9_-]", "-");
|
||||
return string.IsNullOrWhiteSpace(cleaned) ? "unknown" : cleaned;
|
||||
}
|
||||
|
||||
private static string BuildTitle(string? providedTitle, string text, string documentType)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(providedTitle)) return providedTitle.Trim();
|
||||
var firstLine = text.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length > 20);
|
||||
if (!string.IsNullOrWhiteSpace(firstLine)) return firstLine.Length <= 120 ? firstLine : firstLine[..120];
|
||||
return $"{documentType} document";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Api.Services.Contracts;
|
||||
using Rag.Models.Requests;
|
||||
using Rag.Models.Responses;
|
||||
using Rag.Models.Settings;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using Rag.Models;
|
||||
using CommonHelpers;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
public sealed class RagService : IRagService
|
||||
{
|
||||
private readonly ITextExtractor _textExtractor;
|
||||
private readonly ITextChunker _chunker;
|
||||
private readonly IDocumentClassifier _classifier;
|
||||
private readonly IAiClient _ai;
|
||||
private readonly IRagRepository _repository;
|
||||
private readonly RagSettings _settings;
|
||||
|
||||
public RagService(
|
||||
ITextExtractor textExtractor,
|
||||
ITextChunker chunker,
|
||||
IDocumentClassifier classifier,
|
||||
IAiClient ai,
|
||||
IRagRepository repository,
|
||||
IOptions<RagSettings> options)
|
||||
{
|
||||
_textExtractor = textExtractor;
|
||||
_chunker = chunker;
|
||||
_classifier = classifier;
|
||||
_ai = ai;
|
||||
_repository = repository;
|
||||
_settings = options.Value;
|
||||
}
|
||||
|
||||
public async Task<IndexDocumentResponse> IndexTextAsync(IndexDocumentRequest request, CancellationToken ct)
|
||||
{
|
||||
var text = _textExtractor.Normalize(request.Text ?? string.Empty);
|
||||
if (text.Length < 40) throw new InvalidOperationException("Document text is too short.");
|
||||
if (text.Length > _settings.MaxTextChars) text = text[.._settings.MaxTextChars];
|
||||
return await IndexNormalizedTextAsync(text, request.DocumentType, request.Title, request.SourceUrl, request.Metadata, ct);
|
||||
}
|
||||
|
||||
public async Task<IndexDocumentResponse> IndexPdfAsync(IFormFile file, string? documentType, string? title, string? sourceUrl, CancellationToken ct)
|
||||
{
|
||||
if (file.Length <= 0) throw new InvalidOperationException("Uploaded file is empty.");
|
||||
if (file.Length > _settings.MaxFileSizeMb * 1024L * 1024L) throw new InvalidOperationException($"File is too large. Max size is {_settings.MaxFileSizeMb} MB.");
|
||||
if (!string.Equals(Path.GetExtension(file.FileName), ".pdf", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Only PDF files are supported by this endpoint.");
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var text = await _textExtractor.ExtractPdfAsync(stream, ct);
|
||||
if (text.Length > _settings.MaxTextChars) text = text[.._settings.MaxTextChars];
|
||||
if (text.Length < 40) throw new InvalidOperationException("Could not extract enough text from the PDF.");
|
||||
return await IndexNormalizedTextAsync(text, documentType, title ?? file.FileName, sourceUrl, new Dictionary<string, string> { ["fileName"] = file.FileName }, ct);
|
||||
}
|
||||
|
||||
public async Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken ct)
|
||||
{
|
||||
var query = _textExtractor.Normalize(request.QueryText);
|
||||
if (query.Length < 10) throw new InvalidOperationException("Search query is too short.");
|
||||
var topK = Math.Clamp(request.TopK ?? _settings.DefaultTopK, 1, Math.Max(1, _settings.MaxTopK));
|
||||
var queryEmbedding = await _ai.CreateEmbeddingAsync(query, ct);
|
||||
var candidates = await _repository.SearchChunksAsync(queryEmbedding, request.TargetDocumentTypes, topK, ct);
|
||||
|
||||
var results = candidates
|
||||
.GroupBy(x => x.Document.Id)
|
||||
.Select(group =>
|
||||
{
|
||||
var best = group.OrderByDescending(x => x.Score).First();
|
||||
return new SearchDocumentResult
|
||||
{
|
||||
DocumentId = best.Document.Id,
|
||||
DocumentType = best.Document.DocumentType,
|
||||
Title = best.Document.Title,
|
||||
SourceUrl = best.Document.SourceUrl,
|
||||
Score = group.Max(x => x.Score),
|
||||
MatchedChunks = group
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Take(3)
|
||||
.Select(x => new SearchChunkResult
|
||||
{
|
||||
ChunkId = x.Chunk.Id,
|
||||
ChunkIndex = x.Chunk.ChunkIndex,
|
||||
Text = x.Chunk.Text,
|
||||
Score = x.Score
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Take(topK)
|
||||
.ToList();
|
||||
|
||||
return new SearchResponse { Results = results };
|
||||
}
|
||||
|
||||
public async Task<RagDocumentDetailsResponse?> GetDocumentAsync(string documentId, CancellationToken ct)
|
||||
{
|
||||
var document = await _repository.GetDocumentByIdAsync(documentId, ct);
|
||||
return document is null ? null : new RagDocumentDetailsResponse
|
||||
{
|
||||
Id = document.Id,
|
||||
DocumentType = document.DocumentType,
|
||||
Title = document.Title,
|
||||
SourceUrl = document.SourceUrl,
|
||||
Text = document.Text,
|
||||
TextHash = document.TextHash,
|
||||
CreatedAt = document.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IndexDocumentResponse> IndexNormalizedTextAsync(
|
||||
string text,
|
||||
string? documentType,
|
||||
string? title,
|
||||
string? sourceUrl,
|
||||
Dictionary<string, string>? metadata,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var textHash = HashHelper.Compute(text);
|
||||
var cached = await _repository.GetDocumentByTextHashAsync(textHash, sourceUrl, ct);
|
||||
if (cached is not null)
|
||||
{
|
||||
return new IndexDocumentResponse
|
||||
{
|
||||
DocumentId = cached.Id,
|
||||
TextHash = cached.TextHash,
|
||||
DocumentType = cached.DocumentType,
|
||||
DocumentTypeConfidence = cached.TypeConfidence,
|
||||
Title = cached.Title,
|
||||
Chunks = 0,
|
||||
Characters = cached.Text.Length,
|
||||
Cached = true
|
||||
};
|
||||
}
|
||||
|
||||
var classification = await _classifier.ClassifyAsync(text, documentType, title, ct);
|
||||
var chunks = _chunker.Chunk(text, _settings.ChunkSize, _settings.ChunkOverlap);
|
||||
var document = new RagDocumentRecord
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
DocumentType = classification.DocumentType,
|
||||
Title = classification.Title,
|
||||
SourceUrl = sourceUrl,
|
||||
Text = text,
|
||||
TextHash = textHash,
|
||||
TypeConfidence = classification.Confidence,
|
||||
MetadataJson = JsonSerializer.Serialize(metadata ?? classification.Metadata),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var records = new List<RagChunkRecord>();
|
||||
for (var i = 0; i < chunks.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
records.Add(new RagChunkRecord
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
DocumentId = document.Id,
|
||||
ChunkIndex = i,
|
||||
Text = chunks[i],
|
||||
Embedding = await _ai.CreateEmbeddingAsync(chunks[i], ct)
|
||||
});
|
||||
}
|
||||
|
||||
await _repository.SaveDocumentAsync(document, records, ct);
|
||||
return new IndexDocumentResponse
|
||||
{
|
||||
DocumentId = document.Id,
|
||||
TextHash = document.TextHash,
|
||||
DocumentType = document.DocumentType,
|
||||
DocumentTypeConfidence = document.TypeConfidence,
|
||||
Title = document.Title,
|
||||
Chunks = records.Count,
|
||||
Characters = text.Length,
|
||||
Cached = false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Api.Services.Contracts;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
public sealed class TextChunker : ITextChunker
|
||||
{
|
||||
public IReadOnlyList<string> Chunk(string text, int chunkSize, int overlap)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return [];
|
||||
chunkSize = Math.Clamp(chunkSize, 300, 3000);
|
||||
overlap = Math.Clamp(overlap, 0, chunkSize / 2);
|
||||
|
||||
var chunks = new List<string>();
|
||||
var start = 0;
|
||||
while (start < text.Length)
|
||||
{
|
||||
var length = Math.Min(chunkSize, text.Length - start);
|
||||
var chunk = text.Substring(start, length).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(chunk)) chunks.Add(chunk);
|
||||
start += chunkSize - overlap;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text;
|
||||
using Api.Services.Contracts;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
public sealed class TextExtractor : ITextExtractor
|
||||
{
|
||||
public Task<string> ExtractPdfAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
using var document = PdfDocument.Open(stream);
|
||||
var builder = new StringBuilder();
|
||||
foreach (var page in document.GetPages())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
builder.AppendLine(page.Text);
|
||||
builder.AppendLine();
|
||||
}
|
||||
return Task.FromResult(Normalize(builder.ToString()));
|
||||
}
|
||||
|
||||
public string Normalize(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
return string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.Hosting": "Information",
|
||||
"Microsoft.AspNetCore.Routing": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning",
|
||||
"Api": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/api-.log",
|
||||
"rollingInterval": "Day",
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[mihes.ro API] Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithEnvironmentName"
|
||||
]
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.Hosting": "Information",
|
||||
"Microsoft.AspNetCore.Routing": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning",
|
||||
"Api": "Information"
|
||||
}
|
||||
},
|
||||
"LogEnvironmentOnStartup": true,
|
||||
"AllowedHosts": "*",
|
||||
"KeyVault": {
|
||||
"VaultUri": "",
|
||||
"Enabled": false
|
||||
},
|
||||
"Database": {
|
||||
"Host": "localhost",
|
||||
"Port": 1433,
|
||||
"Name": "MyAiCvMatcher",
|
||||
"User": "sa",
|
||||
"Password": "",
|
||||
"TrustServerCertificate": true
|
||||
},
|
||||
"InternalApi": {
|
||||
"ApiKey": "",
|
||||
"RequireApiKey": false
|
||||
},
|
||||
"Rag": {
|
||||
"MaxFileSizeMb": 8,
|
||||
"ChunkSize": 900,
|
||||
"ChunkOverlap": 150,
|
||||
"MaxTextChars": 60000,
|
||||
"DefaultTopK": 20,
|
||||
"MaxTopK": 50,
|
||||
"ClassifyWithAi": false
|
||||
},
|
||||
"Ai": {
|
||||
"Provider": "OpenAI",
|
||||
"OpenAI": {
|
||||
"ApiKey": "",
|
||||
"ChatModel": "gpt-4o-mini",
|
||||
"EmbeddingModel": "text-embedding-3-small",
|
||||
"TimeoutSeconds": 90
|
||||
},
|
||||
"Ollama": {
|
||||
"BaseUrl": "http://localhost:11434",
|
||||
"ChatModel": "llama3.1:8b",
|
||||
"EmbeddingModel": "nomic-embed-text",
|
||||
"TimeoutSeconds": 180
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user