Photo Search Engine:本地照片可检索实践
把本地照片库变成可搜索的个人记忆入口:从结构化图像理解、时间线索、向量召回到混合检索与重排,这是一套真正面向找图任务的系统设计。
很多照片其实不是“丢了”,而是“想不起来该怎么找”。
你记得它大概拍于去年夏天,记得画面里可能有海报文字,或者只记得它“像一张截图”。但当这些线索落到本地文件系统里,最后剩下的往往只有目录、文件名和一张张需要手动翻找的缩略图。
Photo Search Engine 想解决的,正是这条从“脑海里的模糊记忆”到“硬盘上的具体文件”之间的断层:让用户通过自然语言、参考图片或临时上传图片,在本地照片库中更快找到真正想找的那一张。
它不是一个单纯展示照片的图库页,也不是只做 caption 的图像 demo,而是一个围绕本地照片检索搭建的完整原型:从索引构建、图片理解、查询理解,到召回、重排和结果解释,链路已经闭环。
一、产品是什么:把本地相册变成可搜索的个人记忆入口
一句话说,Photo Search Engine 是一个 local-first 的 AI 照片搜索引擎。
它会扫描本地照片目录,抽取图片内容和时间信息,构建本地索引,并通过 Web 界面与 HTTP 接口提供搜索能力。当前项目已经支持三类核心入口:
- 文本搜图:直接输入自然语言,例如“去年夏天傍晚在海边拍的照片”;
- 以图搜图:用已经入库的本地图片作为参考图,找视觉上更接近的结果;
- 上传图片搜图:上传一张临时图片做一次性搜索,不写入索引。
这个产品真正解决的问题,不是“如何把照片展示得更漂亮”,而是:
当用户掌握的线索是语义化、时间化、视觉化的,系统能不能把这些模糊记忆重新映射成一张真实图片?
这里的 local-first 也不是一句口号。项目当前的边界很清楚:图片文件、索引文件和结构化元数据留在本地;图片分析、查询理解、embedding 与 rerank 走外部模型接口。 这意味着它既保留了本地照片库的控制权,又能借助模型能力提升检索效果。
二、亮点功能:不是“能搜”,而是尽量搜得更接近人的回忆方式
1)文本搜图:直接用“人话”描述目标照片
用户不需要知道文件名,也不需要记得目录结构,只要说出自己还记得的线索,例如:
- “去年夏天傍晚在海边拍的照片”
- “有明显海报文字的图片”
- “像截图一样的照片”
- “某位公众人物出镜的图片”
项目不是把 query 当作一串关键词,而是先尝试理解用户真正想找的对象,再把它翻译成适合检索的表达。
2)时间感知:让“去年”“夏天”“傍晚”真正参与检索
照片搜索很大一部分难点,不在内容本身,而在时间线索。项目在索引阶段会读取 EXIF 和文件时间,在查询阶段用时间解析器提取时间约束,并在启用关键词索引时通过独立字段做过滤。这意味着“去年”“某个月”“夏天”“傍晚”不只是提示词,而是实际参与检索的条件。
3)结构化图像理解:不是一段 caption,而是一组可检索信号
项目不会只为每张图生成一句描述,而是尽量把图片拆成可利用的结构化字段,例如:
descriptionouter_scene_summaryinner_content_summarymedia_typestagsocr_textidentity_candidatesretrieval_text
其中,retrieval_text 会聚合高价值字段,作为 embedding 与检索召回的重要输入。这样做的意义在于:系统不是只“看过这张图”,而是在为后续搜索准备一份更像机器记忆的检索材料。
4)以图搜图:让视觉相似性成为第一入口之一
很多时候,用户已经有一张“差不多”的图,只是想找到同一场景、同一系列,或更接近的那一张。项目支持两种视觉入口:
- 对已入库图片直接读取已有向量,执行相似检索;
- 对临时上传图片即时分析与向量化,完成一次性搜索。
这使系统不只会“听懂描述”,也能直接“拿图找图”。
5)混合检索 + 双阶段重排:兼顾召回率与前排质量
项目当前不是单一路径检索,而是组合多类信号:
- FAISS 向量召回:负责语义相似度;
- Elasticsearch 关键词检索(可选):强化 OCR、标签、文件名、结构化字段与时间过滤;
- 文本 rerank(可选):重新衡量 query 与候选结果的文本相关性;
- 视觉 rerank(可选):用视觉模型对候选图片再次排序。
这种设计的重点不是让某一项分数“看起来聪明”,而是尽量先把候选找全,再把最可能命中的结果往前推。
6)结果可解释:不仅返回结果,还返回命中线索
项目返回的不只是图片路径和得分,还包括 match_summary 与 search_debug。这两部分很关键:前者告诉前端“为什么像这张图”,后者帮助开发者理解查询理解、扩写、召回、反思、重排哪一环起了作用,哪一环还需要继续改进。
三、创意来源:为什么照片搜索值得重新做一遍
这个项目的出发点,不是“再做一个图像 demo”,而是一个很现实的判断:人的记忆方式,本来就不是文件系统式的。
我们记住的常常是场景、媒介感、时间、人物和气氛;但本地相册管理依赖的却主要是文件名、目录和人工翻找。这两套系统天生错位,所以“找图”这件事至今仍然是一个高频但低效的任务。
我对这个方向的判断,受两个成熟产品思路影响很深。
第一类启发来自像 Pine 这样的结果导向型产品。它最有价值的地方不在于“会解释”,而在于围绕结果设计系统。对于账单协商如此,对于照片搜索也是如此。用户并不想听系统解释“这张图可能包含什么”,用户只想知道:你到底有没有把我要的那张图找出来。
第二类启发来自 OpenClaw 代表的 local-first / sovereign agent 思路。个人照片库是天然的本地优先场景:数据私密、持续积累、上下文强、长期价值高。对这类数据,系统更应该在用户自己的世界里工作,而不是强依赖把全部相册上传给远端黑箱。
也正因为这样,Photo Search Engine 没有把问题简化成“图片进模型、输出一句 caption”,而是把它当作一个完整的检索系统来设计:先准备语义材料,再理解查询,再做召回融合与排序,最后给出可解释结果。
四、技术路线:围绕“索引、理解、召回、重排”搭建完整检索闭环
如果把仓库当前实现抽象成一条链路,大致可以分成五层:
- 索引构建层:扫描本地照片、提取元数据、生成结构化分析、写入本地索引;
- 查询理解层:把自然语言 query 解析为可执行的检索意图;
- 召回融合层:结合向量检索、关键词检索、时间过滤与 metadata boost;
- 重排层:按文本和视觉相关性进一步精排;
- 结果解释层:输出
match_summary与search_debug。
从代码结构上看,这条链路也很清晰:
main.py:启动 Flask 应用;api/routes.py:暴露索引、文本搜图、以图搜图、上传图片搜图接口;core/indexer.py:负责扫描、分析、embedding、索引持久化;core/searcher.py:负责查询理解、混合召回、扩写、反思与排序;utils/:提供 embedding、视觉分析、FAISS、Elasticsearch、rerank 等服务。
下面重点讲几个最关键的技术选型。
4.1 索引构建:先把照片变成“可检索对象”
索引阶段不是简单扫目录,而是把本地图片转成一组可用于搜索的对象。
在 core/indexer.py 中,单张图片的索引流程大致是:
- 扫描与合法性校验:递归遍历照片目录,只处理有效图片;
- 结构化图像分析:调用视觉模型生成
description、outer_scene_summary、inner_content_summary、media_types、ocr_text、identity_candidates等字段; - 时间信息提取:读取 EXIF 和文件时间,补齐可过滤的时间字段;
- 构建
retrieval_text:把更有检索价值的字段聚合成统一文本; - 生成 embedding:调用 OpenAI 兼容 embedding 接口向量化;
- 落盘存储:写入 FAISS 索引、
metadata.json,并在启用时同步写入 Elasticsearch。
这里很关键的一点是:embedding 不是直接对原始文件名做,而是对整理过的 retrieval_text 做。 这让向量检索不再只依赖一段松散 caption,而是建立在更稳定的检索语义上。
4.2 查询理解:不是直接搜,而是先把 query 变成“检索意图”
文本检索的第一步不是向量化,而是理解 query。
utils/query_formatter.py 做的事情,实际上很接近一个轻量的查询规划器。它会把用户输入解析成一组结构化字段:
search_textmedia_termsidentity_termsstrict_identity_filterintent_modeintent_contracttime_hintseasontime_period
其中最重要的是两件事:
1)intent_mode 与 intent_contract
项目把 query 区分为 open 和 strict 两种意图模式。
open:可以接受一定程度的语义泛化;strict:存在不可替换的核心目标,例如特定人物、特定载体、特定内容组合。
而 intent_contract 则定义了本次检索最不能丢掉的核心目标。这是一个很重要的工程判断:扩写可以有,但扩写不能漂移。
2)时间信息单独抽取,不混进 search_text
例如“去年傍晚的篮球场”,真正适合做语义召回的是“篮球场”,而“去年”“傍晚”更适合进入时间字段。这种拆法比把所有词粗暴拼进 embedding 更稳,因为时间条件更适合做过滤或约束,而不是让语义空间自己碰运气。
4.3 自动扩写与反思:不是一轮没命中就结束
这是项目里我认为最有意思的一部分。
在 core/searcher.py 中,系统不是只执行一次原始查询,而是会根据结果质量决定是否继续做两类补救:
- 保守扩写(query expansion):为当前意图生成少量替代表达,默认最多 2 个;
- 弱结果反思(reflection):当初轮结果不理想时,根据已有弱结果分析“为什么没搜准”,再生成一轮更稳妥的检索表达。
但这里的扩写不是无限发散,而是受 intent_contract 约束的。也就是说,系统允许为“怎么表达这个目标”做有限优化,但不允许把“这个目标本身”改掉。
这和很多 demo 型检索项目最大的区别在于:它开始把搜索视为一个小型决策过程,而不是一次静态匹配。
4.4 向量检索:为什么选 FAISS,为什么用 embedding 搜索
向量检索由 utils/vector_store.py 封装,底层使用 FAISS。当前实现支持 cosine 与 l2 两种度量方式:
- 余弦相似度使用
IndexFlatIP,并在入库前做归一化; - L2 距离使用
IndexFlatL2。
这个选型很合理:
- FAISS 足够轻量,适合当前 local-first 原型;
- IndexFlat 系列实现简单稳定,便于先把正确性做扎实;
- embedding 检索适合语义表达,尤其适合“海边傍晚”“像截图一样”这种很难靠文件名命中的需求。
项目当前还做了一些很务实的细节:
- 向量和元数据一一对应,启动时会校验数量一致性;
- 支持通过
photo_path直接取回已有 embedding,便于以图搜图; - 搜索结果会把距离转换成更直观的分数区间,便于后续融合排序。
4.5 关键词索引:为什么还要保留 Elasticsearch
如果只有向量检索,语义相关性会不错,但在 OCR、文件名、结构化字段过滤和时间精确约束上就不够强。
因此项目保留了一个 可选的 Elasticsearch 关键字层,由 utils/keyword_store.py 实现。它的作用不是替代向量检索,而是补足它不擅长的部分:
- 用 BM25 强化文本命中;
- 为
ocr_text、retrieval_text、file_name等字段提供精确或半精确匹配; - 为年、月、日、季节、时段、weekday、camera 等字段提供结构化过滤;
- 在单机环境下把分片数固定为 1、副本数固定为 0,降低本地部署复杂度。
换句话说,向量检索负责“像不像”,关键词索引负责“是不是”。两者叠加后,真实可用性会明显提升。
4.6 以图搜图:为什么它不是额外功能,而是并列主入口
项目的以图搜图路径并不是外挂,而是直接复用了索引体系。
- 对 已入库图片:系统会优先读取已有向量,不必重复分析;
- 对 上传图片:接口
POST /search_by_uploaded_image会先保存临时文件,再即时分析与搜索,且不会把这张图片写入索引。
后续如果启用视觉 rerank,系统还会把参考图与候选图一起送入视觉模型,再做一次基于视觉相似度的精排。对“找同一场景/同一系列/同一种媒介感”的需求来说,这条路径非常自然。
4.7 双阶段重排:为什么还要再排一次序
召回找的是“候选集合”,重排解决的是“谁该排最前面”。
项目当前提供两种可选的重排:
- Text Rerank:对 query 和候选文本重新计算相关性;
- Visual Rerank:把 query 文本或参考图与候选图片一起送入视觉模型,让模型直接给候选排序。
这种做法的好处是明显的:
- 召回阶段可以更激进,尽量别漏;
- 排序阶段再用更贵但更准的模型,把前排质量提起来;
- 通过
rerank_top_k控制成本,只对少量候选做精排。
这是一种很典型、也很成熟的搜索工程思路:便宜的召回负责广覆盖,昂贵的重排负责高质量。
4.8 大规模数据处理:当前项目怎样为“持续增长的照片库”做准备
这里的“大规模”不是把它说成一个分布式搜图平台,而是指:当照片库持续增长时,系统如何避免每次都从零开始,以及如何控制本地资源与远程模型调用成本。
仓库当前已经体现出几项重要的工程取舍:
1)默认支持增量索引
项目在 API 层区分了 incremental 与 full 两种索引模式。对真实个人相册来说,这非常关键:新增了一批图片后,不应该每次都重建全部索引。
2)索引流程按批处理推进
Indexer 中有明确的 batch_size 配置,会按批次处理新图片,并为单张失败保留容错空间。这让索引过程更适合长时间运行,也更利于在远程模型调用不稳定时做恢复。
3)状态文件与 ready marker 分离
项目会维护索引状态文件、lock 文件和 index_ready.marker。这意味着前端与 API 不必盲猜“索引是否可用”,而是能拿到更稳定的可用性信号。
4)元数据与向量索引分开存储
当前产物里既有 photo_search.index,也有 metadata.json。这种分离方式很实用:向量负责召回,元数据负责解释、过滤和前端展示;当后续要替换检索策略时,也更容易演进。
5)embedding 支持批量接口并保留降级路径
EmbeddingService 同时提供了单条与批量向量化接口;如果批量调用失败,会降级到单条生成。这种设计很朴素,但对实际稳定性很重要:在远程模型接口不稳定时,系统仍然可以慢一点完成,而不是整批失败。
所以,从工程角度看,这个项目虽然还不是面向分布式海量图库的架构,但它已经走在一条正确的演进路径上:先把“本地、持续增长、可恢复、可解释”的检索系统做好,再逐步增强吞吐与规模能力。
五、GitHub 入口:可以直接查看当前实现
当前仓库里已经可以直接对应到这些能力:
- 基于 Flask 的可运行 Web 应用;
- 面向本地照片目录的索引构建与增量更新;
- 文本搜图、以图搜图、上传图片搜图三类入口;
- 基于
retrieval_text的 embedding 检索; - FAISS 向量召回与可选 Elasticsearch 混合检索;
- 查询自动扩写、弱结果反思与意图约束;
- 可选文本 rerank 与视觉 rerank;
- 返回
match_summary与search_debug的可解释结果。
结语
照片管理表面上像“文件整理”,本质上更像“记忆检索”。
Photo Search Engine 值得被认真介绍,不是因为它把很多模型能力堆在了一起,而是因为它围绕一个真实问题做了正确拆解:
- 先把图片变成可检索对象;
- 再把 query 变成可执行意图;
- 再用向量、关键词、时间和重排把结果尽量找准;
- 最后把命中依据解释给用户和开发者。
这也正是我认为它最有产品潜力的地方:它不是在做“会看图的模型演示”,而是在做“真正能把那张图找出来的系统”。