使用 Flutter 和 OpenAI 构建内容推荐应用程序

向用户推荐相关内容对于保持用户对应用程序的兴趣至关重要。尽管这是我们希望在应用程序中拥有的常见功能,但构建它并不简单。随着矢量数据库和开放人工智能的出现,这种情况发生了变化。今天,我们只需对向量数据库进行一次查询,就可以执行高度了解内容上下文的语义搜索。在本文中,我们将介绍如何创建一个 Flutter 观影应用,该应用根据用户正在观看的内容推荐另一部电影。

作为快速免责声明,本文概述了您可以使用向量数据库构建的内容,因此不会深入探讨实现的每个细节。您可以在本文中找到该应用程序的完整代码库,以查找更多详细信息。

为什么要使用矢量数据库来推荐内容

在机器学习中,经常使用将一段内容转换为向量表示的过程,称为嵌入,因为它允许我们从数学上分析语义内容。假设我们有一个引擎,可以创建非常了解数据上下文的嵌入,我们可以查看每个嵌入之间的距离,看看这两个内容是否相似。Open AI 提供了一个训练有素的模型,用于将文本内容转换为嵌入,因此使用它可以让我们创建一个高质量的推荐引擎。

向量数据库有很多选择,但在本文中,我们将使用 Supabase 作为我们的向量数据库,因为我们也想存储非嵌入数据,并且我们希望能够从我们的 Flutter 应用程序中轻松查询它们。

我们将构建什么

我们将构建一个电影列表应用程序。 想想Netflix,除非用户无法实际观看电影。此应用程序的目的是演示如何显示相关内容以保持用户的参与度。

使用的工具/技术

  • Flutter - 用于创建应用的界面
  • Supabase- 用于在数据库中存储嵌入和其他电影数据
  • Open AI API - 用于将电影数据转换为嵌入
  • TMDB API - 获取电影数据的免费API

创建应用程序

我们首先需要用一些关于电影及其嵌入的数据来填充数据库。为此,我们将使用 Supabase 边缘函数调用 TMDB API 和 Open AI API 来获取电影数据并生成嵌入。一旦我们有了数据,我们就会把它们存储在 Supabase 数据库中,然后从我们的 Flutter 应用程序中查询它们。

步骤 1:创建表

我们将为这个项目准备一张桌子,那就是电影桌子。Films 表将存储有关每部电影的一些基本信息,例如标题或发行数据,以及嵌入每部电影的概述,以便我们可以对彼此进行向量相似性搜索。

sql 复制代码
-- Enable pgvector extension
create extension vector
with
  schema extensions;

-- Create table
create table public.films (
  id integer primary key,
  title text,
  overview text,
  release_date date,
  backdrop_path text,
  embedding vector(1536)
);

-- Enable row level security
alter table public.films enable row level security;

-- Create policy to allow anyone to read the films table
create policy "Fils are public." on public.films for select using (true);

第 2 步:获取动画数据

获取电影数据相对简单。TMDB API提供了一个易于使用的电影端点,用于查询有关电影的信息,同时提供了广泛的过滤器来缩小查询结果的范围。

我们需要一个后端来安全地调用 API,为此,我们将使用 Supabase Edge Functions。第 2 步到第 4 步将构造此边缘函数代码,完整的代码示例可在此处找到。

以下代码将为我们提供给定年份最受欢迎的 20 部电影。

csharp 复制代码
const searchParams = new URLSearchParams()
searchParams.set('sort_by', 'popularity.desc')
searchParams.set('page', '1')
searchParams.set('language', 'en-US')
searchParams.set('primary_release_year', `${year}`)
searchParams.set('include_adult', 'false')
searchParams.set('include_video', 'false')
searchParams.set('region', 'US')
searchParams.set('watch_region', 'US')
searchParams.set('with_original_language', 'en')

const tmdbResponse = await fetch(
  `https://api.themoviedb.org/3/discover/movie?${searchParams.toString()}`,
  {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${tmdbApiKey}`,
    },
  }
)

const tmdbJson = await tmdbResponse.json()

const tmdbStatus = tmdbResponse.status
if (!(200 <= tmdbStatus && tmdbStatus <= 299)) {
  return returnError({
    message: 'Error retrieving data from tmdb API',
  })
}

const films = tmdbJson.results

第 3 步:生成嵌入

我们可以从上一步中获取电影数据,并为每个数据生成嵌入。在这里,我们调用 Open AI Embeddings API 将每部电影的概述转换为嵌入。"概述"包含每个电影的摘要,是创建表示每个电影的嵌入的良好来源。

php 复制代码
const response = await fetch('https://api.openai.com/v1/embeddings', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${openAiApiKey}`,
  },
  body: JSON.stringify({
    input: film.overview,
    model: 'text-embedding-3-small',
  }),
})

