Skip to content

Commit 0ec5e8e

Browse files
franklinicclaude
andcommitted
feat(rag): central RagPipeline orchestrator with tenant isolation (#33)
The RAG subsystem had every component but no single class that composed them — callers wired chunk→embed→store and retrieve→rerank→compress→generate by hand. Add RagPipeline<T>: - IngestAsync: optional chunk → embed → upsert (returns chunk count). - QueryAsync: retrieve → optional rerank → optional compress → optional generate, returning a RagResult<T> (contexts + grounded answer). Fully async/cancellation-aware. Optional tenant/namespace: stamped on ingested documents' metadata and applied as a retrieval filter for cross-tenant isolation. Streaming is already provided by IStreamingGenerator/ChatClientGenerator. Verified: 3 tests (chunked ingest into a real InMemoryDocumentStore, query→generate, tenant stamp + filter). Remaining #33 items — rich boolean/$in/range metadata filters across every external store, and RAG-path GPU hooks — are larger enhancements tracked as follow-ups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent db1b88a commit 0ec5e8e

2 files changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using AiDotNet.Interfaces;
6+
using AiDotNet.RetrievalAugmentedGeneration;
7+
using AiDotNet.RetrievalAugmentedGeneration.ChunkingStrategies;
8+
using AiDotNet.RetrievalAugmentedGeneration.DocumentStores;
9+
using AiDotNet.RetrievalAugmentedGeneration.Embeddings;
10+
using AiDotNet.RetrievalAugmentedGeneration.Generators;
11+
using AiDotNet.RetrievalAugmentedGeneration.Models;
12+
using Xunit;
13+
14+
namespace AiDotNet.Tests.UnitTests.RetrievalAugmentedGeneration;
15+
16+
/// <summary>
17+
/// Tests for the end-to-end <see cref="RagPipeline{T}"/> orchestrator: ingest (chunk→embed→store), query
18+
/// (retrieve→rerank→compress→generate), and tenant/namespace isolation.
19+
/// </summary>
20+
public class RagPipelineTests
21+
{
22+
private const int Dim = 8;
23+
24+
// Minimal retriever that records the query + filters it was called with and returns a preset doc.
25+
private sealed class RecordingRetriever : IRetriever<double>
26+
{
27+
public string? LastQuery { get; private set; }
28+
public Dictionary<string, object>? LastFilters { get; private set; }
29+
public int DefaultTopK => 5;
30+
31+
public IEnumerable<Document<double>> Retrieve(string query) => Retrieve(query, DefaultTopK, new Dictionary<string, object>());
32+
public IEnumerable<Document<double>> Retrieve(string query, int topK) => Retrieve(query, topK, new Dictionary<string, object>());
33+
public IEnumerable<Document<double>> Retrieve(string query, int topK, Dictionary<string, object> metadataFilters)
34+
{
35+
LastQuery = query;
36+
LastFilters = metadataFilters;
37+
return new[] { new Document<double>("d1", "retrieved context") { RelevanceScore = 1.0, HasRelevanceScore = true } };
38+
}
39+
40+
public Task<IEnumerable<Document<double>>> RetrieveAsync(string query, int topK, Dictionary<string, object>? metadataFilters = null, CancellationToken cancellationToken = default)
41+
{
42+
cancellationToken.ThrowIfCancellationRequested();
43+
return Task.FromResult(Retrieve(query, topK, metadataFilters ?? new Dictionary<string, object>()));
44+
}
45+
}
46+
47+
private static RagPipeline<double> CreatePipeline(RecordingRetriever retriever, string? tenant = null, IChunkingStrategy? chunking = null)
48+
=> new RagPipeline<double>(
49+
embedding: new StubEmbeddingModel<double>(Dim),
50+
store: new InMemoryDocumentStore<double>(Dim),
51+
retriever: retriever,
52+
chunking: chunking,
53+
generator: new StubGenerator<double>(),
54+
tenant: tenant);
55+
56+
[Fact]
57+
public async Task IngestAsync_StoresChunkedEmbeddedDocument()
58+
{
59+
var store = new InMemoryDocumentStore<double>(Dim);
60+
var pipeline = new RagPipeline<double>(
61+
new StubEmbeddingModel<double>(Dim), store, new RecordingRetriever(),
62+
chunking: new TokenBasedChunkingStrategy(maxTokens: 3, overlapTokens: 0));
63+
64+
int stored = await pipeline.IngestAsync("doc1", "alpha beta gamma delta epsilon zeta eta theta");
65+
66+
Assert.True(stored >= 2, "long content should be split into multiple chunks");
67+
Assert.Equal(stored, store.DocumentCount);
68+
}
69+
70+
[Fact]
71+
public async Task QueryAsync_RunsRetrieveAndGenerate()
72+
{
73+
var retriever = new RecordingRetriever();
74+
var pipeline = CreatePipeline(retriever);
75+
76+
var result = await pipeline.QueryAsync("what is X?", topK: 3);
77+
78+
Assert.Equal("what is X?", retriever.LastQuery);
79+
Assert.Single(result.Contexts);
80+
Assert.Equal("d1", result.Contexts[0].Id);
81+
Assert.NotNull(result.Answer); // StubGenerator produced a grounded answer
82+
}
83+
84+
[Fact]
85+
public async Task Tenant_IsStampedOnIngestAndUsedAsRetrievalFilter()
86+
{
87+
var retriever = new RecordingRetriever();
88+
var store = new InMemoryDocumentStore<double>(Dim);
89+
var pipeline = new RagPipeline<double>(
90+
new StubEmbeddingModel<double>(Dim), store, retriever, tenant: "acme");
91+
92+
await pipeline.IngestAsync("doc1", "hello world");
93+
await pipeline.QueryAsync("q");
94+
95+
// The retrieval filter carries the tenant so cross-tenant data is isolated.
96+
Assert.True(retriever.LastFilters!.ContainsKey(RagPipeline<double>.TenantMetadataKey));
97+
Assert.Equal("acme", retriever.LastFilters![RagPipeline<double>.TenantMetadataKey]);
98+
99+
// And the stored document was stamped with the tenant.
100+
var all = store.GetAll().ToList();
101+
Assert.Single(all);
102+
Assert.Equal("acme", all[0].Metadata[RagPipeline<double>.TenantMetadataKey]);
103+
}
104+
}

0 commit comments

Comments
 (0)