Skip to content

Commit 65071fc

Browse files
fix: address code review issues in embedding bulk update
- Rename candidates_list → candidatesList (C# camelCase convention) - Make NpgsqlDataSource required in EmbeddingService constructor (always registered in DI; optional+throw was misleading anti-pattern) - Add EmbeddingBatchSize = 2048 constant and batch the GenerateAsync call to respect Azure OpenAI input limit - Validate collectionName against safe identifier regex before SQL use - Add best-effort staging cleanup on UpsertAsync failure (nested try so cleanup exception cannot mask the original) - Document ChapterNumber nullability on BookContentChunk property and ToBookContentChunks public method Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 76d6d17 commit 65071fc

4 files changed

Lines changed: 53 additions & 21 deletions

File tree

EssentialCSharp.Chat.Shared/Models/BookContentChunk.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ public sealed class BookContentChunk
3232
public string ChunkText { get; set; } = string.Empty;
3333

3434
/// <summary>
35-
/// Chapter number extracted from filename (e.g., "Chapter01.md" -> 1)
35+
/// Chapter number extracted from filename (e.g., "Chapter01.md" -> 1).
36+
/// Null for files that do not follow the ChapterNN naming pattern.
3637
/// </summary>
3738
[VectorStoreData]
3839
public int? ChapterNumber { get; set; }

EssentialCSharp.Chat.Shared/Services/AISearchService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Diagnostics;
1+
using System.Diagnostics;
22
using EssentialCSharp.Chat.Common.Models;
33
using Microsoft.Extensions.Logging;
44
using Microsoft.Extensions.VectorData;

EssentialCSharp.Chat.Shared/Services/ChunkingResultExtensions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ namespace EssentialCSharp.Chat.Common.Services;
66

77
public static partial class ChunkingResultExtensions
88
{
9+
/// <summary>
10+
/// Converts a <see cref="FileChunkingResult"/> into a list of <see cref="BookContentChunk"/> records
11+
/// ready for embedding and vector store upload.
12+
/// </summary>
13+
/// <remarks>
14+
/// <see cref="BookContentChunk.ChapterNumber"/> is set to null for files that do not match
15+
/// the <c>ChapterNN</c> naming pattern (e.g. appendix or non-chapter markdown files).
16+
/// </remarks>
917
public static List<BookContentChunk> ToBookContentChunks(this FileChunkingResult result)
1018
{
1119
int? chapterNumber = ExtractChapterNumber(result.FileName);

EssentialCSharp.Chat.Shared/Services/EmbeddingService.cs

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Text.RegularExpressions;
12
using EssentialCSharp.Chat.Common.Models;
23
using Microsoft.Extensions.AI;
34
using Microsoft.Extensions.VectorData;
@@ -12,10 +13,18 @@ namespace EssentialCSharp.Chat.Common.Services;
1213
public 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

Comments
 (0)