news 2026/10/19 12:03:19

玩转 Spring AI 第三篇:自建 SearXNG 给大模型接上实时搜索——容器起来了,`format=json` 照样 403

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
玩转 Spring AI 第三篇:自建 SearXNG 给大模型接上实时搜索——容器起来了,`format=json` 照样 403

玩转 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:10

url直接指到/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(...)直接映射,字段严格照代码:

类字段类型
SearXNGResponsequeryString
SearXNGResponseresultsList<SearchResult>
SearchResulttitle/url/contentString
SearchResultscoredouble

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:-html

searx/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 顺带两个同样"看着没事、其实坏了"的配置点

  1. BASE_URL里的$PORT未定义。同一条docker run里写-e "BASE_URL=http://localhost:$PORT/",shell 展开成空串。浏览器主页面不受影响,但由实例拼出的绝对链接(分页等)会带上一个畸形主机串。落地时应写成明确的http://<你的地址>:6080/。
  2. -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 = 1020 条,第 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()。

问题分三层:

  1. buildSesrXngPrompt对入参零防护。它直接对列表forEach,一旦search()返回null,这里立刻 NPE。现有代码里的 null 从哪来需要具体条件——例如上游返回体不带results键时,JSONUtil.toBean会把该字段留为null,dealResults第一行results.subList(...)就先炸(这条链路是笔者推演,未实测)。但"调用方没有任何 null / isEmpty 判断"这一点本身,看代码就能确认。
  2. 异常直接冒到 Controller,工程里没有全局异常处理器——整个仓库没有@ControllerAdvice/@ExceptionHandler(事实)。统一返回体LeeResult定义了 500/501/555 等状态码,但在联网这条路上没有任何地方把失败转成LeeResult,前端拿到的是裸 500,而不是一个可读的"搜索失败"。
  3. 空列表会静默拼出一个空{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 / 知识库 OFFdoctorApi.internetSearchPOST /internet/searchSearXNG Top N 摘要
联网 OFF / 知识库 ONdoctorApi.ragSearchPOST /rag/searchRedis 向量库命中片段
两个都 OFFdoctorApi.doChatPOST /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.02026-06-12
v2.0.12026-08-21
v2.1.0-M12026-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 + 实时搜索),仅作为本文事实来源,凭据与个人信息已全部脱敏;文中类名、配置项、行位置与缺失清单均据此核对。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 12:03:10

Windows部署OpenClaw:WSL2与Docker Desktop完整指南

1. 先搞清楚运行链路&#xff1a;OpenClaw容器为什么站在WSL2肩膀上我第一次在Windows上装OpenClaw&#xff08;“小龙虾”&#xff09;的时候&#xff0c;其实吃过一个认知上的亏。那时候我下意识地认为&#xff0c;GitHub上给出了一份docker-compose.yml&#xff0c;所以只要…

作者头像 李华
网站建设 2026/10/19 12:00:33

鸿蒙构建排除指定模块:hvigorfile脚本实战与踩坑记录

鸿蒙开发做到后面&#xff0c;工程里的模块一定会多起来。我之前维护的一个项目就是典型&#xff1a;入口模块 entry&#xff0c;几个业务能力模块&#xff0c;一个跨端公共库&#xff0c;还有一个内部调试工具模块。平时开发调试离不开那个工具模块&#xff0c;但发布正式包的…

作者头像 李华
网站建设 2026/10/19 11:59:27

rsync同步Shell脚本实战:带锁、日志与SSH探活的自动化方案

最怕半夜爬起来手动同步数据。我之前维护一个发布项目时&#xff0c;线上和备份目录靠 rsync 维持一致&#xff0c;每次改完文件都得手工敲一遍命令&#xff0c;敲错一个参数就可能把线上多余文件删掉。后来我把常用的 rsync 同步命令整理成了下面这个 shell 脚本&#xff0c;带…

作者头像 李华
网站建设 2026/10/19 11:59:25

机器学习怎么学?从零基础到入门的实操路径

“机器学习怎么学&#xff1f;”这个问题&#xff0c;这几年我大概被问了不下几十次。问的人里有刚上大学的学生&#xff0c;也有工作了三五年想转行的从业者&#xff0c;还有不少是连代码都没写过几行的零基础朋友。有意思的是&#xff0c;真正把这个问题问到底并一直学下去的…

作者头像 李华