namespace AIStudio.Tools.RAG; /// /// Cuts what a search found into pages, without keeping anything between two of them. /// /// /// /// Neither the vector store nor the keyword index knows an offset, and neither needs one: page p /// of size k is cut from the first p·k + 1 matches of every channel. The one match beyond the /// page tells whether a next page is worth asking for. The first page is therefore exactly what a /// search for k matches always returned. /// /// /// Staying without state is not a shortcut but a requirement: tool results do not travel into /// later turns, so a page has to come out of the query and its number alone. /// /// public static class RetrievalPaging { /// /// How many matches a page beyond the first may fetch at most, per channel. /// /// /// Every page fetches its whole window again, from the vector store and the keyword index, or /// from the ERI server. The first page is exempt: its size is what the user or the organization /// configured, and fetching it is what the retrieval always did. /// public const int MAX_RESULT_WINDOW = 100; /// /// The last page which can be retrieved for the given page size. /// /// The number of matches per page. /// The number of the last page, which is at least 1. public static int GetLastPage(int pageSize) => pageSize < 1 ? 1 : Math.Max(1, (MAX_RESULT_WINDOW - 1) / pageSize); /// /// How many matches every channel has to deliver for the given page. /// /// The page, starting at 1. /// The number of matches per page. /// The size of the window, i.e., the page, all pages before it, and one match more. /// The page is below 1 or beyond the last page. public static int GetWindowSize(int page, int pageSize) => GetPageEnd(page, pageSize) + 1; /// /// Cuts one page out of what a single channel found. /// /// What the channel found, the most relevant first, fetched with the window of this page. /// The page, starting at 1. /// The number of matches per page. /// The matches of this page, and whether the next page is worth asking for. /// The page is below 1 or beyond the last page. public static (IReadOnlyList Matches, bool HasMore) Cut(IReadOnlyList matches, int page, int pageSize) { var end = GetPageEnd(page, pageSize); var start = end - pageSize; var pageMatches = matches.Skip(start).Take(pageSize).ToList(); return (pageMatches, HasMore(page, pageSize, matches.Count)); } /// /// Cuts one page out of what two channels found. /// /// /// /// A page holds the page of the first channel, followed by the page of the second one. This /// order is deterministic on purpose; reranking would replace it, and change the first page /// with it. /// /// /// A match both channels found is shown once, on the earlier of its two pages; on the same /// page, in the part of the first channel. Hence, no match turns up on two pages. Matches /// without a key are never taken for one another. /// /// /// What the first channel found, the most relevant first, fetched with the window of this page. /// What the second channel found, likewise. /// What identifies a match across both channels. Letter case does not matter. /// The page, starting at 1. /// The number of matches per page and channel. /// The matches of this page, and whether the next page is worth asking for. /// The page is below 1 or beyond the last page. public static (IReadOnlyList Matches, bool HasMore) Merge(IReadOnlyList first, IReadOnlyList second, Func getKey, int page, int pageSize) { var end = GetPageEnd(page, pageSize); var start = end - pageSize; var firstRanks = GetFirstRanks(first, end + 1, getKey); var secondRanks = GetFirstRanks(second, end + 1, getKey); var pageMatches = new List(2 * pageSize); for (var rank = start; rank < Math.Min(end, first.Count); rank++) { var match = first[rank]; var key = getKey(match); if (!string.IsNullOrWhiteSpace(key)) { // The first channel found it further up already: if (firstRanks[key] != rank) continue; // The second channel showed it on an earlier page: if (secondRanks.TryGetValue(key, out var secondRank) && secondRank < start) continue; } pageMatches.Add(match); } for (var rank = start; rank < Math.Min(end, second.Count); rank++) { var match = second[rank]; var key = getKey(match); if (!string.IsNullOrWhiteSpace(key)) { // The second channel found it further up already: if (secondRanks[key] != rank) continue; // The first channel shows it on this page or showed it on an earlier one: if (firstRanks.TryGetValue(key, out var firstRank) && firstRank < end) continue; } pageMatches.Add(match); } return (pageMatches, HasMore(page, pageSize, first.Count, second.Count)); } /// /// Where the given page ends, i.e., the number of matches on it and on all pages before it. /// private static int GetPageEnd(int page, int pageSize) { var lastPage = GetLastPage(pageSize); if (page < 1 || page > lastPage) throw new ArgumentOutOfRangeException(nameof(page), page, $"With {pageSize} matches per page, the page has to be between 1 and {lastPage}."); return page * Math.Max(0, pageSize); } /// /// Whatever a channel found beyond this page is enough to ask for the next one. That page can /// still turn out empty, when the other channel showed all of it before. Saying there is more /// when there is not costs one empty page; saying the opposite would hide matches. /// private static bool HasMore(int page, int pageSize, params int[] channelCounts) { if (page >= GetLastPage(pageSize)) return false; var end = page * pageSize; return channelCounts.Any(count => count > end); } private static Dictionary GetFirstRanks(IReadOnlyList matches, int window, Func getKey) { var ranks = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var rank = 0; rank < Math.Min(window, matches.Count); rank++) { var key = getKey(matches[rank]); if (!string.IsNullOrWhiteSpace(key)) ranks.TryAdd(key, rank); } return ranks; } }