const responseData = await response.json()
if (responseData.error) {
  return returnError({
    message: `Error obtaining Open API embedding: ${responseData.error.message}`,
  })
}

const embedding = responseData.data[0].embedding

第 4 步:将数据存储在 Supabase 数据库中

一旦我们有了电影数据以及嵌入数据,我们就剩下存储它们的任务了。我们可以在 Supabase 客户端上调用 upsert() 函数来轻松存储数据。

同样,为了简单起见,我在这里省略了很多代码,但您可以在此处找到步骤 2 到步骤 4 的完整边缘函数代码。

php 复制代码
// Code from Step 2
// Get movie data and store them in `films` variable
...

for(const film of films) {
        // Code from Step 3
  // Get the embedding and store it in `embeddings` variable

        filmsWithEmbeddings.push({
          id: film.id,
          title: film.title,
          overview: film.overview,
          release_date: film.release_date,
          backdrop_path: film.backdrop_path,
          embedding,
        })
}

// Store each movies as well as their embeddings into Supabase database
const { error } = await supabase.from('films').upsert(filmsWithEmbeddings)

步骤五:创建数据库函数查询相似影片

为了使用 Supabase 执行向量相似性搜索,我们需要创建一个数据库函数。此数据库函数将采用嵌入和film_id作为其参数。embedding 参数将是用于在数据库中搜索类似电影的嵌入,film_id将用于筛选出正在查询的同一部电影。

此外,我们将在嵌入列上设置 HSNW 索引,以便即使使用大型数据集也能有效地运行查询。

sql 复制代码
-- Set index on embedding column
create index on films using hnsw (embedding vector_cosine_ops);

-- Create function to find related films
create or replace function get_related_film(embedding vector(1536), film_id integer)
returns setof films
language sql
as $$
    select *
    from films
    where id != film_id
    order by films.embedding <=> get_related_film.embedding
    limit 6;
$$ security invoker;

第 6 步:创建 Flutter 接口

现在我们已经准备好了后端,我们需要做的就是创建一个界面来显示和查询数据。由于本文的主要重点是演示使用向量进行相似性搜索,因此我不会详细介绍 Flutter 实现的所有细节,但你可以在这里找到完整的代码库。

我们的应用程序将包含以下页面:

  • 主页:应用程序的入口点,并显示电影列表
  • DetailsPage:显示电影及其相关电影的详细信息
css 复制代码
lib/
├── components/
│   └── film_cell.dart          # Component displaying a single movie.
├── models/
│   └── film.dart               # A data model representing a single movie.
├── pages/
│   ├── details_page.dart       # A page to display the details of a movie and other recommended movies.
│   └── home_page.dart          # A page to display a list of movies.
└── main.dart

components/film_cell.dart 是一个共享组件,用于显示主页和详细信息页面的可点击单元格。models/film.dart 包含表示单个电影的数据模型。

这两个页面如下所示。神奇的事情发生在详细信息页面底部标有"您可能还喜欢:"的部分。我们正在执行矢量相似性搜索,以使用我们之前实现的数据库函数获取与所选电影相似的电影列表。

以下是主页的代码。这是一个简单的 ListView,带有来自我们 films 表的标准选择查询。这里没什么特别的。

scala 复制代码
import 'package:filmsearch/components/film_cell.dart';
import 'package:filmsearch/main.dart';
import 'package:filmsearch/models/film.dart';

import 'package:flutter/material.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final filmsFuture = supabase
      .from('films')
      .select<List<Map<String, dynamic>>>()
      .withConverter<List<Film>>((data) => data.map(Film.fromJson).toList());

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Films'),
      ),
      body: FutureBuilder(
          future: filmsFuture,
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              return Center(
                child: Text(snapshot.error.toString()),
              );
            }
            if (!snapshot.hasData) {
              return const Center(child: CircularProgressIndicator());
            }
            final films = snapshot.data!;
            return ListView.builder(
              itemBuilder: (context, index) {
                final film = films[index];
                return FilmCell(film: film);
              },
              itemCount: films.length,
            );
          }),
    );
  }
}

在详情页中,我们调用步骤5中创建的get_related_film数据库函数,获取最相关的6部电影并展示出来。

