Logo
Published on

从一部电影到下一部:CineMind 的 AI 电影发现平台开发记

Authors

从一部电影到下一部:CineMind 的 AI 电影发现平台开发记

项目仓库:5SRT7/CineMind

演示视频:【我做了一个"以电影找电影"的 AI 推荐系统】

为什么想做这个项目

我一直想要一个真正的"电影推荐系统",不是那种"你看过《黑天鹅》,所以推荐《黑天鹅》的演员还演过什么"的简单逻辑,而是当我说:

我想看一部心理恐怖电影,主角逐渐精神崩溃,结局最好比较压抑。

系统真的能理解这种描述,并从庞大的电影库里找到合适的那一部。

所以我开始做 CineMind。最初定位是一个类似 IMDb / 豆瓣的电影搜索网站,但核心不是关键词搜索,而是 AI 理解、语义检索和推荐解释。

一开始的设想

第一版设计里,我希望用户直接用自然语言搜索:

找一些类似《燃烧》的电影,最好是韩国或者日本的,节奏慢一点,压抑、有悬疑感,不要商业大片。

整个流程大概是这样:

用户输入
  -> LLM 理解需求
  -> 提取结构化条件
  -> TMDB 获取候选
  -> 获取电影详情
  -> 构建电影语义文档
  -> 生成 Embedding
  -> pgvector 语义检索
  -> 混合排序
  -> 生成推荐解释

当时我给自己定了几条原则:

  • TMDB 是电影事实数据源,不自己编剧情。
  • 不要同步整个 TMDB 到数据库。
  • PostgreSQL + pgvector 只做 AI 搜索的临时语义索引。
  • 成本尽量为 0,部署尽量简单。
  • 不要过度工程化。

技术选型

最终选择了下面这套组合:

部分选择
前端Next.js 16 + TypeScript + Tailwind CSS
流程编排LangGraph
电影数据TMDB API
向量检索Neon PostgreSQL + pgvector
LLM / EmbeddingOpenAI 或硅基流动
图标lucide-react

Next.js 的好处是前后端在一个项目里,API Route 可以直接承担服务端逻辑,不需要为了第一阶段单独拆 FastAPI。

核心架构:把电影变成"可检索的语义文档"

传统电影推荐往往依赖类型标签。比如用户说"心理恐怖",系统就去找 Horror + Thriller 标签,结果可能全是新上映的商业恐怖片。

我在 CineMind 里做了一件事:把电影转成语义文档。

Title: The Machinist

Overview:
A machinist suffering from severe insomnia...

Genres:
Drama, Thriller

Keywords:
insomnia, paranoia, guilt, psychological

Director:
Brad Anderson

Cast:
Christian Bale, Jennifer Jason Leigh

AI Tags:
Psychological, Sleepless, Paranoid, Slow Burn

这段文本同时包含剧情、情绪、主题、人物和导演信息,再交给 Embedding 模型生成向量。这样用户说"主角长期失眠、逐渐精神崩溃",系统就能通过语义相似度找到《机械师》,而不是只靠类型硬匹配。

LangGraph:把 AI 搜索变成一条有状态的流水线

项目里用 LangGraph 管理 AI 搜索流程。它不是一个 Agent 到处乱跑,而是一条明确的状态图:

START
  -> parse_query
  -> retrieve_candidates
  -> fetch_movie_details
  -> build_documents
  -> embedding
  -> vector_search
  -> rerank
  -> generate_explanation
  -> END

每个节点职责单一:

  • parse_query:解析自然语言或选片信息,生成结构化需求。
  • retrieve_candidates:调用 TMDB 获取候选电影。
  • fetch_movie_details:补齐剧情、关键词、演职员和分级。
  • build_documents:构造语义文档。
  • embedding:生成向量并写入临时索引。
  • vector_search:在 pgvector 里做语义检索。
  • rerank:结合结构化条件和口碑重新排序。
  • generate_explanation:为每部电影生成推荐理由。

用 LangGraph 不是为了炫技,而是让每一步都能单独调试、单独测试,也方便以后扩展。

混合排序:不只看语义相似度

只用 Embedding 相似度会遇到一个问题:语义很像但口碑很差的电影可能排得很靠前。

所以我做了混合评分:

final_score =
    semantic_similarity * 0.50
  + structured_match    * 0.20
  + preference_match    * 0.20
  + quality_score       * 0.10
  • 语义分来自 pgvector 的向量相似度。
  • 结构化分来自类型、地区、语言、年份等条件。
  • 偏好分来自情绪、风格和主题关键词。
  • 口碑分来自 TMDB 评分和投票数。

这也是我理解中"Hybrid Search"的意义:向量负责理解语义,结构化条件负责约束候选,口碑负责兜底质量。

产品演化:从"让用户提问"到"让用户选电影"

第一版我做了自然语言 AI 搜索。后来用户反馈说:

我不想输入一段话,我就是看完一部电影,想再看类似的。

这个反馈很关键。它让我重新思考:普通用户不会像 Prompt Engineer 一样组织语言,但他们很清楚地知道自己喜欢哪几部电影。

于是我把 AI 搜索改成了"选电影找相似":

  1. 用户搜索并选择 1-5 部电影。
  2. 系统分析这些电影的共同类型、关键词、氛围和地区。
  3. 通过 TMDB 的 /similar/recommendations 获取候选。
  4. 构建包含共同点的语义查询。
  5. 在已 Embedding 的候选中排序,并解释推荐原因。

