1+ using System . Text . RegularExpressions ;
12using EssentialCSharp . Chat . Common . Models ;
23using Microsoft . Extensions . AI ;
34using Microsoft . Extensions . VectorData ;
@@ -12,10 +13,18 @@ namespace EssentialCSharp.Chat.Common.Services;
1213public class EmbeddingService (
1314 VectorStore vectorStore ,
1415 IEmbeddingGenerator < string , Embedding < float > > embeddingGenerator ,
15- NpgsqlDataSource ? dataSource = null )
16+ NpgsqlDataSource dataSource )
1617{
1718 public static string CollectionName { get ; } = "markdown_chunks" ;
1819
20+ /// <summary>
21+ /// Maximum number of inputs per Azure OpenAI embedding batch call.
22+ /// </summary>
23+ private const int EmbeddingBatchSize = 2048 ;
24+
25+ // Only allow simple identifiers: letters, digits, and underscores, starting with a letter or underscore.
26+ private static readonly Regex _safeIdentifierRegex = new ( @"^[a-zA-Z_][a-zA-Z0-9_]*$" , RegexOptions . Compiled ) ;
27+
1928 /// <summary>
2029 /// Generate an embedding for the given text.
2130 /// </summary>
@@ -26,13 +35,13 @@ public async Task<ReadOnlyMemory<float>> GenerateEmbeddingAsync(string text, Can
2635 }
2736
2837 /// <summary>
29- /// Generate embeddings for all chunks in a single batch call and upload them to the vector
30- /// store using a staging-then-atomic-swap pattern so the live collection stays queryable
38+ /// Generate embeddings for all chunks in batches and upload them to the vector store
39+ /// using a staging-then-atomic-swap pattern so the live collection stays queryable
3140 /// throughout the rebuild.
3241 ///
3342 /// Steps:
3443 /// 1. Create a staging collection ({collectionName}_staging).
35- /// 2. Embed all chunks in one batch API call (Azure OpenAI supports up to 2048 inputs ).
44+ /// 2. Embed all chunks in batches of <see cref="EmbeddingBatchSize"/> (Azure OpenAI limit ).
3645 /// 3. Batch-upsert all chunks into staging.
3746 /// 4. Atomically swap staging → live via three SQL RENAMEs in a single transaction.
3847 /// PostgreSQL ALTER TABLE acquires AccessExclusiveLock automatically; no explicit
@@ -48,39 +57,54 @@ public async Task GenerateBookContentEmbeddingsAndUploadToVectorStore(
4857 string ? collectionName = null )
4958 {
5059 collectionName ??= CollectionName ;
60+
61+ if ( ! _safeIdentifierRegex . IsMatch ( collectionName ) )
62+ throw new ArgumentException (
63+ $ "Collection name '{ collectionName } ' contains unsafe characters. Use only letters, digits, and underscores.",
64+ nameof ( collectionName ) ) ;
65+
5166 string stagingName = $ "{ collectionName } _staging";
5267 string oldName = $ "{ collectionName } _old";
5368
54- if ( dataSource is null )
55- throw new InvalidOperationException ( "NpgsqlDataSource is required for the staging swap. Ensure it is registered in DI." ) ;
56-
5769 // ── Step 1: Prepare staging collection ────────────────────────────────────────
5870 var staging = vectorStore . GetCollection < string , BookContentChunk > ( stagingName ) ;
5971 await staging . EnsureCollectionDeletedAsync ( cancellationToken ) ;
6072 await staging . EnsureCollectionExistsAsync ( cancellationToken ) ;
6173
62- // ── Step 2: Batch-embed all chunks in a single API call ───────────────────────
63- // IEmbeddingGenerator.GenerateAsync natively accepts IEnumerable<string>.
64- // The single-string overload used previously is a convenience extension method
65- // that wraps one item and calls this same method.
74+ // ── Step 2: Batch-embed all chunks ────────────────────────────────────────────
75+ // Azure OpenAI supports at most EmbeddingBatchSize inputs per GenerateAsync call.
6676 var chunkList = bookContents . ToList ( ) ;
6777 var texts = chunkList . Select ( c => c . ChunkText ) . ToList ( ) ;
6878
69- GeneratedEmbeddings < Embedding < float > > embeddings =
70- await embeddingGenerator . GenerateAsync ( texts , cancellationToken : cancellationToken ) ;
79+ var allEmbeddings = new List < Embedding < float > > ( chunkList . Count ) ;
80+ foreach ( var batch in texts . Chunk ( EmbeddingBatchSize ) )
81+ {
82+ var batchEmbeddings = await embeddingGenerator . GenerateAsync ( batch , cancellationToken : cancellationToken ) ;
83+ allEmbeddings . AddRange ( batchEmbeddings ) ;
84+ }
7185
72- if ( embeddings . Count != chunkList . Count )
86+ if ( allEmbeddings . Count != chunkList . Count )
7387 throw new InvalidOperationException (
74- $ "Embedding count mismatch: expected { chunkList . Count } , got { embeddings . Count } .") ;
88+ $ "Embedding count mismatch: expected { chunkList . Count } , got { allEmbeddings . Count } .") ;
7589
7690 for ( int i = 0 ; i < chunkList . Count ; i ++ )
7791 {
78- chunkList [ i ] . TextEmbedding = embeddings [ i ] . Vector ;
92+ chunkList [ i ] . TextEmbedding = allEmbeddings [ i ] . Vector ;
7993 }
8094
8195 // ── Step 3: Batch-upsert all chunks into staging ──────────────────────────────
82- await staging . UpsertAsync ( chunkList , cancellationToken ) ;
83- Console . WriteLine ( $ "Uploaded { chunkList . Count } chunks to staging collection '{ stagingName } '.") ;
96+ try
97+ {
98+ await staging . UpsertAsync ( chunkList , cancellationToken ) ;
99+ Console . WriteLine ( $ "Uploaded { chunkList . Count } chunks to staging collection '{ stagingName } '.") ;
100+ }
101+ catch
102+ {
103+ // Best-effort cleanup: drop the partially-populated staging table so the
104+ // next run starts clean. Do not let this secondary failure mask the original.
105+ try { await staging . EnsureCollectionDeletedAsync ( cancellationToken ) ; } catch { }
106+ throw ;
107+ }
84108
85109 // ── Step 4: Atomic swap — staging → live ──────────────────────────────────────
86110 // Three ALTER TABLE RENAME statements in one transaction.
@@ -98,7 +122,6 @@ public async Task GenerateBookContentEmbeddingsAndUploadToVectorStore(
98122 await cmd . ExecuteNonQueryAsync ( cancellationToken ) ;
99123
100124 // Rename live → old. IF EXISTS is a no-op on first run when no live table exists.
101- // Using ALTER TABLE IF EXISTS avoids PL/pgSQL string interpolation entirely.
102125 cmd . CommandText = $ "ALTER TABLE IF EXISTS \" { collectionName } \" RENAME TO \" { oldName } \" ";
103126 await cmd . ExecuteNonQueryAsync ( cancellationToken ) ;
104127
0 commit comments