
1. 一个老影迷的痛点为什么我又造了一个电影搜索轮子我收藏的电影清单大概从2012年就开始了最早是Excel表格后来换成豆瓣标记再后来用Notion建库。十几年下来硬盘里躺着两千多部片子标记看过的也有三千多部。但有个问题一直没解决我经常想不起某部电影的名字只记得一个模糊的画面、一句台词、或者某个演员的某个表情。比如前阵子突然想起一个场景一个男人在雨里对着电话亭说话电话那头没人应答镜头慢慢拉远。我翻遍了豆瓣的“看过”列表翻了半小时没找到。最后是在一个论坛里发帖问人才有人告诉我那是某部韩国电影里的桥段。这件事让我意识到传统的电影数据库都是“已知片名找信息”而真实需求往往是“已知碎片找片名”。这就是“电影狗”这个项目诞生的起点。它不是又一个豆瓣或者IMDb的克隆而是一个面向模糊记忆的搜索引擎。你可以输入“雨夜 电话亭 男人 孤独”这样的关键词组合也可以输入“那个演过《杀人回忆》的胖子在这部片里演了个警察”这种自然语言描述它都能帮你把片子捞出来。这个项目适合谁如果你是那种硬盘里存了几百部片子、经常想重温某部老片却死活想不起名字的人那这套东西就是给你准备的。如果你只是偶尔看看院线片可能用不上。另外如果你对搜索引擎技术、向量检索、或者如何用开源工具搭建个人知识库感兴趣这篇文章里的很多思路也可以直接抄作业。我先把话说在前头电影狗不是一个商业产品它是我自己用开源工具拼出来的个人工具。代码量不大核心逻辑大概两千行左右但涉及的技术栈比较杂——爬虫、文本向量化、倒排索引、语义检索、前端交互都得碰一点。下面我会把整个设计思路、技术选型、实操步骤、踩过的坑全部倒出来你照着做大概率能跑通一个自己的版本。2. 整体设计思路为什么不用Elasticsearch而是自己拼了一套2.1 核心需求拆解模糊记忆到底怎么搜先想清楚一个问题用户输入“雨夜 电话亭 男人 孤独”的时候搜索引擎到底该做什么传统关键词搜索的做法是把这句话分词然后去倒排索引里找同时包含这些词的文档。但问题是电影简介里不一定有“电话亭”这个词。可能简介写的是“他在街头公用电话旁徘徊”或者干脆只写了“雨中的街头”。这时候纯关键词匹配就歇菜了。所以电影狗的核心检索逻辑是双通道的通道一结构化字段精确匹配。演员、导演、年份、类型、地区这些字段是结构化的直接走倒排索引速度快、准确率高。通道二语义向量检索。把电影简介、影评摘要、甚至用户自己写的备注全部通过文本嵌入模型转成向量存进向量数据库。查询时把查询语句也转成向量算余弦相似度把语义上最接近的片子捞出来。最后把两个通道的结果做融合排序用一个简单的加权公式final_score 0.4 * keyword_score 0.6 * vector_score。这个权重是我试出来的后面会细说为什么这么定。2.2 技术选型为什么放弃Elasticsearch一开始我确实想用Elasticsearch毕竟它自带向量检索功能从7.x开始支持dense_vector而且倒排索引是现成的。但实际搭起来发现几个问题第一资源占用太大。ES跑起来至少吃2G内存我是在一台旧笔记本上跑这个服务的总共才8G内存还要留给他MySQL和前端。第二向量检索性能一般。ES的向量检索是基于HNSW的但它的实现更偏向通用场景对于我这种几万条数据的小规模场景反而显得笨重。第三配置太复杂。光是mapping和analyzer就调了半天中文分词还得装ik插件版本兼容性一堆坑。最后我选了一套更轻量的组合组件选型理由倒排索引SQLite FTS5零配置单文件支持中文分词配合jieba向量数据库ChromaDB轻量Python原生支持持久化API简单文本嵌入text2vec-base-chinese中文效果好模型小约400MBCPU可跑后端框架FastAPI异步支持好自动生成文档开发快前端Vue3 Vite组件化热更新快打包体积小这套组合的总内存占用不到1.5G在我的旧笔记本上跑得很稳。如果你只是做个人工具千万别上重型分布式那一套纯属给自己找麻烦。2.3 数据从哪来爬虫策略与数据清洗电影数据来源主要有三个TMDB API获取电影元数据片名、年份、演员、导演、简介、海报。免费额度是每秒50次请求对于几千部片子完全够用。豆瓣获取中文简介和短评。注意豆瓣没有公开API我是用爬虫抓的但只抓自己需要的字段频率控制在每秒1次避免给对方造成压力。个人笔记我自己在Notion里写的一些观影备注导出成Markdown后解析。数据清洗这块踩了不少坑。比如TMDB的简介是英文的直接拿来做中文语义检索效果很差。我的做法是优先用豆瓣的中文简介如果没有就用TMDB的英文简介过一遍翻译API我用的是某开源翻译模型最后再人工抽检。另外演员名字有各种别名比如“梁朝伟”和“Tony Leung Chiu Wai”得做归一化不然搜“梁朝伟”会漏掉很多片子。3. 核心细节解析向量检索到底怎么调才准3.1 文本嵌入模型的选择与对比文本嵌入模型是语义检索的核心。我试了三个模型text2vec-base-chinese约400MBCPU推理速度约50ms/条中文语义相似度效果不错。m3e-base约400MB在中文语义相似度榜单上比text2vec略好但推理速度慢20%左右。bge-large-zh约1.3GB效果最好但CPU推理要200ms/条对于几千条数据建索引要等十几分钟。最后我选了text2vec-base-chinese理由是在个人工具场景下速度比极致精度更重要。而且我实测下来对于电影简介这种长文本三个模型的检索结果差异不大text2vec完全够用。这里有个关键细节电影简介的长度差异很大有的只有一句话有的有几百字。直接整段嵌入会导致长文本的向量被稀释短文本的向量太集中。我的做法是把简介按句号切分成句子每个句子单独嵌入检索时取最高分的句子作为该电影的得分。这样短文本和长文本的检索公平性会好很多。3.2 向量维度的选择与存储优化text2vec-base-chinese的输出维度是768维。ChromaDB默认用余弦相似度存储时会把向量归一化所以实际存储的是单位向量。这里有个优化点768维的float32向量每条占3KB左右。一万条电影就是30MB看起来不大但ChromaDB的HNSW索引会额外占用内存。我的做法是把向量降维到256维。用PCA做降维实测检索精度只掉了不到2%但内存占用减少了三分之二检索速度也快了将近一倍。降维的代码大概长这样from sklearn.decomposition import PCA import numpy as np # 假设embeddings是N x 768的矩阵 pca PCA(n_components256) reduced pca.fit_transform(embeddings) # 保存PCA模型查询时也要用同一个模型降维 import joblib joblib.dump(pca, pca_model.pkl)注意PCA模型必须保存下来查询时用同一个模型对新查询做降维否则向量空间不一致检索结果会完全乱掉。3.3 混合排序的权重调参实录前面提到最终得分是0.4 * keyword_score 0.6 * vector_score。这个权重不是拍脑袋定的我做了个小规模的A/B测试。我找了20个查询样本每个查询我都知道正确答案是哪部电影。然后分别用不同的权重组合跑检索看Top10里有没有正确答案。结果如下关键词权重向量权重Top10命中率1.00.045%0.70.360%0.50.575%0.40.685%0.30.780%0.01.070%可以看到纯关键词匹配命中率只有45%纯向量检索70%而0.4/0.6的混合权重达到了85%。这说明两种通道确实互补关键词擅长精确匹配演员、导演、年份向量擅长语义模糊匹配剧情描述。另外关键词得分和向量得分的量纲不一样直接加权会出问题。我的做法是分别对两个通道的得分做min-max归一化映射到[0,1]区间后再加权。这样权重才有意义。4. 实操过程从零搭建电影狗的完整步骤4.1 环境准备与依赖安装我是在Ubuntu 22.04上跑的Python版本3.10。Windows和macOS应该也能跑但有些依赖的编译可能需要额外配置。先建虚拟环境python -m venv movie_dog_env source movie_dog_env/bin/activate然后安装核心依赖pip install fastapi uvicorn chromadb sentence-transformers pip install jieba scikit-learn pandas numpy pip install requests beautifulsoup4这里有个坑sentence-transformers会默认安装PyTorch的GPU版本如果你没有NVIDIA显卡会下载一个2GB多的CUDA包纯属浪费。正确的做法是先装CPU版的PyTorchpip install torch --index-url https://download.pytorch.org/whl/cpu pip install sentence-transformers这样PyTorch只会占200MB左右CPU推理速度也够用。4.2 数据抓取与预处理流水线数据抓取我写了一个脚本分三步走第一步从TMDB拉取电影列表。我用的是/discover/movie接口按年份和热度排序每次拉20条翻页拉取。关键参数是sort_bypopularity.desc和vote_count.gte100过滤掉那些没人看的片子。第二步对每部电影拉取详情。包括/movie/{id}获取基本信息/movie/{id}/credits获取演员和导演/movie/{id}/keywords获取关键词标签。第三步从豆瓣补充中文信息。用TMDB的IMDb ID去豆瓣搜索页找对应的条目然后抓取中文简介和短评。这里要注意豆瓣的HTML结构经常变解析规则得定期更新。我的做法是用BeautifulSoup的find方法配合多个备选选择器提高容错性。数据清洗的代码逻辑def clean_movie_data(raw): # 合并中英文简介优先中文 overview raw.get(douban_overview) or raw.get(tmdb_overview, ) # 演员名字归一化 cast [] for actor in raw.get(cast, []): name actor[name] # 如果有中文名优先用中文名 if actor.get(chinese_name): name actor[chinese_name] cast.append(name) # 类型标签统一小写 genres [g.lower() for g in raw.get(genres, [])] return { title: raw[title], year: raw.get(release_date, )[:4], overview: overview, cast: cast, director: raw.get(director, ), genres: genres, keywords: raw.get(keywords, []) }4.3 向量索引构建与持久化建索引的流程是读取清洗后的数据对每部电影的简介分句每句嵌入成向量存入ChromaDB。import chromadb from sentence_transformers import SentenceTransformer # 初始化 client chromadb.PersistentClient(path./chroma_db) collection client.get_or_create_collection( namemovies, metadata{hnsw:space: cosine} ) model SentenceTransformer(shibing624/text2vec-base-chinese) # 批量处理 for movie in movies: sentences split_sentences(movie[overview]) for i, sent in enumerate(sentences): embedding model.encode(sent).tolist() collection.add( ids[f{movie[id]}_sent_{i}], embeddings[embedding], metadatas[{ movie_id: movie[id], title: movie[title], sentence: sent }] )这里有个性能优化点不要一条一条add要批量add。ChromaDB的add方法支持传列表一次传100条比传100次快10倍以上。我实测下来一万条句子批量插入大概30秒逐条插入要5分钟。4.4 查询接口的实现与优化查询接口的逻辑是接收用户查询分别走关键词通道和向量通道然后融合排序。app.post(/search) async def search(query: str, top_k: int 20): # 通道一关键词检索 keyword_results search_by_keyword(query, top_k * 2) # 通道二向量检索 query_embedding model.encode(query).tolist() vector_results collection.query( query_embeddings[query_embedding], n_resultstop_k * 2 ) # 融合排序 merged merge_results(keyword_results, vector_results) return merged[:top_k]关键词检索这块我用的是SQLite的FTS5。建表时创建一个虚拟表CREATE VIRTUAL TABLE movie_fts USING fts5( title, overview, cast, director, keywords, tokenizejieba );注意SQLite默认不支持jieba分词需要自己编译一个扩展。如果不想折腾可以用tokenizeunicode61然后自己在Python层做分词把分词结果用空格拼起来再存入FTS表。我就是这么干的简单粗暴但有效。5. 常见问题与排查技巧实录5.1 检索结果不相关怎么办这是最常见的问题。我遇到过的原因大概有这几类原因一嵌入模型对某些领域词汇不敏感。比如“赛博朋克”这个词text2vec-base-chinese的向量可能和“科幻”很接近但和“霓虹灯”“雨夜”的相似度就不够高。解决办法是在电影简介里手动补充关键词标签。比如给《银翼杀手》加上“赛博朋克 霓虹 雨夜 复制人”这些标签嵌入时把标签和简介拼在一起。原因二查询太短。用户只输入“孤独”两个字向量检索会召回一大堆无关的片子。解决办法是对短查询做查询扩展。用同义词词典或者简单的词向量近邻把“孤独”扩展成“孤独 寂寞 疏离 边缘人”再一起嵌入。原因三归一化没做好。前面提到关键词得分和向量得分量纲不同如果忘了归一化向量得分会完全主导排序。排查方法是打印出两个通道的原始得分看看数量级是否一致。如果关键词得分是0-10向量得分是0-1那肯定有问题。5.2 索引构建太慢怎么优化一万部电影每部平均10个句子就是10万条向量。用CPU跑text2vec每条50ms总共要5000秒将近一个半小时。这显然太慢了。我的优化方案是用ONNX Runtime加速推理。把PyTorch模型导出成ONNX格式然后用ONNX Runtime跑CPU推理速度能提升2-3倍。具体做法from optimum.onnxruntime import ORTModelForFeatureExtraction from transformers import AutoTokenizer model ORTModelForFeatureExtraction.from_pretrained( shibing624/text2vec-base-chinese, exportTrue ) tokenizer AutoTokenizer.from_pretrained(shibing624/text2vec-base-chinese)这样一万条向量大概20分钟能跑完可以接受。如果还是嫌慢可以只对简介的前三个句子做嵌入因为大部分关键信息都在开头。5.3 内存占用过高怎么排查ChromaDB的HNSW索引是内存大户。一万条256维向量索引大概占200MB内存。如果数据量再大就得考虑用磁盘索引或者量化。我的做法是开启ChromaDB的持久化模式并且定期重启服务。因为ChromaDB在长期运行后会有内存泄漏的问题至少我用的版本有重启能释放掉。另外把collection的metadata设置成{hnsw:space: cosine}不要用默认的L2距离因为余弦相似度对归一化向量更稳定而且计算量更小。还有一个技巧把向量维度从768降到256后内存占用直接少了三分之二。这个前面提过但值得再强调一次因为效果太明显了。5.4 常见问题速查表问题现象可能原因排查方法解决方案检索结果完全不相关嵌入模型加载失败检查模型路径和版本重新下载模型确认输出维度关键词检索无结果FTS表未建索引执行SELECT * FROM movie_fts LIMIT 1重建FTS表确认分词正确向量检索报维度错误查询向量和索引向量维度不一致打印两者shape统一使用同一个嵌入模型和PCA模型服务启动后内存暴涨ChromaDB索引加载查看进程内存占用减少collection数量开启持久化查询响应超过2秒向量检索未加缓存打印各阶段耗时对热门查询加LRU缓存6. 一些实操心得和后续扩展方向这个项目我断断续续做了大概三个月大部分时间花在调参和数据清洗上真正写代码的时间反而不多。有几个心得值得分享第一不要追求大而全。一开始我想把IMDb、TMDB、豆瓣、烂番茄的数据全爬下来结果光数据清洗就搞了两周最后发现很多字段根本用不上。后来砍到只保留片名、年份、简介、演员、导演、类型、关键词这七个字段反而检索效果更好了。第二向量检索不是银弹。我试过纯向量检索命中率只有70%。加上关键词通道后提升到85%。所以混合检索是必须的别偷懒。第三个人工具的核心是“够用就好”。我用的是旧笔记本CPU是i5-8250U内存8G。这套配置跑电影狗完全没问题查询响应在500ms以内。如果你用更好的硬件当然可以上更大的模型但边际收益递减很明显。后续我打算加两个功能一是以图搜片用CLIP模型把海报和截图也嵌入进去这样用户上传一张截图就能找到电影二是观影记录自动同步从我的Notion数据库定时拉取新标记的片子自动更新索引。这两个功能都不难但需要额外的时间折腾。如果你也想搭一个自己的电影狗我的建议是先从100部电影的小数据集开始跑通全流程再逐步扩大数据量。不要一上来就爬几万条那样出了问题很难定位。另外把每个模块的日志打全尤其是检索阶段各通道的得分这样调参的时候才有依据。