Skip to content

Commit 76d6d17

Browse files
Update embedding logic
1 parent 621e630 commit 76d6d17

8 files changed

Lines changed: 174 additions & 67 deletions

File tree

EssentialCSharp.Chat.Shared/Models/BookContentChunk.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ public sealed class BookContentChunk
3737
[VectorStoreData]
3838
public int? ChapterNumber { get; set; }
3939

40+
/// <summary>
41+
/// Zero-based ordinal of this chunk within its source file.
42+
/// Together with FileName, forms the basis for the deterministic Id.
43+
/// </summary>
44+
[VectorStoreData]
45+
public int ChunkIndex { get; set; }
46+
4047
/// <summary>
4148
/// SHA256 hash of the chunk content for change detection
4249
/// </summary>

EssentialCSharp.Chat.Shared/Services/AISearchService.cs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,29 @@ public async Task<IReadOnlyList<VectorSearchResult<BookContentChunk>>> ExecuteVe
3535
{
3636
try
3737
{
38-
var results = new List<VectorSearchResult<BookContentChunk>>();
39-
await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: top, cancellationToken: cancellationToken))
38+
// Fetch more candidates than needed so we can deduplicate by heading.
39+
// Multiple chunks from the same section share the same Heading; without dedup
40+
// all top-N results could come from one long section, reducing context diversity.
41+
int candidates = top * 3;
42+
43+
var candidatesList = new List<VectorSearchResult<BookContentChunk>>();
44+
await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: candidates, cancellationToken: cancellationToken))
4045
{
41-
results.Add(result);
46+
candidatesList.Add(result);
4247
}
48+
49+
// Keep only the highest-scoring chunk per unique heading, then take the globally
50+
// top-N by score. GroupBy on a materialized list preserves insertion (score desc)
51+
// order, but we make the ordering explicit via OrderByDescending so the result
52+
// is correct regardless of provider sort guarantees.
53+
// MaxBy on a non-empty IGrouping never returns null; ! asserts this invariant.
54+
var results = candidatesList
55+
.GroupBy(r => r.Record.Heading)
56+
.Select(g => g.MaxBy(r => r.Score)!)
57+
.OrderByDescending(r => r.Score)
58+
.Take(top)
59+
.ToList();
60+
4361
return results;
4462
}
4563
catch (PostgresException ex) when (ex.SqlState == "28000" && attempt == 0)

EssentialCSharp.Chat.Shared/Services/ChunkingResultExtensions.cs

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System.Security.Cryptography;
22
using System.Text;
3-
using System.Linq;
43
using EssentialCSharp.Chat.Common.Models;
54

