using AIStudio.Models; using AIStudio.Tools.Security; using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.SemanticSearch; namespace AIStudio.Tools.RAG; /// /// How much room the passages of the classic RAG process may take in a request, and which of them fit into it. /// /// /// /// The classic RAG process sends its passages along with every message, in the system prompt. A /// chunk is as long as the embedding model takes at once, by default 8,192 tokens, so the default /// of ten passages of a single data source may already exceed what many models read, and the /// provider then refuses the whole message. The budget keeps the passages within a share of the /// model's context window, see For. /// /// /// The size of a passage is that of its Markdown as the model reads it, measured after the prompt /// injection filter: the filter may make a text longer, and the budget has to judge what the model /// receives. Which number a passage gets depends on which passages fit, so it is measured with the /// longest header it can get. /// /// /// How much the passages may take together, in the unit of measure; at least 1. /// Measures a text in the unit of the budget, e.g., in tokens. public sealed class PassageBudget(int limit, Func> measure) { /// /// The share of a known context window the passages may take. /// /// /// The rest of the window stays for everything else of the request: the conversation, its /// attachments, the system prompt, the tools, and the answer. Half of it leaves a long /// conversation room, while the passages still get as much as the window of most models holds /// of them. /// internal const double SHARE_OF_THE_CONTEXT_WINDOW = 0.5; /// /// How many characters the passages may take when nothing is known about the context window of the model. /// /// /// As many as one page of Semantic Search returns, so that both ways of searching hand the /// model at most the same amount of text when nothing more is known. /// internal const int CHARACTERS_WITHOUT_A_KNOWN_CONTEXT_WINDOW = SemanticSearchTool.MAX_RESULT_CHARACTERS; /// /// How much the passages may take together, in the unit of the budget. /// public int Limit { get; } = limit >= 1 ? limit : throw new ArgumentOutOfRangeException(nameof(limit), limit, "A budget has to hold at least 1."); /// /// A budget which counts characters. /// /// How many characters the passages may take together; at least 1. /// The budget. public static PassageBudget InCharacters(int limit) => new(limit, (text, _) => Task.FromResult(text.Length)); /// /// The budget for a model with the given context window. /// /// /// A known window is counted in tokens, as the model counts it. Of an unknown one, nothing can /// be said, so the passages get as many characters as one page of Semantic Search. /// /// The context window of the model which answers. /// Counts the tokens of a text for that model. /// The budget. public static PassageBudget For(ContextWindow contextWindow, Func> countTokensAsync) => contextWindow.IsKnown ? new(Math.Max(1, (int)(contextWindow.DefaultTokens * SHARE_OF_THE_CONTEXT_WINDOW)), countTokensAsync) : InCharacters(CHARACTERS_WITHOUT_A_KNOWN_CONTEXT_WINDOW); /// /// Selects the passages which fit into the budget. /// /// /// /// The data sources take turns, rank by rank: the best passage of each data source comes before /// the second best of any, so that a data source with many long passages does not crowd out the /// others. A passage which does not fit into what is left is skipped, and the next ones still /// get their chance: the classic RAG process has no further pages which could bring it later. /// /// /// A passage longer than the whole budget is shortened until it fits, and filtered again, see /// IRetrievalContextExtensions.ShortenToFitAsync. It then has to fit into what is left like any /// other passage. /// /// /// The prepared passages of each data source, each in the order of their rank. /// The prompt injection filter, for passages which have to be shortened. /// The action the passages were retrieved for, or null when they were retrieved for none. /// The cancellation token. /// The passages which fit, grouped by data source, how many of each data source fit, and how many did not. public async Task SelectAsync(IReadOnlyList> contextsByDataSource, PromptInjectionGuardService guardService, PromptInjectionReportingScope? reportingScope = null, CancellationToken token = default) { var candidateCount = contextsByDataSource.Sum(contexts => contexts.Count); var mostPassages = contextsByDataSource.Select(contexts => contexts.Count).DefaultIfEmpty(0).Max(); var selected = contextsByDataSource.Select(_ => new List()).ToList(); var remaining = this.Limit; var leftOutCount = 0; for (var rank = 0; rank < mostPassages; rank++) { for (var index = 0; index < contextsByDataSource.Count; index++) { if (rank >= contextsByDataSource[index].Count) continue; // A budget which is used up holds nothing more, so there is nothing to measure: if (remaining is 0) { leftOutCount++; continue; } var context = contextsByDataSource[index][rank]; var size = await this.MeasureAsync(context, candidateCount, token); while (size > this.Limit && await this.ShortenAsync(context, size, candidateCount, guardService, reportingScope, token) is { } shorterContext) { context = shorterContext; size = await this.MeasureAsync(context, candidateCount, token); } if (size > remaining) { leftOutCount++; continue; } selected[index].Add(context); remaining -= size; } } return new([..selected.SelectMany(contexts => contexts)], [..selected.Select(contexts => contexts.Count)], leftOutCount); } /// /// Measures a passage as the model reads it, in the unit of the budget. /// private Task MeasureAsync(PreparedRetrievalContext context, int candidateCount, CancellationToken token) => measure(WithLongestHeader(context, candidateCount), token); /// /// Shortens a passage which is longer than the whole budget by about as much as it exceeds it. /// /// /// The budget may count something else than characters, e.g., tokens, while a passage is /// shortened by characters. Both grow roughly in proportion, so the passage keeps the share of /// its characters which the budget holds of its size. Where the proportion is off, the caller /// measures again and shortens once more. /// /// The passage. /// The size of the passage in the unit of the budget, more than its limit. /// How many passages there are to select from. /// The prompt injection filter. /// The action the passage was retrieved for, or null. /// The cancellation token. /// The shorter passage, or null when nothing of it is left to cut. private async Task ShortenAsync(PreparedRetrievalContext context, int size, int candidateCount, PromptInjectionGuardService guardService, PromptInjectionReportingScope? reportingScope, CancellationToken token) { var characters = WithLongestHeader(context, candidateCount).Length; var maxCharacters = (int)((long)characters * this.Limit / size); var shorterContext = await context.ShortenToFitAsync(maxCharacters, shorter => WithLongestHeader(shorter, candidateCount).Length, guardService, reportingScope, token); return WithLongestHeader(shorterContext, candidateCount).Length < characters ? shorterContext : null; } /// /// Writes a passage with the longest header it can get: neither its number nor the count of the passages the model receives exceeds the count of the passages to select from. /// private static string WithLongestHeader(PreparedRetrievalContext context, int candidateCount) => context.ToMarkdown(candidateCount, candidateCount); }