2026-07-28 15:25:10 +02:00
using System.Globalization ;
2026-08-03 20:56:19 +02:00
using System.Text.RegularExpressions ;
2026-07-28 15:25:10 +02:00
using AIStudio.Settings ;
using AIStudio.Tools.PluginSystem ;
using AIStudio.Tools.Services ;
2026-08-04 17:27:26 +02:00
using Microsoft.EntityFrameworkCore ;
2026-07-28 15:25:10 +02:00
namespace AIStudio.Tools.Databases.EmbeddingState ;
public sealed class SqliteEmbeddingStateClientImplementation (
string name ,
string databasePath ,
string basePath ,
string version ) : EmbeddingStateClient ( name , basePath )
{
2026-08-03 19:45:47 +02:00
private const string DATABASE_NAME = "Local RAG Index" ;
private const string DATABASE_FILENAME = "rag-index.sqlite3" ;
2026-08-03 20:56:19 +02:00
private const int MAX_FTS_QUERY_TERMS = 32 ;
2026-08-04 17:27:26 +02:00
private const int CHUNK_UPSERT_BATCH_SIZE = 500 ;
2026-08-03 20:56:19 +02:00
private static readonly Regex FTS_TOKEN_REGEX = new ( @"[\p{L}\p{Nd}_]+" , RegexOptions . Compiled | RegexOptions . CultureInvariant );
2026-07-28 15:25:10 +02:00
private readonly string databasePath = databasePath ;
2026-08-04 17:27:26 +02:00
private readonly DbContextOptions < EmbeddingStateDbContext > dbContextOptions = EmbeddingStateDbContext . CreateOptions ( databasePath );
2026-07-28 15:25:10 +02:00
private static string TB ( string fallbackEN ) => I18N . I . T ( fallbackEN , typeof ( SqliteEmbeddingStateClientImplementation ). Namespace , nameof ( SqliteEmbeddingStateClientImplementation ));
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 CreateNoEmbeddingStateClient ( 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 SqliteEmbeddingStateClientImplementation ( DATABASE_NAME , databasePath , basePath , string . Empty );
await client . InitializeAsync ( cancellationToken );
var version = await client . GetSqliteVersionAsync ( cancellationToken );
client = new SqliteEmbeddingStateClientImplementation ( DATABASE_NAME , databasePath , basePath , version );
client . SetLogger ( databaseClientLogger );
return client ;
}
catch ( Exception exception )
{
2026-08-03 19:45:47 +02:00
logger . LogWarning ( exception , "{DatabaseName} is not available. Indexed file fingerprints and search chunks are disabled." , DATABASE_NAME );
2026-07-28 15:25:10 +02:00
return CreateNoEmbeddingStateClient ( DATABASE_NAME , exception . Message , DatabaseClientStatus . UNAVAILABLE , databaseClientLogger );
}
}
public override async IAsyncEnumerable <( string Label , string Value )> GetDisplayInfo ()
{
2026-08-04 17:27:26 +02:00
await using var context = this . CreateContext ();
2026-07-28 15:25:10 +02:00
yield return ( TB ( "Reported version" ), version );
yield return ( TB ( "Database path" ), this . databasePath );
yield return ( TB ( "Storage size" ), this . GetStorageSize ());
2026-08-04 17:27:26 +02:00
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 ));
2026-07-28 15:25:10 +02:00
}
public override async Task < DataSourceEmbeddingManifest > GetManifestAsync ( string dataSourceId , CancellationToken token )
{
2026-08-04 17:27:26 +02:00
await using var context = this . CreateContext ();
2026-07-28 15:25:10 +02:00
var manifest = new DataSourceEmbeddingManifest ();
2026-08-04 17:27:26 +02:00
var dataSource = await context . DataSources
. AsNoTracking ()
. FirstOrDefaultAsync ( source => source . DataSourceId == dataSourceId , token );
if ( dataSource is null )
return manifest ;
2026-07-28 15:25:10 +02:00
2026-08-04 17:27:26 +02:00
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 )
2026-07-28 15:25:10 +02:00
{
2026-08-04 17:27:26 +02:00
manifest . Files [ file . AbsolutePath ] = new EmbeddedFileRecord (
file . Fingerprint ,
file . FileSize ,
file . LastWriteUtc ,
file . EmbeddedAtUtc ,
file . ChunkCount );
2026-07-28 15:25:10 +02:00
}
return manifest ;
}
public override async Task UpsertDataSourceAsync (
string dataSourceId ,
string dataSourceName ,
string dataSourceType ,
string embeddingProviderId ,
string embeddingSignature ,
2026-08-03 17:53:31 +02:00
string sourceHash ,
2026-07-28 15:25:10 +02:00
int vectorSize ,
CancellationToken token )
{
2026-08-04 17:27:26 +02:00
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 );
2026-07-28 15:25:10 +02:00
}
public override async Task UpdateVectorSizeAsync ( string dataSourceId , int vectorSize , CancellationToken token )
{
2026-08-04 17:27:26 +02:00
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 ;
2026-08-10 20:06:51 +02:00
dataSource . UpdatedAtUtc = DateTimeOffset . UtcNow ;
2026-08-04 17:27:26 +02:00
await context . SaveChangesAsync ( token );
2026-07-28 15:25:10 +02:00
}
2026-08-03 17:53:31 +02:00
public override async Task UpdateDataSourceHashAsync ( string dataSourceId , string sourceHash , CancellationToken token )
{
2026-08-04 17:27:26 +02:00
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 ;
2026-08-10 20:06:51 +02:00
dataSource . UpdatedAtUtc = DateTimeOffset . UtcNow ;
2026-08-04 17:27:26 +02:00
await context . SaveChangesAsync ( token );
2026-08-03 17:53:31 +02:00
}
2026-07-28 15:25:10 +02:00
public override async Task UpsertFileAsync ( string dataSourceId , EmbeddingStateFile file , CancellationToken token )
{
2026-08-04 17:27:26 +02:00
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 );
2026-07-28 15:25:10 +02:00
}
public override async Task DeleteFileAsync ( string dataSourceId , string filePath , CancellationToken token )
{
2026-08-04 17:27:26 +02:00
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 );
2026-07-28 15:25:10 +02:00
}
2026-08-03 19:45:47 +02:00
public override async Task UpsertChunksAsync ( string dataSourceId , IReadOnlyList < EmbeddingStateChunk > chunks , CancellationToken token )
{
if ( chunks . Count == 0 )
return ;
2026-08-04 17:27:26 +02:00
await using var context = this . CreateContext ();
await using var transaction = await context . Database . BeginTransactionAsync ( token );
2026-08-03 19:45:47 +02:00
2026-08-04 17:27:26 +02:00
foreach ( var chunkBatch in chunks . Chunk ( CHUNK_UPSERT_BATCH_SIZE ))
2026-08-03 19:45:47 +02:00
{
token . ThrowIfCancellationRequested ();
2026-08-04 17:27:26 +02:00
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 ();
2026-08-03 19:45:47 +02:00
}
await transaction . CommitAsync ( token );
}
2026-08-03 20:56:19 +02:00
public override async Task < IReadOnlyList < EmbeddingStateSearchResult >> SearchChunksAsync ( string dataSourceId , string query , int maxMatches , CancellationToken token )
{
if ( maxMatches <= 0 )
return [];
var ftsQuery = BuildFtsQuery ( query );
if ( string . IsNullOrWhiteSpace ( ftsQuery ))
return [];
2026-08-04 17:27:26 +02:00
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.compliance_level AS ComplianceLevel,
f.compliance_level_rank AS ComplianceLevelRank
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 ();
2026-08-03 20:56:19 +02:00
}
2026-07-28 15:25:10 +02:00
public override async Task DeleteDataSourceAsync ( string dataSourceId , CancellationToken token )
{
2026-08-04 17:27:26 +02:00
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 );
2026-07-28 15:25:10 +02:00
}
public override void Dispose ()
{
}
private async Task InitializeAsync ( CancellationToken token )
{
2026-08-04 17:27:26 +02:00
await using var context = this . CreateContext ();
await EmbeddingStateSchemaMigrator . MigrateAsync ( context , token );
2026-07-28 15:25:10 +02:00
}
private async Task < string > GetSqliteVersionAsync ( CancellationToken token )
{
2026-08-04 17:27:26 +02:00
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 ;
2026-07-28 15:25:10 +02:00
}
2026-08-04 17:27:26 +02:00
private EmbeddingStateDbContext CreateContext () => new ( this . dbContextOptions );
2026-07-28 15:25:10 +02:00
2026-08-04 17:27:26 +02:00
private static void ApplyDataSource (
EmbeddingStateDataSourceEntity dataSource ,
string dataSourceName ,
string dataSourceType ,
string embeddingProviderId ,
string embeddingSignature ,
string sourceHash ,
int vectorSize )
2026-07-28 15:25:10 +02:00
{
2026-08-04 17:27:26 +02:00
dataSource . DataSourceName = dataSourceName ;
dataSource . DataSourceType = dataSourceType ;
dataSource . EmbeddingProviderId = embeddingProviderId ;
dataSource . EmbeddingSignature = embeddingSignature ;
dataSource . SourceHash = sourceHash ;
dataSource . VectorSize = vectorSize ;
2026-08-10 20:06:51 +02:00
dataSource . UpdatedAtUtc = DateTimeOffset . UtcNow ;
2026-07-28 15:25:10 +02:00
}
2026-08-04 17:27:26 +02:00
private static void ApplyFile ( EmbeddingStateFileEntity fileEntity , string dataSourceId , EmbeddingStateFile file )
2026-08-03 19:45:47 +02:00
{
2026-08-04 17:27:26 +02:00
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 . ComplianceLevel = file . ComplianceLevel ;
fileEntity . ComplianceLevelRank = file . ComplianceLevelRank ;
2026-08-03 19:45:47 +02:00
}
2026-08-04 17:27:26 +02:00
private static void ApplyChunk ( EmbeddingStateChunkEntity chunkEntity , EmbeddingStateChunk chunk )
2026-07-28 15:25:10 +02:00
{
2026-08-04 17:27:26 +02:00
chunkEntity . ChunkId = chunk . ChunkId ;
chunkEntity . ParentFileId = chunk . ParentFileId ;
chunkEntity . PageNumber = chunk . PageNumber ;
chunkEntity . ChunkIndex = chunk . ChunkIndex ;
chunkEntity . ChunkText = chunk . ChunkText ;
chunkEntity . EmbeddedAtUtc = chunk . EmbeddedAtUtc ;
2026-07-28 15:25:10 +02:00
}
2026-08-04 17:27:26 +02:00
private static EmbeddingStateSearchResult ToSearchResult ( EmbeddingStateSearchResultEntity 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 . ComplianceLevel ,
result . ComplianceLevelRank );
2026-07-28 15:25:10 +02:00
2026-08-03 20:56:19 +02:00
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 );
}
2026-07-28 15:25:10 +02:00
private static NoEmbeddingStateClient CreateNoEmbeddingStateClient ( string name , string? unavailableReason , DatabaseClientStatus status , ILogger < DatabaseClient > databaseClientLogger )
{
var client = new NoEmbeddingStateClient ( name , unavailableReason , status );
client . SetLogger ( databaseClientLogger );
return client ;
}
}