65
namespace EssentialCSharp.Chat.Common.Services;
@@ -12,16 +11,17 @@ public static List<BookContentChunk> ToBookContentChunks(this FileChunkingResult
1211
int? chapterNumber = ExtractChapterNumber(result.FileName);
1312

1413
var chunks = result.Chunks
15-
.Select(chunkText =>
14+
.Select((markdownChunk, index) =>
1615
{
17-
var contentHash = ComputeSha256Hash(chunkText);
16+
var contentHash = ComputeSha256Hash(markdownChunk.ChunkText);
1817
return new BookContentChunk
1918
{
20-
Id = Guid.NewGuid().ToString(),
19+
Id = $"{result.FileName}_{index}",
2120
FileName = result.FileName,
22-
Heading = ExtractHeading(chunkText),
23-
ChunkText = chunkText,
21+
Heading = markdownChunk.Heading,
22+
ChunkText = markdownChunk.ChunkText,
2423
ChapterNumber = chapterNumber,
24+
ChunkIndex = index,
2525
ContentHash = contentHash
2626
};
2727
})
@@ -30,25 +30,13 @@ public static List<BookContentChunk> ToBookContentChunks(this FileChunkingResult
3030
return chunks;
3131
}
3232

33-
private static string ExtractHeading(string chunkText)
33+
private static int? ExtractChapterNumber(string fileName)
3434
{
35-
// get characters until the first " - " or newline
36-
var firstLine = chunkText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None)[0];
37-
var headingParts = firstLine.Split([" - "], StringSplitOptions.None);
38-
return headingParts.Length > 0 ? headingParts[0].Trim() : string.Empty;
39-
}
40-
41-
private static int ExtractChapterNumber(string fileName)
42-
{
43-
// Example: "Chapter01.md" -> 1
44-
// Regex: Chapter(?<ChapterNumber>[0-9]{2})
35+
// Example: "Chapter01.md" -> 1; non-chapter files return null.
4536
var match = ChapterNumberRegex().Match(fileName);
4637
if (match.Success && int.TryParse(match.Groups["ChapterNumber"].Value, out int chapterNumber))
47-
48-
{
4938
return chapterNumber;
50-
}
51-
throw new InvalidOperationException($"File name '{fileName}' does not contain a valid chapter number in the expected format.");
39+
return null;
5240
}
5341

5442
private static string ComputeSha256Hash(string text)
Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,122 @@
11
using EssentialCSharp.Chat.Common.Models;
22
using Microsoft.Extensions.AI;
33
using Microsoft.Extensions.VectorData;
4+
using Npgsql;
45

56
namespace EssentialCSharp.Chat.Common.Services;
67

78
/// <summary>
8-
/// Service for generating embeddings for markdown chunks using Azure OpenAI
9+
/// Service for generating embeddings for markdown chunks using Azure OpenAI and uploading
10+
/// them to a PostgreSQL vector store via a staging-then-swap pattern to avoid downtime.
911
/// </summary>
10-
public class EmbeddingService(VectorStore vectorStore, IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
12+
public class EmbeddingService(
13+
VectorStore vectorStore,
14+
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator,
15+
NpgsqlDataSource? dataSource = null)
1116
{
1217
public static string CollectionName { get; } = "markdown_chunks";
1318

1419
/// <summary>
1520
/// Generate an embedding for the given text.
1621
/// </summary>
17-
/// <param name="text">The text to generate an embedding for.</param>
18-
/// <param name="cancellationToken">The cancellation token.</param>
19-
/// <returns>A search vector as ReadOnlyMemory&lt;float&gt;.</returns>
2022
public async Task<ReadOnlyMemory<float>> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken = default)
2123
{
2224
var embedding = await embeddingGenerator.GenerateAsync(text, cancellationToken: cancellationToken);
2325
return embedding.Vector;
2426
}
2527

2628
/// <summary>
27-
/// Generate an embedding for each text paragraph and upload it to the specified collection.
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
31+
/// throughout the rebuild.
32+
///
33+
/// Steps:
34+
/// 1. Create a staging collection ({collectionName}_staging).
35+
/// 2. Embed all chunks in one batch API call (Azure OpenAI supports up to 2048 inputs).
36+
/// 3. Batch-upsert all chunks into staging.
37+
/// 4. Atomically swap staging → live via three SQL RENAMEs in a single transaction.
38+
/// PostgreSQL ALTER TABLE acquires AccessExclusiveLock automatically; no explicit
39+
/// LOCK TABLE is needed. The transaction ensures no reader sees an intermediate state.
40+
/// 5. Drop the old live backup table.
41+
///
42+
/// If an error occurs before the swap, only the staging table is affected — the live
43+
/// collection is untouched.
2844
/// </summary>
29-
/// <param name="collectionName">The name of the collection to upload the text paragraphs to.</param>
30-
/// <returns>An async task.</returns>
31-
public async Task GenerateBookContentEmbeddingsAndUploadToVectorStore(IEnumerable<BookContentChunk> bookContents, CancellationToken cancellationToken, string? collectionName = null)
45+
public async Task GenerateBookContentEmbeddingsAndUploadToVectorStore(
46+
IEnumerable<BookContentChunk> bookContents,
47+
CancellationToken cancellationToken,
48+
string? collectionName = null)
3249
{
3350
collectionName ??= CollectionName;
51+
string stagingName = $"{collectionName}_staging";
52+
string oldName = $"{collectionName}_old";
3453

35-
var collection = vectorStore.GetCollection<string, BookContentChunk>(collectionName);
36-
await collection.EnsureCollectionDeletedAsync(cancellationToken);
37-
await collection.EnsureCollectionExistsAsync(cancellationToken);
54+
if (dataSource is null)
55+
throw new InvalidOperationException("NpgsqlDataSource is required for the staging swap. Ensure it is registered in DI.");
3856

39-
int uploadedCount = 0;
57+
// ── Step 1: Prepare staging collection ────────────────────────────────────────
58+
var staging = vectorStore.GetCollection<string, BookContentChunk>(stagingName);
59+
await staging.EnsureCollectionDeletedAsync(cancellationToken);
60+
await staging.EnsureCollectionExistsAsync(cancellationToken);
4061

41-
foreach (var chunk in bookContents)
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.
66+
var chunkList = bookContents.ToList();
67+
var texts = chunkList.Select(c => c.ChunkText).ToList();
68+
69+
GeneratedEmbeddings<Embedding<float>> embeddings =
70+
await embeddingGenerator.GenerateAsync(texts, cancellationToken: cancellationToken);
71+
72+
if (embeddings.Count != chunkList.Count)
73+
throw new InvalidOperationException(
74+
$"Embedding count mismatch: expected {chunkList.Count}, got {embeddings.Count}.");
75+
76+
for (int i = 0; i < chunkList.Count; i++)
77+
{
78+
chunkList[i].TextEmbedding = embeddings[i].Vector;
79+
}
80+
81+
// ── 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}'.");
84+
85+
// ── Step 4: Atomic swap — staging → live ──────────────────────────────────────
86+
// Three ALTER TABLE RENAME statements in one transaction.
87+
// Each RENAME auto-acquires AccessExclusiveLock on its table; the transaction
88+
// guarantees all three renames are visible atomically to other sessions.
89+
await using var conn = await dataSource.OpenConnectionAsync(cancellationToken);
90+
await using var tx = await conn.BeginTransactionAsync(cancellationToken);
91+
92+
await using (var cmd = conn.CreateCommand())
4293
{
43-
cancellationToken.ThrowIfCancellationRequested();
44-
chunk.TextEmbedding = await GenerateEmbeddingAsync(chunk.ChunkText, cancellationToken);
45-
await collection.UpsertAsync(chunk, cancellationToken);
46-
Console.WriteLine($"Uploaded chunk '{chunk.Id}' to collection '{collectionName}' for file '{chunk.FileName}' with heading '{chunk.Heading}'.");
47-
uploadedCount++;
94+
cmd.Transaction = tx;
95+
96+
// Drop any leftover backup from a previous run
97+
cmd.CommandText = $"DROP TABLE IF EXISTS \"{oldName}\"";
98+
await cmd.ExecuteNonQueryAsync(cancellationToken);
99+
100+
// 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.
102+
cmd.CommandText = $"ALTER TABLE IF EXISTS \"{collectionName}\" RENAME TO \"{oldName}\"";
103+
await cmd.ExecuteNonQueryAsync(cancellationToken);
104+
105+
// Rename staging → live
106+
cmd.CommandText = $"ALTER TABLE \"{stagingName}\" RENAME TO \"{collectionName}\"";
107+
await cmd.ExecuteNonQueryAsync(cancellationToken);
108+
}
109+
110+
await tx.CommitAsync(cancellationToken);
111+
Console.WriteLine($"Swapped '{stagingName}' → '{collectionName}' atomically.");
112+
113+
// ── Step 5: Drop the old backup ───────────────────────────────────────────────
114+
await using (var cmd = conn.CreateCommand())
115+
{
116+
cmd.CommandText = $"DROP TABLE IF EXISTS \"{oldName}\"";
117+
await cmd.ExecuteNonQueryAsync(cancellationToken);
48118
}
49-
Console.WriteLine($"Successfully generated embeddings and uploaded {uploadedCount} chunks to collection '{collectionName}'.");
119+
120+
Console.WriteLine($"Successfully generated embeddings and uploaded {chunkList.Count} chunks to collection '{collectionName}'.");
50121
}
51122
}

EssentialCSharp.Chat.Shared/Services/FileChunkingResult.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
namespace EssentialCSharp.Chat.Common.Services;
22

3+
/// <summary>
4+
/// A single chunk from a markdown file, paired with the section heading it belongs to.
5+
/// </summary>
6+
/// <param name="Heading">Full breadcrumb heading for the section (e.g. "Chapter: 1: Intro: Summary").</param>
7+
/// <param name="ChunkText">The raw chunk text, including the "Heading - " prefix prepended by TextChunker.</param>
8+
public record MarkdownChunk(string Heading, string ChunkText);
9+
310
/// <summary>
411
/// Data structure to hold chunking results for a single file
512
/// </summary>
@@ -9,6 +16,6 @@ public class FileChunkingResult
916
public string FilePath { get; set; } = string.Empty;
1017
public int OriginalCharCount { get; set; }
1118
public int ChunkCount { get; set; }
12-
public List<string> Chunks { get; set; } = [];
19+
public List<MarkdownChunk> Chunks { get; set; } = [];
1320
public int TotalChunkCharacters { get; set; }
1421
}

