2026-08-03 18:56:19 +00:00
using AIStudio.Chat ;
using AIStudio.Provider ;
using AIStudio.Settings ;
using AIStudio.Settings.DataModel ;
using AIStudio.Tools.Databases ;
2026-08-14 10:37:50 +00:00
using AIStudio.Tools.Databases.IndexStore ;
2026-08-03 18:56:19 +00:00
using AIStudio.Tools.Databases.VectorStore ;
2026-08-14 14:02:40 +00:00
using AIStudio.Tools.PluginSystem ;
2026-08-03 18:56:19 +00:00
using AIStudio.Tools.RAG ;
2026-08-12 12:19:32 +00:00
using AIStudio.Tools.Rust ;
2026-08-03 18:56:19 +00:00
namespace AIStudio.Tools.Services ;
public sealed class DataSourceLocalRetrievalService (
2026-08-14 12:25:53 +00:00
SettingsManager settingsManager , RustService rustService , DatabaseClientProvider databaseClientProvider ,
2026-08-03 18:56:19 +00:00
ILogger < DataSourceLocalRetrievalService > logger )
{
2026-08-14 14:02:40 +00:00
private static string TB ( string fallbackEN ) = > I18N . I . T ( fallbackEN , typeof ( DataSourceLocalRetrievalService ) . Namespace , nameof ( DataSourceLocalRetrievalService ) ) ;
2026-08-03 18:56:19 +00:00
private enum RetrievalChannel
{
VECTOR ,
BM25 ,
}
private sealed record LocalRetrievalHit (
RetrievalChannel Channel ,
string ChunkId ,
string ParentFileId ,
string DataSourceId ,
string DataSourceName ,
string DataSourceType ,
string AbsolutePath ,
string FileName ,
string RelativePath ,
string FileType ,
int? PageNumber ,
int ChunkIndex ,
string Text ,
double Score ,
int Rank ,
2026-08-14 10:03:16 +00:00
string ConfidenceLevel ,
int ConfidenceLevelRank ) ;
2026-08-03 18:56:19 +00:00
public Task < IReadOnlyList < IRetrievalContext > > RetrieveDataAsync ( DataSourceLocalFile dataSource , IContent lastUserPrompt , ChatThread thread , CancellationToken token = default ) = >
this . RetrieveDataAsync ( ( IInternalDataSource ) dataSource , lastUserPrompt , token ) ;
public Task < IReadOnlyList < IRetrievalContext > > RetrieveDataAsync ( DataSourceLocalDirectory dataSource , IContent lastUserPrompt , ChatThread thread , CancellationToken token = default ) = >
this . RetrieveDataAsync ( ( IInternalDataSource ) dataSource , lastUserPrompt , token ) ;
private async Task < IReadOnlyList < IRetrievalContext > > RetrieveDataAsync ( IInternalDataSource dataSource , IContent lastUserPrompt , CancellationToken token )
{
var query = GetQueryText ( lastUserPrompt ) ;
if ( string . IsNullOrWhiteSpace ( query ) )
{
logger . LogDebug ( "Skipping local retrieval for data source '{DataSourceName}' ({DataSourceId}) because the latest prompt does not contain text." , dataSource . Name , dataSource . Id ) ;
return [ ] ;
}
var maxMatches = ( int ) dataSource . MaxMatches ;
if ( maxMatches = = 0 )
return [ ] ;
2026-08-11 13:22:07 +00:00
var collectionName = DataSourceEmbeddingNames . GetCollectionName ( dataSource . Id ) ;
2026-08-04 13:08:47 +00:00
var vectorTask = this . SearchVectorAsync ( dataSource , query , maxMatches , collectionName , token ) ;
var bm25Task = this . SearchBm25Async ( dataSource , query , maxMatches , token ) ;
2026-08-03 18:56:19 +00:00
await Task . WhenAll ( vectorTask , bm25Task ) ;
token . ThrowIfCancellationRequested ( ) ;
var hits = MergeResults ( vectorTask . Result , bm25Task . Result , maxMatches ) ;
logger . LogInformation (
"Retrieved {MergedHits} local RAG hits for data source '{DataSourceName}' ({DataSourceId}). VectorCandidates={VectorHits}, BM25Candidates={BM25Hits}, RequestedPerChannel={RequestedPerChannel}." ,
hits . Count ,
dataSource . Name ,
dataSource . Id ,
vectorTask . Result . Count ,
bm25Task . Result . Count ,
maxMatches ) ;
return hits
. Where ( hit = > ! string . IsNullOrWhiteSpace ( hit . Text ) )
. Select ( ToRetrievalContext )
. ToList ( ) ;
}
private async Task < IReadOnlyList < VectorSearchResult > > SearchVectorAsync (
IInternalDataSource dataSource ,
string query ,
int maxMatches ,
string collectionName ,
CancellationToken token )
{
try
{
var vectorStore = await databaseClientProvider . GetVectorStoreAsync ( token ) ;
if ( ! vectorStore . IsAvailable )
{
logger . LogWarning (
"Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because vector store '{VectorStoreName}' is unavailable." ,
dataSource . Name ,
dataSource . Id ,
vectorStore . Name ) ;
return [ ] ;
}
if ( ! DataSourceEmbeddingProviders . TryResolve ( settingsManager , dataSource , out var embeddingProvider ) )
{
logger . LogWarning ( "Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because the selected embedding provider is not available." , dataSource . Name , dataSource . Id ) ;
return [ ] ;
}
2026-08-10 17:42:39 +00:00
if ( ! await this . QueryFitsEmbeddingProviderAsync ( dataSource , embeddingProvider , query , token ) )
return [ ] ;
2026-08-03 18:56:19 +00:00
var provider = embeddingProvider . CreateProvider ( ) ;
var vectors = await provider . EmbedTextAsync ( embeddingProvider . Model , settingsManager , token , [ query ] ) ;
token . ThrowIfCancellationRequested ( ) ;
var vector = vectors . FirstOrDefault ( ) ;
if ( vector is null | | vector . Count = = 0 )
{
logger . LogWarning ( "Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because query embedding returned no vector." , dataSource . Name , dataSource . Id ) ;
return [ ] ;
}
2026-08-04 13:08:47 +00:00
var results = this . LimitSearchResults (
dataSource ,
"vector" ,
await vectorStore . SearchEmbeddingAsync ( collectionName , vector , maxMatches , token ) ,
maxMatches ) ;
this . LogVectorResults ( dataSource , results ) ;
return results ;
2026-08-03 18:56:19 +00:00
}
catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
{
throw ;
}
catch ( Exception exception )
{
logger . LogWarning ( exception , "Vector retrieval failed for data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
return [ ] ;
}
}
2026-08-10 17:42:39 +00:00
private async Task < bool > QueryFitsEmbeddingProviderAsync (
IInternalDataSource dataSource ,
EmbeddingProvider embeddingProvider ,
string query ,
CancellationToken token )
{
var providerTokenLimit = Math . Max ( 1 , embeddingProvider . EffectiveTokenLimit ) ;
if ( query . Length > RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH )
{
logger . LogWarning (
"Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because the latest prompt has {CharacterCount} characters and exceeds the safe tokenizer request length of {MaxCharacterCount}. ProviderTokenLimit={ProviderTokenLimit}." ,
dataSource . Name ,
dataSource . Id ,
query . Length ,
RustService . MAX_TOKEN_COUNT_REQUEST_TEXT_LENGTH ,
providerTokenLimit ) ;
return false ;
}
2026-08-10 19:36:01 +00:00
var tokenCountResponse = await rustService . GetTokenCount ( embeddingProvider , query , token ) ;
if ( tokenCountResponse is not { Success : true } )
2026-08-10 17:42:39 +00:00
{
logger . LogWarning (
"Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because the token count for embedding provider '{EmbeddingProviderName}' could not be determined. Reason='{Reason}'." ,
dataSource . Name ,
dataSource . Id ,
embeddingProvider . Name ,
tokenCountResponse ? . Message ? ? "No response was returned by the tokenizer service." ) ;
return false ;
}
var queryTokenCount = tokenCountResponse . Value . TokenCount ;
if ( queryTokenCount > providerTokenLimit )
{
logger . LogWarning (
"Skipping vector retrieval for data source '{DataSourceName}' ({DataSourceId}) because the latest prompt has {QueryTokenCount} tokens, exceeding embedding provider '{EmbeddingProviderName}' limit of {ProviderTokenLimit} tokens." ,
dataSource . Name ,
dataSource . Id ,
queryTokenCount ,
embeddingProvider . Name ,
providerTokenLimit ) ;
return false ;
}
return true ;
}
2026-08-14 10:37:50 +00:00
private async Task < IReadOnlyList < IndexStoreSearchResult > > SearchBm25Async ( IInternalDataSource dataSource , string query , int maxMatches , CancellationToken token )
2026-08-03 18:56:19 +00:00
{
try
{
2026-08-14 10:37:50 +00:00
var indexStore = await databaseClientProvider . GetIndexStoreAsync ( token ) ;
if ( ! indexStore . IsAvailable )
2026-08-03 18:56:19 +00:00
{
logger . LogWarning (
"Skipping BM25 retrieval for data source '{DataSourceName}' ({DataSourceId}) because local RAG index '{DatabaseName}' is unavailable." ,
dataSource . Name ,
dataSource . Id ,
2026-08-14 10:37:50 +00:00
indexStore . Name ) ;
2026-08-03 18:56:19 +00:00
return [ ] ;
}
2026-08-04 13:08:47 +00:00
var results = this . LimitSearchResults (
dataSource ,
"BM25" ,
2026-08-14 10:37:50 +00:00
await indexStore . SearchChunksAsync ( dataSource . Id , query , maxMatches , token ) ,
2026-08-04 13:08:47 +00:00
maxMatches ) ;
this . LogBm25Results ( dataSource , results ) ;
return results ;
2026-08-03 18:56:19 +00:00
}
catch ( OperationCanceledException ) when ( token . IsCancellationRequested )
{
throw ;
}
catch ( Exception exception )
{
logger . LogWarning ( exception , "BM25 retrieval failed for data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
return [ ] ;
}
}
2026-08-04 13:08:47 +00:00
private IReadOnlyList < T > LimitSearchResults < T > ( IInternalDataSource dataSource , string searchName , IReadOnlyList < T > results , int maxMatches )
{
if ( results . Count < = maxMatches )
return results ;
logger . LogWarning (
"Local RAG {SearchName} search returned {ReturnedHits} chunks for data source '{DataSourceName}' ({DataSourceId}), which exceeds the configured maximum {MaxMatches}. Truncating to the datasource limit." ,
searchName ,
results . Count ,
dataSource . Name ,
dataSource . Id ,
maxMatches ) ;
return results . Take ( maxMatches ) . ToList ( ) ;
}
2026-08-03 18:56:19 +00:00
private static IReadOnlyList < LocalRetrievalHit > MergeResults (
IReadOnlyList < VectorSearchResult > vectorResults ,
2026-08-14 10:37:50 +00:00
IReadOnlyList < IndexStoreSearchResult > bm25Results ,
2026-08-03 18:56:19 +00:00
int maxMatches )
{
// Future reranking should replace this deterministic channel merge.
var merged = new List < LocalRetrievalHit > ( maxMatches * 2 ) ;
var seenChunkIds = new HashSet < string > ( StringComparer . OrdinalIgnoreCase ) ;
AppendHits (
merged ,
seenChunkIds ,
vectorResults
. Select ( ( result , index ) = > FromVectorResult ( result , index + 1 ) ) ,
maxMatches ) ;
AppendHits (
merged ,
seenChunkIds ,
bm25Results
. Select ( ( result , index ) = > FromBm25Result ( result , index + 1 ) ) ,
maxMatches ) ;
return merged ;
}
private static void AppendHits ( List < LocalRetrievalHit > merged , HashSet < string > seenChunkIds , IEnumerable < LocalRetrievalHit > hits , int maxNewHits )
{
var added = 0 ;
foreach ( var hit in hits )
{
if ( ! string . IsNullOrWhiteSpace ( hit . ChunkId ) & & ! seenChunkIds . Add ( hit . ChunkId ) )
continue ;
merged . Add ( hit ) ;
added + + ;
if ( added > = maxNewHits )
return ;
}
}
private static LocalRetrievalHit FromVectorResult ( VectorSearchResult result , int rank ) = >
new (
RetrievalChannel . VECTOR ,
result . ChunkId ,
result . ParentFileId ,
result . DataSourceId ,
result . DataSourceName ,
result . DataSourceType ,
FirstNonEmpty ( result . AbsolutePath , result . FilePath ) ,
result . FileName ,
result . RelativePath ,
result . FileType ,
result . PageNumber ,
result . ChunkIndex ,
result . Text ,
result . Score ,
rank ,
2026-08-14 10:03:16 +00:00
result . ConfidenceLevel ,
result . ConfidenceLevelRank ) ;
2026-08-03 18:56:19 +00:00
2026-08-14 10:37:50 +00:00
private static LocalRetrievalHit FromBm25Result ( IndexStoreSearchResult result , int rank ) = >
2026-08-03 18:56:19 +00:00
new (
RetrievalChannel . BM25 ,
result . ChunkId ,
result . ParentFileId ,
result . DataSourceId ,
result . DataSourceName ,
result . DataSourceType ,
result . AbsolutePath ,
result . FileName ,
result . RelativePath ,
result . FileType ,
result . PageNumber ,
result . ChunkIndex ,
result . ChunkText ,
result . Score ,
rank ,
2026-08-14 10:03:16 +00:00
result . ConfidenceLevel ,
result . ConfidenceLevelRank ) ;
2026-08-03 18:56:19 +00:00
private static RetrievalTextContext ToRetrievalContext ( LocalRetrievalHit hit )
{
var sourceName = FirstNonEmpty ( hit . FileName , hit . DataSourceName ) ;
var path = FirstNonEmpty ( hit . AbsolutePath , hit . RelativePath ) ;
var referenceLink = string . IsNullOrWhiteSpace ( path ) ? string . Empty : BuildReferenceLink ( path , hit ) ;
return new RetrievalTextContext
{
DataSourceName = sourceName ,
Category = RetrievalContentCategory . TEXT ,
Type = GetRetrievalContentType ( hit . FileType ) ,
Path = path ,
Links = [ ] ,
MatchedText = hit . Text ,
SurroundingContent = [ ] ,
ReferenceTitle = BuildReferenceTitle ( hit ) ,
ReferenceLink = referenceLink ,
} ;
}
private static string BuildReferenceTitle ( LocalRetrievalHit hit )
{
var sourceName = FirstNonEmpty ( hit . FileName , hit . DataSourceName ) ;
2026-08-14 14:02:40 +00:00
return BuildLocatedReferenceTitle ( sourceName , hit . ChunkIndex , hit . PageNumber ) ;
2026-08-04 13:08:47 +00:00
}
2026-08-14 14:02:40 +00:00
private static string BuildLocatedReferenceTitle ( string sourceName , int chunkIndex , int? pageNumber )
2026-08-04 13:08:47 +00:00
{
2026-08-14 14:02:40 +00:00
var location = pageNumber is > 0
? string . Format ( TB ( "Page {0}" ) , pageNumber )
: string . Format ( TB ( "Chunk {0}" ) , chunkIndex + 1 ) ;
return $"{sourceName} ({location})" ;
2026-08-03 18:56:19 +00:00
}
private static string BuildReferenceLink ( string path , LocalRetrievalHit hit )
{
var link = NormalizeLocalReferencePath ( path ) ;
var separator = link . Contains ( '#' , StringComparison . Ordinal ) ? "&" : "#" ;
return $"{link}{separator}chunk={hit.ChunkIndex}" ;
}
private static string NormalizeLocalReferencePath ( string path )
{
try
{
return Path . IsPathRooted ( path )
? new Uri ( Path . GetFullPath ( path ) ) . AbsoluteUri
: path ;
}
catch
{
return path ;
}
}
2026-08-12 12:19:32 +00:00
private static RetrievalContentType GetRetrievalContentType ( string fileType )
2026-08-03 18:56:19 +00:00
{
2026-08-12 12:19:32 +00:00
if ( FileTypes . IsAllowedExtension ( fileType , FileTypes . DELIMITED_TABLE , FileTypes . SPREADSHEET ) )
return RetrievalContentType . TEXT_SPREADSHEET ;
if ( FileTypes . IsAllowedExtension ( fileType , FileTypes . POWER_POINT ) )
return RetrievalContentType . TEXT_PRESENTATION ;
return FileTypes . IsAllowedExtension ( fileType , FileTypes . HTML )
? RetrievalContentType . TEXT_WEBSITE
: RetrievalContentType . TEXT_DOCUMENT ;
}
2026-08-03 18:56:19 +00:00
private static string GetQueryText ( IContent lastUserPrompt ) = > lastUserPrompt switch
{
ContentText text = > text . Text ,
_ = > string . Empty
} ;
private static string FirstNonEmpty ( params string [ ] values ) = >
values . FirstOrDefault ( value = > ! string . IsNullOrWhiteSpace ( value ) ) ? ? string . Empty ;
2026-08-04 13:08:47 +00:00
private void LogVectorResults ( IInternalDataSource dataSource , IReadOnlyList < VectorSearchResult > results )
{
if ( results . Count = = 0 )
{
logger . LogInformation ( "Local RAG vector search found no chunks for data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
return ;
}
foreach ( var result in results . Select ( ( result , index ) = > ( Result : result , Rank : index + 1 ) ) )
{
logger . LogInformation (
"Local RAG vector search found chunk for data source '{DataSourceName}' ({DataSourceId}). Rank={Rank}, Score={Score}, ChunkId='{ChunkId}', ParentFileId='{ParentFileId}', File='{FileName}', Path='{Path}', Title='{Title}'." ,
dataSource . Name ,
dataSource . Id ,
result . Rank ,
result . Result . Score ,
result . Result . ChunkId ,
result . Result . ParentFileId ,
result . Result . FileName ,
FirstNonEmpty ( result . Result . AbsolutePath , result . Result . FilePath ) ,
2026-08-14 14:02:40 +00:00
BuildLocatedReferenceTitle ( FirstNonEmpty ( result . Result . FileName , dataSource . Name ) , result . Result . ChunkIndex , result . Result . PageNumber ) ) ;
2026-08-04 13:08:47 +00:00
}
}
2026-08-14 10:37:50 +00:00
private void LogBm25Results ( IInternalDataSource dataSource , IReadOnlyList < IndexStoreSearchResult > results )
2026-08-04 13:08:47 +00:00
{
if ( results . Count = = 0 )
{
logger . LogInformation ( "Local RAG BM25 search found no chunks for data source '{DataSourceName}' ({DataSourceId})." , dataSource . Name , dataSource . Id ) ;
return ;
}
foreach ( var result in results . Select ( ( result , index ) = > ( Result : result , Rank : index + 1 ) ) )
{
logger . LogInformation (
"Local RAG BM25 search found chunk for data source '{DataSourceName}' ({DataSourceId}). Rank={Rank}, Score={Score}, ChunkId='{ChunkId}', ParentFileId='{ParentFileId}', File='{FileName}', Path='{Path}', Title='{Title}'." ,
dataSource . Name ,
dataSource . Id ,
result . Rank ,
result . Result . Score ,
result . Result . ChunkId ,
result . Result . ParentFileId ,
result . Result . FileName ,
result . Result . AbsolutePath ,
2026-08-14 14:02:40 +00:00
BuildLocatedReferenceTitle ( FirstNonEmpty ( result . Result . FileName , dataSource . Name ) , result . Result . ChunkIndex , result . Result . PageNumber ) ) ;
2026-08-04 13:08:47 +00:00
}
}
2026-08-03 18:56:19 +00:00
}