watsonx.data

watsonx.data

Put your data to work, wherever it resides, with the hybrid, open data lakehouse for AI and analytics

 View Only

Building an AI-driven Video Recommendation System with Astra DB and C#

By Aaron Ploetz posted 23 days ago

  

Introduction 

One of the many possible uses for AI is with a recommendation system. For this article, we will discuss building a recommendation system for the “KillrVideo video streaming service. In such systems, vector embeddings are usually generated from video names or descriptions, and then one video is used in a vector search to bring back additional, similar videos

When the AI “boom” first hit a few years ago, much of the available tooling was built around Python. Additional AI frameworks began to surface for enterprise languages like Java, as well as web languages like Javascript. Unfortunately, today it remains a difficult task to find AI tools for some languages, with C# being among them. In this article, we will show one path toward working with AI and vector embeddings using C# and IBM’s Granite embedding models.

Modern AI-based recommendation systems will also need a database capable of supporting vector data types. DataStax Astra DB makes a great fit, as its lineage with Apache Cassandra makes it capable of handling large-scale, high-throughput data processing scenarios. Additionally, its serverless, cloud-native features make it super-easy to use.

We will build our video service using the Model View Controller (MVC) approach. This will give us specific tasks to complete around our data access (Model), our presentation layer with the KillrVideo website (View), and the service layer (Controller). 

Note: While code examples will be shown in this article, in the interests of brevity, they will not be entirely complete. Please see the referenced GitHub repositories for the complete code. 

Database 

First, we will log into our Astra DB account and create a new database with a keyspace named “killrvideo.” Inside that keyspace, we will create a new table named “videos.” The videos table definition can be seen below: 

CREATE TABLE killrvideo.videos ( 
    videoid uuid PRIMARY KEY, 
    added_date timestamp, 
    category text, 
    content_features vector<float, 384>, 
    content_rating text, 
    description text, 
    language text, 
    location text, 
    location_type int, 
    name text, 
    preview_image_location text, 
    tags set<text>, 
    userid uuid, 
    views int, 
    youtube_id text); 

Notice that the content_features column is defined as a 384-dimensional vector type. This will allow it to store vector embeddings generated by the IBM granite-embedding-30m-english model. We will also need a cosine-based Storage Attached Index (SAI) on the content_features column. This is necessary for a vector search operation to function properly. Here is the index definition: 

CREATE CUSTOM INDEX videos_content_features_idx 
ON killrvideo.videos (content_features) 
USING 'StorageAttachedIndex' 
WITH OPTIONS = {'similarity_function': 'COSINE'}; 

With the table and vector index defined, let’s move on to loading it with data. 

Loading Data 

To load data for this project, we have a special C# data loader for the KillrVideo videos table. It uses the videos.csv file found in the KillrVideo/killrvideo-data repository. The loader itself can be found at the following repository: https://github.com/KillrVideo/kv-dataloader-csharp 

The data loader itself is straight-forward. It uses the CsvHelper and CassandraCSharpDriver packages to read the CSV file, and insert its data into Astra DB. Additionally, it calls a custom HuggingFace space to access the granite-embedding-30m-english model and uses it to generate a vector embedding based on the title of the video.

After defining an environment variable for our HuggingFace API key (named HF_API_KEY), we can test the HuggingFace endpoint with one of the video titles (“DataStax Astra DB Vector Experience”) like this: 

curl -X POST https://aploetz-granite-embeddings.hf.space/embed 
    -H "Content-Type: application/json" 
    -d'{"model": "ibm-granite/granite-embedding-30m-english", "text": "DataStax Astra DB Vector Experience"}' 

That should return a JSON response similar to this: 

{"embedding":[-0.01985352672636509,0.0054968586191535,0.04430244863033295,0.07449939846992493,...,-0.11424736678600311,-0.016334468498826027],"dim":384,"model":"ibm-granite/granite-embedding-30m-english"}

 

Both the loader and service layer will define that endpoint as a constant, global variable named _HF_APLOETZ_SPACE_ENDPOINT.

Now we can move on to the data loader code. First, we will need to create an object class named “Video.”

