2026-05-13 18:13:34 +02:00
using System.Security.Cryptography ;
using System.Text ;
2026-07-28 16:37:07 +02:00
using System.Text.RegularExpressions ;
2026-05-13 18:13:34 +02:00
2026-08-03 19:45:47 +02:00
using AIStudio.Provider ;
2026-05-13 18:13:34 +02:00
using AIStudio.Settings ;
using AIStudio.Settings.DataModel ;
2026-08-14 12:37:50 +02:00
using AIStudio.Tools.Databases.IndexStore ;
2026-05-13 18:13:34 +02:00
using AIStudio.Tools.Rust ;
namespace AIStudio.Tools.Services ;
public sealed partial class DataSourceEmbeddingService
{
2026-07-28 15:25:10 +02:00
private const string OFFICE_LOCK_FILE_PREFIX = "~$" ;
2026-08-14 16:02:40 +02:00
internal const int DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH = 300 ;
2026-08-12 11:43:47 +02:00
private const bool IMAGE_EMBEDDING_ENABLED = false ;
2026-07-28 15:25:10 +02:00
private enum RagFileIndexingDecision
{
INDEXABLE ,
EXCLUDED ,
UNSUPPORTED ,
}
2026-08-10 18:21:31 +02:00
private sealed record ExtractedFileSegment ( string Text , int? TokenCount );
private sealed record ExtractedFileContent ( string Text , IReadOnlyList < ExtractedFileSegment > SourceSegments );
2026-07-29 18:47:59 +02:00
2026-08-03 19:45:47 +02:00
private sealed record EmbeddingChunkDraft ( string ChunkId , string Text , int ChunkIndex , int? PageNumber );
2026-07-29 18:47:59 +02:00
private sealed record ChunkingOptions ( int MaxChunkTokenLength , int OverlapTokenLength );
private sealed record ChunkingStrategy ( string Name , IReadOnlyList < ChunkingRule > Rules );
2026-08-10 18:21:31 +02:00
private sealed record ChunkingRule ( string Name , Func < string , IReadOnlyList < string >, IReadOnlyList < string >>? Split , bool UsesSourceSegmentCounts = false );
2026-07-29 18:47:59 +02:00
2026-08-03 17:53:31 +02:00
private sealed record DataSourceMetadataSnapshot ( string SourceHash , IReadOnlyDictionary < string , string > FileHashes );
2026-07-29 18:47:59 +02:00
private async IAsyncEnumerable < string > StreamEmbeddingChunksAsync ( string filePath , IDataSource dataSource , EmbeddingProvider embeddingProvider , [ System . Runtime . CompilerServices . EnumeratorCancellation ] CancellationToken token )
2026-05-13 18:13:34 +02:00
{
2026-07-29 18:47:59 +02:00
var options = this . GetChunkingOptions ( dataSource , embeddingProvider );
var strategy = this . GetChunkingStrategy ( filePath );
2026-08-12 11:43:47 +02:00
var content = await this . ReadExtractedFileContentAsync ( filePath , embeddingProvider , token );
2026-05-13 18:13:34 +02:00
2026-07-29 18:47:59 +02:00
await foreach ( var chunk in this . SplitByChunkingStrategyAsync ( content , strategy , options , embeddingProvider , token ))
yield return chunk ;
}
2026-08-10 18:21:31 +02:00
private async Task < ExtractedFileContent > ReadExtractedFileContentAsync ( string filePath , EmbeddingProvider embeddingProvider , CancellationToken token )
2026-07-29 18:47:59 +02:00
{
2026-08-10 18:21:31 +02:00
var segments = new List < ExtractedFileSegment >();
2026-05-13 18:13:34 +02:00
2026-08-10 21:36:01 +02:00
await foreach ( var segment in rustService . StreamArbitraryFileDataWithTokenCounts ( filePath , embeddingProvider , token ))
2026-05-13 18:13:34 +02:00
{
2026-08-10 18:21:31 +02:00
var normalized = NormalizeChunkSegment ( segment . Content );
2026-07-29 18:47:59 +02:00
if (! string . IsNullOrWhiteSpace ( normalized ))
2026-08-10 18:21:31 +02:00
segments . Add ( new ( normalized , segment . TokenCount ));
2026-07-29 18:47:59 +02:00
}
2026-05-13 18:13:34 +02:00
2026-08-10 18:21:31 +02:00
return new ( string . Join ( "\n" , segments . Select ( segment => segment . Text )). Trim (), segments );
2026-07-29 18:47:59 +02:00
}
2026-05-13 18:13:34 +02:00
2026-07-29 18:47:59 +02:00
private async IAsyncEnumerable < string > SplitByChunkingStrategyAsync ( ExtractedFileContent content , ChunkingStrategy strategy , ChunkingOptions options , EmbeddingProvider embeddingProvider , [ System . Runtime . CompilerServices . EnumeratorCancellation ] CancellationToken token )
{
2026-08-10 18:21:31 +02:00
var estimatedTokenCount = SumTokenCounts ( content . SourceSegments );
await foreach ( var chunk in this . SplitTextByRulesAsync ( content . Text , content . SourceSegments , strategy , 0 , options , embeddingProvider , token , estimatedTokenCount : estimatedTokenCount ))
2026-07-29 18:47:59 +02:00
yield return chunk ;
}
private async IAsyncEnumerable < string > SplitTextByRulesAsync (
string text ,
2026-08-10 18:21:31 +02:00
IReadOnlyList < ExtractedFileSegment > sourceSegments ,
2026-07-29 18:47:59 +02:00
ChunkingStrategy strategy ,
int ruleIndex ,
ChunkingOptions options ,
EmbeddingProvider embeddingProvider ,
2026-08-04 15:08:47 +02:00
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token ,
2026-08-10 18:21:31 +02:00
string requiredOverlapPrefix = "" ,
int? estimatedTokenCount = null )
2026-07-29 18:47:59 +02:00
{
text = text . Trim ();
if ( string . IsNullOrWhiteSpace ( text ))
yield break ;
2026-08-10 18:21:31 +02:00
var tokenCount = estimatedTokenCount ;
var textWithOverlap = AddOverlapPrefix ( text , requiredOverlapPrefix );
2026-08-10 19:42:39 +02:00
if ( textWithOverlap . Length <= RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH &&
2026-08-10 18:21:31 +02:00
( estimatedTokenCount is null || estimatedTokenCount <= options . MaxChunkTokenLength ))
2026-07-29 18:47:59 +02:00
{
2026-08-10 18:21:31 +02:00
tokenCount = await this . GetEmbeddingTokenCountAsync ( embeddingProvider , textWithOverlap , token );
if ( tokenCount <= options . MaxChunkTokenLength )
{
yield return textWithOverlap ;
yield break ;
}
2026-05-13 18:13:34 +02:00
}
2026-07-29 18:47:59 +02:00
if ( ruleIndex >= strategy . Rules . Count )
2026-07-28 16:37:07 +02:00
{
2026-08-10 18:21:31 +02:00
await foreach ( var hardChunk in this . SplitTextByHardCutAsync ( text , options , embeddingProvider , token , requiredOverlapPrefix , estimatedTokenCount ))
2026-07-29 18:47:59 +02:00
yield return hardChunk ;
yield break ;
2026-07-28 16:37:07 +02:00
}
2026-07-29 18:47:59 +02:00
var rule = strategy . Rules [ ruleIndex ];
if ( rule . Split is null )
2026-07-28 16:37:07 +02:00
{
2026-08-10 18:21:31 +02:00
await foreach ( var hardChunk in this . SplitTextByHardCutAsync ( text , options , embeddingProvider , token , requiredOverlapPrefix , estimatedTokenCount ))
2026-07-29 18:47:59 +02:00
yield return hardChunk ;
2026-07-28 16:37:07 +02:00
yield break ;
}
2026-08-10 18:21:31 +02:00
var units = NormalizeSplitUnits ( rule . Split ( text , sourceSegments . Select ( segment => segment . Text ). ToList ()), text );
2026-07-29 18:47:59 +02:00
if ( units . Count <= 1 )
2026-07-28 17:36:06 +02:00
{
2026-08-10 18:21:31 +02:00
await foreach ( var chunk in this . SplitTextByRulesAsync ( text , sourceSegments , strategy , ruleIndex + 1 , options , embeddingProvider , token , requiredOverlapPrefix , estimatedTokenCount ))
2026-07-29 18:47:59 +02:00
yield return chunk ;
yield break ;
2026-07-28 17:36:06 +02:00
}
2026-07-28 16:37:07 +02:00
logger . LogDebug (
2026-08-10 18:21:31 +02:00
"Splitting content for embedding provider '{EmbeddingProviderName}' with strategy '{ChunkingStrategy}' and rule '{ChunkingRule}'. EstimatedTokenCount={EstimatedTokenCount}, MaxChunkTokenLength={MaxChunkTokenLength}, OverlapTokenLength={OverlapTokenLength}." ,
2026-07-28 16:37:07 +02:00
embeddingProvider . Name ,
2026-07-29 18:47:59 +02:00
strategy . Name ,
rule . Name ,
2026-07-28 16:37:07 +02:00
tokenCount ,
2026-08-04 15:08:47 +02:00
options . MaxChunkTokenLength ,
options . OverlapTokenLength );
2026-07-28 16:37:07 +02:00
var index = 0 ;
2026-08-04 15:08:47 +02:00
var overlapPrefix = requiredOverlapPrefix ;
2026-08-10 18:21:31 +02:00
var unitTokenCounts = EstimateSplitUnitTokenCounts ( units , sourceSegments , rule . UsesSourceSegmentCounts , estimatedTokenCount );
2026-07-28 16:37:07 +02:00
while ( index < units . Count )
{
token . ThrowIfCancellationRequested ();
2026-08-10 18:21:31 +02:00
var unitCount = await this . FindLargestUnitCountWithinMaxChunkLengthAsync ( units , unitTokenCounts , index , embeddingProvider , options . MaxChunkTokenLength , token , overlapPrefix );
2026-07-28 16:37:07 +02:00
if ( unitCount > 0 )
{
2026-08-04 15:08:47 +02:00
var rawChunk = string . Concat ( units . Skip ( index ). Take ( unitCount )). Trim ();
var chunk = AddOverlapPrefix ( rawChunk , overlapPrefix );
overlapPrefix = string . Empty ;
2026-07-28 16:37:07 +02:00
if (! string . IsNullOrWhiteSpace ( chunk ))
yield return chunk ;
2026-07-29 18:47:59 +02:00
var nextIndex = index + unitCount ;
if ( nextIndex >= units . Count )
yield break ;
2026-08-04 15:08:47 +02:00
var nextStartIndex = await this . CalculateNextStartIndexAsync ( units , index , nextIndex , options , embeddingProvider , token );
if ( nextStartIndex < nextIndex )
{
logger . LogDebug (
"Applied delimiter overlap while chunking. Strategy='{ChunkingStrategy}', Rule='{ChunkingRule}', PreviousStartUnitIndex={PreviousStartUnitIndex}, PreviousEndUnitIndex={PreviousEndUnitIndex}, NextStartUnitIndex={NextStartUnitIndex}, OverlapUnits={OverlapUnits}, OverlapTokenLength={OverlapTokenLength}." ,
strategy . Name ,
rule . Name ,
index ,
nextIndex ,
nextStartIndex ,
nextIndex - nextStartIndex ,
options . OverlapTokenLength );
index = nextStartIndex ;
}
else
{
overlapPrefix = await this . CreateOverlapPrefixAsync ( chunk , strategy , rule , options , embeddingProvider , token );
index = nextIndex ;
}
2026-07-28 16:37:07 +02:00
continue ;
}
2026-08-04 15:08:47 +02:00
string? lastSplitUnit = null ;
2026-08-10 18:21:31 +02:00
var unitTokenCount = unitTokenCounts ?[ index ];
await foreach ( var splitUnit in this . SplitTextByRulesAsync ( units [ index ], [ new ( units [ index ], unitTokenCount )], strategy , ruleIndex + 1 , options , embeddingProvider , token , overlapPrefix , unitTokenCount ))
2026-08-04 15:08:47 +02:00
{
lastSplitUnit = splitUnit ;
2026-07-28 16:37:07 +02:00
yield return splitUnit ;
2026-08-04 15:08:47 +02:00
}
2026-07-28 16:37:07 +02:00
2026-08-04 15:08:47 +02:00
overlapPrefix = lastSplitUnit is null
? string . Empty
: await this . CreateOverlapPrefixAsync ( lastSplitUnit , strategy , rule , options , embeddingProvider , token );
2026-07-28 16:37:07 +02:00
index ++;
}
}
2026-08-10 18:21:31 +02:00
private async Task < int > FindLargestUnitCountWithinMaxChunkLengthAsync ( IReadOnlyList < string > units , IReadOnlyList < int >? estimatedUnitTokenCounts , int startUnitIndex , EmbeddingProvider embeddingProvider , int maxChunkTokenLength , CancellationToken token , string overlapPrefix = "" )
2026-07-28 16:37:07 +02:00
{
2026-08-10 18:21:31 +02:00
var minimumCandidateUnitCount = 1 ;
var availableUnitCount = units . Count - startUnitIndex ;
var maximumCandidateUnitCount = availableUnitCount ;
var largestValidUnitCount = 0 ;
2026-07-28 16:37:07 +02:00
2026-08-10 18:21:31 +02:00
if ( estimatedUnitTokenCounts is not null )
2026-07-28 16:37:07 +02:00
{
2026-08-10 18:21:31 +02:00
maximumCandidateUnitCount = 0 ;
var cumulativeEstimatedTokenCount = 0L ;
for ( var unitIndex = startUnitIndex ; unitIndex < units . Count ; unitIndex ++)
{
cumulativeEstimatedTokenCount += estimatedUnitTokenCounts [ unitIndex ];
if ( cumulativeEstimatedTokenCount > maxChunkTokenLength )
break ;
2026-07-28 16:37:07 +02:00
2026-08-10 18:21:31 +02:00
maximumCandidateUnitCount ++;
}
if ( maximumCandidateUnitCount == 0 )
maximumCandidateUnitCount = 1 ;
}
while ( true )
{
var searchedMaximumCandidateUnitCount = maximumCandidateUnitCount ;
while ( minimumCandidateUnitCount <= maximumCandidateUnitCount )
2026-07-28 16:37:07 +02:00
{
2026-08-10 18:21:31 +02:00
token . ThrowIfCancellationRequested ();
var candidateUnitCount = minimumCandidateUnitCount + ( maximumCandidateUnitCount - minimumCandidateUnitCount ) / 2 ;
var candidateText = AddOverlapPrefix ( string . Concat ( units . Skip ( startUnitIndex ). Take ( candidateUnitCount )). Trim (), overlapPrefix );
2026-08-10 19:42:39 +02:00
var candidateFits = candidateText . Length <= RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH &&
2026-08-10 18:21:31 +02:00
await this . GetEmbeddingTokenCountAsync ( embeddingProvider , candidateText , token ) <= maxChunkTokenLength ;
if ( candidateFits )
{
largestValidUnitCount = candidateUnitCount ;
minimumCandidateUnitCount = candidateUnitCount + 1 ;
}
else
maximumCandidateUnitCount = candidateUnitCount - 1 ;
2026-07-28 16:37:07 +02:00
}
2026-08-10 18:21:31 +02:00
if ( largestValidUnitCount < searchedMaximumCandidateUnitCount || largestValidUnitCount >= availableUnitCount )
break ;
minimumCandidateUnitCount = searchedMaximumCandidateUnitCount + 1 ;
maximumCandidateUnitCount = ( int ) Math . Min (
availableUnitCount ,
2026-09-06 13:26:56 +02:00
Math . Max ( minimumCandidateUnitCount , ( long ) searchedMaximumCandidateUnitCount * 2 ));
2026-07-28 16:37:07 +02:00
}
2026-08-10 18:21:31 +02:00
return largestValidUnitCount ;
}
private static int? SumTokenCounts ( IReadOnlyList < ExtractedFileSegment > segments )
{
var result = 0L ;
foreach ( var segment in segments )
{
if ( segment . TokenCount is null )
return null ;
result += segment . TokenCount . Value ;
}
return ( int ) Math . Min ( result , int . MaxValue );
}
private static IReadOnlyList < int >? EstimateSplitUnitTokenCounts (
IReadOnlyList < string > units ,
IReadOnlyList < ExtractedFileSegment > sourceSegments ,
bool usesSourceSegmentCounts ,
int? sourceTokenCount )
{
if ( usesSourceSegmentCounts && sourceSegments . Count == units . Count && sourceSegments . All ( segment => segment . TokenCount is not null ))
return sourceSegments . Select ( segment => segment . TokenCount . GetValueOrDefault ()). ToList ();
if ( sourceTokenCount is null )
return null ;
var totalLength = Math . Max ( 1 , units . Sum ( unit => unit . Length ));
var result = new List < int >( units . Count );
var allocatedTokenCount = 0 ;
var consumedLength = 0L ;
foreach ( var unit in units )
{
consumedLength += unit . Length ;
2026-09-06 13:26:56 +02:00
var tokenCountAtBoundary = ( int ) Math . Min ( sourceTokenCount . Value , sourceTokenCount . Value * consumedLength / totalLength );
2026-08-10 18:21:31 +02:00
result . Add ( Math . Max ( 0 , tokenCountAtBoundary - allocatedTokenCount ));
allocatedTokenCount = tokenCountAtBoundary ;
}
if ( result . Count > 0 && allocatedTokenCount < sourceTokenCount . Value )
result [^ 1 ] += sourceTokenCount . Value - allocatedTokenCount ;
return result ;
2026-07-28 16:37:07 +02:00
}
2026-08-04 15:08:47 +02:00
private async Task < string > CreateOverlapPrefixAsync ( string chunk , ChunkingStrategy strategy , ChunkingRule rule , ChunkingOptions options , EmbeddingProvider embeddingProvider , CancellationToken token )
{
return await this . CreateOverlapPrefixAsync ( chunk , strategy . Name , rule . Name , options , embeddingProvider , token );
}
private async Task < string > CreateOverlapPrefixAsync ( string chunk , string strategyName , string ruleName , ChunkingOptions options , EmbeddingProvider embeddingProvider , CancellationToken token )
{
if ( options . OverlapTokenLength <= 0 )
return string . Empty ;
chunk = chunk . Trim ();
if ( string . IsNullOrWhiteSpace ( chunk ))
return string . Empty ;
var chunkTokenCount = await this . GetEmbeddingTokenCountAsync ( embeddingProvider , chunk , token );
if ( chunkTokenCount <= options . OverlapTokenLength )
{
logger . LogDebug (
"Applied whole-chunk overlap while chunking because the previous chunk is smaller than the requested overlap. Strategy='{ChunkingStrategy}', Rule='{ChunkingRule}', RequestedOverlapTokenLength={RequestedOverlapTokenLength}, ActualOverlapTokenCount={ActualOverlapTokenCount}." ,
strategyName ,
ruleName ,
options . OverlapTokenLength ,
chunkTokenCount );
return chunk ;
}
var overlapStartIndex = await this . CalculateHardCutOverlapStartIndexAsync ( chunk , 0 , chunk . Length , options , embeddingProvider , token );
if ( overlapStartIndex >= chunk . Length )
overlapStartIndex = FindLastNonWhitespaceStartIndex ( chunk );
if ( overlapStartIndex >= chunk . Length )
return string . Empty ;
var overlapPrefix = chunk [ overlapStartIndex ..]. Trim ();
if ( string . IsNullOrWhiteSpace ( overlapPrefix ))
return string . Empty ;
var tokenCount = await this . GetEmbeddingTokenCountAsync ( embeddingProvider , overlapPrefix , token );
logger . LogDebug (
"Applied hard-cut overlap while chunking because delimiter overlap was not available. Strategy='{ChunkingStrategy}', Rule='{ChunkingRule}', RequestedOverlapTokenLength={RequestedOverlapTokenLength}, ActualOverlapTokenCount={ActualOverlapTokenCount}." ,
strategyName ,
ruleName ,
options . OverlapTokenLength ,
tokenCount );
return overlapPrefix ;
}
2026-07-29 18:47:59 +02:00
private async Task < int > CalculateNextStartIndexAsync ( IReadOnlyList < string > units , int chunkStartIndex , int chunkEndIndex , ChunkingOptions options , EmbeddingProvider embeddingProvider , CancellationToken token )
{
if ( options . OverlapTokenLength <= 0 )
return chunkEndIndex ;
var bestStartIndex = chunkEndIndex ;
var bestDistance = int . MaxValue ;
for ( var candidateStartIndex = chunkEndIndex - 1 ; candidateStartIndex > chunkStartIndex ; candidateStartIndex --)
{
token . ThrowIfCancellationRequested ();
var candidate = string . Concat ( units . Skip ( candidateStartIndex ). Take ( chunkEndIndex - candidateStartIndex )). Trim ();
if ( string . IsNullOrWhiteSpace ( candidate ))
continue ;
var tokenCount = await this . GetEmbeddingTokenCountAsync ( embeddingProvider , candidate , token );
var distance = Math . Abs ( tokenCount - options . OverlapTokenLength );
if ( distance < bestDistance )
{
bestStartIndex = candidateStartIndex ;
bestDistance = distance ;
}
if ( tokenCount >= options . OverlapTokenLength && bestStartIndex < chunkEndIndex )
break ;
}
return bestStartIndex <= chunkStartIndex ? chunkEndIndex : bestStartIndex ;
}
2026-08-04 15:08:47 +02:00
private async IAsyncEnumerable < string > SplitTextByHardCutAsync (
string text ,
ChunkingOptions options ,
EmbeddingProvider embeddingProvider ,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token ,
2026-08-10 18:21:31 +02:00
string requiredOverlapPrefix = "" ,
int? estimatedTokenCount = null )
2026-07-28 16:37:07 +02:00
{
2026-08-04 15:08:47 +02:00
text = text . Trim ();
2026-07-28 16:37:07 +02:00
var startIndex = 0 ;
2026-08-04 15:08:47 +02:00
var overlapPrefix = requiredOverlapPrefix ;
2026-07-28 16:37:07 +02:00
while ( startIndex < text . Length )
{
token . ThrowIfCancellationRequested ();
2026-08-04 15:08:47 +02:00
while ( startIndex < text . Length && char . IsWhiteSpace ( text [ startIndex ]))
startIndex ++;
if ( startIndex >= text . Length )
yield break ;
2026-07-28 16:37:07 +02:00
var bestEndIndex = startIndex ;
2026-08-10 19:42:39 +02:00
var maximumCandidateEndIndex = Math . Min ( text . Length , startIndex + RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH );
2026-08-10 18:21:31 +02:00
if ( estimatedTokenCount > options . MaxChunkTokenLength )
{
var estimatedChunkLength = Math . Max ( 1L , ( long ) text . Length * options . MaxChunkTokenLength / estimatedTokenCount . Value );
maximumCandidateEndIndex = ( int ) Math . Min ( text . Length , startIndex + estimatedChunkLength );
}
2026-07-28 16:37:07 +02:00
2026-08-10 18:21:31 +02:00
while ( true )
2026-07-28 16:37:07 +02:00
{
2026-08-10 18:21:31 +02:00
var minimumCandidateEndIndex = bestEndIndex + 1 ;
var currentMaximumCandidateEndIndex = maximumCandidateEndIndex ;
while ( minimumCandidateEndIndex <= currentMaximumCandidateEndIndex )
2026-07-28 16:37:07 +02:00
{
2026-08-10 18:21:31 +02:00
var candidateEndIndex = minimumCandidateEndIndex + ( currentMaximumCandidateEndIndex - minimumCandidateEndIndex ) / 2 ;
var candidate = AddOverlapPrefix ( text [ startIndex .. candidateEndIndex ]. Trim (), overlapPrefix );
2026-08-10 19:42:39 +02:00
var candidateFits = candidate . Length <= RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH &&
2026-08-10 18:21:31 +02:00
await this . GetEmbeddingTokenCountAsync ( embeddingProvider , candidate , token ) <= options . MaxChunkTokenLength ;
if ( candidateFits )
{
bestEndIndex = candidateEndIndex ;
minimumCandidateEndIndex = candidateEndIndex + 1 ;
}
else
currentMaximumCandidateEndIndex = candidateEndIndex - 1 ;
2026-07-28 16:37:07 +02:00
}
2026-08-10 18:21:31 +02:00
if ( bestEndIndex < maximumCandidateEndIndex || bestEndIndex >= text . Length ||
2026-08-10 19:42:39 +02:00
maximumCandidateEndIndex - startIndex >= RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH )
2026-08-10 18:21:31 +02:00
break ;
var previousCandidateLength = maximumCandidateEndIndex - startIndex ;
maximumCandidateEndIndex = ( int ) Math . Min (
2026-08-10 19:42:39 +02:00
Math . Min ( text . Length , startIndex + ( long ) RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH ),
2026-08-10 18:21:31 +02:00
startIndex + Math . Max ( previousCandidateLength + 1L , previousCandidateLength * 2L ));
2026-07-28 16:37:07 +02:00
}
if ( bestEndIndex == startIndex )
{
2026-08-04 15:08:47 +02:00
if (! string . IsNullOrWhiteSpace ( overlapPrefix ))
{
var smallestOverlapPrefix = GetSmallestOverlapPrefix ( overlapPrefix );
if (! string . IsNullOrWhiteSpace ( smallestOverlapPrefix ) && ! string . Equals ( smallestOverlapPrefix , overlapPrefix , StringComparison . Ordinal ))
{
logger . LogDebug (
"Reduced hard-cut overlap because the configured overlap leaves no room for new content. RequestedOverlapTokenLength={RequestedOverlapTokenLength}, MaxChunkTokenLength={MaxChunkTokenLength}." ,
options . OverlapTokenLength ,
options . MaxChunkTokenLength );
overlapPrefix = smallestOverlapPrefix ;
continue ;
}
}
var smallestCandidate = AddOverlapPrefix ( text [ startIndex .. Math . Min ( startIndex + 1 , text . Length )]. Trim (), overlapPrefix );
2026-07-28 16:37:07 +02:00
var smallestCandidateTokenCount = await this . GetEmbeddingTokenCountAsync ( embeddingProvider , smallestCandidate , token );
2026-09-06 15:54:53 +02:00
throw new InvalidOperationException ( string . Format ( TB ( "The chunk size configured for the embedding provider '{0}' is too small: the smallest piece the text can be cut into still has {1} tokens, while the limit is {2}." ), embeddingProvider . Name , smallestCandidateTokenCount , options . MaxChunkTokenLength ));
2026-07-28 16:37:07 +02:00
}
2026-08-04 15:08:47 +02:00
var chunk = AddOverlapPrefix ( text [ startIndex .. bestEndIndex ]. Trim (), overlapPrefix );
2026-07-28 16:37:07 +02:00
if (! string . IsNullOrWhiteSpace ( chunk ))
yield return chunk ;
2026-07-29 18:47:59 +02:00
if ( bestEndIndex >= text . Length )
yield break ;
2026-08-04 15:08:47 +02:00
overlapPrefix = await this . CreateOverlapPrefixAsync ( chunk , "hard-cut" , "Hard cut" , options , embeddingProvider , token );
startIndex = bestEndIndex ;
2026-07-28 16:37:07 +02:00
}
}
2026-07-29 18:47:59 +02:00
private async Task < int > CalculateHardCutOverlapStartIndexAsync ( string text , int chunkStartIndex , int chunkEndIndex , ChunkingOptions options , EmbeddingProvider embeddingProvider , CancellationToken token )
{
if ( options . OverlapTokenLength <= 0 || chunkEndIndex - chunkStartIndex <= 1 )
return chunkEndIndex ;
var low = chunkStartIndex + 1 ;
var high = chunkEndIndex - 1 ;
var bestStartIndex = chunkEndIndex ;
while ( low <= high )
{
token . ThrowIfCancellationRequested ();
var mid = low + ( high - low ) / 2 ;
var candidate = text [ mid .. chunkEndIndex ]. Trim ();
var tokenCount = await this . GetEmbeddingTokenCountAsync ( embeddingProvider , candidate , token );
if ( tokenCount <= options . OverlapTokenLength )
{
bestStartIndex = mid ;
high = mid - 1 ;
}
else
low = mid + 1 ;
}
return bestStartIndex <= chunkStartIndex ? chunkEndIndex : bestStartIndex ;
}
2026-08-04 15:08:47 +02:00
private static int FindLastNonWhitespaceStartIndex ( string text )
{
for ( var index = text . Length - 1 ; index >= 0 ; index --)
{
if (! char . IsWhiteSpace ( text [ index ]))
return index ;
}
return text . Length ;
}
private static string GetSmallestOverlapPrefix ( string text )
{
var index = FindLastNonWhitespaceStartIndex ( text );
return index >= text . Length ? string . Empty : text [ index ..]. Trim ();
}
private static string AddOverlapPrefix ( string chunk , string overlapPrefix )
{
if ( string . IsNullOrWhiteSpace ( overlapPrefix ))
return chunk . Trim ();
return $"{overlapPrefix.TrimEnd()}\n{chunk.TrimStart()}" . Trim ();
}
2026-07-28 16:37:07 +02:00
private async Task < int > GetEmbeddingTokenCountAsync ( EmbeddingProvider embeddingProvider , string text , CancellationToken token )
{
2026-08-10 21:36:01 +02:00
var response = await rustService . GetTokenCount ( embeddingProvider , text , token );
if ( response is { Success : true })
2026-07-28 16:37:07 +02:00
return response . Value . TokenCount ;
var message = response ?. Message ?? "No response was returned by the tokenizer service." ;
2026-09-06 15:54:53 +02:00
throw new InvalidOperationException ( string . Format ( TB ( "The tokens of the text could not be counted for the embedding provider '{0}'. {1}" ), embeddingProvider . Name , message ));
2026-07-28 16:37:07 +02:00
}
2026-07-29 18:47:59 +02:00
private ChunkingOptions GetChunkingOptions ( IDataSource dataSource , EmbeddingProvider embeddingProvider )
{
var providerMaxChunkTokenLength = Math . Max ( 1 , embeddingProvider . EffectiveTokenLimit );
var dataSourceMaxChunkTokenLength = dataSource is IInternalDataSource { MaxChunkTokenLength : > 0 } internalDataSource
? internalDataSource . MaxChunkTokenLength
: 0 ;
var maxChunkTokenLength = dataSourceMaxChunkTokenLength > 0
? Math . Min ( dataSourceMaxChunkTokenLength , providerMaxChunkTokenLength )
: providerMaxChunkTokenLength ;
var configuredOverlapTokenLength = dataSource is IInternalDataSource overlapDataSource
? overlapDataSource . ChunkOverlapTokenLength
2026-08-04 15:08:47 +02:00
: DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH ;
2026-08-14 16:02:40 +02:00
var overlapTokenLength = Math . Clamp ( configuredOverlapTokenLength , 0 , Math . Max ( 0 , maxChunkTokenLength - 1 ));
2026-07-29 18:47:59 +02:00
return new ( maxChunkTokenLength , overlapTokenLength );
}
private ChunkingStrategy GetChunkingStrategy ( string filePath )
{
if ( this . IsPresentationFilePath ( filePath ))
return new ( "presentation" , [
2026-08-10 18:21:31 +02:00
new ( "Slide" , SplitBySourceSegments , true ),
2026-07-29 18:47:59 +02:00
new ( "Line break" , SplitByLineBreaks ),
new ( "Whitespace" , SplitByWhitespace ),
new ( "Hard cut" , null ),
]);
if ( this . IsDelimitedTableFilePath ( filePath ) || this . IsSpreadsheetFilePath ( filePath ))
return new ( "table" , [
2026-08-10 18:21:31 +02:00
new ( "Row or sheet" , SplitBySourceSegments , true ),
2026-07-29 18:47:59 +02:00
new ( "Line break" , SplitByLineBreaks ),
new ( "Whitespace" , SplitByWhitespace ),
new ( "Hard cut" , null ),
]);
if ( this . IsSourceCodeFilePath ( filePath ))
2026-08-12 14:19:32 +02:00
return GetSourceCodeChunkingStrategy ();
2026-07-29 18:47:59 +02:00
return new ( "document" , [
2026-08-10 18:21:31 +02:00
new ( "Page or extracted section" , SplitBySourceSegments , true ),
2026-07-29 18:47:59 +02:00
new ( "Heading" , SplitByDocumentHeadings ),
new ( "Paragraph" , SplitByParagraphs ),
new ( "Line break" , SplitByLineBreaks ),
new ( "Whitespace" , SplitByWhitespace ),
new ( "Hard cut" , null ),
]);
}
2026-08-12 14:19:32 +02:00
private static ChunkingStrategy GetSourceCodeChunkingStrategy () =>
new ( "source-code" , [
2026-08-10 18:21:31 +02:00
new ( "Extracted section" , SplitBySourceSegments , true ),
2026-08-12 14:19:32 +02:00
new ( "Line break" , SplitByLineBreaks ),
new ( "Whitespace" , SplitByWhitespace ),
new ( "Hard cut" , null ),
]);
2026-07-29 18:47:59 +02:00
private static List < string > NormalizeSplitUnits ( IReadOnlyList < string > units , string fallbackText )
{
var result = units
. Where ( unit => ! string . IsNullOrWhiteSpace ( unit ))
. ToList ();
return result . Count == 0 ? [ fallbackText ] : result ;
}
private static IReadOnlyList < string > SplitBySourceSegments ( string text , IReadOnlyList < string > sourceSegments )
{
return sourceSegments . Count > 1
? sourceSegments . Select ( segment => segment + "\n" ). ToList ()
: [ text ];
}
private static IReadOnlyList < string > SplitByDocumentHeadings ( string text , IReadOnlyList < string > sourceSegments )
{
var lines = ReadLines ( text );
if ( lines . Count < 2 )
return [ text ];
var result = new List < string >();
var segmentStart = 0 ;
for ( var i = 0 ; i < lines . Count ; i ++)
{
var ( lineStart , _ , lineText ) = lines [ i ];
if ( lineStart == 0 )
continue ;
var previousLine = i > 0 ? lines [ i - 1 ]. Text : string . Empty ;
var nextLine = i + 1 < lines . Count ? lines [ i + 1 ]. Text : string . Empty ;
if (! IsDocumentHeadingLine ( lineText , previousLine , nextLine ))
continue ;
result . Add ( text [ segmentStart .. lineStart ]);
segmentStart = lineStart ;
}
if ( segmentStart == 0 )
return [ text ];
result . Add ( text [ segmentStart ..]);
return result ;
}
private static IReadOnlyList < string > SplitByParagraphs ( string text , IReadOnlyList < string > sourceSegments )
{
var matches = Regex . Matches ( text , @"\n[ \t]*\n" , RegexOptions . CultureInvariant );
if ( matches . Count == 0 )
return [ text ];
var result = new List < string >();
var start = 0 ;
foreach ( Match match in matches )
{
var end = match . Index + match . Length ;
result . Add ( text [ start .. end ]);
start = end ;
}
if ( start < text . Length )
result . Add ( text [ start ..]);
return result ;
}
private static IReadOnlyList < string > SplitByLineBreaks ( string text , IReadOnlyList < string > sourceSegments )
{
var result = new List < string >();
var start = 0 ;
for ( var i = 0 ; i < text . Length ; i ++)
{
if ( text [ i ] != '\n' )
continue ;
result . Add ( text [ start ..( i + 1 )]);
start = i + 1 ;
}
if ( start < text . Length )
result . Add ( text [ start ..]);
return result . Count == 0 ? [ text ] : result ;
}
private static IReadOnlyList < string > SplitByWhitespace ( string text , IReadOnlyList < string > sourceSegments )
2026-07-28 16:37:07 +02:00
{
var matches = Regex . Matches ( text , @"\S+\s*" , RegexOptions . CultureInvariant );
if ( matches . Count == 0 )
return [ text ];
2026-09-06 14:20:53 +02:00
return matches . Select ( match => match . Value ). ToList ();
2026-05-13 18:13:34 +02:00
}
2026-07-29 18:47:59 +02:00
private static List <( int Start , int End , string Text )> ReadLines ( string text )
{
var result = new List <( int Start , int End , string Text )>();
var start = 0 ;
for ( var i = 0 ; i < text . Length ; i ++)
{
if ( text [ i ] != '\n' )
continue ;
result . Add (( start , i + 1 , text [ start ..( i + 1 )]));
start = i + 1 ;
}
if ( start < text . Length )
result . Add (( start , text . Length , text [ start ..]));
return result ;
}
private static bool IsDocumentHeadingLine ( string line , string previousLine , string nextLine )
{
var trimmed = line . Trim ();
if ( string . IsNullOrWhiteSpace ( trimmed ))
return false ;
if ( Regex . IsMatch ( trimmed , @"^#{1,6}\s+\S" , RegexOptions . CultureInvariant ))
return true ;
if (! string . IsNullOrWhiteSpace ( previousLine ) || ! string . IsNullOrWhiteSpace ( nextLine ))
return false ;
if ( trimmed . Length is < 3 or > 120 )
return false ;
if ( trimmed . Contains ( "|" , StringComparison . Ordinal ) || trimmed . EndsWith ( "." , StringComparison . Ordinal ))
return false ;
return Regex . IsMatch ( trimmed , @"^(\d+(\.\d+)*\.?\s+\S|(?i:chapter|section)\s+\S|[A-Z0-9][A-Z0-9 ,:;'/&()_-]{2,})$" , RegexOptions . CultureInvariant );
}
2026-05-13 18:13:34 +02:00
private FileEnumerationResult GetInputFiles ( IDataSource dataSource )
{
var result = new FileEnumerationResult ();
switch ( dataSource )
{
case DataSourceLocalFile localFile when File . Exists ( localFile . FilePath ):
2026-07-28 15:25:10 +02:00
var file = new FileInfo ( localFile . FilePath );
switch ( this . GetRagFileIndexingDecision ( file ))
2026-05-13 18:13:34 +02:00
{
2026-07-28 15:25:10 +02:00
case RagFileIndexingDecision . INDEXABLE :
result . Files . Add ( file );
break ;
case RagFileIndexingDecision . EXCLUDED :
logger . LogDebug ( "Skipping excluded file '{FilePath}' while indexing." , file . FullName );
break ;
default :
2026-09-06 15:54:53 +02:00
result . AddFailure ( localFile . FilePath , string . Format ( TB ( "The file '{0}' has a type AI Studio cannot index." ), localFile . FilePath ));
2026-07-28 15:25:10 +02:00
break ;
2026-05-13 18:13:34 +02:00
}
return result ;
case DataSourceLocalDirectory localDirectory when Directory . Exists ( localDirectory . Path ):
this . EnumerateAccessibleFiles ( localDirectory . Path , result );
return result ;
}
switch ( dataSource )
{
case DataSourceLocalFile localFile :
2026-09-06 15:54:53 +02:00
result . AddFailure ( localFile . FilePath , string . Format ( TB ( "The file '{0}' does not exist." ), localFile . FilePath ));
2026-05-13 18:13:34 +02:00
break ;
case DataSourceLocalDirectory localDirectory :
2026-09-06 15:54:53 +02:00
result . AddFailure ( localDirectory . Path , string . Format ( TB ( "The folder '{0}' does not exist." ), localDirectory . Path ));
2026-05-13 18:13:34 +02:00
break ;
}
return result ;
}
private void EnumerateAccessibleFiles ( string rootPath , FileEnumerationResult result )
{
var pendingDirectories = new Stack < string >();
pendingDirectories . Push ( rootPath );
while ( pendingDirectories . Count > 0 )
{
var currentPath = pendingDirectories . Pop ();
IEnumerable < string > subDirectories ;
IEnumerable < string > files ;
try
{
subDirectories = Directory . EnumerateDirectories ( currentPath );
files = Directory . EnumerateFiles ( currentPath );
}
catch ( Exception exception )
{
2026-05-27 20:02:43 +02:00
logger . LogWarning ( exception , "Cannot access directory '{DirectoryPath}' while indexing." , currentPath );
2026-09-06 15:54:53 +02:00
result . AddFailure ( currentPath , string . Format ( TB ( "The folder '{0}' could not be opened. Please check whether you are allowed to read it." ), currentPath ));
2026-05-13 18:13:34 +02:00
continue ;
}
foreach ( var filePath in files )
{
FileInfo fileInfo ;
try
{
fileInfo = new FileInfo ( filePath );
if (! fileInfo . Exists )
continue ;
}
catch ( Exception exception )
{
2026-05-27 20:02:43 +02:00
logger . LogWarning ( exception , "Cannot inspect file '{FilePath}' while indexing." , filePath );
2026-09-06 15:54:53 +02:00
result . AddFailure ( filePath , string . Format ( TB ( "The file '{0}' could not be read. Please check whether you are allowed to read it." ), filePath ));
2026-05-13 18:13:34 +02:00
continue ;
}
2026-07-28 15:25:10 +02:00
switch ( this . GetRagFileIndexingDecision ( fileInfo ))
{
case RagFileIndexingDecision . INDEXABLE :
result . Files . Add ( fileInfo );
break ;
2026-05-13 18:13:34 +02:00
2026-07-28 15:25:10 +02:00
case RagFileIndexingDecision . EXCLUDED :
logger . LogDebug ( "Skipping excluded file '{FilePath}' while indexing." , fileInfo . FullName );
break ;
}
2026-05-13 18:13:34 +02:00
}
foreach ( var subDirectory in subDirectories )
2026-07-28 15:25:10 +02:00
{
if ( this . IsSkippedRagDirectory ( subDirectory ))
continue ;
2026-05-13 18:13:34 +02:00
pendingDirectories . Push ( subDirectory );
2026-07-28 15:25:10 +02:00
}
2026-05-13 18:13:34 +02:00
}
}
private string TryGetRelativePath ( IDataSource dataSource , FileInfo file ) => dataSource switch
{
DataSourceLocalDirectory localDirectory => Path . GetRelativePath ( localDirectory . Path , file . FullName ),
_ => file . Name
};
private static string NormalizeChunkSegment ( string input )
{
return input
. Replace ( "\r\n" , "\n" , StringComparison . Ordinal )
. Replace ( '\r' , '\n' )
. Trim ();
}
private bool IsImageFilePath ( string filePath )
{
return FileTypes . IsAllowedPath ( filePath , FileTypes . IMAGE );
}
2026-07-29 18:47:59 +02:00
private bool IsPresentationFilePath ( string filePath )
{
return FileTypes . IsAllowedPath ( filePath , FileTypes . POWER_POINT );
}
private bool IsDelimitedTableFilePath ( string filePath )
{
2026-09-05 21:17:42 +02:00
return FileTypes . IsAllowedPath ( filePath , FileTypes . TABULAR );
2026-07-29 18:47:59 +02:00
}
private bool IsSpreadsheetFilePath ( string filePath )
{
2026-08-12 14:19:32 +02:00
return FileTypes . IsAllowedPath ( filePath , FileTypes . SPREADSHEET );
2026-07-29 18:47:59 +02:00
}
private bool IsSourceCodeFilePath ( string filePath )
{
return ! this . IsHtmlFilePath ( filePath ) && FileTypes . IsAllowedPath ( filePath , FileTypes . SOURCE_CODE );
}
private bool IsHtmlFilePath ( string filePath )
{
2026-08-12 14:19:32 +02:00
return FileTypes . IsAllowedPath ( filePath , FileTypes . HTML );
2026-07-29 18:47:59 +02:00
}
2026-05-13 18:13:34 +02:00
private bool IsSupportedRagFilePath ( string filePath )
{
2026-08-12 14:19:32 +02:00
return FileTypes . IsAllowedPath ( filePath , FileTypes . DOCUMENT );
2026-07-28 15:25:10 +02:00
}
private RagFileIndexingDecision GetRagFileIndexingDecision ( FileInfo file )
{
if ( this . IsSkippedRagFile ( file ))
return RagFileIndexingDecision . EXCLUDED ;
2026-08-12 11:43:47 +02:00
if (! IMAGE_EMBEDDING_ENABLED && this . IsImageFilePath ( file . FullName ))
return RagFileIndexingDecision . EXCLUDED ;
2026-07-28 15:25:10 +02:00
return this . IsSupportedRagFilePath ( file . FullName )
? RagFileIndexingDecision . INDEXABLE
: RagFileIndexingDecision . UNSUPPORTED ;
}
private bool IsSkippedRagFile ( FileInfo file )
{
2026-07-28 15:52:59 +02:00
if ( IsSkippedRagFileName ( file . Name ))
2026-07-28 15:25:10 +02:00
return true ;
try
{
return file . Attributes . HasFlag ( FileAttributes . ReparsePoint )
|| file . Attributes . HasFlag ( FileAttributes . Offline )
|| file . Attributes . HasFlag ( FileAttributes . Temporary )
|| file . Attributes . HasFlag ( FileAttributes . System );
}
catch ( Exception exception )
{
logger . LogWarning ( exception , "Cannot inspect file '{FilePath}' while indexing." , file . FullName );
return true ;
}
}
2026-07-28 15:52:59 +02:00
private static bool IsSkippedRagFileName ( string fileName )
{
2026-08-12 14:19:32 +02:00
return FileTypes . IsAllowedPath ( fileName , FileTypes . SHORTCUT )
2026-07-28 15:52:59 +02:00
|| fileName . StartsWith ( OFFICE_LOCK_FILE_PREFIX , StringComparison . Ordinal );
}
2026-07-28 15:25:10 +02:00
private bool IsSkippedRagDirectory ( string path )
{
try
{
var directory = new DirectoryInfo ( path );
return directory . Attributes . HasFlag ( FileAttributes . ReparsePoint )
|| directory . Attributes . HasFlag ( FileAttributes . Offline )
|| directory . Attributes . HasFlag ( FileAttributes . System );
}
catch ( Exception exception )
{
logger . LogWarning ( exception , "Cannot inspect directory '{DirectoryPath}' while indexing." , path );
return true ;
}
2026-05-13 18:13:34 +02:00
}
2026-07-29 18:47:59 +02:00
private string BuildEmbeddingSignature ( IDataSource dataSource , EmbeddingProvider embeddingProvider , ChunkingOptions chunkingOptions )
2026-05-13 18:13:34 +02:00
{
return string . Join ( '|' ,
embeddingProvider . Id ,
embeddingProvider . UsedLLMProvider ,
embeddingProvider . Model . Id ,
embeddingProvider . Host ,
embeddingProvider . Hostname ,
2026-07-28 16:37:07 +02:00
embeddingProvider . TokenizerPath ,
2026-07-28 17:36:06 +02:00
embeddingProvider . EffectiveTokenLimit ,
2026-08-14 12:03:16 +02:00
GetDataSourceConfidenceLevel ( dataSource ). ToString (),
2026-07-29 18:47:59 +02:00
dataSource is IInternalDataSource internalDataSource ? internalDataSource . MaxChunkTokenLength : 0 ,
2026-08-14 16:02:40 +02:00
dataSource is IInternalDataSource overlapDataSource ? overlapDataSource . ChunkOverlapTokenLength : DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH ,
2026-07-29 18:47:59 +02:00
chunkingOptions . MaxChunkTokenLength ,
chunkingOptions . OverlapTokenLength );
2026-05-13 18:13:34 +02:00
}
2026-08-03 17:53:31 +02:00
private DataSourceMetadataSnapshot BuildDataSourceMetadataSnapshot ( IDataSource dataSource , IReadOnlyList < FileInfo > indexedFiles )
{
var fileHashes = indexedFiles
. OrderBy ( file => file . FullName , StringComparer . OrdinalIgnoreCase )
. ToDictionary ( file => file . FullName , BuildFileMetadataHash , StringComparer . OrdinalIgnoreCase );
var sourceHash = dataSource switch
{
DataSourceLocalFile localFile => indexedFiles . Count > 0
? fileHashes [ indexedFiles [ 0 ]. FullName ]
2026-09-06 13:26:56 +02:00
: BuildMetadataHash ( "file" , localFile . FilePath , Path . GetFileName ( localFile . FilePath ), "missing" , "0" ),
2026-08-03 17:53:31 +02:00
DataSourceLocalDirectory localDirectory => this . BuildDirectoryMetadataHash ( localDirectory , indexedFiles , fileHashes ),
_ => BuildMetadataHash ( dataSource . Type . ToString (), dataSource . Id , dataSource . Name )
};
return new ( sourceHash , fileHashes );
}
private string BuildDirectoryMetadataHash ( DataSourceLocalDirectory dataSource , IReadOnlyList < FileInfo > indexedFiles , IReadOnlyDictionary < string , string > fileHashes )
{
var directory = new DirectoryInfo ( dataSource . Path );
directory . Refresh ();
var totalSize = 0L ;
var latestFileWriteTicks = 0L ;
foreach ( var file in indexedFiles )
{
file . Refresh ();
if (! file . Exists )
continue ;
totalSize += file . Length ;
latestFileWriteTicks = Math . Max ( latestFileWriteTicks , file . LastWriteTimeUtc . Ticks );
}
var latestWriteTicks = Math . Max ( directory . LastWriteTimeUtc . Ticks , latestFileWriteTicks );
var parts = new List < string >
{
"directory" ,
directory . FullName ,
directory . Name ,
latestWriteTicks . ToString (),
totalSize . ToString (),
indexedFiles . Count . ToString ()
};
foreach ( var file in indexedFiles . OrderBy ( file => file . FullName , StringComparer . OrdinalIgnoreCase ))
{
parts . Add ( this . TryGetRelativePath ( dataSource , file ));
parts . Add ( fileHashes [ file . FullName ]);
}
return BuildMetadataHash ( parts );
}
private static string BuildFileMetadataHash ( FileInfo file )
2026-05-13 18:13:34 +02:00
{
2026-08-03 17:53:31 +02:00
file . Refresh ();
if (! file . Exists )
{
return BuildMetadataHash (
"file" ,
file . FullName ,
file . Name ,
"missing" ,
"0" );
}
return BuildMetadataHash (
"file" ,
2026-07-28 15:25:10 +02:00
file . FullName ,
2026-08-03 17:53:31 +02:00
file . Name ,
file . LastWriteTimeUtc . Ticks . ToString (),
file . Length . ToString ());
}
private static string BuildMetadataHash ( params string [] parts )
{
return BuildMetadataHash (( IEnumerable < string >) parts );
}
private static string BuildMetadataHash ( IEnumerable < string > parts )
{
var fingerprintSource = new StringBuilder ();
foreach ( var part in parts )
fingerprintSource . Append ( part . Length ). Append ( ':' ). Append ( part ). Append ( '|' );
var bytes = SHA256 . HashData ( Encoding . UTF8 . GetBytes ( fingerprintSource . ToString ()));
2026-05-13 18:13:34 +02:00
return Convert . ToHexString ( bytes );
}
2026-08-10 20:06:51 +02:00
private EmbeddingStateFile CreateEmbeddingStateFile ( IDataSource dataSource , FileInfo file , string fingerprint , int chunkCount , DateTimeOffset embeddedAtUtc )
2026-08-03 19:45:47 +02:00
{
file . Refresh ();
var absolutePath = Path . GetFullPath ( file . FullName );
2026-08-14 12:03:16 +02:00
var confidenceLevel = GetDataSourceConfidenceLevel ( dataSource );
2026-08-03 19:45:47 +02:00
return new (
this . CreateParentFileId ( dataSource . Id , absolutePath ),
absolutePath ,
file . Name ,
this . TryGetRelativePath ( dataSource , file ),
GetFileType ( file ),
fingerprint ,
file . Exists ? file . Length : 0 ,
2026-08-10 20:06:51 +02:00
file . Exists ? new DateTimeOffset ( file . CreationTimeUtc ) : DateTimeOffset . UnixEpoch ,
file . Exists ? new DateTimeOffset ( file . LastWriteTimeUtc ) : DateTimeOffset . UnixEpoch ,
2026-08-03 19:45:47 +02:00
embeddedAtUtc ,
chunkCount ,
2026-08-14 12:03:16 +02:00
confidenceLevel . ToString (),
( int ) confidenceLevel );
2026-08-03 19:45:47 +02:00
}
2026-08-10 20:06:51 +02:00
private IReadOnlyList < EmbeddingStateChunk > CreateEmbeddingStateChunks ( EmbeddingStateFile parentFile , IReadOnlyList < EmbeddingChunkDraft > batch , DateTimeOffset embeddedAtUtc )
2026-08-03 19:45:47 +02:00
{
return batch
. Select ( chunk => new EmbeddingStateChunk (
chunk . ChunkId ,
parentFile . ParentFileId ,
chunk . PageNumber ,
chunk . ChunkIndex ,
chunk . Text ,
embeddedAtUtc ))
. ToList ();
}
2026-08-14 12:03:16 +02:00
private static ConfidenceLevel GetDataSourceConfidenceLevel ( IDataSource dataSource ) =>
dataSource is not IInternalDataSource internalDataSource || internalDataSource . ConfidenceLevel is ConfidenceLevel . NONE
2026-08-03 19:45:47 +02:00
? ConfidenceLevel . UNKNOWN
2026-08-14 12:03:16 +02:00
: internalDataSource . ConfidenceLevel ;
2026-08-03 19:45:47 +02:00
private static string GetFileType ( FileInfo file )
{
var extension = file . Extension . TrimStart ( '.' ). ToLowerInvariant ();
return string . IsNullOrWhiteSpace ( extension ) ? "unknown" : extension ;
}
private static int? TryExtractPageNumber ( string chunk )
{
var match = Regex . Match ( chunk , @"^\s*#\s+Page\s+(\d+)\b" , RegexOptions . CultureInvariant | RegexOptions . IgnoreCase );
return match . Success && int . TryParse ( match . Groups [ 1 ]. Value , out var pageNumber ) && pageNumber > 0
? pageNumber
: null ;
}
private string CreatePointId ( string dataSourceId , string fingerprint , int chunkIndex ) =>
CreateStableGuid ( $"{dataSourceId}:chunk:{fingerprint}:{chunkIndex}" );
private string CreateParentFileId ( string dataSourceId , string absolutePath ) =>
CreateStableGuid ( $"{dataSourceId}:parent-file:{absolutePath}" );
private static string CreateStableGuid ( string source )
2026-05-13 18:13:34 +02:00
{
var hash = SHA256 . HashData ( Encoding . UTF8 . GetBytes ( source ));
var guidBytes = hash [.. 16 ]. ToArray ();
guidBytes [ 6 ] = ( byte )(( guidBytes [ 6 ] & 0x0F ) | 0x40 );
guidBytes [ 8 ] = ( byte )(( guidBytes [ 8 ] & 0x3F ) | 0x80 );
return new Guid ( guidBytes ). ToString ();
}
}