EssentialCSharp.Chat.Shared/Services/MarkdownChunkingService.cs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,24 @@ public async Task<List<FileChunkingResult>> ProcessMarkdownFilesAsync(
6161
public FileChunkingResult ProcessSingleMarkdownFile(
6262
string[] fileContent, string fileName, string filePath)
6363
{
64-
// Remove all multiple empty lines so there is no more than one empty line between paragraphs
65-
string[] lines = [.. fileContent
66-
.Select(line => line.Trim())
67-
.Where(line => !string.IsNullOrWhiteSpace(line))];
68-
64+
// Collapse consecutive blank lines to at most one blank line. Single blank lines must
65+
// be preserved because TextChunker.SplitMarkdownParagraphs uses them as paragraph
66+
// separators — stripping all blanks defeats paragraph-aware chunking.
67+
var normalizedLines = new List<string>(fileContent.Length);
68+
bool lastWasBlank = false;
69+
foreach (var raw in fileContent)
70+
{
71+
var line = raw.Trim();
72+
var isBlank = string.IsNullOrWhiteSpace(line);
73+
if (!isBlank || !lastWasBlank)
74+
normalizedLines.Add(line);
75+
lastWasBlank = isBlank;
76+
}
77+
string[] lines = [.. normalizedLines];
6978
string content = string.Join(Environment.NewLine, lines);
7079

7180
var sections = MarkdownContentToHeadersAndSection(content);
72-
var allChunks = new List<string>();
81+
var allChunks = new List<MarkdownChunk>();
7382
int totalChunkCharacters = 0;
7483
int chunkCount = 0;
7584

@@ -83,7 +92,7 @@ public FileChunkingResult ProcessSingleMarkdownFile(
8392
chunkHeader: Header + " - "
8493
);
8594
#pragma warning restore SKEXP0050
86-
allChunks.AddRange(chunks);
95+
allChunks.AddRange(chunks.Select(c => new MarkdownChunk(Header, c)));
8796
chunkCount += chunks.Count;
8897
totalChunkCharacters += chunks.Sum(c => c.Length);
8998
}
@@ -155,18 +164,24 @@ public FileChunkingResult ProcessSingleMarkdownFile(
155164
}
156165
i++;
157166

158-
// Collect content until next header
167+
// Collect content until next header, preserving blank lines as paragraph separators
168+
// for TextChunker.SplitMarkdownParagraphs.
159169
var contentLines = new List<string>();
160170
while (i < lines.Length && !headerRegex.IsMatch(lines[i]))
161171
{
162-
if (!string.IsNullOrWhiteSpace(lines[i]))
163-
contentLines.Add(lines[i]);
172+
contentLines.Add(lines[i]);
164173
i++;
165174
}
166175

176+
// Strip leading and trailing blank lines; keep internal blanks for paragraph detection.
177+
while (contentLines.Count > 0 && string.IsNullOrWhiteSpace(contentLines[0]))
178+
contentLines.RemoveAt(0);
179+
while (contentLines.Count > 0 && string.IsNullOrWhiteSpace(contentLines[^1]))
180+
contentLines.RemoveAt(contentLines.Count - 1);
181+
167182
// Compose full header context
168183
var fullHeader = string.Join(": ", headerStack.Select(h => h.Text));
169-
if (contentLines.Count > 0)
184+
if (contentLines.Any(l => !string.IsNullOrWhiteSpace(l)))
170185
sections.Add((fullHeader, contentLines));
171186
}
172187
return sections;

EssentialCSharp.Chat.Tests/MarkdownChunkingServiceTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,9 @@ public async Task ProcessSingleMarkdownFile_ProducesExpectedChunksAndHeaders()
183183
await Assert.That(result).IsNotNull();
184184
await Assert.That(result.FileName).IsEqualTo(fileName);
185185
await Assert.That(result.FilePath).IsEqualTo(filePath);
186-
await Assert.That(string.Join("\n", result.Chunks)).Contains("This is the first section.");
187-
await Assert.That(string.Join("\n", result.Chunks)).Contains("Console.WriteLine(\"Hello World\");");
188-
await Assert.That(result.Chunks).Contains(c => c.Contains("This is the second section."));
186+
await Assert.That(string.Join("\n", result.Chunks.Select(c => c.ChunkText))).Contains("This is the first section.");
187+
await Assert.That(string.Join("\n", result.Chunks.Select(c => c.ChunkText))).Contains("Console.WriteLine(\"Hello World\");");
188+
await Assert.That(result.Chunks).Contains(c => c.ChunkText.Contains("This is the second section."));
189189
}
190190
#endregion ProcessSingleMarkdownFile
191191
}

0 commit comments

Comments
 (0)