public class Video 
{
    public Guid videoId { get; set; } = Guid.NewGuid(); 
    public Guid userId { get; set; } = Guid.NewGuid(); 
    public string name { get; set; } = string.Empty; 
    public string description { get; set; } = string.Empty; 
    public string location { get; set; } = string.Empty; 
    public string previewImageLocation { get; set; } = string.Empty; 
    public CqlVector<float>? contentFeatures { get; set; } 
    public DateTime addedDate { get; set; } = DateTime.UtcNow; 
    public string youtubeId { get; set; } = string.Empty; 
    public string contentRating { get; set; } = string.Empty; 
    public string category { get; set; } = string.Empty; 
    public string language { get; set; } = string.Empty; 
} 

To write into Astra DB, we will use the Mapper component from the Cassandra C# driver. For this, we will build a short class called MappingHelper: 

using Cassandra.Mapping; 
using kv_dataloader_csharp.models; 
 
public class MappingHelper : Mappings 
{ 
    public MappingHelper() 
    { 
        For<Video>() 
            .TableName("videos") 
            .PartitionKey("videoid") 
            .Column(v => v.addedDate, cm =>cm.WithName("added_date")) 
            .Column(v => v.contentFeatures, cm =>cm.WithName("content_features")) 
            .Column(v => v.locationType, cm =>cm.WithName("location_type")) 
            .Column(v => v.previewImageLocation, cm =>cm.WithName("preview_image_location")) 
            .Column(v => v.youtubeId, cm =>cm.WithName("youtube_id")) 
            .Column(v => v.contentRating, cm =>cm.WithName("content_rating")); 
    } 
} 

 

This class essentially binds the Video class to the videos table in Astra DB. You should notice that not all of the columns are specified, as columns with matching names will automatically map to each other. With the mapper in place, we can simply iterate through the CSV file and write data to the videos table.

Additionally, we will need a private method to retrieve the vector embeddings for each video from the HuggingFace endpoint: 

private static async Task<string> getEmbeddings(HuggingFaceRequest req) 
{ 
    var json = JsonConvert.SerializeObject(req); 
    var data = new StringContent(json, Encoding.UTF8,"application/json"); 
    var hFRequestMessage = new HttpRequestMessage(HttpMethod.Post,_HF_APLOETZ_SPACE_ENDPOINT) 
    { 
       Content = data
    }; 

    HttpResponseMessage hFResponse =await _hFHttpClient.SendAsync(hFRequestMessage); 
    return await hFResponse.Content.ReadAsStringAsync(); 
} 

 

We will also need to define classes for the HuggingFaceRequest and HuggingFaceResponse objects (can be found in the Git repo). Once that is complete, we can begin processing the CSV file: 

using (var reader = new StreamReader(_dataDir + "videos.csv")) 
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture)) 
{ 
    csv.Read(); 
    csv.ReadHeader(); 

For each line read from the CSV file, we take the columns and assign them to properties on a new Video object: 

    while (csv.Read()) 
    { 
        Video video = new Video() { 
            videoId = csv.GetField<Guid>("videoid"), 
            userId = csv.GetField<Guid>("userid"), 
            addedDate = csv.GetField<DateTime>("added_date"), 
            name = csv.GetField<string>("name"), 
            description = csv.GetField<string>("description"), 
            location = csv.GetField<string>("location"), 
            previewImageLocation = csv.GetField<string("preview_image_location"), 
            contentRating = csv.GetField<string>("content_rating"), 
            category = csv.GetField<string>("category"), 
            language = csv.GetField<string>("language") 
        }; 

Next, we will call the HuggingFace Space endpoint and assign the returned vector embedding to contentFeatures property on the video object. With that complete, we can then call the mapper object’s Insert method to write the video data into Astra DB. 

        var request = new HuggingFaceRequest(); 
        request.text = video.name; 
        request.model = _modelId; 
        string jsonResponse = await getEmbeddings(request); 

        HuggingFaceResponse hFResp =JsonConvert.DeserializeObject<HuggingFaceResponse>(jsonResponse); 

        video.contentFeatures = (CqlVector<float>)hFResp.embedding; 
        mapper.Insert(video); 
    } 
} 