后来又加入了可选筛选:

  • 类型
  • 年份范围
  • 语言
  • 分级
  • 最低评分
  • 备注

备注不会被当作硬性条件,而是写进语义查询文本,让 AI 在排序时更理解用户偏好。

踩坑记录

这个项目最大的收获不是"功能做出来了",而是遇到并解决了一堆真实问题。

1. TMDB 域名在不同网络环境下不可达

开发过程中,api.themoviedb.org 在本地网络一直连接超时,但硅基流动可以正常访问。

排查后发现不是 Key 的问题,而是网络对 TMDB 域名的连通性不稳定。

解决方案:

  • 支持配置 TMDB_API_BASE_URL 切换镜像。
  • 支持 TMDB_PROXY / HTTPS_PROXY 走代理。
  • 自动尝试备用域名 api.tmdb.org
  • 请求超时后自动降级到演示数据。

2. cast 是 PostgreSQL 保留字

建表时我把演员列表字段命名为 cast,结果 pgvector 建表一直失败:

syntax error at or near "cast"

AI 推荐看起来还能用,因为它自动降级到了内存缓存。但这意味着 Neon + pgvector 从来没真正启用。

最后把字段名改成 cast_names,pgvector 才真正生效。

3. TMDB 的 certification 参数不可靠

筛选分级时,TMDB Discover 的 certification 参数表现很不稳定:

  • R 可能直接返回空。
  • NR 会混入大量 R 级片。

我最后改成"候选先拿回来,再逐部读取 /release_dates 的真实分级,然后过滤"。虽然多了请求,但结果准确。

4. 分页不是简单的 page + 1

TMDB 每页固定 20 条,而我想让产品每页显示 25 条。加上分级过滤后,一页可能只剩几条。

最后我在应用层重新分页:

  • 普通浏览:抓取 2 个 TMDB 页,再切成 25 条。
  • 分级筛选:持续扫描候选页,直到补满 25 条符合条件的电影。
  • 前端提供页码选择、上一页、下一页,并自动回到页面顶部。

5. React Hydration 与 sessionStorage

AI 推荐结果保存在页面内存里,用户点进电影详情再返回,推荐就丢了。

我改用 sessionStorage 保存选片、筛选、备注和推荐结果。结果第一次实现时产生了 Hydration Mismatch,因为服务端渲染时没有 sessionStorage,客户端却用已保存状态初始化。

正确做法是:首屏永远用空状态渲染,页面挂载后再从 sessionStorage 恢复。

待看列表:用户数据要跟 AI 索引分开

详情页加入了"想看"按钮,数据保存在 Neon 的 user_watchlist 表。

这里我坚持了一个边界:

  • movie_search_cache 是 AI 搜索专用的临时语义索引。
  • user_watchlist 是用户数据。
  • AI 的 Embedding 流程不读取待看列表。

因为待看列表代表的是用户偏好,不应该污染电影本身的语义索引。以后如果要做"根据待看列表推荐",再单独加一个入口,把列表 ID 喂给现有的电影推荐流水线。

为什么先不做登录?

待看列表目前是单用户模式,user_key 默认为 default。以后如果公开部署,可以把 user_key 换成用户 ID,不需要改表结构。

UI 设计:红黑电影感

整个界面采用深红 + 黑色主题,背景接近纯黑,主色是深红色 #B91C1C,配合金色评分。

电影卡片做了类似"抽卡"的悬浮效果:

  • 鼠标移上去卡片向上浮起。
  • 轻微放大并旋转。
  • 深红色投影和斜向高光。

项目 Logo 也用统一的深红色重新调整,保持视觉一致。

一些取舍

为什么没有同步整个 TMDB?

同步整个 TMDB 成本太高,也不符合第一阶段目标。我只需要在 AI 搜索时缓存当前使用的候选电影,24 小时后过期。

为什么不用 Redis?

当前阶段只有一个简单的 TTL 需求,pgvector 表本身就可以承担,不需要引入额外组件。

现在做到什么程度

当前项目已经完成:

  • 首页电影浏览
  • 普通搜索
  • 电影详情页
  • 筛选浏览 + 分页
  • 选电影找相似
  • AI 推荐解释
  • 待看列表
  • OpenAI / 硅基流动双 Provider
  • Neon pgvector 临时语义索引
  • 演示数据降级

以后想做什么

  • 用户登录和真正的多用户待看列表。
  • "看过"状态管理。
  • 基于待看列表的显式推荐入口。
  • 长期电影语义索引。
  • 更细粒度的推荐解释,比如按情绪、叙事节奏、导演风格拆分。

最后

CineMind 是我做过的完整度最高、也最贴近真实产品的一个 AI / RAG 项目。

它让我真正理解了几件事:

  • 向量检索不是银弹,要和结构化条件、质量分一起用。
  • 产品交互会改变技术设计。用户不想写 Prompt,于是推荐流程从"自然语言理解"变成了"电影共同点分析"。
  • 真实项目里大量时间花在网络环境、数据库连接、保留字、Hydration 这些"不性感"的问题上。
  • 个人项目也要控制边界,MVP 永远比完美架构更有价值。

如果你也在做 AI 应用,我的建议是:先让核心闭环跑通,再根据真实使用反馈迭代。功能可以少,但每一个功能都要能解释"为什么存在"。