文章

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,而是一组可检索信号

项目不会只为每张图生成一句描述,而是尽量把图片拆成可利用的结构化字段,例如:

  • description
  • outer_scene_summary
  • inner_content_summary
  • media_types
  • tags
  • ocr_text
  • identity_candidates
  • retrieval_text

其中,retrieval_text 会聚合高价值字段,作为 embedding 与检索召回的重要输入。这样做的意义在于:系统不是只“看过这张图”,而是在为后续搜索准备一份更像机器记忆的检索材料。

4)以图搜图:让视觉相似性成为第一入口之一

很多时候,用户已经有一张“差不多”的图,只是想找到同一场景、同一系列,或更接近的那一张。项目支持两种视觉入口:

  • 对已入库图片直接读取已有向量,执行相似检索;
  • 对临时上传图片即时分析与向量化,完成一次性搜索。

这使系统不只会“听懂描述”,也能直接“拿图找图”。

5)混合检索 + 双阶段重排:兼顾召回率与前排质量

项目当前不是单一路径检索,而是组合多类信号:

  • FAISS 向量召回:负责语义相似度;
  • Elasticsearch 关键词检索(可选):强化 OCR、标签、文件名、结构化字段与时间过滤;
  • 文本 rerank(可选):重新衡量 query 与候选结果的文本相关性;
  • 视觉 rerank(可选):用视觉模型对候选图片再次排序。

这种设计的重点不是让某一项分数“看起来聪明”,而是尽量先把候选找全,再把最可能命中的结果往前推。

6)结果可解释:不仅返回结果,还返回命中线索

项目返回的不只是图片路径和得分,还包括 match_summarysearch_debug。这两部分很关键:前者告诉前端“为什么像这张图”,后者帮助开发者理解查询理解、扩写、召回、反思、重排哪一环起了作用,哪一环还需要继续改进。

三、创意来源:为什么照片搜索值得重新做一遍

这个项目的出发点,不是“再做一个图像 demo”,而是一个很现实的判断:人的记忆方式,本来就不是文件系统式的。

我们记住的常常是场景、媒介感、时间、人物和气氛;但本地相册管理依赖的却主要是文件名、目录和人工翻找。这两套系统天生错位,所以“找图”这件事至今仍然是一个高频但低效的任务。

我对这个方向的判断,受两个成熟产品思路影响很深。

第一类启发来自像 Pine 这样的结果导向型产品。它最有价值的地方不在于“会解释”,而在于围绕结果设计系统。对于账单协商如此,对于照片搜索也是如此。用户并不想听系统解释“这张图可能包含什么”,用户只想知道:你到底有没有把我要的那张图找出来。

第二类启发来自 OpenClaw 代表的 local-first / sovereign agent 思路。个人照片库是天然的本地优先场景:数据私密、持续积累、上下文强、长期价值高。对这类数据,系统更应该在用户自己的世界里工作,而不是强依赖把全部相册上传给远端黑箱。

也正因为这样,Photo Search Engine 没有把问题简化成“图片进模型、输出一句 caption”,而是把它当作一个完整的检索系统来设计:先准备语义材料,再理解查询,再做召回融合与排序,最后给出可解释结果。

四、技术路线:围绕“索引、理解、召回、重排”搭建完整检索闭环

如果把仓库当前实现抽象成一条链路,大致可以分成五层:

  1. 索引构建层:扫描本地照片、提取元数据、生成结构化分析、写入本地索引;
  2. 查询理解层:把自然语言 query 解析为可执行的检索意图;
  3. 召回融合层:结合向量检索、关键词检索、时间过滤与 metadata boost;
  4. 重排层:按文本和视觉相关性进一步精排;
  5. 结果解释层:输出 match_summarysearch_debug