When this console application finishes running, we should have 500 rows in our videos table. Having loaded our data, we can move on to building our Data Access Layer. 

Data Access Layer 

As we start building our Data Access Layer (DAL), we will need to define a class named CassandraConnection. This class will handle our connection with Astra DB and provide a session object for us to work with. Its constructor defines our data mapper, and pulls in our environment variables to configure our Astra DB connection: 

public class CassandraConnection : ICassandraConnection 
{ 
    private readonly string? _astraDbApplicationToken; 
    private readonly string? _astraDbKeyspace; 
    private readonly string? _secureBundleLocation; 
 
    public CassandraConnection() 
    { 
        _astraDbApplicationToken = System.Environment.GetEnvironmentVariable("ASTRA_DB_APPLICATION_TOKEN") 
        _astraDbKeyspace = System.Environment.GetEnvironmentVariable("ASTRA_DB_KEYSPACE"); 
        _secureBundleLocation = System.Environment.GetEnvironmentVariable("ASTRA_DB_SECURE_BUNDLE_LOCATION"); 

        MappingConfiguration.Global.Define<MappingHelper>(); 
    } 

We will also create a method named GetCQLSession. This method will allow our data access code to communicate with Astra DB via the session object: 

public Cassandra.ISession GetCQLSession() 
{ 
    Cassandra.ISession session = Cluster.Builder() 
        .WithCloudSecureConnectionBundle(_secureBundleLocation) 
        .WithCredentials("token", _astraDbApplicationToken) 
        .WithDefaultKeyspace(_astraDbKeyspace) 
        .Build() 
        .Connect(); 

    return session; 
} 

 

Now we can move on to the video-specific data access code. We will build a new class named VideoDAL. Its constructor will define local variables for the Cassandra session object, as well as the Cassandra data mapper component: 

public class VideoDAL : IVideoDAL 
{ 
    private readonly Cassandra.ISession _session; 
    private readonly IMapper _mapper; 
 
