AI应用基础篇01-多模态AI实践:从零搭建本地“以文搜图”系统(华为MateBook 16S + WSL2 + CLIP + Chroma) 【导航台账】老蒋的技术博客全系列文章汇总持续更新一台Windows笔记本32G内存无独显从照片索引到语义搜索我用一天时间跑通了一个完整的“以文搜图”系统。这篇文章记录了踩坑全过程——CLIP模型加载、中文语义失效、相似度坍缩、查询扩展优化……希望能给同样在“平民设备”上探索多模态AI的同行一些参考。作者Javy21javy21csdn时间2026年6月22日环境华为MateBook 16S / Windows 11 WSL2 Ubuntu 22.04 / Python 3.10一、引言为什么要做“以文搜图”我在上一篇《老攻城狮的AI开发环境搭建全记录从零到跑通本地大模型一日速通版-CSDN博客》中我完成了本地AI聊天应用的搭建。这次的目标更进一步——让电脑“看懂”图片。具体来说我想实现一个简单的功能输入文字描述从本地照片库中检索出最匹配的图片。这个需求在现实中非常普遍管理手机里成千上万张照片想找“去年在海边拍的那张夕阳”整理产品图库搜索“红色背景的白色产品”个人知识管理通过文字回忆图片内容技术选型上我遵循三条原则全部本地运行不上传云端数据安全CPU可跑没有GPU充分利用32G内存开源工具链CLIP Chroma Flask本文完整记录了我从零开始、踩坑无数、最终跑通全流程的经验。如果你也有一台32G内存的Windows笔记本照着做就能复现。二、技术方案概览2.1 核心组件组件选型作用向量化模型sentence-transformers/clip-ViT-B-32将图片和文字统一映射到向量空间向量数据库Chroma存储图片向量支持相似度检索Web框架Flask提供API和前端界面图片处理Pillow读取、缩放、编码图片2.2 系统架构用户输入文字 → CLIP编码 → Chroma向量检索 → 返回相似图片 ↓ 前端展示缩略图 相似度2.3 关于CLIP模型CLIPContrastive Language-Image Pre-training是OpenAI提出的多模态模型核心思想是将图片和文字映射到同一个向量空间。在这个空间里“一张红色花朵的照片”和“red flower”的文字向量距离很近而和“汽车”距离很远。本次使用的clip-ViT-B-32是轻量版400M参数在CPU上即可运行适合学习和小规模应用。三、环境准备3.1 已有基础设施基于上一篇搭建的环境WSL2 Ubuntu 22.04Python 3.10 venv 虚拟环境PyCharm 集成开发环境Chroma 向量数据库3.2 新增依赖bash# 激活虚拟环境 source ~/ai_project/venv/bin/activate # 安装必要库 pip install sentence-transformers pillow chromadb flask注意sentence-transformers会同时安装 PyTorchCPU版约 500MB请耐心等待。四、第一阶段照片索引数据入库4.1 目录结构text~/ai_project/photo_ai/ ├── photos/ # 存放照片34张 ├── photo_chroma/ # Chroma数据目录自动生成 ├── index_photos.py # 索引脚本 ├── app_search.py # 搜索服务 └── templates/ └── search.html # 前端页面4.2 索引脚本index_photos.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- 照片索引脚本将指定目录下的所有图片用CLIP编码并存入Chroma 作者Javy21 import os # 设置Hugging Face镜像源国内加速 os.environ[HF_ENDPOINT] https://hf-mirror.com import glob from PIL import Image from sentence_transformers import SentenceTransformer import chromadb # ---------- 配置 ---------- PHOTO_FOLDER ./photo_ai/photos # 照片目录 CHROMA_PATH ./photo_ai/photo_chroma # 向量库路径 COLLECTION_NAME my_photos # 集合名称 # ---------- 初始化 ---------- print(⏳ 加载CLIP模型...) model SentenceTransformer(clip-ViT-B-32) print(✅ 模型加载完成) client chromadb.PersistentClient(pathCHROMA_PATH) # 删除旧集合确保使用新配置 try: client.delete_collection(COLLECTION_NAME) print(️ 删除旧集合) except: pass collection client.create_collection( nameCOLLECTION_NAME, metadata{hnsw:space: cosine} # 使用余弦相似度 ) print(✅ 新建集合使用余弦相似度) # ---------- 查找图片 ---------- extensions (*.jpg, *.jpeg, *.png, *.bmp, *.webp) photo_paths [] for ext in extensions: photo_paths.extend(glob.glob(os.path.join(PHOTO_FOLDER, ext))) photo_paths.extend(glob.glob(os.path.join(PHOTO_FOLDER, ext.upper()))) if not photo_paths: print(f❌ 在 {PHOTO_FOLDER} 下未找到任何图片) exit(1) print(f 发现 {len(photo_paths)} 张图片开始编码...) # ---------- 批量编码 ---------- images [Image.open(p).convert(RGB) for p in photo_paths] embeddings model.encode(images, convert_to_numpyTrue).tolist() # ---------- 入库 ---------- ids [fphoto_{i} for i in range(len(photo_paths))] metadatas [{file_path: p, file_name: os.path.basename(p)} for p in photo_paths] collection.add( embeddingsembeddings, metadatasmetadatas, idsids ) print(f✅ 成功索引 {len(photo_paths)} 张图片到 Chroma) print(f 数据库位置: {os.path.abspath(CHROMA_PATH)})4.3 运行索引bashcd ~/ai_project source venv/bin/activate python photo_ai/index_photos.py首次运行会自动下载 CLIP 模型约 600MB请确保网络通畅或已配置镜像源。索引结果 发现 34 张图片开始编码...✅ 成功索引 34 张图片到 Chroma五、第二阶段搜索服务开发5.1 核心难点中文语义失效索引完成后我迫不及待地输入“红花”进行测试——结果一张都搜不出来。经过排查发现clip-ViT-B-32是英文模型对中文输入的支持极差。输入red flower等英文内容则能正常检索。解决方案在后续实践中统一使用英文关键词进行测试。5.2 服务端代码app_search.pypython#!/usr/bin/env python3 # -*- coding: utf-8 -*- 照片搜索服务 v2.1 - 以文搜图优化版 功能用户输入文字返回最匹配的照片 作者Javy21 import os os.environ[HF_ENDPOINT] https://hf-mirror.com import base64 from io import BytesIO from PIL import Image from flask import Flask, render_template, request, jsonify from sentence_transformers import SentenceTransformer import chromadb # ---------- 配置 ---------- CHROMA_PATH ./photo_ai/photo_chroma COLLECTION_NAME my_photos TOP_K 12 # ---------- 初始化 ---------- app Flask(__name__) print(⏳ 加载CLIP模型...) model SentenceTransformer(clip-ViT-B-32) print(✅ CLIP模型加载完成) print(⏳ 连接Chroma数据库...) client chromadb.PersistentClient(pathCHROMA_PATH) collection client.get_collection(COLLECTION_NAME) print(f✅ 连接成功共 {collection.count()} 张图片) def encode_image_to_base64(image_path, max_size(300, 300)): 将图片编码为Base64字符串用于前端显示 try: with Image.open(image_path) as img: if img.mode in (RGBA, LA, P): img img.convert(RGB) img.thumbnail(max_size, Image.Resampling.LANCZOS) buffer BytesIO() img.save(buffer, formatJPEG, quality85) img_base64 base64.b64encode(buffer.getvalue()).decode(utf-8) return fdata:image/jpeg;base64,{img_base64} except Exception as e: print(f⚠️ 图片编码失败: {image_path}, 错误: {e}) return None def expand_query(text): 查询扩展对英文关键词进行简单扩展提升召回率 expansions [] words text.lower().split() for w in words: expansions.append(w) # 复数形式 if w.endswith(s): expansions.append(w[:-1]) else: expansions.append(w s) # 同义词可扩展 if w flower: expansions.extend([bloom, blossom]) elif w rice: expansions.extend([paddy, grain]) elif w red: expansions.extend([crimson, scarlet]) elif w yellow: expansions.extend([golden, amber]) elif w bamboo: expansions.extend([shoot, cane]) # 去重保持原词在前 seen set() result [] for w in expansions: if w not in seen: seen.add(w) result.append(w) return .join(result) app.route(/) def index(): return render_template(search.html) app.route(/api/search, methods[POST]) def search(): data request.get_json() query_text data.get(query, ).strip() if not query_text: return jsonify({error: 请输入搜索内容}), 400 top_k data.get(top_k, TOP_K) try: # 查询扩展 expanded_query expand_query(query_text) print(f\n 原始: {query_text} → 扩展: {expanded_query}) # 向量检索 query_embedding model.encode([expanded_query], convert_to_numpyTrue).tolist() results collection.query( query_embeddingsquery_embedding, n_resultstop_k * 2, include[metadatas, distances] ) result_list [] if results[ids] and len(results[ids][0]) 0: for photo_id, meta, dist in zip( results[ids][0], results[metadatas][0], results[distances][0] ): # 余弦距离 → 相似度归一化到0~1 similarity 1 - (dist / 2) if similarity 0.05: # 过滤完全不相关的 continue file_path meta.get(file_path, ) thumbnail encode_image_to_base64(file_path) if os.path.exists(file_path) else None result_list.append({ id: photo_id, file_path: file_path, file_name: meta.get(file_name, os.path.basename(file_path)), similarity: round(similarity, 4), similarity_percent: f{round(similarity * 100, 1)}%, thumbnail: thumbnail }) if len(result_list) top_k: break print(f ✅ 返回 {len(result_list)} 张) return jsonify({ query: query_text, expanded: expanded_query, total: len(result_list), results: result_list }) except Exception as e: print(f❌ 搜索出错: {e}) return jsonify({error: str(e)}), 500 if __name__ __main__: app.run(host0.0.0.0, port5001, debugTrue)5.3 前端页面templates/search.html前端采用纯HTMLCSSJavaScript无外部依赖。关键特性搜索框 发送按钮图片网格展示缩略图相似度进度条可视化响应式布局支持手机访问完整代码如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title 以文搜图 v2.0/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: Segoe UI, Microsoft YaHei, sans-serif; background: #f5f7fb; min-height: 100vh; padding: 30px; } .container { max-width: 1200px; margin: 0 auto; } .header { text-align: center; padding: 30px 0 20px; } .header h1 { font-size: 28px; color: #1a2332; } .header h1 span { color: #2d5a88; } .header p { color: #6b7a8f; margin-top: 8px; font-size: 15px; } .search-box { display: flex; gap: 12px; max-width: 640px; margin: 0 auto 12px; background: white; padding: 8px; border-radius: 50px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); } .search-box input { flex: 1; padding: 12px 20px; border: none; outline: none; font-size: 16px; background: transparent; } .search-box input::placeholder { color: #aab3c5; } .search-box button { padding: 12px 32px; background: #2d5a88; color: white; border: none; border-radius: 50px; font-size: 16px; font-weight: 600; cursor: pointer; transition: background 0.2s; } .search-box button:hover { background: #1f4569; } .search-box button:disabled { background: #aab3c5; cursor: not-allowed; } .search-stats { text-align: center; color: #6b7a8f; font-size: 14px; min-height: 24px; } .results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; margin-top: 24px; } .result-card { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.06); transition: transform 0.2s, box-shadow 0.2s; } .result-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); } .result-card .thumbnail { width: 100%; aspect-ratio: 1 / 1; background: #f0f2f5; overflow: hidden; display: flex; align-items: center; justify-content: center; } .result-card .thumbnail img { width: 100%; height: 100%; object-fit: cover; } .result-card .info { padding: 12px 14px; } .result-card .info .file-name { font-size: 13px; color: #1a2332; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .result-card .info .similarity { font-size: 12px; color: #2d5a88; font-weight: 600; margin-top: 2px; } .result-card .info .similarity-bar { width: 100%; height: 3px; background: #e9ecef; border-radius: 2px; margin-top: 6px; overflow: hidden; } .result-card .info .similarity-bar .fill { height: 100%; background: #2d5a88; border-radius: 2px; transition: width 0.3s ease; } .empty-state { text-align: center; padding: 60px 20px; color: #aab3c5; } .empty-state .icon { font-size: 48px; margin-bottom: 16px; } .loading-spinner { text-align: center; padding: 40px; color: #6b7a8f; } .loading-spinner .spinner { display: inline-block; width: 32px; height: 32px; border: 4px solid #e9ecef; border-top-color: #2d5a88; border-radius: 50%; animation: spin 0.8s linear infinite; } keyframes spin { to { transform: rotate(360deg); } } media (max-width: 600px) { .search-box { flex-direction: column; border-radius: 16px; padding: 12px; } .search-box input { padding: 12px; } .search-box button { padding: 12px; border-radius: 30px; } .results-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } body { padding: 16px; } } /style /head body div classcontainer div classheader h1 span以文搜图/span v2.0/h1 p输入文字描述从你的照片库中智能搜索最匹配的图片/p /div div classsearch-box input typetext idsearchInput placeholder例如红花、狗狗、天空、人物合影... / button idsearchBtn 搜索/button /div div classsearch-stats idstats/div div idresultsContainer div classempty-state div classicon️/div p输入关键词开始搜索你的照片/p /div /div /div script (function() { use strict; const searchInput document.getElementById(searchInput); const searchBtn document.getElementById(searchBtn); const stats document.getElementById(stats); const container document.getElementById(resultsContainer); function renderResults(data) { if (!data || !data.results || data.results.length 0) { container.innerHTML div classempty-state div classicon/div p没有找到匹配的图片试试其他关键词/p p stylefont-size:12px;color:#aaa;margin-top:8px;提示尝试用更具体的描述词/p /div ; stats.textContent 未找到匹配结果共 ${data.total || 0} 张; return; } let html div classresults-grid; data.results.forEach(item { const thumb item.thumbnail || ; const simPercent item.similarity_percent || 0%; const simValue item.similarity || 0; const barWidth Math.min(simValue * 100, 100); html div classresult-card title${item.file_name} div classthumbnail ${thumb ? img src${thumb} alt${item.file_name} loadinglazy / : } /div div classinfo div classfile-name${item.file_name}/div div classsimilarity相似度 ${simPercent}/div div classsimilarity-bar div classfill stylewidth:${barWidth}%;/div /div /div /div ; }); html /div; container.innerHTML html; stats.textContent 共找到 ${data.total} 张相关图片; } function setLoading(loading) { if (loading) { searchBtn.disabled true; searchBtn.textContent 搜索中...; container.innerHTML div classloading-spinner div classspinner/div p stylemargin-top:12px;正在检索最匹配的照片.../p /div ; stats.textContent ; } else { searchBtn.disabled false; searchBtn.textContent 搜索; } } async function performSearch() { const query searchInput.value.trim(); if (!query) { stats.textContent ⚠️ 请输入搜索内容; return; } setLoading(true); try { const response await fetch(/api/search, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ query: query, top_k: 24 }) }); const data await response.json(); if (response.ok) { renderResults(data); } else { container.innerHTML div classempty-state div classicon❌/div p${data.error || 搜索失败请稍后重试}/p /div ; stats.textContent 搜索出错; } } catch (err) { container.innerHTML div classempty-state div classicon/div p网络错误${err.message}/p /div ; stats.textContent 网络连接失败; } finally { setLoading(false); } } searchBtn.addEventListener(click, performSearch); searchInput.addEventListener(keydown, function(e) { if (e.key Enter) performSearch(); }); searchInput.focus(); })(); /script /body /html5.4 运行搜索服务bashpython photo_ai/app_search.py访问http://localhost:5001输入英文关键词即可测试。六、踩坑全记录6.1 模型下载网络问题现象text[Errno 101] Network is unreachable thrown while requesting HEAD https://huggingface.co/sentence-transformers/clip-ViT-B-32/原因Hugging Face 被墙或网络不稳定。解决方案设置镜像源环境变量pythonos.environ[HF_ENDPOINT] https://hf-mirror.com6.2 中文检索完全无效现象输入“红花”返回空结果输入“red flower”正常。原因clip-ViT-B-32是纯英文模型不支持中文语义。解决方案短期使用英文关键词长期换用clip-ViT-B-32-multilingual-v1支持100语言6.3 相似度坍缩核心问题现象搜索paddy rice时水稻图片相似度 63.7%无关图片也有 59.7%差距极小排序不准。分析CLIP 对相似主题如各种花卉的区分能力有限向量距离被压缩在 0.69~0.80 的窄区间内轻量级模型的表达能力不足解决方案查询扩展已实施添加同义词提升召回图片描述生成规划中用 BLIP 生成每张图的文字描述再进行重排序6.4 文件名与内容无关现象手机导出的图片文件名如_cgi-bin_mmwebwx-bin_webwxgetmsgimg__MsgID...无法提取任何语义。影响任何基于文件名的文本方法如 Cross-Encoder 重排序都无效。解决方案必须依赖图像内容生成描述见下一阶段规划。七、优化效果对比7.1 优化前关键词正确图片排序相似度范围排序质量red flower第1、3位正确60%~65%一般paddy rice第1、2位正确60%~64%一般yellow flower第2、4位正确60%~63%较差7.2 优化后查询扩展关键词扩展后查询改进效果red flowerred flower flowers crimson scarlet bloom blossom召回率提升paddy ricepaddy rice paddy rices grain cereal排序略有改善7.3 当前局限准确度仍受限于 CLIP 模型的区分能力同主题图片区分困难如不同品种的花中文支持需要换模型八、总结与感悟8.1 核心收获多模态AI在CPU上完全可行32GB内存 CLIP轻量版足以支撑学习和小规模应用中文支持是分水岭英文模型效果尚可中文场景需要专用模型相似度坍缩是轻量模型的通病需要额外的精排机制弥补文件名的误导性手机自动生成的文件名无语义必须依赖图像内容8.2 代码与资源完整代码本文中已经更新后续有时间再整理到github仓库CLIP 模型Hugging Face - clip-ViT-B-32Chroma 文档docs.trychroma.com8.3 写在最后从纯文本对话到“以文搜图”这一步跨过了多模态的门槛。虽然准确度还有提升空间但整个技术链路已经跑通——CLIP 编码、Chroma 检索、Flask 服务、前端展示。AI 应用开发的核心能力不是调参而是能快速把想法变成可运行的原型。这篇文章记录的正是从想法到原型的完整过程。我会继续探索图片描述生成 重排序方案争取将检索准确度提升到可实用水平。如果你也感兴趣多交流欢迎留言探讨欢迎关注后续文章。作者Javy21javy21csdn博客主页javy21-CSDN博客首发日期2026年6月22日本文采用CC BY-NC 4.0许可协议。欢迎转载请注明出处。如有问题或建议欢迎在评论区交流。