从代码结构上看,这条链路也很清晰:

  • main.py:启动 Flask 应用;
  • api/routes.py:暴露索引、文本搜图、以图搜图、上传图片搜图接口;
  • core/indexer.py:负责扫描、分析、embedding、索引持久化;
  • core/searcher.py:负责查询理解、混合召回、扩写、反思与排序;
  • utils/:提供 embedding、视觉分析、FAISS、Elasticsearch、rerank 等服务。

下面重点讲几个最关键的技术选型。

4.1 索引构建:先把照片变成“可检索对象”

索引阶段不是简单扫目录,而是把本地图片转成一组可用于搜索的对象。

core/indexer.py 中,单张图片的索引流程大致是:

  1. 扫描与合法性校验:递归遍历照片目录,只处理有效图片;
  2. 结构化图像分析:调用视觉模型生成 descriptionouter_scene_summaryinner_content_summarymedia_typesocr_textidentity_candidates 等字段;
  3. 时间信息提取:读取 EXIF 和文件时间,补齐可过滤的时间字段;
  4. 构建 retrieval_text:把更有检索价值的字段聚合成统一文本;
  5. 生成 embedding:调用 OpenAI 兼容 embedding 接口向量化;
  6. 落盘存储:写入 FAISS 索引、metadata.json,并在启用时同步写入 Elasticsearch。

这里很关键的一点是:embedding 不是直接对原始文件名做,而是对整理过的 retrieval_text 做。 这让向量检索不再只依赖一段松散 caption,而是建立在更稳定的检索语义上。

4.2 查询理解:不是直接搜,而是先把 query 变成“检索意图”

文本检索的第一步不是向量化,而是理解 query。

utils/query_formatter.py 做的事情,实际上很接近一个轻量的查询规划器。它会把用户输入解析成一组结构化字段:

  • search_text
  • media_terms
  • identity_terms
  • strict_identity_filter
  • intent_mode
  • intent_contract
  • time_hint
  • season
  • time_period

其中最重要的是两件事:

1)intent_modeintent_contract

项目把 query 区分为 openstrict 两种意图模式。

  • 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。当前实现支持 cosinel2 两种度量方式:

  • 余弦相似度使用 IndexFlatIP,并在入库前做归一化;
  • L2 距离使用 IndexFlatL2

这个选型很合理:

  1. FAISS 足够轻量,适合当前 local-first 原型;
  2. IndexFlat 系列实现简单稳定,便于先把正确性做扎实;
  3. embedding 检索适合语义表达,尤其适合“海边傍晚”“像截图一样”这种很难靠文件名命中的需求。

项目当前还做了一些很务实的细节:

  • 向量和元数据一一对应,启动时会校验数量一致性;
  • 支持通过 photo_path 直接取回已有 embedding,便于以图搜图;
  • 搜索结果会把距离转换成更直观的分数区间,便于后续融合排序。

4.5 关键词索引:为什么还要保留 Elasticsearch

如果只有向量检索,语义相关性会不错,但在 OCR、文件名、结构化字段过滤和时间精确约束上就不够强。

因此项目保留了一个 可选的 Elasticsearch 关键字层,由 utils/keyword_store.py 实现。它的作用不是替代向量检索,而是补足它不擅长的部分:

  • 用 BM25 强化文本命中;
  • ocr_textretrieval_textfile_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 层区分了 incrementalfull 两种索引模式。对真实个人相册来说,这非常关键:新增了一批图片后,不应该每次都重建全部索引。

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_summarysearch_debug 的可解释结果。

结语

照片管理表面上像“文件整理”,本质上更像“记忆检索”。

Photo Search Engine 值得被认真介绍,不是因为它把很多模型能力堆在了一起,而是因为它围绕一个真实问题做了正确拆解:

  • 先把图片变成可检索对象;
  • 再把 query 变成可执行意图;
  • 再用向量、关键词、时间和重排把结果尽量找准;
  • 最后把命中依据解释给用户和开发者。

这也正是我认为它最有产品潜力的地方:它不是在做“会看图的模型演示”,而是在做“真正能把那张图找出来的系统”。