玩转 Spring AI 第三篇:自建 SearXNG 给大模型接上实时搜索——容器起来了,format=json照样 403
💡这是"玩转 Spring AI"系列的第三篇。前两篇聊的是 ChatClient 怎么把话接住、RAG 怎么把私有文档塞进上下文,这一篇解决另一个问题:模型不知道"刚刚发生的事"。
笔者最初照着一份 Spring AI + RAG + MCP + 实时搜索的课程笔记复现联网搜索那一段,容器docker run一把过,浏览器打开http://localhost:6080也正常,搜索框里能出结果。于是很有信心地按笔记里的?q=...&format=json试了一下接口——403 Forbidden。
当时第一反应是镜像版本问题、是网络问题、是宿主机防火墙。实际原因是 SearXNG 上游配置里 JSON 输出默认就是关的*,而这一步在整份讲义里一个字都没提。这类"照着视频能跑、照着笔记必崩"的缺口,恰恰是自己动手时最花钱的地方。***
这篇把这条链路完整拆开:为什么要自建一层搜索、课程是怎么实现的、那个 403 的根因在哪一行源码里、代码本身有哪几处经不起推敲、前端那个"联网搜索"勾选框到底把请求路由去了哪。
1. 后端接大模型,为什么早晚要自己架一层搜索
先把"缺的是什么"说清楚,再谈选型。
1.1 模型缺的从来不是智商,是三样东西
模型权重(weights)一旦训练完成,模型对世界的认知就被冻结在那个时点。问它"上周发布的某个版本是多少",它要么答旧值,要么答一个看起来很合理的编造值——这两种都比你想象的更难发现,因为它语气一点问题都没有。各家具体的知识截止时间请以厂商模型卡为准,本文不写死日期,避免过期。
| 缺口 | 真实表现 | 只改提示词能不能救 |
|---|---|---|
| 知识截止时间 | 新版本、新 CVE、新价格、新政策一律答不上,或按旧知识自信作答 | 不能。提示词改不出模型没见过的信息 |
| 引用来源 | 业务上要求"这句话出自哪个链接",模型只会给你一段没有出处的流畅文本 | 半能。能要求它给出处,但给不出真实出处 |
| 成本 | 为覆盖未知问题把大量资料塞进长上下文,token 账单和延迟同步上涨 | 反向恶化,越补救越贵 |
还有一类缺口是私有数据:内部制度、工单、商品表,模型完全不知道,这条路只能靠外挂检索,属于 RAG 的地盘,本文第 2 节之后不展开。
1.2 四条"补信息"的路,成本不在一个量级
这是笔者自己排过的一张表,定性的相对成本,不是报价:
| 路线 | 数据新鲜度 | 来源可溯 | 前期改造量 | 单次调用成本 | 典型失效场景 |
|---|---|---|---|---|---|
| 长上下文硬塞 | 取决于塞进去的东西 | 差(模型不保证引用) | 几乎为零 | 最高,全量文档每次重算 | 文档一多就爆上下文、命中率反而下降 |
| RAG(私有知识库 + 向量检索) | 静态,入库那一刻 | 好,片段能带回 metadata | 中:切分 / Embedding / 向量库 / 入库管道 | 中,只塞回 Top K 片段 | 回答不了库外的事,也回答不了"今天的事" |
| 联网实时搜索(本文) | 实时 | 中,返回 URL 可展示但需二次校验 | 低:起一个搜索服务 + 拼 prompt | 中,多一次 HTTP + Top N 摘要 | 搜索引擎本身被封、无结果、延迟不可控 |
| 微调 | 训练数据那一刻 | 最差,学进去就查不到出处 | 最高:数据构造 / 训练 / 评测 / 回滚 | 低(推理期无额外检索) | 想改一条事实要重训一次;时效类知识根本不该走这条路 |
简单理解:
知识在库里 → RAG 知识在外面且一直变 → 联网搜索 行为/风格/领域话术 → 微调1.3 三个常见疑问
Q1:模型厂商不是自己有联网搜索吗,为什么后端还要自己架一层?
因为后端要的不是"会搜",而是可控:搜哪些引擎、返回几条、以什么字段结构进 prompt、失败怎么降级、请求出不出自己的网络边界。自建一层 SearXNG 这类元搜索(Meta Search,聚合多家引擎的开源搜索聚合器)之后,这条链路整个在你手里,搜索引擎聚合、翻页、语言、安全过滤都在一个settings.yml里。这一条属于笔者工程判断,不是对任何厂商功能的评价。
Q2:已经有 RAG 了,联网搜索算不算重复建设?
不算,两者检索的是两个不相交的集合。这套课程里两条链路的代码形状甚至几乎一样——都是"检索 → 拼{context}→ 交给 Spring AI 的 ChatClient",区别只在检索源:RAG 走redisVectorStore.similaritySearch(question),联网走sesrXngService.search(question)。私有知识进库,公开时效进搜索,这是很自然的分工。
Q3:自建搜索层最贵的是什么?
不是钱,是**“它坏了你不知道”**。多出来的这段链路是纯外部依赖:容器状态、上游引擎可用性、返回结构变更、超时。工程上这段的默认失败模式是"静默降级成没搜索"——模型照旧很流畅地答一段过时内容。所以本文第 3、4 节花最多篇幅的就是失败路径。
2. 这套课程的联网搜索链路:七段,每段都能单独坏
先给全景,再逐段对代码。
docker run searxng/searxng → http://<host>:6080/search?q=&format=json → OkHttp 请求 → JSON 字符串 → hutool JSONUtil.toBean → List<SearchResult> → dealResults 截断/排序 → <context>[来源]...[摘要]...</context> → sesrXngPROMPT.replace → Prompt → chatClient.prompt(prompt).stream().content() → SSE 逐 token 推给前端2.1 起容器:讲义里那条docker run
课程用的是官方镜像searxng/searxng(Docker Hub 镜像页,Docker 本体官网见参考资料)。讲义原文去掉个人路径后是这样:
dockerpull searxng/searxng:latestdockerrun-p6080:8080--namesearxng-d--restart=always\-v"<挂载根目录>/SearXNG:/etc/searxng"\-e"BASE_URL=http://localhost:$PORT/"\-e"INSTANCE_NAME=<实例名>"\searxng/searxng| 参数 | 作用 | 值得注意的点 |
|---|---|---|
-p 6080:8080 | 容器内 SearXNG 监听8080,宿主映射到6080 | 官方 Docker 文档的示例用的也是容器侧 8080(它示例映射到宿主 8888),端口号是约定不是硬性要求 |
-v ...:/etc/searxng | 把配置目录挂出来,settings.yml就在这里 | 这是本篇第 3 节能被修好的唯一前提。没挂这个目录,你连改配置的地方都没有 |
-e "BASE_URL=..." | 实例对外基础 URL,影响分页/回调等绝对链接 | 讲义这行有个坑:$PORT在 shell 里从未定义,展开成空串,实际值是http://localhost:/。这个坑在浏览器里几乎看不出来,但拼出来的绝对 URL 是坏的 |
-e "INSTANCE_NAME=..." | 实例名,页面标题等展示用途 | 官方文档把BASE_URL/INSTANCE_NAME归到 server / general 配置,并说明$SEARXNG_*环境变量可覆盖配置项 |
--restart=always | 宿主机重启后自动拉起 | 云服务器那章同一条命令只换了挂载根目录 |
两个小事实顺便记一下:讲义docker pull带了:latest,但docker run不带 tag——两条命令引用的其实是同一个默认 tag,但镜像没有锁版本。另外官方文档的示例还多挂了一个-v ./data/:/var/cache/searxng/(缓存目录),讲义没有,容器一删缓存就没了。
2.2 后端配置项
internet:websearch:searxng:url:http://localhost:6080/searchcounts:10url直接指到/search端点,counts是进入 prompt 的结果条数。Java 侧字段名是大写的SEARXNG_URL/COUNTS(常量风格命名,实际是实例字段),靠@Value注入。
2.3 发起请求:一个全局 OkHttpClient
OKHttpConfig里只有一段(OkHttp 官网见参考资料):
@BeanpublicOkHttpClientokHttpClient(){returnnewOkHttpClient.Builder().connectTimeout(30,TimeUnit.SECONDS).readTimeout(60,TimeUnit.SECONDS).build();}服务实现侧的调用骨架(按工程事实还原,非逐字抄录):
HttpUrlurl=HttpUrl.get(SEARXNG_URL).newBuilder().addQueryParameter("q",query).addQueryParameter("format","json")// 关键:就是这一个参数触发第 3 节的 403.build();Requestrequest=newRequest.Builder().url(url).build();try(Responseresponse=okHttpClient.newCall(request).execute()){if(!response.isSuccessful()){thrownewRuntimeException("请求失败: HTTP "+response.code());}StringresponseBody=/* response.body() 读出字符串 */;SearXNGResponsebean=JSONUtil.toBean(responseBody,SearXNGResponse.class);returndealResults(bean.getResults());}// IOException → new RuntimeException(e);方法末尾兜底 return Collections.emptyList();参数拼装走的是 OkHttp 的HttpUrl.newBuilder()而不是字符串拼接,这一点是对的:查询词里带中文、空格、&时会被正确转义。
但 30s / 60s 这两个超时值得说一句。这是一条"用户正在等首 token"的交互式链路:readTimeout60 秒意味着搜索卡住时,前端可以整整一分钟收不到任何响应,而工程里没有任何重试或熔断。笔者会把它压到连接 3~5s、读 8~10s,超时即降级为"无搜索结果 + 明确话术"(这是笔者判断,不是课程设定)。
2.4 反序列化:只映射了两个字段
SearXNGResponse和SearchResult都是 Hutool 的JSONUtil.toBean(...)直接映射,字段严格照代码:
| 类 | 字段 | 类型 |
|---|---|---|
SearXNGResponse | query | String |
SearXNGResponse | results | List<SearchResult> |
SearchResult | title/url/content | String |
SearchResult | score | double |
SearXNG 的 JSON 响应里还有answers、corrections、number_of_results、response_time、unresponsive_engines、infinite_scroll这些字段,工程一个都没建模。其中unresponsive_engines(哪些上游引擎这次没响应)和response_time恰恰是排障和可观测最该看的两项:它们能告诉你"这次结果少,是因为上游挂了一半"。丢弃它们意味着搜索质量退化在系统里是完全不可见的(这条批评属笔者判断,字段本身在工程里未建模是事实)。
2.5 结果处理:dealResults
results.subList(0,Math.min(COUNTS,results.size())).parallelStream().sorted(Comparator.comparingDouble(SearchResult::getScore).reversed()).limit(COUNTS).toList();四步:截断 → 并行流 → 按score降序 → 再limit。这里埋着一个语义问题,单独放到第 4 节讲。
2.6 拼上下文与提示词
ChatServiceImpl.buildSesrXngPrompt把每条结果格式化成:
<context> [来源] {url} [摘要] {content} </context>注意它只用了url和content,title和score被完全丢弃。用户看到的引用是一串裸 URL,没有标题,可读性和可核验性都差一截——这是本节里成本最低、收益最高的一处改动。
模板全文(工程里的sesrXngPROMPT):
你是一个互联网搜索大师,请基于以下互联网返回的结果作为上下文,根据你的理解结合用户的 提问综合后,生成并且输出专业的回答:【上下文】{context}【问题】{question}【输出】 如果没有查到,请回复:不知道。如果查到,请回复具体的内容。和 RAG 那段ragPROMPT相比,只差两处:角色描述不同、RAG 多一句"不相关的近似内容不必提到"。这说明课程里"联网"和"知识库"在实现上就是同一套手拼 prompt 模板换了个检索源。
2.7 交给 Spring AI 流式输出
Promptprompt=newPrompt(finalPrompt);Flux<String>stringFlux=chatClient.prompt(prompt).stream().content();后面和另外两条链路完全汇合:Flux经toStream()阻塞式逐 token 消费,每段内容以 SSE 的add事件推给前端,聚合完再发一条finish。
2.8 总归来说
- 这条链路没有用任何 Spring AI 的搜索抽象,就是"手写 HTTP + 手拼 prompt"。好处是每一步都能自己 debug,坏处是每一步都得自己兜。
- 七个环节里,只有第 6、7 段是 Spring AI 的,前面五段全是自己搭的胶水——而本篇的坑恰好全在胶水里。
3. 最关键的一节:容器起来了,format=json还是 403
这一节是我复现时卡最久的一段,也是整份讲义最大的一处缺失。
3.1 现象
# 浏览器打开,一切正常http://localhost:6080/?q=<关键词># 换成 JSON 输出,立刻 403curl-i"http://localhost:6080/search?q=<关键词>&format=json"# HTTP/1.1 403 FORBIDDEN网页能用,接口不能用。这个组合很容易把人带偏:因为 403 通常意味着"权限/鉴权/防火墙",而这里既没有 token 也没有 ACL——它其实是在说"这个输出格式在本实例的配置里不存在"。
3.2 根因:上游默认只开 html
笔者于 2026-09-29 拉取 SearXNG 上游仓库searxng/searxng的 master 分支核对,两处源码直接给出答案(上游仓库)。
searx/settings.yml(约:83-85,同一段在官方文档页里也原样呈现):
search:# remove format to deny access, use lower case.# formats: [html, csv, json, rss]formats:-htmlsearx/webapp.py(笔者记录约:630-631):
ifoutput_formatnotinsettings['search']['formats']:flask.abort(403)翻译成人话:formats是一份输出格式白名单,默认列表里只有html;请求里带的format=json落不进白名单,直接flask.abort(403)。那两行注释其实已经把语义写明白了——“remove format to deny access”(不在列表里就是拒绝访问),而且要求小写。
| 项 | 事实 |
|---|---|
| 默认值 | search.formats: [html](upstream master,2026-09-29 核对) |
| 可选值 | html/csv/json/rss |
| 校验位置 | webapp.py里对output_format做白名单判断,不在名单即abort(403) |
| 网页为何正常 | html本来就在默认白名单里 |
| 行号 | 随分支版本漂移,别把行号当依据,以内容为准 |
3.3 修复:在挂载出来的settings.yml里加一行
search:formats:-html-json# ← 没有这一行,Java 侧永远拿 403改的是挂载进容器的那个目录里的settings.yml(对应本例-v <挂载根目录>/SearXNG:/etc/searxng)。第一次启动时该目录可能没有这个文件,需要让容器生成或从上游模板拷一份进去(这一步是笔者经验,官方文档只说明配置文件位于挂载出来的settings.yml)。改完要重启容器让配置重新加载——同样属笔者经验,非官方原文。
dockerrestart searxngcurl-s"http://localhost:6080/search?q=<关键词>&format=json"|head-c400看到"query"和"results"两个键,链路才真正通了。
3.4 顺带两个同样"看着没事、其实坏了"的配置点
BASE_URL里的$PORT未定义。同一条docker run里写-e "BASE_URL=http://localhost:$PORT/",shell 展开成空串。浏览器主页面不受影响,但由实例拼出的绝对链接(分页等)会带上一个畸形主机串。落地时应写成明确的http://<你的地址>:6080/。-p 6080:8080+ 开了json= 一台对全网开放的搜索代理。讲义后续把同样的容器直接起在云服务器上,公网地址这里不复现。一旦json白名单打开、又没有限流与访问控制,任何能连到 6080 的人都可以拿你的实例刷上游引擎——被封的是你的出口 IP。合理的做法是只允许后端访问(内网 / 安全组 / 反向代理加认证)。关于 SearXNG 官方 limiter 对该场景的具体配置项,本文未逐条核实,上面这条是笔者工程判断。
3.5 这一点讲义里有没有?
没有。课程第 7 章那份"使用 Docker 安装 SearXNG"讲义只有:docker pull、上面那条docker run、访问http://localhost:6080、以及一条 API 示例http://localhost:6080/search?q=<关键词>&format=json;第 7 章"在项目中集成 SearXNG"那份讲义只给了 okhttp 依赖和一行 URL;第 10 章云服务器那节是同一条命令换个挂载路径,同样直接给了带format=json的 API 示例。三份讲义从头到尾没有出现settings.yml、没有出现formats。
这类缺失的杀伤力在于:视频里老师能跑通,是因为他的容器早就配过;而学员照笔记新建容器,跑通的只有网页那一步。工程侧表现为"搜索服务起来了,接口一直 403,代码看起来完全没问题"。笔记复现型项目里,最贵的从来不是难的功能,是只在演示者机器上成立的那一步。
4. 代码层面的三处真实瑕疵
这三条都不是风格问题,是会改变结果的。
4.1dealResults:先截断,再排序
// 现在的顺序subList(0,min(COUNTS,size))→sorted(scoreDESC)→limit(COUNTS)subList已经先把列表切成了原始顺序的前COUNTS条,之后那次sorted只是在这一小撮里重新排。而 SearXNG 返回的results本身有它自己的排序,所以这段代码的真实语义是:
"在原始结果的前 10 条里,按 score 排一遍" 而不是 "在所有结果里,取 score 最高的 10 条"| 设定 | 上游返回 | 期望结果 | 实际结果 |
|---|---|---|---|
counts = 10 | 20 条,第 12 条score全场最高 | 第 12 条进入上下文 | 第 12 条在第 0~9 条截断时就被丢掉,永远进不了 prompt |
由于两次操作条数上限相同(subList的COUNTS与limit(COUNTS)),最后那次limit实际上是个空操作。正确写法只有一行顺序调整——先排序再截断:
results.stream().sorted(Comparator.comparingDouble(SearchResult::getScore).reversed()).limit(COUNTS).toList();顺带一句:这里用parallelStream()给 10 条数据做排序,除了多起线程池任务之外没有任何收益,属于随手写的。
4.2 null 与异常路径:一个都不拦
现状事实:search()里!response.isSuccessful()会throw new RuntimeException("请求失败: HTTP " + code),IOException也包成RuntimeException抛出,方法末尾有一条兜底return Collections.emptyList()。
问题分三层:
buildSesrXngPrompt对入参零防护。它直接对列表forEach,一旦search()返回null,这里立刻 NPE。现有代码里的 null 从哪来需要具体条件——例如上游返回体不带results键时,JSONUtil.toBean会把该字段留为null,dealResults第一行results.subList(...)就先炸(这条链路是笔者推演,未实测)。但"调用方没有任何 null / isEmpty 判断"这一点本身,看代码就能确认。- 异常直接冒到 Controller,工程里没有全局异常处理器——整个仓库没有
@ControllerAdvice/@ExceptionHandler(事实)。统一返回体LeeResult定义了 500/501/555 等状态码,但在联网这条路上没有任何地方把失败转成LeeResult,前端拿到的是裸 500,而不是一个可读的"搜索失败"。 - 空列表会静默拼出一个空
{context}。搜索没结果时,prompt 里的【上下文】位置是空的,模型只能靠"如果没有查到,请回复:不知道"这一句自己判断。它到底会不会老实地说"不知道",取决于模型,而不是取决于代码——工程上这里必须有显式的分支(无结果 → 明确话术 / 换关键词重试 / 回落普通对话)。这一条是笔者判断,课程没有演示过失败分支。
4.3score的语义,从讲义到代码没人交代
SearchResult.score是double,排序和Comparator.comparingDouble都建立在一个假设上:这个值越大越相关,且可比。但:
| 待确认项 | 本文结论 |
|---|---|
| 不同引擎分组的原始分数量级是否一致 | 未核实 |
score是否已归一化、取值范围是什么 | 未核实,讲义从未说明 |
| 是否存在负分 / 同分 / 缺省 0 | 未核实 |
这条之所以要单独列出来,是因为它直接决定后续能不能做阈值过滤(score < X 就不进 prompt)。在没有实测过score分布之前,任何"相关度阈值"都是拍脑袋。想加这层过滤的话,第一步是把几百条真实响应的score打出来看直方图,而不是找一篇博客抄个数字。
4.4 三条低成本改进
| 改动 | 成本 | 收益 |
|---|---|---|
| 排序与截断换顺序 | 一行 | 真正拿到 top-N |
search()与buildSesrXngPrompt之间加 null/empty 分支 + 全局异常兜底 | 小 | 失败可观测、可降级 |
上下文里补上title,并保留unresponsive_engines/response_time到日志 | 小 | 引用可读、搜索质量退化可见 |
5. 前端那个"联网搜索"勾选框,到底把请求路由去了哪
后端做完了,还得看它是怎么被选中的。
课程前端是 Vue 2 + Element UI 2.15.13,脚本直接<script>引本地库、无构建。页面上有两个开关:internetSearchSelected(联网搜索)与knowledgeSearchSelected(知识库搜索),发送时是三分支互斥路由:
仅"知识库搜索"勾选 → POST /rag/search → similaritySearch → ragPROMPT 仅"联网搜索"勾选 → POST /internet/search → sesrXngService.search → sesrXngPROMPT 两者都不选 → POST /chat/doChat → 无检索,直接对话| 勾选态 | 前端方法 | 接口 | 上下文来源 |
|---|---|---|---|
| 联网 ON / 知识库 OFF | doctorApi.internetSearch | POST /internet/search | SearXNG Top N 摘要 |
| 联网 OFF / 知识库 ON | doctorApi.ragSearch | POST /rag/search | Redis 向量库命中片段 |
| 两个都 OFF | doctorApi.doChat | POST /chat/doChat | 无(只有会话记忆) |
| 两个都 ON | 不存在这种路由 | — | 两个开关在点击处理里互相关闭 |
三条链路在后端最终都收敛到同一个形状:chatClient.prompt(prompt).stream().content()→ SSEadd逐 token → 聚合后finish一次性给全文,前端用marked.parse把整段 Markdown 覆盖渲染。所以前端要同时处理"逐字追加"和"最终整段替换"两种渲染,这是这类 SSE 桥接最容易被忽略的实现细节。
Q1:为什么要让两个开关互斥,而不是同时开?
因为课程的实现是一个 prompt 模板对应一个检索源。要同时开,就得把向量片段和网络摘要拼进同一个{context},并处理两者的相对顺序与总长度——那是另一套代码。互斥是一个成本很低的取舍:先把两条通道分别跑通。代价是"私有资料 + 最新公开信息"这类真实复合问题答不了,而这恰恰是企业场景里最常见的形态。
Q2:前端勾选状态能算安全边界吗?
不能。它只决定"这次调哪个接口",不决定"你有没有权限调"。这三个接口本身没有任何鉴权,任何人绕开页面直接POST /internet/search一样能触发一次搜索 + 一次大模型调用——这就是一个花钱的开放接口。落地时这类接口必须在服务端做身份与配额校验(笔者判断,课程未做)。
Q3:这条链路还有哪些前端/后端错配?
工程事实里有两处可以顺手记下来:接口封装里有一个getRecords → GET /ollama/getRecords,而后端根本没有这个 Controller;主页面监听的事件名是customEvent(驼峰),后端发出的事件名是custom_event(下划线),另两个演示 SSE 页用的才是custom_event。这类"前端先行写了接口名、后端章节还没跟上"的残留,在跟着视频敲的项目里很常见,值得在复现时先全局搜一遍。
6. 如果是我来落地:可控联网搜索的核对清单
把前面所有坑压成可执行的检查项。
笔者(后端 & 架构)的联网搜索落地策略:
| 层 | 必做项 | 依据来自本文哪一节 |
|---|---|---|
| 部署 | 挂载/etc/searxng;镜像 tag 锁到具体版本,不用latest;缓存目录单独挂 | §2.1 |
| 配置 | settings.yml的search.formats显式含json;BASE_URL写死成完整可访问地址 | §3.2 / §3.4 |
| 暴露面 | 6080 不直连公网,只允许后端访问;需要对外时加认证与限流 | §3.4 |
| 调用 | 连接/读超时按交互链路收紧(秒级),加一次重试与熔断;超时也要打点 | §2.3 |
| 解析 | 把unresponsive_engines、response_time、number_of_results落日志;results缺失时视为失败而不是 NPE | §2.4 / §4.2 |
| 排序 | 先排序后截断;score阈值必须先实测分布再启用 | §4.1 / §4.3 |
| 提示词 | 上下文带标题与来源;区分"搜索失败 / 无结果 / 有结果"三种话术 | §2.6 / §4.2 |
| 降级 | 搜索不可用时明确回落普通对话,并在回答里声明"本次未联网" | §4.2 |
| 可观测 | 每次搜索记录耗时、结果条数、HTTP 状态码;403/超时/0 结果单独告警 | §3.1 / §4.2 |
| 接口 | /internet/search前置鉴权与配额 | §5 Q2 |
Q:课程锁 spring-ai-bom 1.0.0,现在应该用哪个版本?
工程里spring-ai-bom是1.0.0(父 POM 之下由 Spring Boot 3.5.0 管理版本)。笔者 2026-09-29 通过 GitHub API 核了官方仓库的 releases:
| 版本 | 发布日期 |
|---|---|
v1.0.9/v1.1.8/v2.0.0 | 2026-06-12 |
v2.0.1 | 2026-08-21 |
v2.1.0-M1 | 2026-09-25 |
也就是说,课程快照落后当前稳定版若干个小版本,并且已经有 2.x 线。这里给两条建议(笔者判断):一是照课程复现时不要顺手升 BOM,1.0.0 → 2.x 属于大版本跨度,MCP 与 vector-store 的 starter 坐标和自动配置项都在动,学习期换版本等于同时改两个变量;二是新项目按稳定线选,并锁定具体小版本,-M1这类里程碑版本不进生产。每个版本具体变更了什么,本文未逐条核对 release notes,请以官方发布说明为准。
Q:联网搜索该不该做成模型可以自己调用的工具?
课程这条路是"前端开关决定要不要搜,后端固定搜一次",模型自己不参与决策,优点是可预测、可缓存、账单可控。另一种做法是把搜索注册成一个 tool,由模型判断"这个问题需不需要查",体验更顺,代价是多轮推理 + 不确定的搜索次数。就这类工程,笔者的顺序是:先把固定检索做稳(超时、403、排序、降级都收干净),再谈工具化。课程另一侧已经有完整的 MCP 工具链(DateTool/EmailTool/ProductTool),把 SearXNG 包成一个搜索 MCP tool 是这套工程最自然的下一步——这是笔者推断,不是课程已有实现。
最后总结
- 联网搜索不是"给模型加个功能",而是给系统加一段外部依赖。它带来的三类问题里,只有"数据新鲜度"是需求,"来源可溯"和"失败可见"才是工程。
- 本篇最重要的一行是
formats: - json。SearXNG 上游默认白名单只有html,未启用的输出格式在webapp.py里直接flask.abort(403);容器能开、网页能用、接口 403,这三件事同时成立是正常的。三份讲义都没有这一步,属于典型"照着视频能跑、照着笔记必崩"。 dealResults先截断后排序,不是风格问题而是语义问题:它实现的是"在前 N 条里排序",不是"取全局最相关 N 条",高分结果会被无声丢掉;交换顺序即可。- null 与异常路径全程裸奔:
buildSesrXngPrompt对入参没有防护,工程里没有全局异常处理器,搜索失败会以 500 或 NPE 的形态出现,而不是一个可读的降级话术。 score是否归一化,讲义和代码都没说,本文也未核实。想做相关度阈值,先实测分布。- 前端两个开关互斥,是简单实现换来的取舍:省掉了多源上下文拼装,代价是"私有资料 + 实时信息"这类复合问题答不了;而这三个接口目前没有任何服务端鉴权。
- 对后端/架构岗,笔者个人最看重这三条:
6080有没有暴露到公网;超时是不是 30s/60s 这种交互链路不该出现的数字;unresponsive_engines这类质量信号有没有被丢掉。 - 版本上,课程锁的
spring-ai-bom是 1.0.0,官方已有 1.0.9 / 1.1.8 / 2.0.x 等 releases(2026-09-29 核对)。复现教学项目别顺手升大版本。
下一篇讲把这套工程里的工具能力彻底搬出去:MCP server 的 stdio 与 SSE 两种传输、工具描述怎么写才不会让模型瞎填参数,以及部署到云服务器时 Nginx 对 SSE 长连接到底缺了哪些配置。
事实说明:本文关于课程工程的类名、配置项、代码片段、前端路由与讲义缺失,均来自 2026-09-29 对该仓库与 21 篇讲义的只读实读(凭据已脱敏,服务器公网地址与讲义中的个人姓名 / 关键词未复现);调用骨架按事实还原、非逐字抄录,已就地标注。关于 SearXNG 的部分为upstream 已核实:
searx/settings.yml的search.formats默认仅html、可选html/csv/json/rss、searx/webapp.py对未启用格式flask.abort(403)(2026-09-29 拉取 upstream master 核对;文中行号随版本漂移,不作依据),官方 Docker 文档示例的容器端口 8080、/etc/searxng配置卷与BASE_URL/INSTANCE_NAME/$SEARXNG_*亦已核实。Spring AI releases 表来自 2026-09-29 GitHub API。以下均未核实,正文已标注且不作断言:score是否归一化及其取值范围;results字段缺失导致 NPE 的具体触发链(笔者推演);配置修改需重启容器生效、挂载目录首次为空时的配置文件生成、SearXNG limiter 针对 JSON/CSV 的具体配置项;ChatMemoryBean 来源及各版本 release notes 明细。“四条补信息路线的成本对比”“超时应收紧”“互斥路由是取舍”“先做固定检索再谈工具化”"6080 不该裸奔公网"等为笔者工程判断,非官方或课程表述。
参考资料 & 致谢
[1] SearXNG 官方文档(设置与安装入口)
[2] SearXNG 文档:Search settings(search.formats默认与可选格式)
[3] SearXNG 文档:Docker 安装(容器侧 8080 端口、/etc/searxng配置卷、SEARXNG_*环境变量)
[4] SearXNG 上游仓库(searx/settings.yml、searx/webapp.py,2026-09-29 master 核对)
[5] SearXNG 官方 Docker 镜像 searxng/searxng
[6] Spring AI 项目主页
[7] Spring AI Reference(参考文档)
[8] Spring AI GitHub Releases(版本与日期依据)
[9] Spring Boot 项目主页
[10] OkHttp 官方文档
[11] Hutool 官方文档
[12] Docker 官网
[13] DeepSeek 开放平台(课程默认模型)
[14] Redis 官网(课程 RAG 向量库对照)
[15] Vue.js 官方文档
[16] Element UI 仓库
[17] 本课程工程与 21 篇讲义(Spring AI + RAG + MCP + 实时搜索),仅作为本文事实来源,凭据与个人信息已全部脱敏;文中类名、配置项、行位置与缺失清单均据此核对。