    public VideoDAL(ICassandraConnection cassandraConnection) 
    { 
        _session = cassandraConnection.GetCQLSession(); 
        _mapper = new Mapper(_session); 
    } 

Our Mapper class will be very similar to the one that we used with our data loader project above, so we won’t restate that. Additionally, we will also need to define a Video class, which will also be similar to the one we used in the loader project. 

Our VideoDAL class will need a method to retrieve a list of videos for a vector. That method should also accept an integer to limit the number of results returned: 

public async Task<IEnumerable<Video>> GetByVector(CqlVector<float> vector, int limit) 
{ 
    var vectorSearchData = await _mapper.FetchAsync<Video>( 
            "ORDER BY content_features ANN OF ? LIMIT ?", 
            vector, limit); 
 
    return vectorSearchData; 
} 

Our controller method will also need access to retrieve a single video by its videoid. We can use the mapper to retrieve this, as well: 

public async Task<Video?> GetVideoByVideoId(Guid videoid) 
{ 
    Video video = await _mapper.SingleAsync<Video>( 
            "WHERE videoid=?", videoid); 
    return video; 
} 

With the DAL in place, we can now move on to the controller. 

Controller 

Our controller class will be named VideoController, and its primary function will be to host the logic behind our web service endpoints. Our controller class should start with attributes to state that it is an ApiController, produces application/json, and supports all routing requests to the /api/v1/videos services: 

[ApiController] 
[Route("/api/v1/videos")] 
[Produces("application/json")] 
public class VideosController : Controller 

The VideoController constructor is actually quite large, so we will not show it here. But it is where we will define a local variable for the VideoDAL, as well as any other DALs that we might need. 

Inside of this class we will define all of our service endpoint methods, including the GetSimilarVideos method. To start, this method will also need to be decorated with a few attributes for it to function as a service endpoint. Its opening definition should look like this:

[HttpGet("id/{id}/related")] 
[ProducesResponseType(typeof(List<VideoResponse>), StatusCodes.Status200OK)] 
[ProducesResponseType(StatusCodes.Status404NotFound)] 
public async Task<ActionResult<List<VideoResponse>>> GetSimilarVideos(string id, int requestedLimit) 
{ 

The method will start by doing some range checking on limit and id, as well as parsing id into a Globally Unique Identifier (GUID) type named videoid. Before we can search with a vector, we first need to get it from a video in Astra DB. We will use the newly-minted videoid to query that: 

var originalVideo = await _videoDAL.GetVideoByVideoId(videoid);

With this video object returned, we can now use it to execute our vector search for similar videos: 

var similarVideos = await _videoDAL.GetByVector(originalVideo.contentFeatures, limit + 1); 

Once we have the result set of rows from the vector search, we can process them: 

List<VideoResponse> response = new(); 

foreach (Video video in similarVideos) 
{ 
    if (!video.videoId.Equals(videoid)) 
    { 
        response.Add(videoResponse); 
    } 
} 

The code shown above is an abbreviated version of the actual loop. Note that we will only want to add a video to response if it does not match the original video. For this data set, a vector search’s top result will always be the video that we searched with. Therefore, we will want to ignore that result. 

Putting it all together 

Let’s start by running our service. Executing a dotnet run in the project directory brings up the service layer on (https) port 7264. With that running, we can open another terminal window and test our related videos endpoint with curl: 

curl -k -X GET "https://localhost:7264/api/v1/videos/id/c31c166f-fde1-46c4-a86e-102e6a4a0df1/related?limit=5" 

[{"key":"e83999f5-50f7-43c2-be11-c4f6f134c689","userId":"bc9a061d-f1e2-4ccc-a39b-9aedf110dad9","videoId":"e83999f5-50f7-43c2-be11-c4f6f134c689","title":"AI for Financial Services: Succeeding with Agents and RAG Applications","description":"Join us for... 

That call should return a response similar to the above (but much longer). We could also test different values for limit or even remove the limit parameter altogether, and it should still function (default to a value of 10). 

Now, we can run the KillrVideo frontend project and configure its backend address to https://localhost:7264 (using the proxy “target” parameter in the vite.config.ts file). If we click on one of the videos from the main screen, we should see the related videos showing up in the right navigation: 

KillrVideo’s watch page, showing the selected video and two of its related videos. 

 

As you can see, our related videos endpoint is indeed working, and returning videos based on a vector similarity search from the original video. 

Conclusions 

In this article we showed how to: 

  • Generate vector embeddings using IBM Granite and HuggingFace. 

  • Save them into a vector database (Astra DB). 

  • Build a MVC service project in C#. 

  • Utilize vector search to support an AI-driven video recommendation service.

There are many possible customer-facing use cases for AI, and a recommendation system is a good one. Newer systems based on vector similarity are the next logical evolution of recommendations. They are also surprisingly easy to build, run, and maintain; especially when compared to their predecessors. 

While the progression of AI toolsets has not reached a parity among software development languages, things are improving. The (non-Azure) tools available for C# are getting better, and the need can be somewhat met by tools like HuggingFace Spaces. HuggingFace also makes it easy to test and work with many different models, including those provided by IBM Granite. 

Is your development team struggling with modern implementations of AI and Cassandra? Then be sure to check out the KillrVideo project. In it, you will find web service backends for a multitude of languages, frameworks, and approaches for working with Cassandra or Astra DB. Odds are, there is one out there which matches your stack and can show you better ways to develop applications for the distributed systems of the future. 

Links 

KillrVideo GitHub org: https://github.com/KillrVideo 

KillrVideo data project: https://github.com/KillrVideo/killrvideo-data 

Granite Embeddings HF space: https://aploetz-granite-embeddings.hf.space/embed 

Granite Embeddings code: https://github.com/aar0np/granite-embeddings 

Astra DB login/sign-up: https://astra.datastax.com 

Apache Cassandra project: https://cassandra.apache.org

DataStax products at IBM: https://www.ibm.com/products/datastax 


#watsonx.data
#datastax
#astradb

0 comments
9 views

Permalink