构建聊天式房屋搜索:融合多模态理解与混合检索的工程实践

发布时间:2026/9/4 12:31:06
构建聊天式房屋搜索:融合多模态理解与混合检索的工程实践 很多搜索类产品都卡在一个非常尴尬的体验上用户已经不再满足于按“几居室、价格区间、地段”这些结构化字段筛选房子。一个能聊天、能读房源照片、还能估算每月成本的房屋搜索工具看起来只是把三个功能塞进一个输入框实际上是在重做一条信息处理链路。用户真正想问的是“两个人在家办公房子最好客厅亮一点月供控制在八千以内”系统要同时理解自然语言请求、照片里的装修和采光信息还要把价格转成一个可比较的月度成本数字。这篇文章会从工程实现的角度把这种“聊天式房屋搜索”拆成四个子系统多模态照片理解、自然语言意图解析、向量加结构化的混合检索、以及月度成本估算模块。你会看到为什么照片信息不能靠模型临时看一次就结束为什么要用函数调用而不是直接把语义查询塞给全表扫描以及怎么把“估算月成本”从提示词里的愿望变成真正可复用的计算函数。示例使用模拟房源数据和公开可替换的开发接口适合作为自己动手复刻的最小参考实现。1. 先拆清“聊天、读图、估算月成本”分别落到哪个系统层1.1 用户问题不是简单关键词而是一组混合条件传统搜索结果页把房子拆成十几个筛选条件核心原因是数据库只能做精确匹配。用户说“有没有客厅朝南的房子”传统系统只能去匹配备注字段是否包含“朝南”一旦文字里没写就永远搜不到。像标题这样的产品难点在于它把“客厅朝南”这种本应来自照片的信息和“月成本不超过多少”这种结构化计算信息放进了同一条对话里。如果只有一个后端请求进来后要做的事情至少包括判断用户是在买房还是在租房这决定“成本”是月供还是租金加杂费。从用户文本里抽取预算、房间数、地段、偏好等筛选条件。把“客厅亮、适合居家办公”这类模糊描述转成向量去匹配照片识别出的结果。对命中的候选房源调用成本计算函数而不是让语言模型口算。最后把结果用自然语言呈现出来。也就是说标题里“chats, reads the photos, and est. monthly costs”并不是三个并列功能而是同一条链路上的三个环节。聊天是入口照片是信息补充成本估算是排序和回答的依据。1.2 四个工程模块和一次请求的依赖关系把产品能力映射到工程模块可以分成下面四层模块主要工作关键能力典型报错点房源数据层保存房源基础字段、照片描述、地理信息CSV、数据库、向量库字段口径不统一照片理解层读取图片抽取出结构化标签和摘要多模态大模型、base64 编码图片过大、返回非 JSON搜索与聊天层理解用户意图混合检索候选并生成回答函数调用、向量检索、LLM 对话预算条件被当作文本处理成本计算层根据价格和参数估算每月成本财务公式、参数配置默认利率或税费口径不清一次完整请求会按“图片处理、候选召回、成本计算、回答生成”的顺序执行。所以一个用户在聊天框里发出图片或文本时不能直接让后端随便抛给大模型回答。先要把信息拆成可处理的结构再决定是否需要重新检索。1.3 技术选型怎么按“演示”和“生产”取舍这个功能可以做得非常轻量也可以做得非常复杂。如果目标是跑通一个 Demo最合理的最小集合是一个支持视觉输入的模型接口、一个支持向量存储的内存数据库、一套语言模型的函数调用能力、一个 200 行以内的 FastAPI 后端以及一个静态 HTML 页面。生产版本则要在模型调用前增加任务队列、图片审核、成本缓存、权限管理、日志追踪和多租户隔离。所以文章后面给出的代码是“能跑通思路”的参考结构。如果你看到某个依赖版本不合适可以按自己环境的稳定版本替换。不要认为这套代码是唯一答案重点在于理解每个调用为什么存在。2. 用可直接运行的 Demo 结构准备项目和数据2.1 环境要求先装对再编码建议在 Python 3.10 以上环境中开发。这个项目涉及异步接口、图片编码和 Pydantic 数据校验旧版本容易遇到奇怪问题。需要准备的基础依赖可以写在requirements.txt里但先不锁死版本安装时以当前稳定版为准fastapi uvicorn[standard] pydantic openai chromadb sentence-transformers python-multipart pandas其中openai是常见的大模型接口库如果你使用兼容 OpenAI 协议的其他服务商也可以在客户端里配置你自己的base_url。为了避免不同环境之间的 SDK 差异下面的代码统一使用“OpenAI 兼容接口”的写法。如果只是做照片理解演示也可以先不接向量库直接把识别结果写进 JSON 文件。先保证链路通再加向量检索排查起来会更容易。2.2 项目目录让每个能力有独立边界建议按下面的方式组织代码照片理解、检索、成本计算分别拆开后面排错时只需要查看对应文件ai-home-search/ ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 服务入口 │ ├── config.py # 模型、数据库、默认成本参数 │ ├── models.py # 请求与响应数据结构 │ ├── vision.py # 照片理解与标签抽取 │ ├── ingest.py # CSV 导入和向量化入库 │ ├── costs.py # 每月成本估算 │ └── chat.py # 聊天式搜索接口 ├── data/ │ ├── listings.csv # 模拟房源数据 │ └── photos/ # 示例照片 ├── frontend/ │ ├── index.html # 简单对话页面 │ └── app.js ├── scripts/ │ ├── analyze_photos.py # 批量分析照片并生成 JSON │ └── load_vectordb.py # 建立向量索引 └── requirements.txt这道拆分逻辑是图片分析可能一天跑一次聊天服务是每秒钟都会调用成本计算可能被多个地方复用。如果都把逻辑写在chat.py里改动成本会越来越高。2.3 用模拟房源 CSV 准备测试集真实房产数据涉及授权和合规问题。本地试验时可以直接构造一个 CSV里面放三五十条模拟房源字段覆盖搜索所需的结构化信息照片可以使用自己生成或获得授权的图片。id,title,deal_type,price,bedrooms,bathrooms,area_sqm,district,floor_level,photo_path,notes H001,梧桐花园两居室,sale,5200000,2,1,89,浦东新区,高楼层,data/photos/h001.jpg,南向客厅带独立工作区 H002,滨江国际一居室,rent,6500,1,1,55,徐汇区,中楼层,data/photos/h002.jpg,精装朝南近地铁 H003,阳光新城三居室,sale,7600000,3,2,120,闵行区,低楼层,data/photos/h003.jpg,采光好适合家庭这里deal_type是决定成本计算口径的核心字段。sale代表买房rent代表租房。price在出售房源里是总价在租房源里是每月租金。需要注意的是这种字段语义一旦混淆后面的估价就会完全错误。2.4 学习环境与生产环境的差异学习环境里可以直接让 Python 应用读取本地 CSV再把图片 base64 后传给模型。生产环境不建议这样设计场景图片来源数据更新请求量成本估算学习 Demo本地图片、模拟 CSV手动重跑脚本低公式放内存即可生产环境对象存储、图片审核定时任务增量入库高独立服务或规则引擎多城市版本城市政策不同表单录入或合作数据中参数表外置化生产环境还需要考虑图片脱敏门牌号、人脸、车牌这类信息要在进入模型前处理掉否则存在隐私风险。3. 让产品读得懂照片多模态识别的抽取与落库3.1 为什么要抽取而不是直接让模型“看一眼”有一种实现思路是用户发来图片后直接把图片和问题一起发给多模态模型让模型给出答案。这个方案对个别问题有效但不利于搜索场景。原因很简单搜索系统需要在几十上百套房子里找到匹配项不可能每个候选都临时调用一次视觉模型另外每次聊天都传一遍大图推理成本和延迟都会成倍上升。更合理的方法是把“看图”从聊天时提前到入库时。系统在房源进入数据库前先对照片做一次结构化抽取得到房间类型、采光、装修标签、文字信息等再写入数据库。这样用户聊天时只需要检索已经抽取好的标签和描述响应速度会快很多。这一步本质上是把非结构化的图片内容转换成一个可查询的中间表示。理解照片并不是搜索引擎最终要做的复杂判断而是为了让后续检索能有据可依。3.2 视觉输入的两种方式目前的视觉模型接口通常支持两种图片输入方式一是直接传网络的图片地址二是把本地图片转成 base64 字符串后放入消息内容。如果演示环境没有公网图片地址最常见的是使用 base64 方式。import base64 from pathlib import Path def encode_image_as_base64(image_path: str) - str: mime image/jpeg if image_path.lower().endswith(.png): mime image/png data base64.b64encode(Path(image_path).read_bytes()).decode(utf-8) return fdata:{mime};base64,{data}这里要注意Path(image_path).read_bytes()会一次性把整张图片读入内存。大批量处理时最好先限制图片分辨率或文件大小避免出现内存飙升或模型接口拒绝超大请求。3.3 提示词设计和 JSON 输出如果希望系统能稳定地识别照片不能让模型随意说一段话而是要定义一套固定结构。下面是一份可用于照片抽取的提示词指令VISION_SYSTEM_PROMPT 你是一个房源照片结构分析器。用户会提供一张或一组房源照片。 你的任务是从照片中提取以下信息 1. 出现了哪些房间类型 2. 采光条件bright / medium / dark 3. 主要装修风格和关键标签例如 落地窗、朝南、木地板、开放式厨房 4. 从照片中看到的可见文字例如门牌、广告牌、门上的提示 5. 一句话总结这个房间对什么样的住户有吸引力 要求只输出 JSON不要添加额外解释。JSON 结构如下 { room_types: [string], lighting: bright, tags: [string], detected_text: [string], quality_rating: 0.0, summary: string } .strip()系统提示词的目的是限制输出范围。但要注意即使你写得再清楚模型仍然有可能返回一段包含 JSON 的 Markdown 文本或者返回多个 JSON 块。所以在解析层要做一次容错。调用代码大致是这样import json from openai import OpenAI client OpenAI() def analyze_photo(image_data_url: str) - dict: response client.chat.completions.create( modelgpt-4o-mini, messages[ {role: system, content: VISION_SYSTEM_PROMPT}, { role: user, content: [ {type: text, text: 请分析这张房源照片。}, {type: image_url, image_url: {url: image_data_url}}, ], }, ], temperature0.1, ) content response.choices[0].message.content return parse_model_json(content)parse_model_json需要先尝试直接json.loads失败后把代码块中的内容提取出来再尝试一次。不要一失败就抛异常因为模型输出不稳定是常态。3.4 批量入库脚本如果房源只有几十套可以用一个脚本依次分析照片并把结果合并到数据表里。为了避免重复调用模型浪费费用应该把结果保存到 JSON 或元数据字段中。import csv import json from pathlib import Path def enrich_photos_into_json(csv_path: str, output_path: str) - None: rows list(csv.DictReader(open(csv_path, encodingutf-8))) enriched [] for row in rows: photo row[photo_path] if not Path(photo).exists(): row[photo_analysis] {} enriched.append(row) continue data_url encode_image_as_base64(photo) row[photo_analysis] analyze_photo(data_url) enriched.append(row) Path(output_path).write_text( json.dumps(enriched, ensure_asciiFalse, indent2), encodingutf-8 )这里有一个常见问题如果某张照片不存在脚本可能会直接中断。应该先做一次文件存在性检查并把失败的图片以空字典写入结果随后单独查看失败列表。3.5 照片理解里最常见的坑图片识别不像普通文本解析坑通常会出现在模型能力之外。需要重点注意这几点图片方向问题部分手机照片带有 EXIF 旋转信息直接读取可能使模型看到倒置的图。批量处理前可以先统一旋转或交给图片预处理库处理。一张照片包含多个角度如果一张图是拼图或多客厅照片最好先切割再分别分析否则模型容易只关注最大区域。文本识别不可靠照片里的价格牌、门牌号不一定准确不要把它当作真实挂牌价保存。识别到的文字最多作为参考字段。隐私内容识别流程中尽量不要把未经处理的人脸、车牌、门牌号留在标签里。在真实项目里我会建议你先拿 20 张效果差异明显的照片做人工校准再决定提示词怎么写。直接相信第一版提示词的结果通常会在上线后被各种图片击穿。4. 让搜索会聊天意图解析、向量检索和回答生成4.1 先解释“聊天式搜索”不等于聊天机器人聊天式搜索和聊天机器人最大的区别在于聊天机器人可能只负责维持对话而聊天式搜索必须返回可打开、可比较的房源列表。所以系统的目标不是“回一段话”而是“把用户的话转成一次查询再基于真实候选生成回答”。如果不做查询转换会出现一种很常见的问题用户说“预算八千以内最好离地铁近”系统把“八千以内”当成文本在描述里找不到“8000”这个词最后返回空结果。为了避免这种问题应该把筛选条件从自然语言输入中抽出来交给结构化过滤。4.2 用函数调用把用户问题变成查询条件目前比较主流的做法是让模型调用一个search_homes函数。函数声明描述参数模型在识别到用户意图后会生成一组结构化参数而不是直接给出最终答案。SEARCH_HOMES_TOOL { type: function, function: { name: search_homes, description: 根据用户需求检索房源返回候选列表, parameters: { type: object, properties: { deal_type: { type: string, enum: [sale, rent], description: 买房还是租房不确定时不传 }, max_price: { type: number, description: 总价或月租的最高预算 }, max_monthly_cost: { type: number, description: 用户能接受的月度总成本上限 }, bedrooms: { type: integer, description: 卧室数量 }, preferred_district: { type: string, description: 用户期望的区域 }, query: { type: string, description: 适合向量检索的模糊描述比如 采光好、客厅朝南、适合居家办公 } }, required: [] } } }然后把函数声明和历史消息一起发给模型。当模型认为用户已经提供了足够信息时会返回一个函数调用请求。后端不要直接把这个函数调用当作最终回答而是真正去执行房源检索。4.3 混合查询的结构化过滤和向量召回用户的需求同时包含结构化条件和语义条件。只做 SQL 过滤会漏掉“采光好、适合居家办公”这种照片层面信息只做向量检索又很难精确匹配“预算 8000 以内”。推荐的做法是两段式先用结构化字段粗筛缩小候选范围再把“模糊偏好”转换成向量在粗筛结果里做语义相似度排序。这样既能保证数字条件准确又能让照片描述的语义参与排序。向量索引可以采用chromadb。把照片理解模块抽取的标签和摘要拼成一段文本作为向量检索的文档import chromadb from chromadb.utils import embedding_functions client chromadb.Client() embed_fn embedding_functions.SentenceTransformerEmbeddingFunction( model_nameparaphrase-multilingual-MiniLM-L12-v2 ) collection client.get_or_create_collection( namehome_search, embedding_functionembed_fn ) def build_search_text(listing: dict) - str: analysis listing.get(photo_analysis) or {} tags .join(analysis.get(tags, [])) rooms .join(analysis.get(room_types, [])) return f{listing.get(title, )} 房间: {rooms} 标签: {tags} 总结: {analysis.get(summary, )}用户问题里的语义偏好例如“客厅亮一点有落地窗适合居家办公”会被编码成同样维度的向量。在结构化粗筛之后可以用 Top-K 向量相似度找出最接近的照片描述。4.4 最终回答要引用候选数据而不是自由发挥很多 Demo 做出来效果不佳是因为模型在拿到候选后仍然按照自己的“常识”补充了很多房源根本没有的信息。要避免这个问题可以把候选转成紧凑的上下文并明确要求模型只能基于这些候选回答。在提示词中要写清楚下面是从系统检索到的真实房源。你只能基于这些房源信息回答。如果用户要求的条件没有完全满足要如实说明不能编造照片、价格或月成本。同时把候选人主要字段和成本估算结果放进上下文而不是让模型去读原始数据库。这样回答就变成了一个有约束的摘要任务而不是开放式生成。4.5 多轮会话如何维护状态如果用户第一轮说“我想在浦东买房”第二轮又说“预算高一些”系统需要记住上一轮的区域和判断。最简单的做法是保留最近几轮对话历史随下一次函数调用一起发送。但要注意每次对话都携带全部历史会很快超过上下文窗口所以通常只保留最近 5 到 10 条。更进阶的做法是维护一个“状态槽位”结构比如用户已经提供的地段、预算上下限、房间数。模型每次解析问题后会更新槽位而不是重复解析整串历史。这个方案能减少 token 消耗也让搜索条件的累计更稳定。5. 每月成本估算成为一个独立计算模块5.1 买房与租房估算参数差异标题里出现“monthly costs”意味着产品需要回答“这个房子每个月从头到尾要花多少钱”。这个成本对购房和租房完全不同。购房场景通常包含月供、房产税、保险、物业费以及可能的水电燃气估算。租房场景则包含租金、水电燃气、物业费和停车费。如果系统不区分deal_type直接统一算“月供”租房的房源就会得到一份毫无意义的数字。所以成本模块的第一步是从数据中读取deal_type然后分两条公式计算。这样用户问“预算八千以内”时租房用户和买房用户拿到的结果口径不同但都在比较每个月的实际现金支出。5.2 默认估算参数表成本计算的参数需要显式配置不能写死在模型提示词里。下面是一组示例默认值不代表任何城市的实际政策。生产环境必须把参数表外置化并且根据城市或小区配置覆盖。参数默认值说明首付比例20%购房场景贷款期限30 年可以改成 20 年或 10 年年利率5.5%需要替换为当前银行参考利率年房产税率1.2%不同地区差异很大年保险费率0.35%按房价估算物业管理费具体字段按挂牌数据填写或由用户设定水电燃气估算300 元/月租房或购房都可用这里的“估算”本身带有很多不确定因素。产品如果直接展示成精确金额会造成误导。正确做法是在数字旁边标注“估算值未包含首付资金占用、税费优惠、维修基金等费用”。5.3 用同一函数给搜索结果补充月成本为了让成本和搜索结合可以在检索到候选后调用成本模块给每个候选补一个estimated_monthly_cost字段。def estimate_monthly_cost(listing: dict, params: dict) - float: deal_type listing.get(deal_type) if deal_type rent: rent float(listing.get(price, 0)) utilities params.get(utilities_est, 300) parking params.get(parking_fee, 0) return rent utilities parking price float(listing.get(price, 0)) down_payment params.get(down_payment_ratio, 0.2) loan_term_years params.get(loan_term_years, 30) annual_rate params.get(annual_interest_rate, 0.055) property_tax_rate params.get(property_tax_rate, 0.012) insurance_rate params.get(insurance_rate, 0.0035) hoa_fee float(params.get(hoa_fee, 0)) loan_amount price * (1 - down_payment) monthly_rate annual_rate / 12 months loan_term_years * 12 if monthly_rate 0: principal_and_interest loan_amount / months else: factor (1 monthly_rate) ** months principal_and_interest loan_amount * monthly_rate * factor / (factor - 1) property_tax price * property_tax_rate / 12 insurance price * insurance_rate / 12 return principal_and_interest property_tax insurance hoa_fee函数计算完成后再把结果写入候选对象。后续无论是渲染卡片还是生成聊天回答都可以统一读取该字段不必在每个地方重复公式。5.4 明确误差来源避免被当成精确报价需要让用户和调用者都理解这里给出的“每月成本”是一个范围参考。它没有考虑贷款能否批下来也没有把利率未来变化可能影响月供写进去。即使是同一套房子不同银行、不同首付比例产生的月供也可能完全不同。所以在聊天回答里建议把默认假设列出来。比如“按首付 20%、30 年、年利率 5.5% 估算这套房每月支出约 6820 元其中包含估算的物业和税费”。如果用户说“我首付可以到 50%”系统需要把新首付比例传给成本计算函数再重新算一次。这个交互比直接在答案里给一个固定金额更贴近真实决策。6. 从聊天接口到页面验证跑通最小闭环6.1 后端请求链路顺序完整后端链路应该按固定的顺序执行不能随意调换接收用户消息和最近会话历史。如果消息里包含图片先走视觉识别把图片结果解析并暂存。调用意图解析函数得到结构化查询条件。先做结构化过滤再做向量相似度排序得到候选 TopK。对候选调用成本计算函数并按其与预算的匹配度排序。如果缺少候选直接返回“没有完全符合条件”的回复。把候选和成本结果组装成上下文调用模型生成自然语言回答。这个顺序的价值在于每一步都有明确的输入和输出。出了问题也能快速判断是哪一环节的责任不用从头到尾猜。6.2 一个简化版 chat endpoint可以用 FastAPI 写一个演示接口。为了阅读方便这里省略了复杂的数据库连接假设已经有一个search_homes仓库模块。from fastapi import FastAPI from pydantic import BaseModel from app import costs, vision, ingest app FastAPI() class ChatMessage(BaseModel): role: str content: str class ChatRequest(BaseModel): message: str image_data_url: str | None None history: list[ChatMessage] [] app.post(/api/chat) async def chat_endpoint(req: ChatRequest): image_analysis None if req.image_data_url: image_analysis vision.analyze_photo(req.image_data_url) conditions await parse_user_requirement( req.message, req.history, image_analysis ) candidates await search_homes(conditions) if not candidates: return {reply: 目前没有找到完全符合条件的房源可以降低预算或扩大区域再试。} enriched costs.attach_cost_estimates(candidates) reply await generate_chat_reply( user_messagereq.message, candidatesenriched, historyreq.history, ) return { reply: reply, candidates: enriched, parsed_conditions: conditions, }这里的parse_user_requirement就是上一节的函数调用流程。search_homes建议同时支持 SQL 过滤和向量召回而不是在 Python 列表里做全量循环否则房源多到一定量级后会明显变慢。Demo 阶段可以用列表循环但要在代码注释里标注这是临时方案。6.3 前端最小聊天 UI为了验证完整链路不需要复杂前端。一个纯 HTML 页面加上fetch请求就够用async function send() { const msg document.getElementById(input).value; const resp await fetch(/api/chat, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({message: msg}) }); const data await resp.json(); document.getElementById(reply).innerText data.reply; }如果要支持图片需要先把图片转成 base64 再放入image_data_url字段。可以在input typefile的 change 事件中用FileReader读取。前端最需要展示的其实是候选卡片包括照片、月成本、地址、房间数、匹配原因。如果没有卡片用户看到一段文字回答会觉得很虚因为不能确定系统真的找到了房源。6.4 验证测试问题与预期结果完成接口后建议准备一组回归测试问题覆盖不同能力点测试问题期望结果浦东两居室总价 600 万以内返回 sale 类型价格过滤正确徐汇租金 7000 以内最好朝南返回 rent 类型按租金和照片标签筛选我喜欢客厅有落地窗的适合居家办公触发语义查询从照片标签召回到“落地窗”预算 8000 以内能买什么房子触发成本估算把“月成本 8000”作为排序条件我发了一张图片帮我找类似户型先识别图片再基于标签检索返回相似候选没有任何房子符合怎么办回复要承认无结果不能编造房源6.5 发布检查清单如果要把这个 Demo 发布出来至少检查下面这些项环境变量中是否泄露 API Key。图片输入是否限制文件大小和类型避免用户传超大文件。是否有用户输入里的恶意提示词注入风险。成本展示是否标注了估算口径。对于没有候选的情况是否做了兜底文案。模型返回的 JSON 是否在失败时有日志记录。是否设置了请求频率限制避免费用失控。是否对房源文本和图片内容做了审核与脱敏。更细一层最好在接口层加请求日志日志里记录原始输入、解析出的结构化条件、候选数量、模型返回耗时和 token 消耗。这样上线后即便出现一次无语的回答也有线索可查。7. 常见问题与排错思路7.1 模型给出的 JSON 是坏的现象是调用成功但返回内容解析失败日志里出现JSONDecodeError。原因通常是模型把 JSON 包裹在 Markdown 代码块中或者生成内容被截断。建议先打印原始content用一个干净的函数做两次解析。如果仍然失败可以调整response_format{type: json_object}同时要求返回内容必须包含某一个固定 key。截断问题则需要检查输出 token 是否设置太少。问题现象常见原因检查方式处理建议返回内容里包含 json模型按 Markdown 输出打印原始 content先剥离代码块再解析解析成功但缺少字段提示词未约束 key检查返回 key 集合增加示例 JSON设置temperature0内容被截断max_tokens过小看返回结束原因加大输出 token 上限并控制提示词长度7.2 照片内容识别不出来或结果不准先搞清楚是模型没识别出来还是识别结果没有进到候选排序。可以做一个小测试单独调用视觉接口打印这张图的识别 JSON。如果识别 JSON 本身为空多半是图片质量问题比如过于模糊、过暗、文件尺寸过大导致被压缩。如果识别结果正常但最终搜索没有命中需要检查向量文档里是否真的写入了tags和summary。很多项目会把照片识别结果存在一个变量里但没有写入数据库搜索时自然找不到。7.3 预算条件总被当成文本检索如果用户说“预算八千以内”系统却返回了很多总价几千万的房源说明模型没有把max_monthly_cost或max_price解析成结构化参数而是把整句话作为 query 去向量检索。修复方式是在意图解析提示词里强调“价格、预算、房间数是结构化字段不能放进 query 文本”。同时在函数声明中把max_price和max_monthly_cost描述得更明确。还可以做一次校验如果解析出的描述里包含“预算”“万”“元”等数字但字段为空就要求模型重新解析一次。7.4 候选为空但模型仍硬答一个不能接受的现象是系统明明没有候选房源语言模型却因为“懂很多房产常识”而编造一个回答。这通常是没有设计候选为空的分支。处理方式是在调用生成回答前先判断列表是否为空。为空就直接返回固定文案不再调用模型。如果有部分条件满足但另有不满足可以在提示词中明确“只有房间数满足价格未满足需要说明”。不要让模型自己脑补“价格接近”之类的结论。7.5 成本数据忽高忽低同一个房子今天估算成本是 6000明天变成 9000多半是因为默认参数或利率发生了变化却没有落库记录。更常见的问题是deal_type字段没被正确读取系统误把一个 rent 房源当 sale 处理按总价算出了惊人数字。排查顺序是先打印这条房源是sale还是rent再看价格字段是总价还是月租最后确认成本函数参数文件有没有被外部修改。成本模块要固定为纯函数并配置版本号避免参数“被悄悄改掉”却无人察觉。8. 最佳实践和下一步扩展8.1 工程化建议如果想把标题中的三个能力做成可靠产品有几条建议要提前定下来。第一照片理解结果必须持久化不要每次聊天临时识别。建立一套图片分析任务队列图片入库时触发分析分析结果存入元数据分析失败要有重试机制。这样聊天响应时只查库不让用户等一个慢模型。第二不要依赖纯向量检索处理价格和房间数。数值条件先走结构化过滤向量只负责语义相似度。这样可以避开 embedding 对数字不敏感的问题也让系统逻辑更容易被测试。第三成本计算要独立成服务或函数包。不同城市、不同贷款政策、不同用户首付比例都会影响月成本。应该把参数模型化而不是把利率写死在代码里。第一次发布时不做参数外置后面一旦规则调整运维成本会成倍增长。第四回答生成前要对模型加约束。系统只能使用候选列表中的字段来回答引用具体地址或月成本时数字必须来自成本模块而不是模型自己计算。8.2 适合继续扩展的方向这个方向继续发展会进入几个比较有深度的子领域图文混合搜索上传一张“喜欢的装修图”不能只返回相似图片还要识别出关键风格标签然后去候选房源里做映射。地图与通勤时间引入地理编码和通勤计算后“离地铁步行十分钟”可以作为新的结构化条件。动态参数更新利率、税费、物业政策频繁变化需要让成本参数表支持远程配置和灰度发布。多轮追问如果第一轮没找到合适房源可以追问“预算增加 10% 是否可以”系统再重新搜索并对比结果。审核和风控真实房源照片往往包含隐私需要建立图片脱敏流程和版权审核机制。对学习项目来说不要一上来把所有方向都做进去。先把“照片识别、语义检索、成本计算、聊天回复”这条路走通再逐步加地图和动态参数。8.3 给开发者的落地顺序建议如果想用业余时间复刻标题里的产品我的建议顺序是先用 20 条模拟房源数据跑通搜索。不做照片只看结构化字段和基础 HTTP 接口。接照片理解把识别结果存成 JSON人工检查 20 张图的输出是否可用。做聊天意图解析确保“预算”“房间数”这些条件能被结构化提取。加入向量检索让模糊偏好参与排序。加入成本计算函数并让成本参与结果排序。最后做一个简单前端用测试问题集反复验证。这套顺序的特点是每步都有可检查和可回退的节点。如果一上来就想把视觉模型、向量库、函数调用全部拼起来遇到问题时会很难判断是模型提示词不对还是数据没有入库还是向量维度不匹配。回到标题里的三个关键词。聊天是产品形态读照片是为了拿到描述性信息估算月成本是为了让描述性信息变成用户真正可以做的财务判断。三部分其实都服务于同一个目标让搜索从“按字段筛”升级成“按真实生活需求找房”。理解了这个目标后续优化才不会被工具或框架方向带偏。