less 复制代码
import 'package:filmsearch/components/film_cell.dart';
import 'package:filmsearch/main.dart';
import 'package:filmsearch/models/film.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

class DetailsPage extends StatefulWidget {
  const DetailsPage({super.key, required this.film});

  final Film film;

  @override
  State<DetailsPage> createState() => _DetailsPageState();
}

class _DetailsPageState extends State<DetailsPage> {
  late final Future<List<Film>> relatedFilmsFuture;

  @override
  void initState() {
    super.initState();

                // Create a future that calls the get_related_film function to query
                // related movies.
    relatedFilmsFuture = supabase.rpc('get_related_film', params: {
      'embedding': widget.film.embedding,
      'film_id': widget.film.id,
    }).withConverter<List<Film>>((data) =>
        List<Map<String, dynamic>>.from(data).map(Film.fromJson).toList());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.film.title),
      ),
      body: ListView(
        children: [
          Hero(
            tag: widget.film.imageUrl,
            child: Image.network(widget.film.imageUrl),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Text(
                  DateFormat.yMMMd().format(widget.film.releaseDate),
                  style: const TextStyle(color: Colors.grey),
                ),
                const SizedBox(height: 8),
                Text(
                  widget.film.overview,
                  style: const TextStyle(fontSize: 16),
                ),
                const SizedBox(height: 24),
                const Text(
                  'You might also like:',
                  style: TextStyle(
                    fontSize: 16,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ],
            ),
          ),
                                        // Display the list of related movies
          FutureBuilder<List<Film>>(
              future: relatedFilmsFuture,
              builder: (context, snapshot) {
                if (snapshot.hasError) {
                  return Center(
                    child: Text(snapshot.error.toString()),
                  );
                }
                if (!snapshot.hasData) {
                  return const Center(child: CircularProgressIndicator());
                }
                final films = snapshot.data!;
                return Wrap(
                  children: films
                      .map((film) => InkWell(
                            onTap: () {
                              Navigator.of(context).push(MaterialPageRoute(
                                  builder: (context) =>
                                      DetailsPage(film: film)));
                            },
                            child: FractionallySizedBox(
                              widthFactor: 0.5,
                              child: FilmCell(
                                film: film,
                                isHeroEnabled: false,
                                fontSize: 16,
                              ),
                            ),
                          ))
                      .toList(),
                );
              }),
        ],
      ),
    );
  }
}

我们现在有一个功能正常的相似性推荐系统,该系统由 Open AI 提供支持,内置在我们的 Flutter 应用程序中。今天使用的上下文是电影,但你可以很容易地想象相同的概念也可以应用于其他类型的内容。

总结

在本文中,我们研究了如何拍摄一部电影,并推荐了与所选电影相似的电影列表。这很有效,但我们只有一个样本可以从中获得相似性。如果我们想根据用户过去观看的 10 部电影推荐要观看的电影列表,该怎么办?有多种方法可以解决这样的问题,我希望通读这篇文章能激发你的求知欲来解决这样的问题。

相关推荐
ujainu2 小时前
护眼又美观:Flutter + OpenHarmony 鸿蒙记事本一键切换夜间模式(四)
android·flutter·harmonyos
ujainu2 小时前
让笔记触手可及:为 Flutter + OpenHarmony 鸿蒙记事本添加实时搜索(二)
笔记·flutter·openharmony
一只大侠的侠2 小时前
Flutter开源鸿蒙跨平台训练营 Day 13从零开发注册页面
flutter·华为·harmonyos
一只大侠的侠2 小时前
Flutter开源鸿蒙跨平台训练营 Day19自定义 useFormik 实现高性能表单处理
flutter·开源·harmonyos
恋猫de小郭3 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
一只大侠的侠7 小时前
Flutter开源鸿蒙跨平台训练营 Day 10特惠推荐数据的获取与渲染
flutter·开源·harmonyos
renke336411 小时前
Flutter for OpenHarmony:色彩捕手——基于HSL色轮与感知色差的交互式色觉训练系统
flutter
子春一13 小时前
Flutter for OpenHarmony:构建一个 Flutter 四色猜谜游戏,深入解析密码逻辑、反馈算法与经典益智游戏重构
算法·flutter·游戏
铅笔侠_小龙虾14 小时前
Flutter 实战: 计算器
开发语言·javascript·flutter
孟健15 小时前
吹爆 OpenClaw!一个人 +6 个 AI 助理,我再也不想招人了
openai·agent·ai编程