Added local RAG (#756)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Paul KoudelkaandThorsten Sommer authored and GitHub committed 2026-09-09 18:43:37 +02:00
1 parent d043fbc8f0
commit c7b42bee96
179 files changed
+276977 -1421

No files matched your search

@@ -0,0 +1,3 @@
namespace AIStudio.Tools.Databases.IndexStore;
public sealed record EmbeddingStateChunk(string ChunkId, string ParentFileId, int? PageNumber, int ChunkIndex, string ChunkText, DateTimeOffset EmbeddedAtUtc);
@@ -0,0 +1,20 @@
namespace AIStudio.Tools.Databases.IndexStore;
internal sealed class EmbeddingStateChunkEntity
{
public int Id { get; set; }
public string ChunkId { get; set; } = string.Empty;
public string ParentFileId { get; set; } = string.Empty;
public int? PageNumber { get; set; }
public int ChunkIndex { get; set; }
public string ChunkText { get; set; } = string.Empty;
public DateTimeOffset EmbeddedAtUtc { get; set; }
public EmbeddingStateFileEntity? File { get; set; }
}
@@ -0,0 +1,24 @@
namespace AIStudio.Tools.Databases.IndexStore;
internal sealed class EmbeddingStateDataSourceEntity
{
public string DataSourceId { get; set; } = string.Empty;
public string DataSourceName { get; set; } = string.Empty;
public string DataSourceType { get; set; } = string.Empty;
public string EmbeddingProviderId { get; set; } = string.Empty;
public string EmbeddingSignature { get; set; } = string.Empty;
public string SourceHash { get; set; } = string.Empty;
public int VectorSize { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; }
public List<EmbeddingStateFileEntity> Files { get; set; } = [];
public List<IndexingFailureEntity> PermanentIndexingFailures { get; set; } = [];
}
@@ -0,0 +1,16 @@
namespace AIStudio.Tools.Databases.IndexStore;
public sealed record EmbeddingStateFile(
string ParentFileId,
string AbsolutePath,
string FileName,
string RelativePath,
string FileType,
string Fingerprint,
long FileSize,
DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc,
int ChunkCount,
string ConfidenceLevel,
int ConfidenceLevelRank);
@@ -0,0 +1,36 @@
namespace AIStudio.Tools.Databases.IndexStore;
internal sealed class EmbeddingStateFileEntity
{
public string ParentFileId { get; set; } = string.Empty;
public string DataSourceId { get; set; } = string.Empty;
public string AbsolutePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string FileType { get; set; } = string.Empty;
public string Fingerprint { get; set; } = string.Empty;
public long FileSize { get; set; }
public DateTimeOffset CreationUtc { get; set; }
public DateTimeOffset LastWriteUtc { get; set; }
public DateTimeOffset EmbeddedAtUtc { get; set; }
public int ChunkCount { get; set; }
public string ConfidenceLevel { get; set; } = string.Empty;
public int ConfidenceLevelRank { get; set; }
public EmbeddingStateDataSourceEntity? DataSource { get; set; }
public List<EmbeddingStateChunkEntity> Chunks { get; set; } = [];
}
@@ -0,0 +1,36 @@
using AIStudio.Tools.Services;
namespace AIStudio.Tools.Databases.IndexStore;
public abstract class IndexStoreClient(string name, string path) : DatabaseClient(name, path)
{
public abstract Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token);
public abstract Task UpsertDataSourceAsync(
string dataSourceId,
string dataSourceName,
string dataSourceType,
string embeddingProviderId,
string embeddingSignature,
string sourceHash,
int vectorSize,
CancellationToken token);
public abstract Task UpdateVectorSizeAsync(string dataSourceId, int vectorSize, CancellationToken token);
public abstract Task UpdateDataSourceHashAsync(string dataSourceId, string sourceHash, CancellationToken token);
public abstract Task UpsertFileAsync(string dataSourceId, EmbeddingStateFile file, CancellationToken token);
public abstract Task DeleteFileAsync(string dataSourceId, string filePath, CancellationToken token);
public abstract Task UpsertPermanentFailureAsync(string dataSourceId, PermanentIndexingFailure failure, CancellationToken token);
public abstract Task DeletePermanentFailureAsync(string dataSourceId, string filePath, CancellationToken token);
public abstract Task UpsertChunksAsync(string dataSourceId, IReadOnlyList<EmbeddingStateChunk> chunks, CancellationToken token);
public abstract Task<IReadOnlyList<IndexStoreSearchResult>> SearchChunksAsync(string dataSourceId, string query, int maxMatches, CancellationToken token);
public abstract Task DeleteDataSourceAsync(string dataSourceId, CancellationToken token);
}
@@ -0,0 +1,18 @@
using System.Globalization;
namespace AIStudio.Tools.Databases.IndexStore;
internal static class IndexStoreDateTimeOffset
{
public static string ToUtcText(DateTimeOffset dateTime)
{
return dateTime.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
}
public static DateTimeOffset ParseUtc(string value)
{
return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dateTime)
? dateTime.ToUniversalTime()
: DateTimeOffset.UnixEpoch;
}
}
@@ -0,0 +1,7 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace AIStudio.Tools.Databases.IndexStore;
internal sealed class IndexStoreDateTimeOffsetConverter() : ValueConverter<DateTimeOffset, string>(
value => IndexStoreDateTimeOffset.ToUtcText(value),
value => IndexStoreDateTimeOffset.ParseUtc(value));
@@ -0,0 +1,141 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.IndexStore;
internal sealed class IndexStoreDbContext(DbContextOptions<IndexStoreDbContext> options) : DbContext(options)
{
public static DbContextOptions<IndexStoreDbContext> CreateOptions(string databasePath) => new DbContextOptionsBuilder<IndexStoreDbContext>()
.UseSqlite(BuildConnectionString(databasePath))
.Options;
public DbSet<EmbeddingStateDataSourceEntity> DataSources => this.Set<EmbeddingStateDataSourceEntity>();
public DbSet<EmbeddingStateFileEntity> EmbeddedFiles => this.Set<EmbeddingStateFileEntity>();
public DbSet<EmbeddingStateChunkEntity> EmbeddingChunks => this.Set<EmbeddingStateChunkEntity>();
public DbSet<IndexingFailureEntity> PermanentIndexingFailures => this.Set<IndexingFailureEntity>();
public DbSet<IndexStoreSearchResultEntity> SearchResults => this.Set<IndexStoreSearchResultEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var utcDateTimeOffsetConverter = new IndexStoreDateTimeOffsetConverter();
modelBuilder.Entity<EmbeddingStateDataSourceEntity>(entity =>
{
entity.ToTable("data_sources");
entity.HasKey(dataSource => dataSource.DataSourceId);
entity.Property(dataSource => dataSource.DataSourceId).HasColumnName("data_source_id");
entity.Property(dataSource => dataSource.DataSourceName).HasColumnName("data_source_name").IsRequired();
entity.Property(dataSource => dataSource.DataSourceType).HasColumnName("data_source_type").IsRequired();
entity.Property(dataSource => dataSource.EmbeddingProviderId).HasColumnName("embedding_provider_id").IsRequired();
entity.Property(dataSource => dataSource.EmbeddingSignature).HasColumnName("embedding_signature").IsRequired();
entity.Property(dataSource => dataSource.SourceHash).HasColumnName("source_hash").IsRequired().HasDefaultValue(string.Empty);
entity.Property(dataSource => dataSource.VectorSize).HasColumnName("vector_size").HasDefaultValue(0);
entity.Property(dataSource => dataSource.UpdatedAtUtc).HasColumnName("updated_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity
.HasMany(dataSource => dataSource.Files)
.WithOne(file => file.DataSource)
.HasForeignKey(file => file.DataSourceId)
.OnDelete(DeleteBehavior.Cascade);
entity
.HasMany(dataSource => dataSource.PermanentIndexingFailures)
.WithOne(failure => failure.DataSource)
.HasForeignKey(failure => failure.DataSourceId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<EmbeddingStateFileEntity>(entity =>
{
entity.ToTable("embedded_files");
entity.HasKey(file => file.ParentFileId);
entity.Property(file => file.ParentFileId).HasColumnName("parent_file_id");
entity.Property(file => file.DataSourceId).HasColumnName("data_source_id").IsRequired();
entity.Property(file => file.AbsolutePath).HasColumnName("absolute_path").UseCollation("NOCASE").IsRequired();
entity.Property(file => file.FileName).HasColumnName("file_name").IsRequired();
entity.Property(file => file.RelativePath).HasColumnName("relative_path").IsRequired();
entity.Property(file => file.FileType).HasColumnName("file_type").IsRequired();
entity.Property(file => file.Fingerprint).HasColumnName("fingerprint").IsRequired();
entity.Property(file => file.FileSize).HasColumnName("file_size");
entity.Property(file => file.CreationUtc).HasColumnName("creation_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.LastWriteUtc).HasColumnName("last_write_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.EmbeddedAtUtc).HasColumnName("embedded_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.ChunkCount).HasColumnName("chunk_count");
entity.Property(file => file.ConfidenceLevel).HasColumnName("confidence_level").IsRequired();
entity.Property(file => file.ConfidenceLevelRank).HasColumnName("confidence_level_rank");
entity.HasIndex(file => file.DataSourceId).HasDatabaseName("idx_embedded_files_data_source");
entity.HasIndex(file => file.AbsolutePath).HasDatabaseName("idx_embedded_files_absolute_path");
entity.HasIndex(file => file.FileType).HasDatabaseName("idx_embedded_files_file_type");
entity.HasIndex(file => file.ConfidenceLevelRank).HasDatabaseName("idx_embedded_files_confidence");
entity.HasIndex(file => new { file.DataSourceId, file.AbsolutePath }).HasDatabaseName("idx_embedded_files_data_source_absolute_path").IsUnique();
entity
.HasMany(file => file.Chunks)
.WithOne(chunk => chunk.File)
.HasForeignKey(chunk => chunk.ParentFileId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<EmbeddingStateChunkEntity>(entity =>
{
entity.ToTable("embedding_chunks");
entity.HasKey(chunk => chunk.Id);
entity.Property(chunk => chunk.Id).HasColumnName("id").ValueGeneratedOnAdd();
entity.Property(chunk => chunk.ChunkId).HasColumnName("chunk_id").IsRequired();
entity.Property(chunk => chunk.ParentFileId).HasColumnName("parent_file_id").IsRequired();
entity.Property(chunk => chunk.PageNumber).HasColumnName("page_number");
entity.Property(chunk => chunk.ChunkIndex).HasColumnName("chunk_index");
entity.Property(chunk => chunk.ChunkText).HasColumnName("chunk_text").IsRequired();
entity.Property(chunk => chunk.EmbeddedAtUtc).HasColumnName("embedded_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.HasIndex(chunk => chunk.ChunkId).HasDatabaseName("idx_embedding_chunks_chunk_id").IsUnique();
entity.HasIndex(chunk => chunk.ParentFileId).HasDatabaseName("idx_embedding_chunks_parent_file");
entity.HasIndex(chunk => chunk.PageNumber).HasDatabaseName("idx_embedding_chunks_page");
entity.HasIndex(chunk => new { chunk.ParentFileId, chunk.ChunkIndex }).HasDatabaseName("idx_embedding_chunks_parent_file_chunk_index").IsUnique();
});
modelBuilder.Entity<IndexingFailureEntity>(entity =>
{
entity.ToTable("permanent_indexing_failures");
entity.HasKey(failure => failure.ParentFileId);
entity.Property(failure => failure.ParentFileId).HasColumnName("parent_file_id");
entity.Property(failure => failure.DataSourceId).HasColumnName("data_source_id").IsRequired();
entity.Property(failure => failure.AbsolutePath).HasColumnName("absolute_path").UseCollation("NOCASE").IsRequired();
entity.Property(failure => failure.Fingerprint).HasColumnName("fingerprint").IsRequired();
entity.Property(failure => failure.FailureCode).HasColumnName("failure_code").IsRequired();
entity.Property(failure => failure.FailureMessage).HasColumnName("failure_message").IsRequired();
entity.Property(failure => failure.OccurredAtUtc).HasColumnName("occurred_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.HasIndex(failure => failure.DataSourceId).HasDatabaseName("idx_permanent_indexing_failures_data_source");
entity.HasIndex(failure => new { failure.DataSourceId, failure.AbsolutePath }).HasDatabaseName("idx_permanent_indexing_failures_data_source_absolute_path").IsUnique();
});
modelBuilder.Entity<IndexStoreSearchResultEntity>(entity =>
{
entity.HasNoKey();
entity.ToView("embedding_chunk_search_results");
entity.Property(result => result.CreationUtc).HasConversion(utcDateTimeOffsetConverter);
entity.Property(result => result.LastWriteUtc).HasConversion(utcDateTimeOffsetConverter);
entity.Property(result => result.EmbeddedAtUtc).HasConversion(utcDateTimeOffsetConverter);
});
}
private static string BuildConnectionString(string databasePath) => new SqliteConnectionStringBuilder
{
DataSource = databasePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Shared,
ForeignKeys = true,
DefaultTimeout = 30,
}.ToString();
}
@@ -0,0 +1,15 @@
using Microsoft.EntityFrameworkCore.Design;
namespace AIStudio.Tools.Databases.IndexStore;
internal sealed class IndexStoreDesignTimeDbContextFactory : IDesignTimeDbContextFactory<IndexStoreDbContext>
{
public IndexStoreDbContext CreateDbContext(string[] args)
{
var databasePath = args.FirstOrDefault(argument => argument.EndsWith(".sqlite3", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrWhiteSpace(databasePath))
databasePath = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-rag-index-design.sqlite3");
return new IndexStoreDbContext(IndexStoreDbContext.CreateOptions(databasePath));
}
}
@@ -0,0 +1,16 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.IndexStore;
internal static class IndexStoreSchemaMigrator
{
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.InitialRagIndex))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.PermanentIndexingFailures))]
public static async Task MigrateAsync(IndexStoreDbContext context, CancellationToken token)
{
await context.Database.MigrateAsync(token);
await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", token);
}
}
@@ -0,0 +1,24 @@
namespace AIStudio.Tools.Databases.IndexStore;
public sealed record IndexStoreSearchResult(
string ChunkId,
string ParentFileId,
string DataSourceId,
string DataSourceName,
string DataSourceType,
string AbsolutePath,
string FileName,
string RelativePath,
string FileType,
int? PageNumber,
int ChunkIndex,
string ChunkText,
double Score,
string Fingerprint,
long FileSize,
DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc,
int ChunkCount,
string ConfidenceLevel,
int ConfidenceLevelRank);
@@ -0,0 +1,46 @@
namespace AIStudio.Tools.Databases.IndexStore;
internal sealed class IndexStoreSearchResultEntity
{
public string ChunkId { get; set; } = string.Empty;
public string ParentFileId { get; set; } = string.Empty;
public string DataSourceId { get; set; } = string.Empty;
public string DataSourceName { get; set; } = string.Empty;
public string DataSourceType { get; set; } = string.Empty;
public string AbsolutePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public string FileType { get; set; } = string.Empty;
public int? PageNumber { get; set; }
public int ChunkIndex { get; set; }
public string ChunkText { get; set; } = string.Empty;
public double Score { get; set; }
public string Fingerprint { get; set; } = string.Empty;
public long FileSize { get; set; }
public DateTimeOffset CreationUtc { get; set; }
public DateTimeOffset LastWriteUtc { get; set; }
public DateTimeOffset EmbeddedAtUtc { get; set; }
public int ChunkCount { get; set; }
public string ConfidenceLevel { get; set; } = string.Empty;
public int ConfidenceLevelRank { get; set; }
}
@@ -0,0 +1,27 @@
namespace AIStudio.Tools.Databases.IndexStore;
internal sealed class IndexingFailureEntity
{
public string ParentFileId { get; set; } = string.Empty;
public string DataSourceId { get; set; } = string.Empty;
public string AbsolutePath { get; set; } = string.Empty;
public string Fingerprint { get; set; } = string.Empty;
/// <summary>
/// The failure code, stored by name.
/// </summary>
/// <remarks>
/// The enum has no explicit numbers, so storing the name keeps the rows readable across
/// versions which add or reorder codes.
/// </remarks>
public string FailureCode { get; set; } = string.Empty;
public string FailureMessage { get; set; } = string.Empty;
public DateTimeOffset OccurredAtUtc { get; set; }
public EmbeddingStateDataSourceEntity? DataSource { get; set; }
}
@@ -0,0 +1,202 @@
#nullable disable
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace AIStudio.Tools.Databases.IndexStore.Migrations;
[DbContext(typeof(IndexStoreDbContext))]
[Migration("20260804000000_InitialRagIndex")]
public partial class InitialRagIndex : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "data_sources",
columns: table => new
{
data_source_id = table.Column<string>(type: "TEXT", nullable: false),
data_source_name = table.Column<string>(type: "TEXT", nullable: false),
data_source_type = table.Column<string>(type: "TEXT", nullable: false),
embedding_provider_id = table.Column<string>(type: "TEXT", nullable: false),
embedding_signature = table.Column<string>(type: "TEXT", nullable: false),
source_hash = table.Column<string>(type: "TEXT", nullable: false, defaultValue: string.Empty),
vector_size = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
updated_at_utc = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_data_sources", source => source.data_source_id);
});
migrationBuilder.CreateTable(
name: "embedded_files",
columns: table => new
{
parent_file_id = table.Column<string>(type: "TEXT", nullable: false),
data_source_id = table.Column<string>(type: "TEXT", nullable: false),
absolute_path = table.Column<string>(type: "TEXT", nullable: false, collation: "NOCASE"),
file_name = table.Column<string>(type: "TEXT", nullable: false),
relative_path = table.Column<string>(type: "TEXT", nullable: false),
file_type = table.Column<string>(type: "TEXT", nullable: false),
fingerprint = table.Column<string>(type: "TEXT", nullable: false),
file_size = table.Column<long>(type: "INTEGER", nullable: false),
creation_utc = table.Column<string>(type: "TEXT", nullable: false),
last_write_utc = table.Column<string>(type: "TEXT", nullable: false),
embedded_at_utc = table.Column<string>(type: "TEXT", nullable: false),
chunk_count = table.Column<int>(type: "INTEGER", nullable: false),
confidence_level = table.Column<string>(type: "TEXT", nullable: false),
confidence_level_rank = table.Column<int>(type: "INTEGER", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_embedded_files", file => file.parent_file_id);
table.ForeignKey(
name: "FK_embedded_files_data_sources_data_source_id",
column: file => file.data_source_id,
principalTable: "data_sources",
principalColumn: "data_source_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "embedding_chunks",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
chunk_id = table.Column<string>(type: "TEXT", nullable: false),
parent_file_id = table.Column<string>(type: "TEXT", nullable: false),
page_number = table.Column<int>(type: "INTEGER", nullable: true),
chunk_index = table.Column<int>(type: "INTEGER", nullable: false),
chunk_text = table.Column<string>(type: "TEXT", nullable: false),
embedded_at_utc = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_embedding_chunks", chunk => chunk.id);
table.ForeignKey(
name: "FK_embedding_chunks_embedded_files_parent_file_id",
column: chunk => chunk.parent_file_id,
principalTable: "embedded_files",
principalColumn: "parent_file_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_embedded_files_absolute_path",
table: "embedded_files",
column: "absolute_path");
migrationBuilder.CreateIndex(
name: "idx_embedded_files_confidence",
table: "embedded_files",
column: "confidence_level_rank");
migrationBuilder.CreateIndex(
name: "idx_embedded_files_data_source",
table: "embedded_files",
column: "data_source_id");
migrationBuilder.CreateIndex(
name: "idx_embedded_files_data_source_absolute_path",
table: "embedded_files",
columns: ["data_source_id", "absolute_path"],
unique: true);
migrationBuilder.CreateIndex(
name: "idx_embedded_files_file_type",
table: "embedded_files",
column: "file_type");
migrationBuilder.CreateIndex(
name: "idx_embedding_chunks_chunk_id",
table: "embedding_chunks",
column: "chunk_id",
unique: true);
migrationBuilder.CreateIndex(
name: "idx_embedding_chunks_page",
table: "embedding_chunks",
column: "page_number");
migrationBuilder.CreateIndex(
name: "idx_embedding_chunks_parent_file",
table: "embedding_chunks",
column: "parent_file_id");
migrationBuilder.CreateIndex(
name: "idx_embedding_chunks_parent_file_chunk_index",
table: "embedding_chunks",
columns: ["parent_file_id", "chunk_index"],
unique: true);
migrationBuilder.Sql("""
CREATE VIRTUAL TABLE IF NOT EXISTS embedding_chunks_fts
USING fts5(chunk_id UNINDEXED, file_name, chunk_text);
CREATE TRIGGER IF NOT EXISTS embedding_chunks_ai
AFTER INSERT ON embedding_chunks
BEGIN
INSERT INTO embedding_chunks_fts(rowid, chunk_id, file_name, chunk_text)
VALUES (
new.id,
new.chunk_id,
(SELECT file_name FROM embedded_files WHERE parent_file_id = new.parent_file_id),
new.chunk_text);
END;
CREATE TRIGGER IF NOT EXISTS embedding_chunks_ad
AFTER DELETE ON embedding_chunks
BEGIN
DELETE FROM embedding_chunks_fts
WHERE rowid = old.id;
END;
CREATE TRIGGER IF NOT EXISTS embedding_chunks_au
AFTER UPDATE ON embedding_chunks
BEGIN
DELETE FROM embedding_chunks_fts
WHERE rowid = old.id;
INSERT INTO embedding_chunks_fts(rowid, chunk_id, file_name, chunk_text)
VALUES (
new.id,
new.chunk_id,
(SELECT file_name FROM embedded_files WHERE parent_file_id = new.parent_file_id),
new.chunk_text);
END;
CREATE TRIGGER IF NOT EXISTS embedded_files_file_name_au
AFTER UPDATE OF file_name ON embedded_files
BEGIN
DELETE FROM embedding_chunks_fts
WHERE rowid IN (
SELECT id
FROM embedding_chunks
WHERE parent_file_id = new.parent_file_id
);
INSERT INTO embedding_chunks_fts(rowid, chunk_id, file_name, chunk_text)
SELECT id, chunk_id, new.file_name, chunk_text
FROM embedding_chunks
WHERE parent_file_id = new.parent_file_id;
END;
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
DROP TRIGGER IF EXISTS embedded_files_file_name_au;
DROP TRIGGER IF EXISTS embedding_chunks_au;
DROP TRIGGER IF EXISTS embedding_chunks_ad;
DROP TRIGGER IF EXISTS embedding_chunks_ai;
DROP TABLE IF EXISTS embedding_chunks_fts;
""");
migrationBuilder.DropTable(name: "embedding_chunks");
migrationBuilder.DropTable(name: "embedded_files");
migrationBuilder.DropTable(name: "data_sources");
}
}
@@ -0,0 +1,53 @@
#nullable disable
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace AIStudio.Tools.Databases.IndexStore.Migrations;
[DbContext(typeof(IndexStoreDbContext))]
[Migration("20260909000000_PermanentIndexingFailures")]
public partial class PermanentIndexingFailures : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "permanent_indexing_failures",
columns: table => new
{
parent_file_id = table.Column<string>(type: "TEXT", nullable: false),
data_source_id = table.Column<string>(type: "TEXT", nullable: false),
absolute_path = table.Column<string>(type: "TEXT", nullable: false, collation: "NOCASE"),
fingerprint = table.Column<string>(type: "TEXT", nullable: false),
failure_code = table.Column<string>(type: "TEXT", nullable: false),
failure_message = table.Column<string>(type: "TEXT", nullable: false),
occurred_at_utc = table.Column<string>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_permanent_indexing_failures", failure => failure.parent_file_id);
table.ForeignKey(
name: "FK_permanent_indexing_failures_data_sources_data_source_id",
column: failure => failure.data_source_id,
principalTable: "data_sources",
principalColumn: "data_source_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_permanent_indexing_failures_data_source",
table: "permanent_indexing_failures",
column: "data_source_id");
migrationBuilder.CreateIndex(
name: "idx_permanent_indexing_failures_data_source_absolute_path",
table: "permanent_indexing_failures",
columns: ["data_source_id", "absolute_path"],
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "permanent_indexing_failures");
}
}
@@ -0,0 +1,392 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace AIStudio.Tools.Databases.IndexStore.Migrations;
// EF Core writes this file and declares the type as partial. Dropping the keyword would only
// last until the next migration is added, so the inspection is silenced instead.
// ReSharper disable once PartialTypeWithSinglePart
[DbContext(typeof(IndexStoreDbContext))]
partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.18");
var utcDateTimeOffsetConverter = new IndexStoreDateTimeOffsetConverter();
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", entity =>
{
entity.Property<string>("DataSourceId")
.HasColumnType("TEXT")
.HasColumnName("data_source_id");
entity.Property<string>("DataSourceName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("data_source_name");
entity.Property<string>("DataSourceType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("data_source_type");
entity.Property<string>("EmbeddingProviderId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("embedding_provider_id");
entity.Property<string>("EmbeddingSignature")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("embedding_signature");
entity.Property<string>("SourceHash")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_hash")
.HasDefaultValue(string.Empty);
entity.Property<DateTimeOffset>("UpdatedAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("updated_at_utc");
entity.Property<int>("VectorSize")
.HasColumnType("INTEGER")
.HasColumnName("vector_size")
.HasDefaultValue(0);
entity.HasKey("DataSourceId");
entity.ToTable("data_sources");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateFileEntity", entity =>
{
entity.Property<string>("ParentFileId")
.HasColumnType("TEXT")
.HasColumnName("parent_file_id");
entity.Property<string>("AbsolutePath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("absolute_path")
.UseCollation("NOCASE");
entity.Property<int>("ChunkCount")
.HasColumnType("INTEGER")
.HasColumnName("chunk_count");
entity.Property<string>("ConfidenceLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("confidence_level");
entity.Property<int>("ConfidenceLevelRank")
.HasColumnType("INTEGER")
.HasColumnName("confidence_level_rank");
entity.Property<DateTimeOffset>("CreationUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("creation_utc");
entity.Property<string>("DataSourceId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("data_source_id");
entity.Property<DateTimeOffset>("EmbeddedAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("embedded_at_utc");
entity.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
entity.Property<long>("FileSize")
.HasColumnType("INTEGER")
.HasColumnName("file_size");
entity.Property<string>("FileType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_type");
entity.Property<string>("Fingerprint")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("fingerprint");
entity.Property<DateTimeOffset>("LastWriteUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("last_write_utc");
entity.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("relative_path");
entity.HasKey("ParentFileId");
entity.HasIndex("AbsolutePath")
.HasDatabaseName("idx_embedded_files_absolute_path");
entity.HasIndex("ConfidenceLevelRank")
.HasDatabaseName("idx_embedded_files_confidence");
entity.HasIndex("DataSourceId")
.HasDatabaseName("idx_embedded_files_data_source");
entity.HasIndex("DataSourceId", "AbsolutePath")
.IsUnique()
.HasDatabaseName("idx_embedded_files_data_source_absolute_path");
entity.HasIndex("FileType")
.HasDatabaseName("idx_embedded_files_file_type");
entity.ToTable("embedded_files");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateChunkEntity", entity =>
{
entity.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasAnnotation("Sqlite:Autoincrement", true);
entity.Property<string>("ChunkId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("chunk_id");
entity.Property<int>("ChunkIndex")
.HasColumnType("INTEGER")
.HasColumnName("chunk_index");
entity.Property<string>("ChunkText")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("chunk_text");
entity.Property<DateTimeOffset>("EmbeddedAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("embedded_at_utc");
entity.Property<int?>("PageNumber")
.HasColumnType("INTEGER")
.HasColumnName("page_number");
entity.Property<string>("ParentFileId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("parent_file_id");
entity.HasKey("Id");
entity.HasIndex("ChunkId")
.IsUnique()
.HasDatabaseName("idx_embedding_chunks_chunk_id");
entity.HasIndex("PageNumber")
.HasDatabaseName("idx_embedding_chunks_page");
entity.HasIndex("ParentFileId")
.HasDatabaseName("idx_embedding_chunks_parent_file");
entity.HasIndex("ParentFileId", "ChunkIndex")
.IsUnique()
.HasDatabaseName("idx_embedding_chunks_parent_file_chunk_index");
entity.ToTable("embedding_chunks");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.IndexingFailureEntity", entity =>
{
entity.Property<string>("ParentFileId")
.HasColumnType("TEXT")
.HasColumnName("parent_file_id");
entity.Property<string>("AbsolutePath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("absolute_path")
.UseCollation("NOCASE");
entity.Property<string>("DataSourceId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("data_source_id");
entity.Property<string>("FailureCode")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failure_code");
entity.Property<string>("FailureMessage")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failure_message");
entity.Property<string>("Fingerprint")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("fingerprint");
entity.Property<DateTimeOffset>("OccurredAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
.HasColumnName("occurred_at_utc");
entity.HasKey("ParentFileId");
entity.HasIndex("DataSourceId")
.HasDatabaseName("idx_permanent_indexing_failures_data_source");
entity.HasIndex("DataSourceId", "AbsolutePath")
.IsUnique()
.HasDatabaseName("idx_permanent_indexing_failures_data_source_absolute_path");
entity.ToTable("permanent_indexing_failures");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.IndexStoreSearchResultEntity", entity =>
{
entity.Property<string>("AbsolutePath")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<int>("ChunkCount")
.HasColumnType("INTEGER");
entity.Property<string>("ChunkId")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<int>("ChunkIndex")
.HasColumnType("INTEGER");
entity.Property<string>("ChunkText")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("ConfidenceLevel")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<int>("ConfidenceLevelRank")
.HasColumnType("INTEGER");
entity.Property<DateTimeOffset>("CreationUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT");
entity.Property<string>("DataSourceId")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("DataSourceName")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("DataSourceType")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<DateTimeOffset>("EmbeddedAtUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT");
entity.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<long>("FileSize")
.HasColumnType("INTEGER");
entity.Property<string>("FileType")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("Fingerprint")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<DateTimeOffset>("LastWriteUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT");
entity.Property<int?>("PageNumber")
.HasColumnType("INTEGER");
entity.Property<string>("ParentFileId")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("RelativePath")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<double>("Score")
.HasColumnType("REAL");
entity.HasNoKey();
entity.ToView("embedding_chunk_search_results");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateFileEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", "DataSource")
.WithMany("Files")
.HasForeignKey("DataSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
entity.Navigation("DataSource");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateChunkEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.EmbeddingStateFileEntity", "File")
.WithMany("Chunks")
.HasForeignKey("ParentFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
entity.Navigation("File");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.IndexingFailureEntity", entity =>
{
entity.HasOne("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", "DataSource")
.WithMany("PermanentIndexingFailures")
.HasForeignKey("DataSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
entity.Navigation("DataSource");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateDataSourceEntity", entity =>
{
entity.Navigation("Files");
entity.Navigation("PermanentIndexingFailures");
});
modelBuilder.Entity("AIStudio.Tools.Databases.IndexStore.EmbeddingStateFileEntity", entity =>
{
entity.Navigation("Chunks");
});
#pragma warning restore 612, 618
}
}
@@ -0,0 +1,61 @@
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
namespace AIStudio.Tools.Databases.IndexStore;
public sealed class NoIndexStoreClient(string name, string? unavailableReason, DatabaseClientStatus status = DatabaseClientStatus.UNAVAILABLE) : IndexStoreClient(name, string.Empty)
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(NoIndexStoreClient).Namespace, nameof(NoIndexStoreClient));
public override DatabaseClientStatus Status => status;
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
yield return (TB("Status"), status switch
{
DatabaseClientStatus.STARTING => TB("Starting"),
_ => TB("Unavailable")
});
if (!string.IsNullOrWhiteSpace(unavailableReason))
yield return (TB("Reason"), unavailableReason);
await Task.CompletedTask;
}
public override Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token) =>
Task.FromResult(new DataSourceEmbeddingManifest());
public override Task UpsertDataSourceAsync(
string dataSourceId,
string dataSourceName,
string dataSourceType,
string embeddingProviderId,
string embeddingSignature,
string sourceHash,
int vectorSize,
CancellationToken token) => Task.CompletedTask;
public override Task UpdateVectorSizeAsync(string dataSourceId, int vectorSize, CancellationToken token) => Task.CompletedTask;
public override Task UpdateDataSourceHashAsync(string dataSourceId, string sourceHash, CancellationToken token) => Task.CompletedTask;
public override Task UpsertFileAsync(string dataSourceId, EmbeddingStateFile file, CancellationToken token) => Task.CompletedTask;
public override Task DeleteFileAsync(string dataSourceId, string filePath, CancellationToken token) => Task.CompletedTask;
public override Task UpsertPermanentFailureAsync(string dataSourceId, PermanentIndexingFailure failure, CancellationToken token) => Task.CompletedTask;
public override Task DeletePermanentFailureAsync(string dataSourceId, string filePath, CancellationToken token) => Task.CompletedTask;
public override Task UpsertChunksAsync(string dataSourceId, IReadOnlyList<EmbeddingStateChunk> chunks, CancellationToken token) => Task.CompletedTask;
public override Task<IReadOnlyList<IndexStoreSearchResult>> SearchChunksAsync(string dataSourceId, string query, int maxMatches, CancellationToken token) =>
Task.FromResult<IReadOnlyList<IndexStoreSearchResult>>([]);
public override Task DeleteDataSourceAsync(string dataSourceId, CancellationToken token) => Task.CompletedTask;
public override void Dispose()
{
}
}
@@ -0,0 +1,3 @@
namespace AIStudio.Tools.Databases.IndexStore;
public sealed record PermanentIndexingFailure(string ParentFileId, string AbsolutePath, string Fingerprint, FileExtractionErrorCode Code, string Message, DateTimeOffset OccurredAtUtc);
@@ -0,0 +1,472 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using Microsoft.EntityFrameworkCore;
namespace AIStudio.Tools.Databases.IndexStore;
public sealed class SqliteIndexStoreClientImplementation(string name, string databasePath, string basePath, string version) : IndexStoreClient(name, basePath)
{
private const string DATABASE_NAME = "Local RAG Index";
private const string DATABASE_FILENAME = "rag-index.sqlite3";
private const int MAX_FTS_QUERY_TERMS = 32;
private const int CHUNK_UPSERT_BATCH_SIZE = 500;
private static readonly Regex FTS_TOKEN_REGEX = new(@"[\p{L}\p{Nd}_]+", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly string databasePath = databasePath;
private readonly DbContextOptions<IndexStoreDbContext> dbContextOptions = IndexStoreDbContext.CreateOptions(databasePath);
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SqliteIndexStoreClientImplementation).Namespace, nameof(SqliteIndexStoreClientImplementation));
public override string CacheKey => $"{this.Name}:{this.databasePath}:{version}";
public static async Task<DatabaseClient> CreateAsync(
ILogger logger,
ILogger<DatabaseClient> databaseClientLogger,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(SettingsManager.DataDirectory))
return CreateNoIndexStoreClient(DATABASE_NAME, "The application data directory is not available yet.", DatabaseClientStatus.STARTING, databaseClientLogger);
try
{
SQLitePCL.Batteries_V2.Init();
var basePath = Path.Combine(SettingsManager.DataDirectory, "databases", "sqlite");
Directory.CreateDirectory(basePath);
var databasePath = Path.Combine(basePath, DATABASE_FILENAME);
var client = new SqliteIndexStoreClientImplementation(DATABASE_NAME, databasePath, basePath, string.Empty);
await client.InitializeAsync(cancellationToken);
var version = await client.GetSqliteVersionAsync(cancellationToken);
client = new SqliteIndexStoreClientImplementation(DATABASE_NAME, databasePath, basePath, version);
client.SetLogger(databaseClientLogger);
return client;
}
catch (Exception exception)
{
logger.LogWarning(exception, "{DatabaseName} is not available. Indexed file fingerprints and search chunks are disabled.", DATABASE_NAME);
return CreateNoIndexStoreClient(DATABASE_NAME, exception.Message, DatabaseClientStatus.UNAVAILABLE, databaseClientLogger);
}
}
public override async IAsyncEnumerable<(string Label, string Value)> GetDisplayInfo()
{
await using var context = this.CreateContext();
yield return (TB("Reported version"), version);
yield return (TB("Database path"), this.databasePath);
yield return (TB("Storage size"), this.GetStorageSize());
yield return (TB("Indexed data sources"), (await context.DataSources.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Indexed files"), (await context.EmbeddedFiles.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Search chunks"), (await context.EmbeddingChunks.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
yield return (TB("Permanently skipped files"), (await context.PermanentIndexingFailures.CountAsync(CancellationToken.None)).ToString(CultureInfo.InvariantCulture));
}
public override async Task<DataSourceEmbeddingManifest> GetManifestAsync(string dataSourceId, CancellationToken token)
{
await using var context = this.CreateContext();
var manifest = new DataSourceEmbeddingManifest();
var dataSource = await context.DataSources
.AsNoTracking()
.FirstOrDefaultAsync(source => source.DataSourceId == dataSourceId, token);
if (dataSource is null)
return manifest;
manifest.EmbeddingProviderId = dataSource.EmbeddingProviderId;
manifest.EmbeddingSignature = dataSource.EmbeddingSignature;
manifest.SourceHash = dataSource.SourceHash;
manifest.VectorSize = dataSource.VectorSize;
var files = await context.EmbeddedFiles
.AsNoTracking()
.Where(file => file.DataSourceId == dataSourceId && file.ChunkCount > 0)
.ToListAsync(token);
foreach (var file in files)
{
manifest.Files[file.AbsolutePath] = new EmbeddedFileRecord(
file.Fingerprint,
file.FileSize,
file.LastWriteUtc,
file.EmbeddedAtUtc,
file.ChunkCount);
}
var permanentFailures = await context.PermanentIndexingFailures
.AsNoTracking()
.Where(failure => failure.DataSourceId == dataSourceId)
.ToListAsync(token);
foreach (var failure in permanentFailures)
{
manifest.PermanentFailures[failure.AbsolutePath] = new PermanentIndexingFailureRecord(
failure.Fingerprint,
ParseFailureCode(failure.FailureCode),
failure.FailureMessage,
failure.OccurredAtUtc);
}
return manifest;
}
public override async Task UpsertDataSourceAsync(
string dataSourceId,
string dataSourceName,
string dataSourceType,
string embeddingProviderId,
string embeddingSignature,
string sourceHash,
int vectorSize,
CancellationToken token)
{
await using var context = this.CreateContext();
var dataSource = await context.DataSources.FirstOrDefaultAsync(source => source.DataSourceId == dataSourceId, token);
if (dataSource is null)
{
dataSource = new EmbeddingStateDataSourceEntity
{
DataSourceId = dataSourceId,
};
context.DataSources.Add(dataSource);
}
ApplyDataSource(dataSource, dataSourceName, dataSourceType, embeddingProviderId, embeddingSignature, sourceHash, vectorSize);
await context.SaveChangesAsync(token);
}
public override async Task UpdateVectorSizeAsync(string dataSourceId, int vectorSize, CancellationToken token)
{
await using var context = this.CreateContext();
var dataSource = await context.DataSources.FirstOrDefaultAsync(source => source.DataSourceId == dataSourceId, token);
if (dataSource is null)
return;
dataSource.VectorSize = vectorSize;
dataSource.UpdatedAtUtc = DateTimeOffset.UtcNow;
await context.SaveChangesAsync(token);
}
public override async Task UpdateDataSourceHashAsync(string dataSourceId, string sourceHash, CancellationToken token)
{
await using var context = this.CreateContext();
var dataSource = await context.DataSources.FirstOrDefaultAsync(source => source.DataSourceId == dataSourceId, token);
if (dataSource is null)
return;
dataSource.SourceHash = sourceHash;
dataSource.UpdatedAtUtc = DateTimeOffset.UtcNow;
await context.SaveChangesAsync(token);
}
public override async Task UpsertFileAsync(string dataSourceId, EmbeddingStateFile file, CancellationToken token)
{
await using var context = this.CreateContext();
var fileEntity = await context.EmbeddedFiles.FirstOrDefaultAsync(entity => entity.ParentFileId == file.ParentFileId, token);
if (fileEntity is null)
{
fileEntity = new EmbeddingStateFileEntity
{
ParentFileId = file.ParentFileId,
};
context.EmbeddedFiles.Add(fileEntity);
}
ApplyFile(fileEntity, dataSourceId, file);
await context.SaveChangesAsync(token);
}
public override async Task DeleteFileAsync(string dataSourceId, string filePath, CancellationToken token)
{
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
var parentFileIds = await context.EmbeddedFiles
.Where(file => file.DataSourceId == dataSourceId && file.AbsolutePath == filePath)
.Select(file => file.ParentFileId)
.ToListAsync(token);
foreach (var parentFileIdBatch in parentFileIds.Chunk(CHUNK_UPSERT_BATCH_SIZE))
await context.EmbeddingChunks
.Where(chunk => parentFileIdBatch.Contains(chunk.ParentFileId))
.ExecuteDeleteAsync(token);
await context.EmbeddedFiles
.Where(file => file.DataSourceId == dataSourceId && file.AbsolutePath == filePath)
.ExecuteDeleteAsync(token);
await transaction.CommitAsync(token);
}
public override async Task UpsertPermanentFailureAsync(string dataSourceId, PermanentIndexingFailure failure, CancellationToken token)
{
await using var context = this.CreateContext();
var failureEntity = await context.PermanentIndexingFailures.FirstOrDefaultAsync(entity => entity.ParentFileId == failure.ParentFileId, token);
if (failureEntity is null)
{
failureEntity = new IndexingFailureEntity
{
ParentFileId = failure.ParentFileId,
};
context.PermanentIndexingFailures.Add(failureEntity);
}
ApplyPermanentFailure(failureEntity, dataSourceId, failure);
await context.SaveChangesAsync(token);
}
public override async Task DeletePermanentFailureAsync(string dataSourceId, string filePath, CancellationToken token)
{
await using var context = this.CreateContext();
await context.PermanentIndexingFailures
.Where(failure => failure.DataSourceId == dataSourceId && failure.AbsolutePath == filePath)
.ExecuteDeleteAsync(token);
}
public override async Task UpsertChunksAsync(string dataSourceId, IReadOnlyList<EmbeddingStateChunk> chunks, CancellationToken token)
{
if (chunks.Count == 0)
return;
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
foreach (var chunkBatch in chunks.Chunk(CHUNK_UPSERT_BATCH_SIZE))
{
token.ThrowIfCancellationRequested();
var chunkIds = chunkBatch
.Select(chunk => chunk.ChunkId)
.Distinct(StringComparer.Ordinal)
.ToArray();
var existingChunks = await context.EmbeddingChunks
.Where(chunk => chunkIds.Contains(chunk.ChunkId))
.ToDictionaryAsync(chunk => chunk.ChunkId, StringComparer.Ordinal, token);
foreach (var chunk in chunkBatch)
{
if (!existingChunks.TryGetValue(chunk.ChunkId, out var chunkEntity))
{
chunkEntity = new EmbeddingStateChunkEntity
{
ChunkId = chunk.ChunkId,
};
context.EmbeddingChunks.Add(chunkEntity);
existingChunks[chunk.ChunkId] = chunkEntity;
}
ApplyChunk(chunkEntity, chunk);
}
await context.SaveChangesAsync(token);
context.ChangeTracker.Clear();
}
await transaction.CommitAsync(token);
}
public override async Task<IReadOnlyList<IndexStoreSearchResult>> SearchChunksAsync(string dataSourceId, string query, int maxMatches, CancellationToken token)
{
if (maxMatches <= 0)
return [];
var ftsQuery = BuildFtsQuery(query);
if (string.IsNullOrWhiteSpace(ftsQuery))
return [];
await using var context = this.CreateContext();
var results = await context.SearchResults
.FromSqlInterpolated($"""
SELECT
c.chunk_id AS ChunkId,
c.parent_file_id AS ParentFileId,
ds.data_source_id AS DataSourceId,
ds.data_source_name AS DataSourceName,
ds.data_source_type AS DataSourceType,
f.absolute_path AS AbsolutePath,
f.file_name AS FileName,
f.relative_path AS RelativePath,
f.file_type AS FileType,
c.page_number AS PageNumber,
c.chunk_index AS ChunkIndex,
c.chunk_text AS ChunkText,
bm25(embedding_chunks_fts) AS Score,
f.fingerprint AS Fingerprint,
f.file_size AS FileSize,
f.creation_utc AS CreationUtc,
f.last_write_utc AS LastWriteUtc,
c.embedded_at_utc AS EmbeddedAtUtc,
f.chunk_count AS ChunkCount,
f.confidence_level AS ConfidenceLevel,
f.confidence_level_rank AS ConfidenceLevelRank
FROM embedding_chunks_fts
JOIN embedding_chunks c ON c.id = embedding_chunks_fts.rowid
JOIN embedded_files f ON f.parent_file_id = c.parent_file_id
JOIN data_sources ds ON ds.data_source_id = f.data_source_id
WHERE ds.data_source_id = {dataSourceId}
AND embedding_chunks_fts MATCH {ftsQuery}
ORDER BY Score
LIMIT {maxMatches}
""")
.AsNoTracking()
.ToListAsync(token);
return results.Select(ToSearchResult).ToList();
}
public override async Task DeleteDataSourceAsync(string dataSourceId, CancellationToken token)
{
await using var context = this.CreateContext();
await using var transaction = await context.Database.BeginTransactionAsync(token);
var parentFileIds = await context.EmbeddedFiles
.Where(file => file.DataSourceId == dataSourceId)
.Select(file => file.ParentFileId)
.ToListAsync(token);
foreach (var parentFileIdBatch in parentFileIds.Chunk(CHUNK_UPSERT_BATCH_SIZE))
await context.EmbeddingChunks
.Where(chunk => parentFileIdBatch.Contains(chunk.ParentFileId))
.ExecuteDeleteAsync(token);
await context.DataSources
.Where(source => source.DataSourceId == dataSourceId)
.ExecuteDeleteAsync(token);
await transaction.CommitAsync(token);
}
public override void Dispose()
{
}
private async Task InitializeAsync(CancellationToken token)
{
await using var context = this.CreateContext();
await IndexStoreSchemaMigrator.MigrateAsync(context, token);
}
private async Task<string> GetSqliteVersionAsync(CancellationToken token)
{
await using var context = this.CreateContext();
var versions = await context.Database
.SqlQueryRaw<string>("SELECT sqlite_version() AS Value")
.ToListAsync(token);
return versions.FirstOrDefault() ?? string.Empty;
}
private IndexStoreDbContext CreateContext() => new(this.dbContextOptions);
private static void ApplyDataSource(
EmbeddingStateDataSourceEntity dataSource,
string dataSourceName,
string dataSourceType,
string embeddingProviderId,
string embeddingSignature,
string sourceHash,
int vectorSize)
{
dataSource.DataSourceName = dataSourceName;
dataSource.DataSourceType = dataSourceType;
dataSource.EmbeddingProviderId = embeddingProviderId;
dataSource.EmbeddingSignature = embeddingSignature;
dataSource.SourceHash = sourceHash;
dataSource.VectorSize = vectorSize;
dataSource.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
private static void ApplyFile(EmbeddingStateFileEntity fileEntity, string dataSourceId, EmbeddingStateFile file)
{
fileEntity.DataSourceId = dataSourceId;
fileEntity.AbsolutePath = file.AbsolutePath;
fileEntity.FileName = file.FileName;
fileEntity.RelativePath = file.RelativePath;
fileEntity.FileType = file.FileType;
fileEntity.Fingerprint = file.Fingerprint;
fileEntity.FileSize = file.FileSize;
fileEntity.CreationUtc = file.CreationUtc;
fileEntity.LastWriteUtc = file.LastWriteUtc;
fileEntity.EmbeddedAtUtc = file.EmbeddedAtUtc;
fileEntity.ChunkCount = file.ChunkCount;
fileEntity.ConfidenceLevel = file.ConfidenceLevel;
fileEntity.ConfidenceLevelRank = file.ConfidenceLevelRank;
}
private static void ApplyPermanentFailure(IndexingFailureEntity failureEntity, string dataSourceId, PermanentIndexingFailure failure)
{
failureEntity.DataSourceId = dataSourceId;
failureEntity.AbsolutePath = failure.AbsolutePath;
failureEntity.Fingerprint = failure.Fingerprint;
failureEntity.FailureCode = failure.Code.ToString();
failureEntity.FailureMessage = failure.Message;
failureEntity.OccurredAtUtc = failure.OccurredAtUtc;
}
/// <remarks>
/// A row written by a newer version may name a code this one does not know. Such a row still
/// says that the file failed permanently, so it keeps its place in the manifest and only loses
/// the reason it names.
/// </remarks>
private static FileExtractionErrorCode ParseFailureCode(string failureCode) =>
Enum.TryParse<FileExtractionErrorCode>(failureCode, ignoreCase: true, out var parsedCode) ? parsedCode : FileExtractionErrorCode.UNKNOWN;
private static void ApplyChunk(EmbeddingStateChunkEntity chunkEntity, EmbeddingStateChunk chunk)
{
chunkEntity.ChunkId = chunk.ChunkId;
chunkEntity.ParentFileId = chunk.ParentFileId;
chunkEntity.PageNumber = chunk.PageNumber;
chunkEntity.ChunkIndex = chunk.ChunkIndex;
chunkEntity.ChunkText = chunk.ChunkText;
chunkEntity.EmbeddedAtUtc = chunk.EmbeddedAtUtc;
}
private static IndexStoreSearchResult ToSearchResult(IndexStoreSearchResultEntity result) => new(
result.ChunkId,
result.ParentFileId,
result.DataSourceId,
result.DataSourceName,
result.DataSourceType,
result.AbsolutePath,
result.FileName,
result.RelativePath,
result.FileType,
result.PageNumber,
result.ChunkIndex,
result.ChunkText,
result.Score,
result.Fingerprint,
result.FileSize,
result.CreationUtc,
result.LastWriteUtc,
result.EmbeddedAtUtc,
result.ChunkCount,
result.ConfidenceLevel,
result.ConfidenceLevelRank);
private static string BuildFtsQuery(string query)
{
var terms = FTS_TOKEN_REGEX
.Matches(query)
.Select(match => match.Value)
.Where(term => !string.IsNullOrWhiteSpace(term))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(MAX_FTS_QUERY_TERMS)
.Select(term => $"\"{term.Replace("\"", "\"\"", StringComparison.Ordinal)}\"")
.ToList();
return terms.Count == 0 ? string.Empty : string.Join(" OR ", terms);
}
private static NoIndexStoreClient CreateNoIndexStoreClient(string name, string? unavailableReason, DatabaseClientStatus status, ILogger<DatabaseClient> databaseClientLogger)
{
var client = new NoIndexStoreClient(name, unavailableReason, status);
client.SetLogger(databaseClientLogger);
return client;
}
}