2026-05-13 16:13:34 +00:00
using System.Security.Cryptography ;
using System.Text ;
2026-07-28 14:37:07 +00:00
using System.Text.RegularExpressions ;
2026-05-13 16:13:34 +00:00
2026-08-03 17:45:47 +00:00
using AIStudio.Provider ;
2026-05-13 16:13:34 +00:00
using AIStudio.Settings ;
using AIStudio.Settings.DataModel ;
2026-08-03 17:45:47 +00:00
using AIStudio.Tools.Databases.EmbeddingState ;
2026-05-13 16:13:34 +00:00
using AIStudio.Tools.PluginSystem ;
using AIStudio.Tools.Rust ;
namespace AIStudio.Tools.Services ;
public sealed partial class DataSourceEmbeddingService
{
2026-07-28 13:25:10 +00:00
private const string OFFICE_LOCK_FILE_PREFIX = "~$" ;
2026-08-04 13:08:47 +00:00
private const int DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH = 300 ;
2026-07-28 13:25:10 +00:00
private static readonly string [ ] RAG_DELIMITED_TABLE_FILE_EXTENSIONS = [ "csv" , "tsv" ] ;
private static readonly string [ ] RAG_SPREADSHEET_FILE_EXTENSIONS = [ "ods" , "xlsm" , "xlsb" ] ;
private static readonly string [ ] RAG_SPREADSHEET_ADD_IN_FILE_EXTENSIONS = [ "xla" , "xlam" ] ;
2026-05-13 16:13:34 +00:00
private static readonly string [ ] SKIPPED_RAG_FILE_EXTENSIONS = [ "lnk" ] ;
2026-07-28 13:25:10 +00:00
private enum RagFileIndexingDecision
{
INDEXABLE ,
EXCLUDED ,
UNSUPPORTED ,
}
2026-08-10 16:21:31 +00:00
private sealed record ExtractedFileSegment ( string Text , int? TokenCount ) ;
private sealed record ExtractedFileContent ( string Text , IReadOnlyList < ExtractedFileSegment > SourceSegments ) ;
2026-07-29 16:47:59 +00:00
2026-08-03 17:45:47 +00:00
private sealed record EmbeddingChunkDraft ( string ChunkId , string Text , int ChunkIndex , int? PageNumber ) ;
2026-07-29 16:47:59 +00:00
private sealed record ChunkingOptions ( int MaxChunkTokenLength , int OverlapTokenLength ) ;
private sealed record ChunkingStrategy ( string Name , IReadOnlyList < ChunkingRule > Rules ) ;
2026-08-10 16:21:31 +00:00
private sealed record ChunkingRule ( string Name , Func < string , IReadOnlyList < string > , IReadOnlyList < string > > ? Split , bool UsesSourceSegmentCounts = false ) ;
2026-07-29 16:47:59 +00:00
2026-08-03 15:53:31 +00:00
private sealed record DataSourceMetadataSnapshot ( string SourceHash , IReadOnlyDictionary < string , string > FileHashes ) ;
2026-07-29 16:47:59 +00:00
private async IAsyncEnumerable < string > StreamEmbeddingChunksAsync ( string filePath , IDataSource dataSource , EmbeddingProvider embeddingProvider , [ System . Runtime . CompilerServices . EnumeratorCancellation ] CancellationToken token )
2026-05-13 16:13:34 +00:00
{
2026-07-29 16:47:59 +00:00
var options = this . GetChunkingOptions ( dataSource , embeddingProvider ) ;
var strategy = this . GetChunkingStrategy ( filePath ) ;
ExtractedFileContent content ;
2026-05-13 16:13:34 +00:00
if ( this . IsImageFilePath ( filePath ) )
{
2026-07-29 16:47:59 +00:00
var imageIndexText = this . BuildImageIndexText ( filePath ) ;
2026-08-10 16:21:31 +00:00
content = new ( imageIndexText , [ new ( imageIndexText , null ) ] ) ;
2026-07-29 16:47:59 +00:00
}
else
{
2026-08-10 16:21:31 +00:00
content = await this . ReadExtractedFileContentAsync ( filePath , embeddingProvider , token ) ;
2026-05-13 16:13:34 +00:00
}
2026-07-29 16:47:59 +00:00
await foreach ( var chunk in this . SplitByChunkingStrategyAsync ( content , strategy , options , embeddingProvider , token ) )
yield return chunk ;
}
2026-08-10 16:21:31 +00:00
private async Task < ExtractedFileContent > ReadExtractedFileContentAsync ( string filePath , EmbeddingProvider embeddingProvider , CancellationToken token )
2026-07-29 16:47:59 +00:00
{
2026-08-10 16:21:31 +00:00
var segments = new List < ExtractedFileSegment > ( ) ;
2026-05-13 16:13:34 +00:00
2026-08-10 19:36:01 +00:00
await foreach ( var segment in rustService . StreamArbitraryFileDataWithTokenCounts ( filePath , embeddingProvider , token ) )
2026-05-13 16:13:34 +00:00
{
2026-08-10 16:21:31 +00:00
var normalized = NormalizeChunkSegment ( segment . Content ) ;
2026-07-29 16:47:59 +00:00
if ( ! string . IsNullOrWhiteSpace ( normalized ) )
2026-08-10 16:21:31 +00:00
segments . Add ( new ( normalized , segment . TokenCount ) ) ;
2026-07-29 16:47:59 +00:00
}
2026-05-13 16:13:34 +00:00
2026-08-10 16:21:31 +00:00
return new ( string . Join ( "\n" , segments . Select ( segment = > segment . Text ) ) . Trim ( ) , segments ) ;
2026-07-29 16:47:59 +00:00
}
2026-05-13 16:13:34 +00:00
2026-07-29 16:47:59 +00:00
private async IAsyncEnumerable < string > SplitByChunkingStrategyAsync ( ExtractedFileContent content , ChunkingStrategy strategy , ChunkingOptions options , EmbeddingProvider embeddingProvider , [ System . Runtime . CompilerServices . EnumeratorCancellation ] CancellationToken token )
{
2026-08-10 16:21:31 +00: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 16:47:59 +00:00
yield return chunk ;
}
private async IAsyncEnumerable < string > SplitTextByRulesAsync (
string text ,
2026-08-10 16:21:31 +00:00
IReadOnlyList < ExtractedFileSegment > sourceSegments ,
2026-07-29 16:47:59 +00:00
ChunkingStrategy strategy ,
int ruleIndex ,
ChunkingOptions options ,
EmbeddingProvider embeddingProvider ,
2026-08-04 13:08:47 +00:00
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token ,
2026-08-10 16:21:31 +00:00
string requiredOverlapPrefix = "" ,
int? estimatedTokenCount = null )
2026-07-29 16:47:59 +00:00
{
text = text . Trim ( ) ;
if ( string . IsNullOrWhiteSpace ( text ) )
yield break ;
2026-08-10 16:21:31 +00:00
var tokenCount = estimatedTokenCount ;
var textWithOverlap = AddOverlapPrefix ( text , requiredOverlapPrefix ) ;
2026-08-10 17:42:39 +00:00
if ( textWithOverlap . Length < = RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH & &
2026-08-10 16:21:31 +00:00
( estimatedTokenCount is null | | estimatedTokenCount < = options . MaxChunkTokenLength ) )
2026-07-29 16:47:59 +00:00
{
2026-08-10 16:21:31 +00:00
tokenCount = await this . GetEmbeddingTokenCountAsync ( embeddingProvider , textWithOverlap , token ) ;
if ( tokenCount < = options . MaxChunkTokenLength )
{
yield return textWithOverlap ;
yield break ;
}
2026-05-13 16:13:34 +00:00
}
2026-07-29 16:47:59 +00:00
if ( ruleIndex > = strategy . Rules . Count )
2026-07-28 14:37:07 +00:00
{
2026-08-10 16:21:31 +00:00
await foreach ( var hardChunk in this . SplitTextByHardCutAsync ( text , options , embeddingProvider , token , requiredOverlapPrefix , estimatedTokenCount ) )
2026-07-29 16:47:59 +00:00
yield return hardChunk ;
yield break ;
2026-07-28 14:37:07 +00:00
}
2026-07-29 16:47:59 +00:00
var rule = strategy . Rules [ ruleIndex ] ;
if ( rule . Split is null )
2026-07-28 14:37:07 +00:00
{
2026-08-10 16:21:31 +00:00
await foreach ( var hardChunk in this . SplitTextByHardCutAsync ( text , options , embeddingProvider , token , requiredOverlapPrefix , estimatedTokenCount ) )
2026-07-29 16:47:59 +00:00
yield return hardChunk ;
2026-07-28 14:37:07 +00:00
yield break ;
}
2026-08-10 16:21:31 +00:00
var units = NormalizeSplitUnits ( rule . Split ( text , sourceSegments . Select ( segment = > segment . Text ) . ToList ( ) ) , text ) ;
2026-07-29 16:47:59 +00:00
if ( units . Count < = 1 )
2026-07-28 15:36:06 +00:00
{
2026-08-10 16:21:31 +00:00
await foreach ( var chunk in this . SplitTextByRulesAsync ( text , sourceSegments , strategy , ruleIndex + 1 , options , embeddingProvider , token , requiredOverlapPrefix , estimatedTokenCount ) )
2026-07-29 16:47:59 +00:00
yield return chunk ;
yield break ;
2026-07-28 15:36:06 +00:00
}
2026-07-28 14:37:07 +00:00
logger . LogDebug (
2026-08-10 16:21:31 +00:00
"Splitting content for embedding provider '{EmbeddingProviderName}' with strategy '{ChunkingStrategy}' and rule '{ChunkingRule}'. EstimatedTokenCount={EstimatedTokenCount}, MaxChunkTokenLength={MaxChunkTokenLength}, OverlapTokenLength={OverlapTokenLength}." ,
2026-07-28 14:37:07 +00:00
embeddingProvider . Name ,
2026-07-29 16:47:59 +00:00
strategy . Name ,
rule . Name ,
2026-07-28 14:37:07 +00:00
tokenCount ,
2026-08-04 13:08:47 +00:00
options . MaxChunkTokenLength ,
options . OverlapTokenLength ) ;
2026-07-28 14:37:07 +00:00
var index = 0 ;
2026-08-04 13:08:47 +00:00
var overlapPrefix = requiredOverlapPrefix ;
2026-08-10 16:21:31 +00:00
var unitTokenCounts = EstimateSplitUnitTokenCounts ( units , sourceSegments , rule . UsesSourceSegmentCounts , estimatedTokenCount ) ;
2026-07-28 14:37:07 +00:00
while ( index < units . Count )
{
token . ThrowIfCancellationRequested ( ) ;
2026-08-10 16:21:31 +00:00
var unitCount = await this . FindLargestUnitCountWithinMaxChunkLengthAsync ( units , unitTokenCounts , index , embeddingProvider , options . MaxChunkTokenLength , token , overlapPrefix ) ;
2026-07-28 14:37:07 +00:00
if ( unitCount > 0 )
{
2026-08-04 13:08:47 +00:00
var rawChunk = string . Concat ( units . Skip ( index ) . Take ( unitCount ) ) . Trim ( ) ;
var chunk = AddOverlapPrefix ( rawChunk , overlapPrefix ) ;
overlapPrefix = string . Empty ;
2026-07-28 14:37:07 +00:00
if ( ! string . IsNullOrWhiteSpace ( chunk ) )
yield return chunk ;
2026-07-29 16:47:59 +00:00
var nextIndex = index + unitCount ;
if ( nextIndex > = units . Count )
yield break ;
2026-08-04 13:08:47 +00: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 14:37:07 +00:00
continue ;
}
2026-08-04 13:08:47 +00:00
string? lastSplitUnit = null ;
2026-08-10 16:21:31 +00: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 13:08:47 +00:00
{
lastSplitUnit = splitUnit ;
2026-07-28 14:37:07 +00:00
yield return splitUnit ;
2026-08-04 13:08:47 +00:00
}
2026-07-28 14:37:07 +00:00
2026-08-04 13:08:47 +00:00
overlapPrefix = lastSplitUnit is null
? string . Empty
: await this . CreateOverlapPrefixAsync ( lastSplitUnit , strategy , rule , options , embeddingProvider , token ) ;
2026-07-28 14:37:07 +00:00
index + + ;
}
}
2026-08-10 16:21:31 +00: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 14:37:07 +00:00
{
2026-08-10 16:21:31 +00:00
var minimumCandidateUnitCount = 1 ;
var availableUnitCount = units . Count - startUnitIndex ;
var maximumCandidateUnitCount = availableUnitCount ;
var largestValidUnitCount = 0 ;
2026-07-28 14:37:07 +00:00
2026-08-10 16:21:31 +00:00
if ( estimatedUnitTokenCounts is not null )
2026-07-28 14:37:07 +00:00
{
2026-08-10 16:21:31 +00:00
maximumCandidateUnitCount = 0 ;
var cumulativeEstimatedTokenCount = 0L ;
for ( var unitIndex = startUnitIndex ; unitIndex < units . Count ; unitIndex + + )
{
cumulativeEstimatedTokenCount + = estimatedUnitTokenCounts [ unitIndex ] ;
if ( cumulativeEstimatedTokenCount > maxChunkTokenLength )
break ;
2026-07-28 14:37:07 +00:00
2026-08-10 16:21:31 +00:00
maximumCandidateUnitCount + + ;
}
if ( maximumCandidateUnitCount = = 0 )
maximumCandidateUnitCount = 1 ;
}
while ( true )
{
var searchedMaximumCandidateUnitCount = maximumCandidateUnitCount ;
while ( minimumCandidateUnitCount < = maximumCandidateUnitCount )
2026-07-28 14:37:07 +00:00
{
2026-08-10 16:21:31 +00:00
token . ThrowIfCancellationRequested ( ) ;
var candidateUnitCount = minimumCandidateUnitCount + ( maximumCandidateUnitCount - minimumCandidateUnitCount ) / 2 ;
var candidateText = AddOverlapPrefix ( string . Concat ( units . Skip ( startUnitIndex ) . Take ( candidateUnitCount ) ) . Trim ( ) , overlapPrefix ) ;
2026-08-10 17:42:39 +00:00
var candidateFits = candidateText . Length < = RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH & &
2026-08-10 16:21:31 +00:00
await this . GetEmbeddingTokenCountAsync ( embeddingProvider , candidateText , token ) < = maxChunkTokenLength ;
if ( candidateFits )
{
largestValidUnitCount = candidateUnitCount ;
minimumCandidateUnitCount = candidateUnitCount + 1 ;
}
else
maximumCandidateUnitCount = candidateUnitCount - 1 ;
2026-07-28 14:37:07 +00:00
}
2026-08-10 16:21:31 +00:00
if ( largestValidUnitCount < searchedMaximumCandidateUnitCount | | largestValidUnitCount > = availableUnitCount )
break ;
minimumCandidateUnitCount = searchedMaximumCandidateUnitCount + 1 ;
maximumCandidateUnitCount = ( int ) Math . Min (
availableUnitCount ,
Math . Max ( ( long ) minimumCandidateUnitCount , ( long ) searchedMaximumCandidateUnitCount * 2 ) ) ;
2026-07-28 14:37:07 +00:00
}
2026-08-10 16:21:31 +00: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 ;
var tokenCountAtBoundary = ( int ) Math . Min ( sourceTokenCount . Value , ( long ) sourceTokenCount . Value * consumedLength / totalLength ) ;
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 14:37:07 +00:00
}
2026-08-04 13:08:47 +00: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 16:47:59 +00: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 13:08:47 +00:00
private async IAsyncEnumerable < string > SplitTextByHardCutAsync (
string text ,
ChunkingOptions options ,
EmbeddingProvider embeddingProvider ,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token ,
2026-08-10 16:21:31 +00:00
string requiredOverlapPrefix = "" ,
int? estimatedTokenCount = null )
2026-07-28 14:37:07 +00:00
{
2026-08-04 13:08:47 +00:00
text = text . Trim ( ) ;
2026-07-28 14:37:07 +00:00
var startIndex = 0 ;
2026-08-04 13:08:47 +00:00
var overlapPrefix = requiredOverlapPrefix ;
2026-07-28 14:37:07 +00:00
while ( startIndex < text . Length )
{
token . ThrowIfCancellationRequested ( ) ;
2026-08-04 13:08:47 +00:00
while ( startIndex < text . Length & & char . IsWhiteSpace ( text [ startIndex ] ) )
startIndex + + ;
if ( startIndex > = text . Length )
yield break ;
2026-07-28 14:37:07 +00:00
var bestEndIndex = startIndex ;
2026-08-10 17:42:39 +00:00
var maximumCandidateEndIndex = Math . Min ( text . Length , startIndex + RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH ) ;
2026-08-10 16:21:31 +00: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 14:37:07 +00:00
2026-08-10 16:21:31 +00:00
while ( true )
2026-07-28 14:37:07 +00:00
{
2026-08-10 16:21:31 +00:00
var minimumCandidateEndIndex = bestEndIndex + 1 ;
var currentMaximumCandidateEndIndex = maximumCandidateEndIndex ;
while ( minimumCandidateEndIndex < = currentMaximumCandidateEndIndex )
2026-07-28 14:37:07 +00:00
{
2026-08-10 16:21:31 +00:00
var candidateEndIndex = minimumCandidateEndIndex + ( currentMaximumCandidateEndIndex - minimumCandidateEndIndex ) / 2 ;
var candidate = AddOverlapPrefix ( text [ startIndex . . candidateEndIndex ] . Trim ( ) , overlapPrefix ) ;
2026-08-10 17:42:39 +00:00
var candidateFits = candidate . Length < = RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH & &
2026-08-10 16:21:31 +00:00
await this . GetEmbeddingTokenCountAsync ( embeddingProvider , candidate , token ) < = options . MaxChunkTokenLength ;
if ( candidateFits )
{
bestEndIndex = candidateEndIndex ;
minimumCandidateEndIndex = candidateEndIndex + 1 ;
}
else
currentMaximumCandidateEndIndex = candidateEndIndex - 1 ;
2026-07-28 14:37:07 +00:00
}
2026-08-10 16:21:31 +00:00
if ( bestEndIndex < maximumCandidateEndIndex | | bestEndIndex > = text . Length | |
2026-08-10 17:42:39 +00:00
maximumCandidateEndIndex - startIndex > = RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH )
2026-08-10 16:21:31 +00:00
break ;
var previousCandidateLength = maximumCandidateEndIndex - startIndex ;
maximumCandidateEndIndex = ( int ) Math . Min (
2026-08-10 17:42:39 +00:00
Math . Min ( text . Length , startIndex + ( long ) RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH ) ,
2026-08-10 16:21:31 +00:00
startIndex + Math . Max ( previousCandidateLength + 1L , previousCandidateLength * 2L ) ) ;
2026-07-28 14:37:07 +00:00
}
if ( bestEndIndex = = startIndex )
{
2026-08-04 13:08:47 +00: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 14:37:07 +00:00
var smallestCandidateTokenCount = await this . GetEmbeddingTokenCountAsync ( embeddingProvider , smallestCandidate , token ) ;
2026-07-29 16:47:59 +00:00
throw new InvalidOperationException ( $"The max chunk length for embedding provider '{embeddingProvider.Name}' is too low. The smallest possible split still has {smallestCandidateTokenCount} tokens, but the configured limit is {options.MaxChunkTokenLength}." ) ;
2026-07-28 14:37:07 +00:00
}
2026-08-04 13:08:47 +00:00
var chunk = AddOverlapPrefix ( text [ startIndex . . bestEndIndex ] . Trim ( ) , overlapPrefix ) ;
overlapPrefix = string . Empty ;
2026-07-28 14:37:07 +00:00
if ( ! string . IsNullOrWhiteSpace ( chunk ) )
yield return chunk ;
2026-07-29 16:47:59 +00:00
if ( bestEndIndex > = text . Length )
yield break ;
2026-08-04 13:08:47 +00:00
overlapPrefix = await this . CreateOverlapPrefixAsync ( chunk , "hard-cut" , "Hard cut" , options , embeddingProvider , token ) ;
startIndex = bestEndIndex ;
2026-07-28 14:37:07 +00:00
}
}
2026-07-29 16:47:59 +00: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 13:08:47 +00: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 14:37:07 +00:00
private async Task < int > GetEmbeddingTokenCountAsync ( EmbeddingProvider embeddingProvider , string text , CancellationToken token )
{
2026-08-10 19:36:01 +00:00
var response = await rustService . GetTokenCount ( embeddingProvider , text , token ) ;
if ( response is { Success : true } )
2026-07-28 14:37:07 +00:00
return response . Value . TokenCount ;
var message = response ? . Message ? ? "No response was returned by the tokenizer service." ;
throw new InvalidOperationException ( $"Could not count tokens for embedding provider '{embeddingProvider.Name}'. {message}" ) ;
}
2026-07-29 16:47:59 +00: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
: 0 ;
2026-08-04 13:08:47 +00:00
var requestedOverlapTokenLength = configuredOverlapTokenLength > 0
? configuredOverlapTokenLength
: DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH ;
var overlapTokenLength = Math . Clamp ( requestedOverlapTokenLength , 0 , Math . Max ( 0 , maxChunkTokenLength - 1 ) ) ;
2026-07-29 16:47:59 +00:00
return new ( maxChunkTokenLength , overlapTokenLength ) ;
}
private ChunkingStrategy GetChunkingStrategy ( string filePath )
{
if ( this . IsImageFilePath ( filePath ) )
return new ( "image" , [
new ( "Whitespace" , SplitByWhitespace ) ,
new ( "Hard cut" , null ) ,
] ) ;
if ( this . IsPresentationFilePath ( filePath ) )
return new ( "presentation" , [
2026-08-10 16:21:31 +00:00
new ( "Slide" , SplitBySourceSegments , true ) ,
2026-07-29 16:47:59 +00: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 16:21:31 +00:00
new ( "Row or sheet" , SplitBySourceSegments , true ) ,
2026-07-29 16:47:59 +00:00
new ( "Line break" , SplitByLineBreaks ) ,
new ( "Whitespace" , SplitByWhitespace ) ,
new ( "Hard cut" , null ) ,
] ) ;
if ( this . IsSourceCodeFilePath ( filePath ) )
return GetSourceCodeChunkingStrategy ( filePath ) ;
return new ( "document" , [
2026-08-10 16:21:31 +00:00
new ( "Page or extracted section" , SplitBySourceSegments , true ) ,
2026-07-29 16:47:59 +00:00
new ( "Heading" , SplitByDocumentHeadings ) ,
new ( "Paragraph" , SplitByParagraphs ) ,
new ( "Line break" , SplitByLineBreaks ) ,
new ( "Whitespace" , SplitByWhitespace ) ,
new ( "Hard cut" , null ) ,
] ) ;
}
private static ChunkingStrategy GetSourceCodeChunkingStrategy ( string filePath )
{
2026-08-10 16:21:31 +00:00
var rules = new List < ChunkingRule >
{
new ( "Extracted section" , SplitBySourceSegments , true ) ,
} ;
rules . AddRange ( GetSourceCodeDelimiterRules ( filePath ) ) ;
2026-07-29 16:47:59 +00:00
rules . Add ( new ( "Line break" , SplitByLineBreaks ) ) ;
rules . Add ( new ( "Whitespace" , SplitByWhitespace ) ) ;
rules . Add ( new ( "Hard cut" , null ) ) ;
return new ( "source-code" , rules ) ;
}
private static IReadOnlyList < ChunkingRule > GetSourceCodeDelimiterRules ( string filePath ) = > Path . GetExtension ( filePath ) . TrimStart ( '.' ) switch
{
_ = > [ ] ,
} ;
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 14:37:07 +00:00
{
var matches = Regex . Matches ( text , @"\S+\s*" , RegexOptions . CultureInvariant ) ;
if ( matches . Count = = 0 )
return [ text ] ;
return matches . Cast < Match > ( ) . Select ( match = > match . Value ) . ToList ( ) ;
2026-05-13 16:13:34 +00:00
}
2026-07-29 16:47:59 +00: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 16:13:34 +00:00
private FileEnumerationResult GetInputFiles ( IDataSource dataSource )
{
var result = new FileEnumerationResult ( ) ;
switch ( dataSource )
{
case DataSourceLocalFile localFile when File . Exists ( localFile . FilePath ) :
2026-07-28 13:25:10 +00:00
var file = new FileInfo ( localFile . FilePath ) ;
switch ( this . GetRagFileIndexingDecision ( file ) )
2026-05-13 16:13:34 +00:00
{
2026-07-28 13:25:10 +00: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-07-28 18:22:37 +00:00
result . AddFailure ( localFile . FilePath , $"The selected file '{localFile.FilePath}' is not supported for background embeddings." ) ;
2026-07-28 13:25:10 +00:00
break ;
2026-05-13 16:13:34 +00:00
}
return result ;
case DataSourceLocalDirectory localDirectory when Directory . Exists ( localDirectory . Path ) :
this . EnumerateAccessibleFiles ( localDirectory . Path , result ) ;
return result ;
}
switch ( dataSource )
{
case DataSourceLocalFile localFile :
2026-07-28 18:22:37 +00:00
result . AddFailure ( localFile . FilePath , $"The selected file '{localFile.FilePath}' does not exist." ) ;
2026-05-13 16:13:34 +00:00
break ;
case DataSourceLocalDirectory localDirectory :
2026-07-28 18:22:37 +00:00
result . AddFailure ( localDirectory . Path , $"The selected directory '{localDirectory.Path}' does not exist." ) ;
2026-05-13 16:13:34 +00: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 18:02:43 +00:00
logger . LogWarning ( exception , "Cannot access directory '{DirectoryPath}' while indexing." , currentPath ) ;
2026-07-28 18:22:37 +00:00
result . AddFailure ( currentPath , $"The directory '{currentPath}' could not be accessed." ) ;
2026-05-13 16:13:34 +00:00
continue ;
}
foreach ( var filePath in files )
{
FileInfo fileInfo ;
try
{
fileInfo = new FileInfo ( filePath ) ;
if ( ! fileInfo . Exists )
continue ;
}
catch ( Exception exception )
{
2026-05-27 18:02:43 +00:00
logger . LogWarning ( exception , "Cannot inspect file '{FilePath}' while indexing." , filePath ) ;
2026-07-28 18:22:37 +00:00
result . AddFailure ( filePath , $"The file '{filePath}' could not be inspected." ) ;
2026-05-13 16:13:34 +00:00
continue ;
}
2026-07-28 13:25:10 +00:00
switch ( this . GetRagFileIndexingDecision ( fileInfo ) )
{
case RagFileIndexingDecision . INDEXABLE :
result . Files . Add ( fileInfo ) ;
break ;
2026-05-13 16:13:34 +00:00
2026-07-28 13:25:10 +00:00
case RagFileIndexingDecision . EXCLUDED :
logger . LogDebug ( "Skipping excluded file '{FilePath}' while indexing." , fileInfo . FullName ) ;
break ;
}
2026-05-13 16:13:34 +00:00
}
foreach ( var subDirectory in subDirectories )
2026-07-28 13:25:10 +00:00
{
if ( this . IsSkippedRagDirectory ( subDirectory ) )
continue ;
2026-05-13 16:13:34 +00:00
pendingDirectories . Push ( subDirectory ) ;
2026-07-28 13:25:10 +00:00
}
2026-05-13 16:13:34 +00: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 16:47:59 +00:00
private bool IsPresentationFilePath ( string filePath )
{
return FileTypes . IsAllowedPath ( filePath , FileTypes . POWER_POINT ) ;
}
private bool IsDelimitedTableFilePath ( string filePath )
{
var extension = Path . GetExtension ( filePath ) . TrimStart ( '.' ) ;
return RAG_DELIMITED_TABLE_FILE_EXTENSIONS . Contains ( extension , StringComparer . OrdinalIgnoreCase ) ;
}
private bool IsSpreadsheetFilePath ( string filePath )
{
var extension = Path . GetExtension ( filePath ) . TrimStart ( '.' ) ;
return FileTypes . IsAllowedPath ( filePath , FileTypes . EXCEL )
| | RAG_SPREADSHEET_FILE_EXTENSIONS . Contains ( extension , StringComparer . OrdinalIgnoreCase )
| | RAG_SPREADSHEET_ADD_IN_FILE_EXTENSIONS . Contains ( extension , StringComparer . OrdinalIgnoreCase ) ;
}
private bool IsSourceCodeFilePath ( string filePath )
{
return ! this . IsHtmlFilePath ( filePath ) & & FileTypes . IsAllowedPath ( filePath , FileTypes . SOURCE_CODE ) ;
}
private bool IsHtmlFilePath ( string filePath )
{
var extension = Path . GetExtension ( filePath ) . TrimStart ( '.' ) ;
return extension . Equals ( "html" , StringComparison . OrdinalIgnoreCase )
| | extension . Equals ( "htm" , StringComparison . OrdinalIgnoreCase ) ;
}
2026-05-13 16:13:34 +00:00
private bool IsSupportedRagFilePath ( string filePath )
{
var extension = Path . GetExtension ( filePath ) . TrimStart ( '.' ) ;
2026-07-28 13:25:10 +00:00
return FileTypes . IsAllowedPath ( filePath , FileTypes . DOCUMENT , FileTypes . IMAGE )
| | RAG_DELIMITED_TABLE_FILE_EXTENSIONS . Contains ( extension , StringComparer . OrdinalIgnoreCase )
| | RAG_SPREADSHEET_FILE_EXTENSIONS . Contains ( extension , StringComparer . OrdinalIgnoreCase )
| | RAG_SPREADSHEET_ADD_IN_FILE_EXTENSIONS . Contains ( extension , StringComparer . OrdinalIgnoreCase ) ;
}
private RagFileIndexingDecision GetRagFileIndexingDecision ( FileInfo file )
{
if ( this . IsSkippedRagFile ( file ) )
return RagFileIndexingDecision . EXCLUDED ;
return this . IsSupportedRagFilePath ( file . FullName )
? RagFileIndexingDecision . INDEXABLE
: RagFileIndexingDecision . UNSUPPORTED ;
}
private bool IsSkippedRagFile ( FileInfo file )
{
2026-07-28 13:52:59 +00:00
if ( IsSkippedRagFileName ( file . Name ) )
2026-07-28 13:25:10 +00: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 13:52:59 +00:00
private static bool IsSkippedRagFileName ( string fileName )
{
var extension = Path . GetExtension ( fileName ) . TrimStart ( '.' ) ;
return SKIPPED_RAG_FILE_EXTENSIONS . Contains ( extension , StringComparer . OrdinalIgnoreCase )
| | fileName . StartsWith ( OFFICE_LOCK_FILE_PREFIX , StringComparison . Ordinal ) ;
}
2026-07-28 13:25:10 +00: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 16:13:34 +00:00
}
private string BuildImageIndexText ( string filePath )
{
var fileName = Path . GetFileName ( filePath ) ;
var fileNameWithoutExtension = Path . GetFileNameWithoutExtension ( filePath ) ;
var extension = Path . GetExtension ( filePath ) . TrimStart ( '.' ) ;
var normalizedName = fileNameWithoutExtension
. Replace ( '_' , ' ' )
. Replace ( '-' , ' ' )
. Trim ( ) ;
return $ $"" "
Image asset
File name : { { fileName } }
Type : { { extension } }
Search terms : { { normalizedName } }
Path : { { filePath } }
Note : The current RAG embedding pipeline stores image files by metadata only . Visual content is not OCRed or captioned yet .
"" ";
}
2026-07-29 16:47:59 +00:00
private string BuildEmbeddingSignature ( IDataSource dataSource , EmbeddingProvider embeddingProvider , ChunkingOptions chunkingOptions )
2026-05-13 16:13:34 +00:00
{
return string . Join ( '|' ,
embeddingProvider . Id ,
embeddingProvider . UsedLLMProvider ,
embeddingProvider . Model . Id ,
embeddingProvider . Host ,
embeddingProvider . Hostname ,
2026-07-28 14:37:07 +00:00
embeddingProvider . TokenizerPath ,
2026-07-28 15:36:06 +00:00
embeddingProvider . EffectiveTokenLimit ,
2026-08-03 17:45:47 +00:00
GetDataSourceComplianceLevel ( dataSource ) . ToString ( ) ,
2026-07-29 16:47:59 +00:00
dataSource is IInternalDataSource internalDataSource ? internalDataSource . MaxChunkTokenLength : 0 ,
dataSource is IInternalDataSource overlapDataSource ? overlapDataSource . ChunkOverlapTokenLength : 0 ,
chunkingOptions . MaxChunkTokenLength ,
chunkingOptions . OverlapTokenLength ) ;
2026-05-13 16:13:34 +00:00
}
2026-08-03 15:53:31 +00: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 ]
: BuildMetadataHash ( "file" , localFile . FilePath , Path . GetFileName ( localFile . FilePath ) ? ? string . Empty , "missing" , "0" ) ,
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 16:13:34 +00:00
{
2026-08-03 15:53:31 +00:00
file . Refresh ( ) ;
if ( ! file . Exists )
{
return BuildMetadataHash (
"file" ,
file . FullName ,
file . Name ,
"missing" ,
"0" ) ;
}
return BuildMetadataHash (
"file" ,
2026-07-28 13:25:10 +00:00
file . FullName ,
2026-08-03 15:53:31 +00: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 16:13:34 +00:00
return Convert . ToHexString ( bytes ) ;
}
2026-08-10 18:06:51 +00:00
private EmbeddingStateFile CreateEmbeddingStateFile ( IDataSource dataSource , FileInfo file , string fingerprint , int chunkCount , DateTimeOffset embeddedAtUtc )
2026-08-03 17:45:47 +00:00
{
file . Refresh ( ) ;
var absolutePath = Path . GetFullPath ( file . FullName ) ;
var complianceLevel = GetDataSourceComplianceLevel ( dataSource ) ;
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 18:06:51 +00:00
file . Exists ? new DateTimeOffset ( file . CreationTimeUtc ) : DateTimeOffset . UnixEpoch ,
file . Exists ? new DateTimeOffset ( file . LastWriteTimeUtc ) : DateTimeOffset . UnixEpoch ,
2026-08-03 17:45:47 +00:00
embeddedAtUtc ,
chunkCount ,
complianceLevel . ToString ( ) ,
( int ) complianceLevel ) ;
}
2026-08-10 18:06:51 +00:00
private IReadOnlyList < EmbeddingStateChunk > CreateEmbeddingStateChunks ( EmbeddingStateFile parentFile , IReadOnlyList < EmbeddingChunkDraft > batch , DateTimeOffset embeddedAtUtc )
2026-08-03 17:45:47 +00:00
{
return batch
. Select ( chunk = > new EmbeddingStateChunk (
chunk . ChunkId ,
parentFile . ParentFileId ,
chunk . PageNumber ,
chunk . ChunkIndex ,
chunk . Text ,
embeddedAtUtc ) )
. ToList ( ) ;
}
private static ConfidenceLevel GetDataSourceComplianceLevel ( IDataSource dataSource ) = >
dataSource . ComplianceLevel is ConfidenceLevel . NONE
? ConfidenceLevel . UNKNOWN
: dataSource . ComplianceLevel ;
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 ;
}
2026-08-03 18:56:19 +00:00
private string GetCollectionName ( string dataSourceName , string dataSourceId ) = >
DataSourceEmbeddingNames . GetCollectionName ( dataSourceName , dataSourceId ) ;
2026-05-13 16:13:34 +00:00
2026-08-03 17:45:47 +00:00
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 16:13:34 +00: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 ( ) ;
}
}