|
| 1 | +using System; |
| 2 | +using System.Collections.Generic; |
| 3 | +using System.Linq; |
| 4 | +using System.Threading; |
| 5 | +using System.Threading.Tasks; |
| 6 | +using AiDotNet.Interfaces; |
| 7 | +using AiDotNet.RetrievalAugmentedGeneration.ChunkingStrategies; |
| 8 | +using AiDotNet.RetrievalAugmentedGeneration.Models; |
| 9 | + |
| 10 | +namespace AiDotNet.RetrievalAugmentedGeneration |
| 11 | +{ |
| 12 | + /// <summary> |
| 13 | + /// End-to-end RAG orchestrator that composes the individual components into the two operations an |
| 14 | + /// application actually performs: <see cref="IngestAsync"/> (chunk → embed → store) and |
| 15 | + /// <see cref="QueryAsync"/> (retrieve → rerank → compress → generate). Previously callers had to wire |
| 16 | + /// these stages by hand; this is the single entry point that ties them together, fully async and |
| 17 | + /// cancellation-aware, with optional tenant/namespace isolation. |
| 18 | + /// </summary> |
| 19 | + /// <typeparam name="T">The numeric type used for embeddings and scoring.</typeparam> |
| 20 | + public sealed class RagPipeline<T> |
| 21 | + { |
| 22 | + private readonly IEmbeddingModel<T> _embedding; |
| 23 | + private readonly IDocumentStore<T> _store; |
| 24 | + private readonly IRetriever<T> _retriever; |
| 25 | + private readonly IChunkingStrategy? _chunking; |
| 26 | + private readonly IReranker<T>? _reranker; |
| 27 | + private readonly IContextCompressor<T>? _compressor; |
| 28 | + private readonly IGenerator<T>? _generator; |
| 29 | + private readonly string? _tenant; |
| 30 | + |
| 31 | + /// <param name="embedding">Embedding model used for ingestion (and dense retrieval upstream).</param> |
| 32 | + /// <param name="store">Vector store documents are upserted into.</param> |
| 33 | + /// <param name="retriever">Retriever used at query time.</param> |
| 34 | + /// <param name="chunking">Optional chunker; when null each document is ingested whole.</param> |
| 35 | + /// <param name="reranker">Optional reranker applied to retrieved documents.</param> |
| 36 | + /// <param name="compressor">Optional context compressor applied before generation.</param> |
| 37 | + /// <param name="generator">Optional generator; when null <see cref="QueryAsync"/> returns retrieved context only.</param> |
| 38 | + /// <param name="tenant"> |
| 39 | + /// Optional tenant/namespace. When set it is stamped on every ingested document's metadata under |
| 40 | + /// <c>_tenant</c> and used as a retrieval filter, isolating this pipeline's data from other tenants. |
| 41 | + /// </param> |
| 42 | + public RagPipeline( |
| 43 | + IEmbeddingModel<T> embedding, |
| 44 | + IDocumentStore<T> store, |
| 45 | + IRetriever<T> retriever, |
| 46 | + IChunkingStrategy? chunking = null, |
| 47 | + IReranker<T>? reranker = null, |
| 48 | + IContextCompressor<T>? compressor = null, |
| 49 | + IGenerator<T>? generator = null, |
| 50 | + string? tenant = null) |
| 51 | + { |
| 52 | + _embedding = embedding ?? throw new ArgumentNullException(nameof(embedding)); |
| 53 | + _store = store ?? throw new ArgumentNullException(nameof(store)); |
| 54 | + _retriever = retriever ?? throw new ArgumentNullException(nameof(retriever)); |
| 55 | + _chunking = chunking; |
| 56 | + _reranker = reranker; |
| 57 | + _compressor = compressor; |
| 58 | + _generator = generator; |
| 59 | + _tenant = string.IsNullOrWhiteSpace(tenant) ? null : tenant; |
| 60 | + } |
| 61 | + |
| 62 | + /// <summary>Metadata key under which the tenant/namespace is stored on ingested documents.</summary> |
| 63 | + public const string TenantMetadataKey = "_tenant"; |
| 64 | + |
| 65 | + /// <summary> |
| 66 | + /// Ingests a document: optionally chunks it, embeds each chunk, and upserts it into the store. |
| 67 | + /// </summary> |
| 68 | + /// <returns>The number of chunks stored.</returns> |
| 69 | + public async Task<int> IngestAsync( |
| 70 | + string id, string content, IReadOnlyDictionary<string, object>? metadata = null, CancellationToken cancellationToken = default) |
| 71 | + { |
| 72 | + if (string.IsNullOrEmpty(id)) throw new ArgumentException("Document id is required.", nameof(id)); |
| 73 | + if (string.IsNullOrEmpty(content)) throw new ArgumentException("Document content is required.", nameof(content)); |
| 74 | + |
| 75 | + var chunks = _chunking != null ? _chunking.Chunk(content).ToList() : new List<string> { content }; |
| 76 | + int index = 0; |
| 77 | + foreach (var chunk in chunks) |
| 78 | + { |
| 79 | + cancellationToken.ThrowIfCancellationRequested(); |
| 80 | + |
| 81 | + var meta = new Dictionary<string, object>(); |
| 82 | + if (metadata != null) |
| 83 | + { |
| 84 | + foreach (var kv in metadata) meta[kv.Key] = kv.Value; |
| 85 | + } |
| 86 | + if (_tenant != null) meta[TenantMetadataKey] = _tenant; |
| 87 | + |
| 88 | + var doc = new Document<T>(chunks.Count == 1 ? id : $"{id}::{index}", chunk) { Metadata = meta }; |
| 89 | + var embedding = await _embedding.EmbedAsync(chunk).ConfigureAwait(false); |
| 90 | + await _store.AddAsync(new VectorDocument<T>(doc, embedding), cancellationToken).ConfigureAwait(false); |
| 91 | + index++; |
| 92 | + } |
| 93 | + |
| 94 | + return chunks.Count; |
| 95 | + } |
| 96 | + |
| 97 | + /// <summary> |
| 98 | + /// Answers a question: retrieve → (rerank) → (compress) → (generate). |
| 99 | + /// </summary> |
| 100 | + public async Task<RagResult<T>> QueryAsync(string question, int topK = 5, CancellationToken cancellationToken = default) |
| 101 | + { |
| 102 | + if (string.IsNullOrEmpty(question)) throw new ArgumentException("Question is required.", nameof(question)); |
| 103 | + |
| 104 | + var filters = new Dictionary<string, object>(); |
| 105 | + if (_tenant != null) filters[TenantMetadataKey] = _tenant; |
| 106 | + |
| 107 | + var retrieved = (await _retriever.RetrieveAsync(question, topK, filters, cancellationToken).ConfigureAwait(false)).ToList(); |
| 108 | + |
| 109 | + if (_reranker != null) |
| 110 | + { |
| 111 | + retrieved = (await _reranker.RerankAsync(question, retrieved, cancellationToken).ConfigureAwait(false)).ToList(); |
| 112 | + } |
| 113 | + |
| 114 | + var context = _compressor != null |
| 115 | + ? await _compressor.CompressAsync(retrieved, question, null, cancellationToken).ConfigureAwait(false) |
| 116 | + : retrieved; |
| 117 | + |
| 118 | + GroundedAnswer<T>? answer = null; |
| 119 | + if (_generator != null && context.Count > 0) |
| 120 | + { |
| 121 | + answer = await _generator.GenerateGroundedAsync(question, context, cancellationToken).ConfigureAwait(false); |
| 122 | + } |
| 123 | + |
| 124 | + return new RagResult<T>(question, context, answer); |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + /// <summary>The outcome of a <see cref="RagPipeline{T}.QueryAsync"/> call.</summary> |
| 129 | + public sealed class RagResult<T> |
| 130 | + { |
| 131 | + public RagResult(string question, List<Document<T>> contexts, GroundedAnswer<T>? answer) |
| 132 | + { |
| 133 | + Question = question; |
| 134 | + Contexts = contexts; |
| 135 | + Answer = answer; |
| 136 | + } |
| 137 | + |
| 138 | + /// <summary>The original question.</summary> |
| 139 | + public string Question { get; } |
| 140 | + |
| 141 | + /// <summary>The retrieved (and reranked/compressed) context documents.</summary> |
| 142 | + public List<Document<T>> Contexts { get; } |
| 143 | + |
| 144 | + /// <summary>The generated grounded answer, or <c>null</c> when no generator was configured.</summary> |
| 145 | + public GroundedAnswer<T>? Answer { get; } |
| 146 | + } |
| 147 | +} |
0 commit comments