向用户推荐相关内容对于保持用户对应用程序的兴趣至关重要。尽管这是我们希望在应用程序中拥有的常见功能,但构建它并不简单。随着矢量数据库和开放人工智能的出现,这种情况发生了变化。今天,我们只需对向量数据库进行一次查询,就可以执行高度了解内容上下文的语义搜索。在本文中,我们将介绍如何创建一个 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 部电影推荐要观看的电影列表,该怎么办?有多种方法可以解决这样的问题,我希望通读这篇文章能激发你的求知欲来解决这样的问题。