diff --git a/README.md b/README.md
index 7faa62a..8c48b6e 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,29 @@
# SQL Database Vector Search Sample
A repository that showcases the native VECTOR type in Azure SQL Database to perform embeddings and RAG with Azure OpenAI.
-The application is a Minimal API that exposes endpoints to load documents, generate embeddings and save them into the database as Vectors, and perform searches using Vector Search and RAG. Currently, PDF, DOCX, TXT and MD files are supported. Vectors are saved and retrieved with Entity Framework Core using the [EFCore.SqlServer.VectorSearch](https://github.com/efcore/EfCore.SqlServer.VectorSearch) library. Embedding and Chat Completion are integrated with [Semantic Kernel](https://github.com/microsoft/semantic-kernel).
+The application allows to load documents, generate embeddings and save them into the database as Vectors, and perform searches using Vector Search and RAG. Currently, PDF, DOCX, TXT and MD files are supported. Vectors are saved and retrieved with Entity Framework Core using the [EFCore.SqlServer.VectorSearch](https://github.com/efcore/EfCore.SqlServer.VectorSearch) library. Embedding and Chat Completion are integrated with [Semantic Kernel](https://github.com/microsoft/semantic-kernel).
> [!NOTE]
> If you prefer to use straight SQL, check out the [sql branch](https://github.com/marcominerva/SqlDatabaseVectorSearch/tree/sql).
-
+This repository contains a Blazor Web App as well as a Minimal API that allows to programatically interact with embeddings and RAG.
+
+### Web App
+
+
+### Web API
+
## Setup
-- [Create an Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/single-database-create-quickstart) on a server that has the Vector Support feature enabled
-- Execute the [Scripts.sql](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/Scripts.sql) file to create the tables needed by the application
- - You may need to update the size of the [`VECTOR`](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/Scripts.sql#L17) column to match the size of the embedding model. Currently, the maximum allowed value is 1998.
+- [Create an Azure SQL Database](https://learn.microsoft.com/en-us/azure/azure-sql/database/single-database-create-quickstart)
- Open the [appsettings.json](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/SqlDatabaseVectorSearch/appsettings.json) file and set the connection string to the database and the other settings required by Azure OpenAI
- - If your embedding model supports shortening, like **text-embedding-3-small** and **text-embedding-3-large**, and you want to use this feature, you need to set the [`Dimensions`](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/SqlDatabaseVectorSearch/appsettings.json#L17) property to match the value you have used in the SQL script. If your model doesn't provide this feature, or do you want to use the default size, just leave the [`Dimensions`](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/SqlDatabaseVectorSearch/appsettings.json#L17) property to NULL. Keep in mind that **text-embedding-3-small** has a dimension of 1536, while **text-embedding-3-large** uses vectors with 3072 elements, so with this latter model it is mandatory to specify a value (that, as said, must be less or equal to 1998).
-- Run the application and start importing your documents with `/api/documents` endpoint.
-- Ask questions using `/api/ask` or `/api/ask-streaming` endpoints.
+ - If your embedding model supports shortening, like **text-embedding-3-small** and **text-embedding-3-large**, and you want to use this feature, you need to set the [`Dimensions`](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/SqlDatabaseVectorSearch/appsettings.json#L17) property to the corresponding value. If your model doesn't provide this feature, or do you want to use the default size, just leave the [`Dimensions`](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/SqlDatabaseVectorSearch/appsettings.json#L17) property to NULL. Keep in mind that **text-embedding-3-small** has a dimension of 1536, while **text-embedding-3-large** uses vectors with 3072 elements, so with this latter model it is mandatory to specify a value (that, as said, must be less or equal to 1998).
+- You may need to update the size of the [`VECTOR`](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/SqlDatabaseVectorSearch/DataAccessLayer/ApplicationDbContext.cs?plain=1#L42C1-L42C47) column to match the size of the embedding model. The default value is 1536. Currently, the maximum allowed value is 1998. If you change it, remember to update also the [Database Migration](https://github.com/marcominerva/SqlDatabaseVectorSearch/blob/master/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/00000000000000_Initial.cs?plain=1#L35C1-L35C92).
+- Run the application and start importing your documents
+- If you want to directly use the APIs:
+ - import your documents with the `/api/documents` endpoint.
+ - Ask questions using `/api/ask` or `/api/ask-streaming` endpoints.
## Supported features
diff --git a/SqlDatabaseVectorSearch/Components/App.razor b/SqlDatabaseVectorSearch/Components/App.razor
new file mode 100644
index 0000000..6b1533a
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/App.razor
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor b/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor
new file mode 100644
index 0000000..c9c7108
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor
@@ -0,0 +1,61 @@
+@using System.Runtime.InteropServices
+
+@inherits LayoutComponentBase
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @Body
+
+
+
+
+@code {
+ private IEnumerable navItems = default!;
+
+ private Task Sidebar2DataProvider(Sidebar2DataProviderRequest request)
+ {
+ if (navItems is null)
+ {
+ navItems = GetNavItems();
+ }
+
+ var result = request.ApplyTo(navItems);
+ return Task.FromResult(result);
+ }
+
+ private IEnumerable GetNavItems()
+ {
+ navItems = [
+ new() { Id = "1", Href = "/", IconName = IconName.HouseDoorFill, Text = "Home", Match = NavLinkMatch.All},
+ new() { Id = "2", Href= "/documents", IconName = IconName.FileText, Text = "Documents" },
+ new() { Id = "3", Href = "/ask", IconName = IconName.ChatDots, Text = "Ask"}
+ ];
+
+ return navItems;
+ }
+}
+
+
+ An unhandled error has occurred.
+
Reload
+
🗙
+
diff --git a/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor.css b/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor.css
new file mode 100644
index 0000000..a3ae64a
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/Layout/MainLayout.razor.css
@@ -0,0 +1,20 @@
+#blazor-error-ui {
+ color-scheme: light only;
+ background: lightyellow;
+ bottom: 0;
+ box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ display: none;
+ left: 0;
+ padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+ position: fixed;
+ width: 100%;
+ z-index: 1000;
+}
+
+ #blazor-error-ui .dismiss {
+ cursor: pointer;
+ position: absolute;
+ right: 0.75rem;
+ top: 0.5rem;
+ }
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
new file mode 100644
index 0000000..a6691ed
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor
@@ -0,0 +1,258 @@
+@page "/ask"
+
+@inject IServiceProvider ServiceProvider
+@inject IJSRuntime JSRuntime
+
+Chat with your data
+
+
+
+
+ @foreach (var message in messages)
+ {
+ if (message.Role == "user")
+ {
+
+ }
+ else if (message.Role == "assistant")
+ {
+
+
+
+
+
+
+ @if (message.Text is null)
+ {
+
+ }
+ else
+ {
+
+
+ @message.Text
+
+ @if (message.IsCompleted)
+ {
+
+
+
+
+
+
+
+
+ await CopyToClipboardAsync(message.Text))">
+
+
+
+
+
+ }
+
+ }
+
+
+
+ }
+ }
+
+
+
+
+
+
+
+@code
+{
+ private Button askButton = default!;
+ private Button resetButton = default!;
+ private ElementReference askInput = default!;
+ private ElementReference chat = default!;
+
+ private IList messages = [];
+ private string? question;
+
+ private Guid conversationId = Guid.NewGuid();
+ private bool isAsking = false;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender)
+ {
+ return;
+ }
+
+ await JSRuntime.InvokeVoidAsync("setFocus", askInput);
+ }
+
+ private async Task HandleKeyDown(KeyboardEventArgs e)
+ {
+ if (isAsking)
+ {
+ return;
+ }
+
+ if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(question))
+ {
+ await AskQuestion();
+ }
+ else if (e.Key == "ArrowUp" && messages.Count >= 2)
+ {
+ question = messages[^2].Text;
+ }
+ }
+
+ private async Task AskQuestion()
+ {
+ isAsking = true;
+
+ var userQuestion = new Question(conversationId, question!);
+ var userMessage = new Message { Text = userQuestion.Text, Role = "user" };
+ messages.Add(userMessage);
+
+ var assistantMessage = new Message { Role = "assistant" };
+ messages.Add(assistantMessage);
+
+ question = null;
+ await Task.Yield();
+
+ await EnsureMessageIsVisibleAsync();
+
+ try
+ {
+ await using var scope = ServiceProvider.CreateAsyncScope();
+ var vectorSearchService = scope.ServiceProvider.GetRequiredService();
+
+ var response = vectorSearchService.AskStreamingAsync(userQuestion);
+ await foreach (var delta in response)
+ {
+ if (delta.StreamState == StreamState.Start)
+ {
+ assistantMessage.TokenUsage = FormatTokenUsage(delta.TokenUsage);
+ }
+ else if (delta.StreamState == StreamState.Append)
+ {
+ assistantMessage.Text += delta.Answer;
+ }
+ else if (delta.StreamState == StreamState.End)
+ {
+ assistantMessage.IsCompleted = true;
+ assistantMessage.TokenUsage += FormatTokenUsage(delta.TokenUsage);
+ }
+
+ await Task.Yield();
+ StateHasChanged();
+
+ await EnsureMessageIsVisibleAsync();
+ }
+ }
+ catch (Exception ex)
+ {
+ assistantMessage.Text = $"There was an error while processing the question: {ex.Message}";
+ assistantMessage.IsCompleted = true;
+ }
+ finally
+ {
+ await EnsureMessageIsVisibleAsync();
+
+ isAsking = false;
+ }
+ }
+
+ private void Reset()
+ {
+ question = null;
+ conversationId = Guid.NewGuid();
+ messages.Clear();
+ }
+
+ private async Task CopyToClipboardAsync(string text)
+ {
+ await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text);
+ }
+
+ private static string FormatTokenUsage(TokenUsageResponse? tokenUsageResponse)
+ {
+ if (tokenUsageResponse is null)
+ {
+ return string.Empty;
+ }
+
+ var reformulation = tokenUsageResponse.Reformulation is not null
+ ? $"Reformulation: {FormatTokenUsageDetails(tokenUsageResponse.Reformulation)}
"
+ : string.Empty;
+
+ var embeddingTokenCount = tokenUsageResponse.EmbeddingTokenCount.HasValue
+ ? $"Embedding Token Count: {tokenUsageResponse.EmbeddingTokenCount}
"
+ : string.Empty;
+
+ var question = tokenUsageResponse.Question is not null
+ ? $"Question: {FormatTokenUsageDetails(tokenUsageResponse.Question)}
"
+ : string.Empty;
+
+ return $"{reformulation}{embeddingTokenCount}{question}";
+
+ static string FormatTokenUsageDetails(TokenUsage? tokenUsage)
+ {
+ if (tokenUsage is null)
+ {
+ return string.Empty;
+ }
+
+ return $"Input Token Count: {tokenUsage.InputTokenCount} " +
+ $"Output Token Count: {tokenUsage.OutputTokenCount} " +
+ $"Total Token Count: {tokenUsage.TotalTokenCount}";
+ }
+ }
+
+ private async Task EnsureMessageIsVisibleAsync()
+ {
+ await JSRuntime.InvokeVoidAsync("scrollTo", chat);
+ }
+
+ public class Message
+ {
+ public string? Text { get; set; }
+
+ public required string Role { get; set; }
+
+ public bool IsCompleted { get; set; }
+
+ public string? TokenUsage { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Ask.razor.css b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor.css
new file mode 100644
index 0000000..0299a21
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/Pages/Ask.razor.css
@@ -0,0 +1,100 @@
+.tooltip-inner {
+ text-align: left;
+}
+
+.avatar {
+ width: 50px;
+ height: 50px;
+ border-radius: 50%;
+ border: 2px solid #ddd;
+ padding: 2px;
+ flex: none;
+}
+
+input:focus {
+ outline: 0px !important;
+ box-shadow: none !important;
+}
+
+input[type="checkbox"],
+input[type="checkbox"] + label {
+ cursor: pointer;
+}
+
+.card-body {
+ overflow: auto;
+ height: 560px;
+}
+
+@media (min-width: 768px) {
+ .card-body {
+ height: 665px;
+ }
+}
+
+@media (min-width: 2560px) {
+ .card-body {
+ height: 1020px;
+ }
+}
+
+.card-text {
+ border: 2px solid #ddd;
+ border-radius: 8px;
+}
+
+.progress-chat {
+ width: 200px;
+ height: 4px;
+}
+
+.progress-bar-chat {
+ height: 4px;
+ background-color: rgba(5, 114, 206, 0.2);
+ width: 100%;
+ overflow: hidden;
+}
+
+.progress-bar-indeterminate {
+ width: 100%;
+ height: 100%;
+ background-color: rgb(5, 114, 206);
+ animation: indeterminate-animation 1s infinite linear;
+ transform-origin: 0% 50%;
+}
+
+@keyframes indeterminate-animation {
+ 0% {
+ transform: translateX(0) scaleX(0);
+ }
+
+ 40% {
+ transform: translateX(0) scaleX(0.4);
+ }
+
+ 100% {
+ transform: translateX(100%) scaleX(0.5);
+ }
+}
+
+.btn-clipboard {
+ line-height: 1;
+ color: var(--bs-body-color);
+ background-color: var(--bd-pre-bg);
+ border: 0;
+ border-radius: .25rem;
+ margin-right: -.4em
+}
+
+ .btn-clipboard:hover {
+ color: var(--bs-link-hover-color)
+ }
+
+ .btn-clipboard:focus {
+ z-index: 3
+ }
+
+.btn-clipboard {
+ position: relative;
+ z-index: 2;
+}
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Documents.razor b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
new file mode 100644
index 0000000..c927cac
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/Pages/Documents.razor
@@ -0,0 +1,280 @@
+@page "/documents"
+@using MimeMapping
+
+@inject IServiceProvider ServiceProvider
+@inject IJSRuntime JSRuntime
+
+
+
+Documents
+
+
+
+ Upload new document
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Document ID
+
+
+
+
+
+
+
+
+
+@if (isLoading && documents.Count == 0)
+{
+
+
+
+}
+else
+{
+
+
+ Available documents
+
+
+
+
+
+
+ ID
+ Name
+ Content type
+ Number of chunks
+ Creation date
+
+
+
+ @foreach (var document in documents)
+ {
+
+
+
+
+ @document.Id
+ @document.Name
+ @document.ContentType
+ @document.ChunkCount
+ @document.LocalCreationDateString
+
+ }
+
+
+
+
+}
+
+@code {
+ private ConfirmDialog dialog = default!;
+ private Button uploadButton = default!;
+ private Button deleteButton = default!;
+
+ private bool isLoading = true;
+ private IList documents = [];
+
+ private UploadDocumentRequest uploadDocumentRequest = new();
+ private EditContext? editContext;
+
+ [Inject]
+ protected ToastService ToastService { get; set; } = default!;
+
+ protected override void OnInitialized()
+ {
+ editContext = new EditContext(uploadDocumentRequest);
+ base.OnInitialized();
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender)
+ {
+ return;
+ }
+
+ await using var scope = ServiceProvider.CreateAsyncScope();
+ await LoadDocumentsAsync(scope.ServiceProvider);
+
+ StateHasChanged();
+ }
+
+ private async Task LoadDocumentsAsync(IServiceProvider services)
+ {
+ isLoading = true;
+
+ try
+ {
+ var documentService = services.GetRequiredService();
+ var dbDocuments = await documentService.GetAsync();
+
+ documents.Clear();
+ foreach (var dbDocument in dbDocuments)
+ {
+ documents.Add(new SelectableDocument(dbDocument.Id, dbDocument.Name, dbDocument.CreationDate, dbDocument.ChunkCount)
+ {
+ LocalCreationDateString = await GetLocalDateTimeStringAsync(dbDocument.CreationDate)
+ });
+ }
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+
+ private void HandleFileSelected(InputFileChangeEventArgs e)
+ {
+ uploadDocumentRequest.File = e.File;
+ }
+
+ private async Task HandleValidSubmit()
+ {
+ if (uploadDocumentRequest.File is null)
+ {
+ return;
+ }
+
+ uploadButton.ShowLoading();
+
+ var fileName = uploadDocumentRequest.File.Name;
+
+ try
+ {
+ await using var inputStream = uploadDocumentRequest.File.OpenReadStream(20 * 1024 * 1024); // 20 MB
+ await using var stream = await inputStream.GetMemoryStreamAsync();
+
+ await using var scope = ServiceProvider.CreateAsyncScope();
+ var vectorSearchService = scope.ServiceProvider.GetRequiredService();
+
+ var documentId = string.IsNullOrWhiteSpace(uploadDocumentRequest.DocumentId) ? null : (Guid?)Guid.Parse(uploadDocumentRequest.DocumentId);
+ await vectorSearchService.ImportAsync(stream, fileName, MimeUtility.GetMimeMapping(fileName), documentId);
+
+ ToastService.Notify(await CreateToastMessageAsync(ToastType.Success, "Upload document", $"The document {fileName} has been successfully uploaded and indexed."));
+
+ await LoadDocumentsAsync(scope.ServiceProvider);
+ }
+ catch (Exception ex)
+ {
+ ToastService.Notify(await CreateToastMessageAsync(ToastType.Danger, "Upload error", $"There was an error while uploading the document {fileName}: {ex.Message}"));
+ }
+ finally
+ {
+ uploadButton.HideLoading();
+ }
+ }
+
+ private async Task DeleteSelectedDocuments()
+ {
+ var selectedDocumentIds = documents?.Where(d => d.IsSelected).Select(d => d.Id) ?? [];
+
+ var options = new ConfirmDialogOptions
+ {
+ YesButtonText = "Yes",
+ YesButtonColor = ButtonColor.Danger,
+ NoButtonText = "No",
+ NoButtonColor = ButtonColor.Secondary
+ };
+
+ var confirmation = await dialog.ShowAsync(
+ title: "Delete the selected documents?",
+ message1: "This will delete the documents and all the corresponding embeddings. The operation cannot be undone.",
+ message2: "Do you want to proceed?",
+ confirmDialogOptions: options);
+
+ if (!confirmation)
+ {
+ return;
+ }
+
+ try
+ {
+ deleteButton.ShowLoading();
+
+ await using var scope = ServiceProvider.CreateAsyncScope();
+ var documentService = scope.ServiceProvider.GetRequiredService();
+
+ await documentService.DeleteAsync(selectedDocumentIds);
+
+ await LoadDocumentsAsync(scope.ServiceProvider);
+ ToastService.Notify(await CreateToastMessageAsync(ToastType.Info, "Delete documents", "The selected documents have been successfully deleted."));
+ }
+ catch (Exception ex)
+ {
+ ToastService.Notify(await CreateToastMessageAsync(ToastType.Danger, "Delete error", $"There was an error while deleting the documents: {ex.Message}"));
+ }
+ finally
+ {
+ deleteButton.HideLoading();
+ }
+ }
+
+ private async Task CreateToastMessageAsync(ToastType toastType, string title, string message)
+ {
+ var toastMessage = new ToastMessage
+ {
+ Type = toastType,
+ Title = title,
+ HelpText = await GetLocalDateTimeStringAsync(DateTimeOffset.UtcNow),
+ Message = message
+ };
+
+ return toastMessage;
+ }
+
+ private async Task GetLocalDateTimeStringAsync(DateTimeOffset dateTime)
+ {
+ return await JSRuntime.InvokeAsync("getLocalTime", dateTime);
+ }
+
+ private record class SelectableDocument(Guid Id, string Name, DateTimeOffset CreationDate, int ChunkCount) : Document(Id, Name, CreationDate, ChunkCount)
+ {
+ public bool IsSelected { get; set; }
+
+ public string ContentType => MimeUtility.GetMimeMapping(Name);
+
+ public string LocalCreationDateString { get; set; } = string.Empty;
+ }
+
+ public class UploadDocumentRequest
+ {
+ [SupplyParameterFromForm]
+ public IBrowserFile? File { get; set; }
+
+ [RegularExpression(@"^(\{|\()?[0-9a-fA-F]{8}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{4}(-?)[0-9a-fA-F]{12}(\}|\))?$", ErrorMessage = "Invalid GUID format.")]
+ public string? DocumentId { get; set; }
+ }
+}
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Error.razor b/SqlDatabaseVectorSearch/Components/Pages/Error.razor
new file mode 100644
index 0000000..8f4830b
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/Pages/Error.razor
@@ -0,0 +1,39 @@
+@page "/Error"
+@using System.Diagnostics
+@rendermode @(new InteractiveServerRenderMode(prerender: false))
+
+
+
+ @if (Code == 404)
+ {
+
Page Not Found
+
+
404
+
Ops! Page Not Found.
+
+ The page you're looking for does not exists.
+
+ }
+ else if (Code > 0)
+ {
+
Unexpected Error
+
+
500
+
Ops! Unexpected error.
+
+ An unexpected error occurred while loading the page. Please, wait a minute and try again.
+
+ }
+
+
Back to Home
+
+
+
+@code {
+ [CascadingParameter]
+ private HttpContext? HttpContext { get; set; }
+
+ [Parameter]
+ [SupplyParameterFromQuery(Name = "code")]
+ public int Code { get; set; }
+}
diff --git a/SqlDatabaseVectorSearch/Components/Pages/Home.razor b/SqlDatabaseVectorSearch/Components/Pages/Home.razor
new file mode 100644
index 0000000..32e8515
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/Pages/Home.razor
@@ -0,0 +1,19 @@
+@page "/"
+@rendermode @(new InteractiveServerRenderMode(prerender: false))
+
+SQL Database Vector Search
+
+SQL Database Vector Search
+
+ How to use the native VECTOR type in Azure SQL Database to perform embeddings and RAG with Azure OpenAI.
+
+
+ This application allows to load documents, generate embeddings and save them into the database as Vectors, and perform searches using Vector Search and RAG. Currently, PDF, DOCX, TXT and MD files are supported. Vectors are saved and retrieved with Entity Framework Core using the EFCore.SqlServer.VectorSearch library. Embedding and Chat Completion are integrated with Semantic Kernel .
+
+
+Supported features
+
+ Conversation history with question reformulation
+ Information about token usage
+ Response streaming
+
diff --git a/SqlDatabaseVectorSearch/Components/Routes.razor b/SqlDatabaseVectorSearch/Components/Routes.razor
new file mode 100644
index 0000000..f756e19
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/Routes.razor
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/SqlDatabaseVectorSearch/Components/_Imports.razor b/SqlDatabaseVectorSearch/Components/_Imports.razor
new file mode 100644
index 0000000..06b940f
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Components/_Imports.razor
@@ -0,0 +1,15 @@
+@using System.ComponentModel.DataAnnotations
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.JSInterop
+@using SqlDatabaseVectorSearch
+@using SqlDatabaseVectorSearch.Components
+@using SqlDatabaseVectorSearch.Extensions
+@using SqlDatabaseVectorSearch.Models
+@using SqlDatabaseVectorSearch.Services
+@using BlazorBootstrap
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/00000000000000_Initial.Designer.cs b/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/00000000000000_Initial.Designer.cs
new file mode 100644
index 0000000..bb1f760
--- /dev/null
+++ b/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/00000000000000_Initial.Designer.cs
@@ -0,0 +1,93 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SqlDatabaseVectorSearch.DataAccessLayer;
+
+#nullable disable
+
+namespace SqlDatabaseVectorSearch.DataAccessLayer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20250224102351_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("SqlDatabaseVectorSearch.DataAccessLayer.Entities.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Documents", (string)null);
+ });
+
+ modelBuilder.Entity("SqlDatabaseVectorSearch.DataAccessLayer.Entities.DocumentChunk", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DocumentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.PrimitiveCollection("Embedding")
+ .IsRequired()
+ .HasColumnType("vector(1536)");
+
+ b.Property("Index")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId");
+
+ b.ToTable("DocumentChunks", (string)null);
+ });
+
+ modelBuilder.Entity("SqlDatabaseVectorSearch.DataAccessLayer.Entities.DocumentChunk", b =>
+ {
+ b.HasOne("SqlDatabaseVectorSearch.DataAccessLayer.Entities.Document", "Document")
+ .WithMany("Chunks")
+ .HasForeignKey("DocumentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_DocumentChunks_Documents");
+
+ b.Navigation("Document");
+ });
+
+ modelBuilder.Entity("SqlDatabaseVectorSearch.DataAccessLayer.Entities.Document", b =>
+ {
+ b.Navigation("Chunks");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/00000000000000_Initial.cs b/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/00000000000000_Initial.cs
new file mode 100644
index 0000000..a44fde0
--- /dev/null
+++ b/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/00000000000000_Initial.cs
@@ -0,0 +1,64 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SqlDatabaseVectorSearch.DataAccessLayer.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Documents",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ Name = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false),
+ CreationDate = table.Column(type: "datetimeoffset", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Documents", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "DocumentChunks",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ DocumentId = table.Column(type: "uniqueidentifier", nullable: false),
+ Index = table.Column(type: "int", nullable: false),
+ Content = table.Column(type: "nvarchar(max)", nullable: false),
+ Embedding = table.Column(type: "vector(1536)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DocumentChunks", x => x.Id);
+ table.ForeignKey(
+ name: "FK_DocumentChunks_Documents",
+ column: x => x.DocumentId,
+ principalTable: "Documents",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DocumentChunks_DocumentId",
+ table: "DocumentChunks",
+ column: "DocumentId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DocumentChunks");
+
+ migrationBuilder.DropTable(
+ name: "Documents");
+ }
+ }
+}
diff --git a/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/ApplicationDbContextModelSnapshot.cs b/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/ApplicationDbContextModelSnapshot.cs
new file mode 100644
index 0000000..8be4784
--- /dev/null
+++ b/SqlDatabaseVectorSearch/DataAccessLayer/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -0,0 +1,90 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using SqlDatabaseVectorSearch.DataAccessLayer;
+
+#nullable disable
+
+namespace SqlDatabaseVectorSearch.DataAccessLayer.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ partial class ApplicationDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("SqlDatabaseVectorSearch.DataAccessLayer.Entities.Document", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("CreationDate")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.HasKey("Id");
+
+ b.ToTable("Documents", (string)null);
+ });
+
+ modelBuilder.Entity("SqlDatabaseVectorSearch.DataAccessLayer.Entities.DocumentChunk", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DocumentId")
+ .HasColumnType("uniqueidentifier");
+
+ b.PrimitiveCollection("Embedding")
+ .IsRequired()
+ .HasColumnType("vector(1536)");
+
+ b.Property("Index")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DocumentId");
+
+ b.ToTable("DocumentChunks", (string)null);
+ });
+
+ modelBuilder.Entity("SqlDatabaseVectorSearch.DataAccessLayer.Entities.DocumentChunk", b =>
+ {
+ b.HasOne("SqlDatabaseVectorSearch.DataAccessLayer.Entities.Document", "Document")
+ .WithMany("Chunks")
+ .HasForeignKey("DocumentId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("FK_DocumentChunks_Documents");
+
+ b.Navigation("Document");
+ });
+
+ modelBuilder.Entity("SqlDatabaseVectorSearch.DataAccessLayer.Entities.Document", b =>
+ {
+ b.Navigation("Chunks");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/SqlDatabaseVectorSearch/Extensions/RequestExtensions.cs b/SqlDatabaseVectorSearch/Extensions/RequestExtensions.cs
new file mode 100644
index 0000000..5b32f3e
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Extensions/RequestExtensions.cs
@@ -0,0 +1,40 @@
+using System.Text.RegularExpressions;
+using Microsoft.Net.Http.Headers;
+
+namespace SqlDatabaseVectorSearch.Extensions;
+
+public static partial class RequestExtensions
+{
+ [GeneratedRegex("(android|bb\\d+|meego).+mobile|avantgo|bada\\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\\.(browser|link)|vodafone|wap|windows ce|xda|xiino", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled)]
+ private static partial Regex MobileBrowserRegex { get; }
+
+ [GeneratedRegex("1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\\-(n|u)|c55\\/|capi|ccwa|cdm\\-|cell|chtm|cldc|cmd\\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\\-s|devi|dica|dmob|do(c|p)o|ds(12|\\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\\-|_)|g1 u|g560|gene|gf\\-5|g\\-mo|go(\\.w|od)|gr(ad|un)|haie|hcit|hd\\-(m|p|t)|hei\\-|hi(pt|ta)|hp( i|ip)|hs\\-c|ht(c(\\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\\-(20|go|ma)|i230|iac( |\\-|\\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\\/)|klon|kpt |kwc\\-|kyo(c|k)|le(no|xi)|lg( g|\\/(k|l|u)|50|54|\\-[a-w])|libw|lynx|m1\\-w|m3ga|m50\\/|ma(te|ui|xo)|mc(01|21|ca)|m\\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\\-2|po(ck|rt|se)|prox|psio|pt\\-g|qa\\-a|qc(07|12|21|32|60|\\-[2-7]|i\\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\\-|oo|p\\-)|sdk\\/|se(c(\\-|0|1)|47|mc|nd|ri)|sgh\\-|shar|sie(\\-|m)|sk\\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\\-|v\\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\\-|tdg\\-|tel(i|m)|tim\\-|t\\-mo|to(pl|sh)|ts(70|m\\-|m3|m5)|tx\\-9|up(\\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\\-|your|zeto|zte\\-", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled)]
+ private static partial Regex MobileBrowserVersionRegex { get; }
+
+ [GeneratedRegex(@"^/(?[a-z]{2})(/|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
+ private static partial Regex RouteCultureRegex { get; }
+
+ public static bool IsMobileRequest(this HttpContext httpContext)
+ => httpContext.Request.IsMobile();
+
+ public static bool IsMobile(this HttpRequest request)
+ {
+ var userAgent = request.Headers[HeaderNames.UserAgent].ToString();
+ var isMobileBrowser = false;
+ if (userAgent?.Length > 4 && (MobileBrowserRegex.IsMatch(userAgent) || MobileBrowserVersionRegex.IsMatch(userAgent.AsSpan(0, 4))))
+ {
+ isMobileBrowser = true;
+ }
+
+ return isMobileBrowser;
+ }
+
+ public static bool IsApiRequest(this HttpContext httpContext)
+ => httpContext.Request.Path.StartsWithSegments("/api");
+
+ public static bool IsSwaggerRequest(this HttpContext httpContext)
+ => httpContext.Request.Path.StartsWithSegments("/swagger");
+
+ public static bool IsWebRequest(this HttpContext httpContext)
+ => !httpContext.IsApiRequest() && !httpContext.IsSwaggerRequest();
+}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Extensions/StreamExtensions.cs b/SqlDatabaseVectorSearch/Extensions/StreamExtensions.cs
new file mode 100644
index 0000000..415eb53
--- /dev/null
+++ b/SqlDatabaseVectorSearch/Extensions/StreamExtensions.cs
@@ -0,0 +1,16 @@
+namespace SqlDatabaseVectorSearch.Extensions;
+
+public static class StreamExtensions
+{
+ public static async Task GetMemoryStreamAsync(this Stream stream)
+ {
+ // Use a BufferedStream to read the file in chunks
+ using var bufferedStream = new BufferedStream(stream);
+
+ var ms = new MemoryStream();
+ await bufferedStream.CopyToAsync(ms);
+
+ ms.Position = 0;
+ return ms;
+ }
+}
diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs
index 1455b34..f6695ca 100644
--- a/SqlDatabaseVectorSearch/Program.cs
+++ b/SqlDatabaseVectorSearch/Program.cs
@@ -5,8 +5,10 @@ using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.SemanticKernel;
using MimeMapping;
+using SqlDatabaseVectorSearch.Components;
using SqlDatabaseVectorSearch.ContentDecoders;
using SqlDatabaseVectorSearch.DataAccessLayer;
+using SqlDatabaseVectorSearch.Extensions;
using SqlDatabaseVectorSearch.Models;
using SqlDatabaseVectorSearch.Services;
using SqlDatabaseVectorSearch.Settings;
@@ -21,6 +23,11 @@ builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, relo
var aiSettings = builder.Services.ConfigureAndGet(builder.Configuration, "AzureOpenAI")!;
var appSettings = builder.Services.ConfigureAndGet(builder.Configuration, nameof(AppSettings))!;
+builder.Services.AddRazorComponents()
+ .AddInteractiveServerComponents();
+
+builder.Services.AddBlazorBootstrap();
+
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
@@ -79,28 +86,55 @@ builder.Services.AddDefaultProblemDetails();
builder.Services.AddDefaultExceptionHandler();
var app = builder.Build();
+await ConfigureDatabaseAsync(app.Services);
// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
-app.UseExceptionHandler(new ExceptionHandlerOptions
+app.UseWhen(context => context.IsWebRequest(), builder =>
{
- StatusCodeSelector = exception => exception switch
+ if (!app.Environment.IsDevelopment())
{
- NotSupportedException => StatusCodes.Status501NotImplemented,
- _ => StatusCodes.Status500InternalServerError
+ builder.UseExceptionHandler("/error");
+
+ // The default HSTS value is 30 days.
+ builder.UseHsts();
}
+
+ builder.UseStatusCodePagesWithRedirects("/error?code={0}");
});
-app.UseStatusCodePages();
+app.UseWhen(context => context.IsApiRequest(), builder =>
+{
+ app.UseExceptionHandler(new ExceptionHandlerOptions
+ {
+ StatusCodeSelector = exception => exception switch
+ {
+ NotSupportedException => StatusCodes.Status501NotImplemented,
+ _ => StatusCodes.Status500InternalServerError
+ }
+ });
+
+ builder.UseStatusCodePages();
+});
app.MapOpenApi();
app.UseSwaggerUI(options =>
{
- options.RoutePrefix = string.Empty;
options.SwaggerEndpoint("/openapi/v1.json", builder.Environment.ApplicationName);
});
+app.UseRouting();
+// app.UseRateLimiter();
+app.UseRequestLocalization();
+// app.UseCors();
+
+app.UseAntiforgery();
+
+app.MapStaticAssets();
+app.MapRazorComponents()
+ .AddInteractiveServerRenderMode();
+
app.MapPost("/api/ask", async (Question question, VectorSearchService vectorSearchService, CancellationToken cancellationToken,
[Description("If true, the question will be reformulated taking into account the context of the chat identified by the given ConversationId.")] bool reformulate = true) =>
{
@@ -184,4 +218,12 @@ documentsApiGroup.MapDelete("{documentId:guid}", async (Guid documentId, Documen
.WithSummary("Deletes a document")
.WithDescription("This endpoint deletes the document and all its chunks.");
-app.Run();
\ No newline at end of file
+app.Run();
+
+static async Task ConfigureDatabaseAsync(IServiceProvider serviceProvider)
+{
+ await using var scope = serviceProvider.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+
+ await dbContext.Database.MigrateAsync();
+}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/Properties/launchSettings.json b/SqlDatabaseVectorSearch/Properties/launchSettings.json
index f677316..3306ea1 100644
--- a/SqlDatabaseVectorSearch/Properties/launchSettings.json
+++ b/SqlDatabaseVectorSearch/Properties/launchSettings.json
@@ -5,8 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
- "launchUrl": "",
- "applicationUrl": "https://localhost:7024;http://localhost:5178",
+ "applicationUrl": "https://localhost:7025;http://localhost:5178",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
diff --git a/SqlDatabaseVectorSearch/Services/DocumentService.cs b/SqlDatabaseVectorSearch/Services/DocumentService.cs
index 5896ecc..2a6c6ef 100644
--- a/SqlDatabaseVectorSearch/Services/DocumentService.cs
+++ b/SqlDatabaseVectorSearch/Services/DocumentService.cs
@@ -36,4 +36,7 @@ public class DocumentService(ApplicationDbContext dbContext)
public Task DeleteAsync(Guid documentId, CancellationToken cancellationToken = default)
=> dbContext.Documents.Where(d => d.Id == documentId).ExecuteDeleteAsync(cancellationToken);
+
+ public Task DeleteAsync(IEnumerable documentIds, CancellationToken cancellationToken = default)
+ => dbContext.Documents.Where(d => documentIds.Contains(d.Id)).ExecuteDeleteAsync(cancellationToken);
}
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
index c8e83e5..016d57c 100644
--- a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
+++ b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj
@@ -8,22 +8,27 @@
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
-
-
-
-
+
+
+
+
-
-
+
+
diff --git a/SqlDatabaseVectorSearch/wwwroot/css/app.css b/SqlDatabaseVectorSearch/wwwroot/css/app.css
new file mode 100644
index 0000000..1f3f172
--- /dev/null
+++ b/SqlDatabaseVectorSearch/wwwroot/css/app.css
@@ -0,0 +1,64 @@
+body, html {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+}
+
+:root {
+ --bb-sidebar2-width: 270px;
+ --bb-sidebar2-collapsed-width: 50px;
+ --bb-sidebar2-background-color: rgba(234, 234, 234, 1);
+ --bb-sidebar2-top-row-background-color: rgba(0,0,0,0.08);
+ --bb-sidebar2-top-row-border-color: rgb(194,192,192);
+ --bb-sidebar2-title-text-color: rgb(0,0,0);
+ --bb-sidebar2-brand-icon-color: rgb(0,0,0);
+ --bb-sidebar2-brand-image-width: 24px;
+ --bb-sidebar2-brand-image-height: 24px;
+ --bb-sidebar2-title-badge-text-color: rgb(255,255,255);
+ --bb-sidebar2-title-badge-background-color: rgba(25,135,84,var(--bs-bg-opacity,1));
+ --bb-sidebar2-navbar-toggler-icon-color: rgb(0,0,0);
+ --bb-sidebar2-navbar-toggler-background-color: rgba(0,0,0,0.08);
+ --bb-sidebar2-content-border-color: rgb(194,192,192);
+ --bb-sidebar2-nav-item-text-color: rgba(0,0,0,0.9);
+ --bb-sidebar2-nav-item-text-active-color-rgb: 0,0,0;
+ --bb-sidebar2-nav-item-text-hover-color: rgba(var(--bb-sidebar-nav-item-text-active-color-rgb),0.9);
+ --bb-sidebar2-nav-item-text-active-color: rgba(var(--bb-sidebar-nav-item-text-active-color-rgb),0.9);
+ --bb-sidebar2-nav-item-background-hover-color: rgba(var(--bb-sidebar-nav-item-text-active-color-rgb),0.08);
+ --bb-sidebar2-nav-item-group-background-color: rgba(var(--bb-sidebar-nav-item-text-active-color-rgb),0.08);
+}
+
+.bb-sidebar2 nav .nav-item a:hover {
+ background-color: rgba(0,0,0,0.08) !important;
+ color: rgba(0,0,0,0.9) !important;
+}
+
+.bb-sidebar2 nav .nav-item a.active {
+ background-color: rgb(194,192,192) !important;
+ color: rgba(0,0,0,0.9) !important;
+}
+
+h1:focus {
+ outline: none;
+}
+
+.valid.modified:not([type=checkbox]) {
+ outline: 1px solid #26b050;
+}
+
+.invalid {
+ outline: 1px solid red;
+}
+
+.validation-message {
+ color: red;
+}
+
+.blazor-error-boundary {
+ background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
+ padding: 1rem 1rem 1rem 3.7rem;
+ color: white;
+}
+
+ .blazor-error-boundary::after {
+ content: "An error has occurred."
+ }
diff --git a/SqlDatabaseVectorSearch/wwwroot/favicon.png b/SqlDatabaseVectorSearch/wwwroot/favicon.png
new file mode 100644
index 0000000..0c3cb7f
Binary files /dev/null and b/SqlDatabaseVectorSearch/wwwroot/favicon.png differ
diff --git a/SqlDatabaseVectorSearch/wwwroot/images/assistant.png b/SqlDatabaseVectorSearch/wwwroot/images/assistant.png
new file mode 100644
index 0000000..64ec75c
Binary files /dev/null and b/SqlDatabaseVectorSearch/wwwroot/images/assistant.png differ
diff --git a/SqlDatabaseVectorSearch/wwwroot/images/openai.svg b/SqlDatabaseVectorSearch/wwwroot/images/openai.svg
new file mode 100644
index 0000000..a0e8f8e
--- /dev/null
+++ b/SqlDatabaseVectorSearch/wwwroot/images/openai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/wwwroot/images/sqldatabase.svg b/SqlDatabaseVectorSearch/wwwroot/images/sqldatabase.svg
new file mode 100644
index 0000000..4271bd1
--- /dev/null
+++ b/SqlDatabaseVectorSearch/wwwroot/images/sqldatabase.svg
@@ -0,0 +1 @@
+Icon-databases-130
\ No newline at end of file
diff --git a/SqlDatabaseVectorSearch/wwwroot/images/user.png b/SqlDatabaseVectorSearch/wwwroot/images/user.png
new file mode 100644
index 0000000..b4a325d
Binary files /dev/null and b/SqlDatabaseVectorSearch/wwwroot/images/user.png differ
diff --git a/SqlDatabaseVectorSearch/wwwroot/js/functions.js b/SqlDatabaseVectorSearch/wwwroot/js/functions.js
new file mode 100644
index 0000000..911b07f
--- /dev/null
+++ b/SqlDatabaseVectorSearch/wwwroot/js/functions.js
@@ -0,0 +1,15 @@
+window.setFocus = (element) => {
+ if (element) {
+ element.focus();
+ }
+};
+
+window.scrollTo = (element) => {
+ if (element) {
+ element.scrollIntoView();
+ }
+}
+
+function getLocalTime(utcDateTime) {
+ return new Date(utcDateTime).toLocaleString();
+}
\ No newline at end of file
diff --git a/assets/SqlDatabaseVectorSearch.mp4 b/assets/SqlDatabaseVectorSearch.mp4
new file mode 100644
index 0000000..0a48f50
Binary files /dev/null and b/assets/SqlDatabaseVectorSearch.mp4 differ
diff --git a/SqlDatabaseVectorSearch.png b/assets/SqlDatabaseVectorSearch_API.png
similarity index 100%
rename from SqlDatabaseVectorSearch.png
rename to assets/SqlDatabaseVectorSearch_API.png
diff --git a/assets/SqlDatabaseVectorSearch_WebApp.png b/assets/SqlDatabaseVectorSearch_WebApp.png
new file mode 100644
index 0000000..97a75a7
Binary files /dev/null and b/assets/SqlDatabaseVectorSearch_WebApp.png differ