<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Redis on XEDCZQ的博客</title><link>https://xedczq.cn/tags/redis/</link><description>Recent content in Redis on XEDCZQ的博客</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><lastBuildDate>Sat, 20 Jun 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://xedczq.cn/tags/redis/index.xml" rel="self" type="application/rss+xml"/><item><title>MapAI：RAG 1.0 系统架构与痛点复盘</title><link>https://xedczq.cn/post/mapai_ragconstruction/</link><pubDate>Wed, 27 May 2026 15:46:26 +0800</pubDate><guid>https://xedczq.cn/post/mapai_ragconstruction/</guid><description>&lt;h1 id="mapairag-10-系统架构与痛点复盘"&gt;&lt;a href="#mapairag-10-%e7%b3%bb%e7%bb%9f%e6%9e%b6%e6%9e%84%e4%b8%8e%e7%97%9b%e7%82%b9%e5%a4%8d%e7%9b%98" class="header-anchor"&gt;&lt;/a&gt;MapAI：RAG 1.0 系统架构与痛点复盘
&lt;/h1&gt;&lt;p&gt;这篇笔记整理我在 &lt;code&gt;MapAI&lt;/code&gt; 早期版本里实现的一套 &lt;code&gt;RAG 1.0&lt;/code&gt; 架构。它已经具备了完整的上传、清洗、切片、向量化、检索和问答闭环，但从今天回头看，很多设计还是明显带着“先跑起来”的 1.0 痕迹。&lt;/p&gt;
&lt;p&gt;这份复盘的重点不是讲一个完美方案，而是把老版本真实的构造流程、运行方式和后续暴露出来的问题讲清楚，方便后面继续演进到更稳的 &lt;code&gt;RAG 2.0&lt;/code&gt;。&lt;/p&gt;
&lt;h2 id="rag-10-总览"&gt;&lt;a href="#rag-10-%e6%80%bb%e8%a7%88" class="header-anchor"&gt;&lt;/a&gt;RAG 1.0 总览
&lt;/h2&gt;&lt;p&gt;整个系统可以分成两条主链路：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;数据入库链路：负责把用户上传的原始文件清洗、切片并写入向量库。&lt;/li&gt;
&lt;li&gt;检索推理链路：负责在用户提问时召回相关 Chunk，并交给大模型做最终回答。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对应到接口层，分别是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /api/rag/upload&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /api/rag/chat&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="数据流管道"&gt;&lt;a href="#%e6%95%b0%e6%8d%ae%e6%b5%81%e7%ae%a1%e9%81%93" class="header-anchor"&gt;&lt;/a&gt;数据流管道
&lt;/h2&gt;&lt;p&gt;当用户调用 &lt;code&gt;POST /api/rag/upload&lt;/code&gt; 上传文件时，系统会依次经过下面 5 步完成处理：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[文件上传]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 1. SHA-256 去重
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 2. Apache Tika 文本提取
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 3. 动态语义分片
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 4. DashScope 向量化
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; 5. Redis 持久化
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="sha-256-去重"&gt;&lt;a href="#sha-256-%e5%8e%bb%e9%87%8d" class="header-anchor"&gt;&lt;/a&gt;SHA-256 去重
&lt;/h3&gt;&lt;p&gt;系统会先计算文件哈希值，在同一个知识库 &lt;code&gt;KB&lt;/code&gt; 内实现秒传与去重。&lt;/p&gt;
&lt;p&gt;这样做的目标很直接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;避免同一份文件被重复写入。&lt;/li&gt;
&lt;li&gt;节省 Redis 存储空间和向量化成本。&lt;/li&gt;
&lt;li&gt;降低重复 Chunk 对检索排序造成的噪声。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步本质上是“知识库内去重”，而不是全局跨库去重，所以它能保证当前 KB 里的数据相对干净，但也意味着多个 KB 之间仍可能保留相同内容。&lt;/p&gt;
&lt;h3 id="文本提取"&gt;&lt;a href="#%e6%96%87%e6%9c%ac%e6%8f%90%e5%8f%96" class="header-anchor"&gt;&lt;/a&gt;文本提取
&lt;/h3&gt;&lt;p&gt;原始文件上传后，系统使用 &lt;code&gt;Apache Tika&lt;/code&gt; 做统一文本解析，把不同格式的文件都转成纯文本 &lt;code&gt;String&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;当时支持的主要格式包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;PDF&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DOCX&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MD&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TXT&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CSV&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;XLSX&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;JSON&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个设计的好处是前面统一输入，后面的切片和 Embedding 都只面对字符串，不需要对每种文档格式分别写检索逻辑。&lt;/p&gt;
&lt;p&gt;代价也很明显：一旦进入纯文本阶段，很多原始结构信息就丢了，比如表格、标题层级、字段列关系和单元格边界。&lt;/p&gt;
&lt;h3 id="动态语义分片"&gt;&lt;a href="#%e5%8a%a8%e6%80%81%e8%af%ad%e4%b9%89%e5%88%86%e7%89%87" class="header-anchor"&gt;&lt;/a&gt;动态语义分片
&lt;/h3&gt;&lt;p&gt;RAG 1.0 的切片策略已经不是最原始的固定长度截断，但本质仍然属于“规则驱动的半语义切片”。&lt;/p&gt;
&lt;p&gt;核心参数：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;minSize = 180&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;targetSize = 700&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maxSize = 1000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;切分优先级：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;优先按双换行拆段落：&lt;code&gt;\n\s*\n+&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;如果段落仍然过长，再按结束标点拆句子：&lt;code&gt;。！？；;.!?&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;如果还拆不开，就按 &lt;code&gt;maxSize&lt;/code&gt; 硬切&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;组装逻辑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;累积文本达到 &lt;code&gt;targetSize&lt;/code&gt; 后，输出为一个独立 Chunk。&lt;/li&gt;
&lt;li&gt;如果尾部碎片小于 &lt;code&gt;minSize&lt;/code&gt;，则强行并到上一个 Chunk，尽量保证语义完整。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这套方案在 1.0 阶段已经比“纯 500 字一刀切”强不少，因为它至少知道先看段落和句子边界，但它仍然不是严格意义上的语义切片。&lt;/p&gt;
&lt;h3 id="向量化与写入"&gt;&lt;a href="#%e5%90%91%e9%87%8f%e5%8c%96%e4%b8%8e%e5%86%99%e5%85%a5" class="header-anchor"&gt;&lt;/a&gt;向量化与写入
&lt;/h3&gt;&lt;p&gt;每个切好的文本块最终会被封装为统一的 &lt;code&gt;Document&lt;/code&gt; 结构：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-java" data-lang="java"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;Document&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;kb_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;file_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;chunk_index&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Embedding 模型使用的是阿里云 &lt;code&gt;DashScope&lt;/code&gt;，把文本转换成稠密向量后写入 Redis 向量索引：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rag_vector_index_v1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这一层本质是一次 &lt;code&gt;Vector Upsert&lt;/code&gt;，完成后系统就具备了按向量相似度检索 Chunk 的能力。&lt;/p&gt;
&lt;h3 id="redis-持久化设计"&gt;&lt;a href="#redis-%e6%8c%81%e4%b9%85%e5%8c%96%e8%ae%be%e8%ae%a1" class="header-anchor"&gt;&lt;/a&gt;Redis 持久化设计
&lt;/h3&gt;&lt;p&gt;除了向量索引本身，RAG 1.0 还在 Redis 中额外保留了一套辅助元数据，方便文件管理和关键词兜底。&lt;/p&gt;
&lt;p&gt;文件列表：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rag:kb:{kbName}:files
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里通常用 &lt;code&gt;Hash&lt;/code&gt; 结构，保存文件名和基础属性映射。&lt;/p&gt;
&lt;p&gt;原始全文：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rag:kb:{kbName}:file:{base64}:text
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里通常用 &lt;code&gt;String&lt;/code&gt; 结构，保留该文件的完整纯文本内容，用于后续兜底搜索。&lt;/p&gt;
&lt;p&gt;Chunk 索引：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;rag:kb:{kbName}:file:{base64}:chunks
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里通常用 &lt;code&gt;Set&lt;/code&gt; 结构，保存该文件下所有 Chunk ID。&lt;/p&gt;
&lt;p&gt;这种设计的好处是简单、直接、落地快。缺点是索引和元数据结构开始变多以后，后期维护成本会上升，而且 Redis 同时承担“向量库 + 文件目录 + 原文仓库”三种角色，边界不够清晰。&lt;/p&gt;
&lt;h2 id="检索与推理管道"&gt;&lt;a href="#%e6%a3%80%e7%b4%a2%e4%b8%8e%e6%8e%a8%e7%90%86%e7%ae%a1%e9%81%93" class="header-anchor"&gt;&lt;/a&gt;检索与推理管道
&lt;/h2&gt;&lt;p&gt;当用户调用 &lt;code&gt;POST /api/rag/chat&lt;/code&gt; 进行对话时，请求会先经过检索层，再进入生成层。&lt;/p&gt;
&lt;p&gt;可以粗略拆成两段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;阶段 A：检索与过滤，主要由 &lt;code&gt;KnowledgeBaseService&lt;/code&gt; 负责&lt;/li&gt;
&lt;li&gt;阶段 B：上下文组装与推理，主要由 &lt;code&gt;RagChatService&lt;/code&gt; 负责&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="阶段-a检索与过滤"&gt;&lt;a href="#%e9%98%b6%e6%ae%b5-a%e6%a3%80%e7%b4%a2%e4%b8%8e%e8%bf%87%e6%bb%a4" class="header-anchor"&gt;&lt;/a&gt;阶段 A：检索与过滤
&lt;/h2&gt;&lt;h3 id="过采样计算"&gt;&lt;a href="#%e8%bf%87%e9%87%87%e6%a0%b7%e8%ae%a1%e7%ae%97" class="header-anchor"&gt;&lt;/a&gt;过采样计算
&lt;/h3&gt;&lt;p&gt;为了降低多知识库混查时目标数据被稀释的风险，系统不会直接只取 &lt;code&gt;topK&lt;/code&gt; 条结果，而是先放大召回范围：&lt;/p&gt;
$$
\text{fetchK} = \text{clamp}(\text{topK} \times 30, 60, 200)
$$&lt;p&gt;也就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;最少取 &lt;code&gt;60&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;最多取 &lt;code&gt;200&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;中间按 &lt;code&gt;topK * 30&lt;/code&gt; 放大&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个思路本身是对的，它相当于承认“第一次粗召回不够准”，所以先多捞一点数据回来再做过滤。&lt;/p&gt;
&lt;h3 id="多租户隔离过滤"&gt;&lt;a href="#%e5%a4%9a%e7%a7%9f%e6%88%b7%e9%9a%94%e7%a6%bb%e8%bf%87%e6%bb%a4" class="header-anchor"&gt;&lt;/a&gt;多租户隔离过滤
&lt;/h3&gt;&lt;p&gt;RAG 1.0 的多知识库隔离不是在向量搜索阶段完成的，而是在向量搜索之后做“后置过滤”。&lt;/p&gt;
&lt;p&gt;流程大致是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;先从 Redis &lt;code&gt;Set&lt;/code&gt; 里取当前 KB 的合法 &lt;code&gt;allowedIds&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;再执行向量库 &lt;code&gt;similaritySearch&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;最后对结果做双重保险过滤，并截断到 &lt;code&gt;topK&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;过滤逻辑可以写成：&lt;/p&gt;
$$
\text{Filter: } \text{id} \in \text{allowedIds} \lor \text{meta.kb\_name} == \text{kbName}
$$&lt;p&gt;最后再执行：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;limit(topK)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这一步保证了逻辑上的多租户隔离，但因为它是“检索后过滤”，所以当全局数据规模变大时，会出现明显的稀疏化问题。&lt;/p&gt;
&lt;h3 id="关键词兜底"&gt;&lt;a href="#%e5%85%b3%e9%94%ae%e8%af%8d%e5%85%9c%e5%ba%95" class="header-anchor"&gt;&lt;/a&gt;关键词兜底
&lt;/h3&gt;&lt;p&gt;如果向量检索结果为空，系统不会直接返回“查不到”，而是退化成原文扫描。&lt;/p&gt;
&lt;p&gt;具体做法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;从 Redis 里的原始全文读取文本&lt;/li&gt;
&lt;li&gt;做大小写不敏感的子串匹配&lt;/li&gt;
&lt;li&gt;命中后返回相关内容作为兜底结果&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这一步的价值主要在于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;防止冷启动时 Embedding 质量不稳定&lt;/li&gt;
&lt;li&gt;防止专有名词、缩写或新词没有被向量很好表示&lt;/li&gt;
&lt;li&gt;至少让系统在“完全搜不到”的情况下还能给一个兜底结果&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;但它本质上还是非常弱的字符串匹配，能力上和真正的信息检索差得很远。&lt;/p&gt;
&lt;h2 id="阶段-b上下文组装与推理"&gt;&lt;a href="#%e9%98%b6%e6%ae%b5-b%e4%b8%8a%e4%b8%8b%e6%96%87%e7%bb%84%e8%a3%85%e4%b8%8e%e6%8e%a8%e7%90%86" class="header-anchor"&gt;&lt;/a&gt;阶段 B：上下文组装与推理
&lt;/h2&gt;&lt;h3 id="上下文拼接"&gt;&lt;a href="#%e4%b8%8a%e4%b8%8b%e6%96%87%e6%8b%bc%e6%8e%a5" class="header-anchor"&gt;&lt;/a&gt;上下文拼接
&lt;/h3&gt;&lt;p&gt;在检索阶段拿到 &lt;code&gt;docs[]&lt;/code&gt; 后，系统会把这些召回文本拼接成 &lt;code&gt;context&lt;/code&gt;，再注入最终提示词。&lt;/p&gt;
&lt;p&gt;这一步是典型的 RAG prompt 组装逻辑：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;检索层负责找到候选证据&lt;/li&gt;
&lt;li&gt;Prompt 层负责把证据组织给大模型&lt;/li&gt;
&lt;li&gt;大模型在此基础上完成回答&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="llm-裁判任务"&gt;&lt;a href="#llm-%e8%a3%81%e5%88%a4%e4%bb%bb%e5%8a%a1" class="header-anchor"&gt;&lt;/a&gt;LLM 裁判任务
&lt;/h3&gt;&lt;p&gt;RAG 1.0 的提示词里，大模型不只是回答问题，还被要求扮演一个“情报查询问答节点”。&lt;/p&gt;
&lt;p&gt;它需要同时承担三件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;做相关性校验&lt;/li&gt;
&lt;li&gt;基于已给出的知识库内容作答&lt;/li&gt;
&lt;li&gt;拦截幻觉和无依据扩写&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这意味着同一次调用里，LLM 既是生成器，又承担了一部分检索判断职责。&lt;/p&gt;
&lt;h3 id="结构化输出"&gt;&lt;a href="#%e7%bb%93%e6%9e%84%e5%8c%96%e8%be%93%e5%87%ba" class="header-anchor"&gt;&lt;/a&gt;结构化输出
&lt;/h3&gt;&lt;p&gt;为了让后端更稳定地消费结果，系统会约束大模型输出固定 JSON，再由后端解析后返回给用户：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;related&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;reply&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;大模型基于知识库给出的回答...&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;reason&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;判断相关或不相关的逻辑解释&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这个设计在工程上很实用，因为后端不需要再从一大段自然语言中猜测模型到底是“拒答”还是“回答成功”。&lt;/p&gt;
&lt;h2 id="rag-10-的核心痛点"&gt;&lt;a href="#rag-10-%e7%9a%84%e6%a0%b8%e5%bf%83%e7%97%9b%e7%82%b9" class="header-anchor"&gt;&lt;/a&gt;RAG 1.0 的核心痛点
&lt;/h2&gt;&lt;p&gt;RAG 1.0 最大的问题不是“完全不能用”，而是它虽然把流程跑通了，但很多关键环节都还是 1.0 的折中方案。一旦文档规模、知识库数量或问题复杂度上去，问题就会集中暴露。&lt;/p&gt;
&lt;h3 id="痛点-1分片策略仍然机械化"&gt;&lt;a href="#%e7%97%9b%e7%82%b9-1%e5%88%86%e7%89%87%e7%ad%96%e7%95%a5%e4%bb%8d%e7%84%b6%e6%9c%ba%e6%a2%b0%e5%8c%96" class="header-anchor"&gt;&lt;/a&gt;痛点 1：分片策略仍然机械化
&lt;/h3&gt;&lt;p&gt;现状：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;虽然切片时已经考虑段落和标点&lt;/li&gt;
&lt;li&gt;但本质仍然是围绕 &lt;code&gt;700 / 1000&lt;/code&gt; 字目标做规则凑字数&lt;/li&gt;
&lt;li&gt;Chunk 之间没有 &lt;code&gt;Overlap&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;物理边界上的上下句容易被切断&lt;/li&gt;
&lt;li&gt;参数表、说明表、列表型内容可能被一分为二&lt;/li&gt;
&lt;li&gt;上下文连续性不够，影响后续召回和回答质量&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;改成真正的 &lt;code&gt;Semantic Chunking&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;或至少引入 &lt;code&gt;10% ~ 15%&lt;/code&gt; 的滚动窗口重叠区&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="痛点-2全局向量搜索--后置过滤"&gt;&lt;a href="#%e7%97%9b%e7%82%b9-2%e5%85%a8%e5%b1%80%e5%90%91%e9%87%8f%e6%90%9c%e7%b4%a2--%e5%90%8e%e7%bd%ae%e8%bf%87%e6%bb%a4" class="header-anchor"&gt;&lt;/a&gt;痛点 2：全局向量搜索 + 后置过滤
&lt;/h3&gt;&lt;p&gt;现状：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;系统先在全量索引里搜索&lt;/li&gt;
&lt;li&gt;再用当前 KB 的合法 ID 集合做后置过滤&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;当总知识库规模变大时，&lt;code&gt;fetchK&lt;/code&gt; 即使放大到 &lt;code&gt;200&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;也可能先被其他知识库的高相似内容占满&lt;/li&gt;
&lt;li&gt;最后过滤完之后，当前 KB 的有效结果几乎没有，甚至直接搜空&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这本质上就是“目标知识被全局噪声稀释”。&lt;/p&gt;
&lt;p&gt;后续方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;改成 &lt;code&gt;Pre-Filtering&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;在向量检索阶段就基于 &lt;code&gt;kb_name&lt;/code&gt; 或其他元数据做过滤&lt;/li&gt;
&lt;li&gt;把相似度计算范围直接收缩到当前知识库内部&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="痛点-3关键词兜底过弱"&gt;&lt;a href="#%e7%97%9b%e7%82%b9-3%e5%85%b3%e9%94%ae%e8%af%8d%e5%85%9c%e5%ba%95%e8%bf%87%e5%bc%b1" class="header-anchor"&gt;&lt;/a&gt;痛点 3：关键词兜底过弱
&lt;/h3&gt;&lt;p&gt;现状：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;向量检索失败后，只能退化成大小写不敏感的原文子串匹配&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不能处理同义词&lt;/li&gt;
&lt;li&gt;不能处理缩写&lt;/li&gt;
&lt;li&gt;不能处理近义表达&lt;/li&gt;
&lt;li&gt;专有术语换一种说法后就直接未命中&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;文档里写的是 &lt;code&gt;DDG&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;用户问的是“驱逐舰”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这类场景下，子串匹配基本没有任何容错能力。&lt;/p&gt;
&lt;p&gt;后续方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;引入 &lt;code&gt;BM25&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;或构建倒排索引 &lt;code&gt;Inverted Index&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;配合中文分词器，做真正的 &lt;code&gt;Hybrid Search&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="痛点-4缺少重排机制"&gt;&lt;a href="#%e7%97%9b%e7%82%b9-4%e7%bc%ba%e5%b0%91%e9%87%8d%e6%8e%92%e6%9c%ba%e5%88%b6" class="header-anchor"&gt;&lt;/a&gt;痛点 4：缺少重排机制
&lt;/h3&gt;&lt;p&gt;现状：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;向量库返回结果后，只依据 Embedding 相似度粗排&lt;/li&gt;
&lt;li&gt;粗排结果直接塞给 LLM&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;前几个 Chunk 可能带有较多低相关噪声&lt;/li&gt;
&lt;li&gt;真正包含标准答案的 Chunk 可能被挤到后面&lt;/li&gt;
&lt;li&gt;一旦上下文窗口有限，LLM 很容易“迷失在中间”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在检索后增加 &lt;code&gt;Reranker&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;比如 &lt;code&gt;BGE-Reranker&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;对候选结果做二次精排&lt;/li&gt;
&lt;li&gt;再结合分数阈值做动态截断&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="痛点-5llm-既当运动员又当裁判"&gt;&lt;a href="#%e7%97%9b%e7%82%b9-5llm-%e6%97%a2%e5%bd%93%e8%bf%90%e5%8a%a8%e5%91%98%e5%8f%88%e5%bd%93%e8%a3%81%e5%88%a4" class="header-anchor"&gt;&lt;/a&gt;痛点 5：LLM 既当运动员又当裁判
&lt;/h3&gt;&lt;p&gt;现状：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;单次调用里，LLM 同时做“相关性判断”和“答案生成”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;只要检索结果“不够完美但有点相关”&lt;/li&gt;
&lt;li&gt;LLM 就可能直接判定 &lt;code&gt;related: false&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;结果是部分可用信息被浪费，系统容错率很低&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把相关性判断前置到检索阶段&lt;/li&gt;
&lt;li&gt;由 &lt;code&gt;Reranker&lt;/code&gt; 或轻量分类模型做相关性过滤&lt;/li&gt;
&lt;li&gt;让大模型专注于信息整合和自然语言生成&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="痛点-6固定切片参数--缺乏评估体系"&gt;&lt;a href="#%e7%97%9b%e7%82%b9-6%e5%9b%ba%e5%ae%9a%e5%88%87%e7%89%87%e5%8f%82%e6%95%b0--%e7%bc%ba%e4%b9%8f%e8%af%84%e4%bc%b0%e4%bd%93%e7%b3%bb" class="header-anchor"&gt;&lt;/a&gt;痛点 6：固定切片参数 + 缺乏评估体系
&lt;/h3&gt;&lt;p&gt;现状：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不区分文档类型，统一用一套切片参数&lt;/li&gt;
&lt;li&gt;参数密集的表格、叙事文档、结构化 JSON 被同样处理&lt;/li&gt;
&lt;li&gt;同时没有标准召回评估&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后果：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;短文档或高密度文档容易被切坏&lt;/li&gt;
&lt;li&gt;系统效果无法量化&lt;/li&gt;
&lt;li&gt;后续优化变成“靠感觉调参”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;后续方向：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;针对不同文件类型使用差异化切片策略&lt;/li&gt;
&lt;li&gt;表格类文档采用结构化切片&lt;/li&gt;
&lt;li&gt;建立标准问题对 &lt;code&gt;Ground Truth&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;评估 &lt;code&gt;Recall@10&lt;/code&gt;、&lt;code&gt;MRR&lt;/code&gt; 等指标&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="从今天回看-rag-10"&gt;&lt;a href="#%e4%bb%8e%e4%bb%8a%e5%a4%a9%e5%9b%9e%e7%9c%8b-rag-10" class="header-anchor"&gt;&lt;/a&gt;从今天回看 RAG 1.0
&lt;/h2&gt;&lt;p&gt;如果用一句话概括这套老版架构，我会说：&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;它已经完成了“能上传、能检索、能回答”的最小闭环，但很多关键模块仍停留在规则驱动、后置修补和弱评估阶段。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;RAG 1.0 最宝贵的价值，不是它有多先进，而是它把问题暴露得足够具体：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;切片为什么会影响召回&lt;/li&gt;
&lt;li&gt;全局搜索为什么会造成稀疏化&lt;/li&gt;
&lt;li&gt;为什么不能只靠向量检索兜底一切&lt;/li&gt;
&lt;li&gt;为什么 LLM 不能既做检索判断又做最终回答&lt;/li&gt;
&lt;li&gt;为什么没有评估体系的优化几乎等于盲调&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些坑踩出来之后，后面的 RAG 2.0 才会更有方向。&lt;/p&gt;
&lt;h2 id="小结"&gt;&lt;a href="#%e5%b0%8f%e7%bb%93" class="header-anchor"&gt;&lt;/a&gt;小结
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;MapAI&lt;/code&gt; 的 &lt;code&gt;RAG 1.0&lt;/code&gt; 已经具备了完整的数据入库和问答链路：上传文件后完成去重、解析、切片、向量化和 Redis 持久化；用户提问时完成召回、过滤、上下文组装和 JSON 化回答。&lt;/p&gt;
&lt;p&gt;但它的核心问题也很清楚：切片不够语义化、检索过滤顺序不合理、兜底检索太弱、缺少重排、LLM 职责过重、效果无法量化。对一个 1.0 系统来说，这其实很正常。真正重要的是把问题讲清楚，然后让下一版架构不再重复这些老坑。&lt;/p&gt;</description></item><item><title>Web 登录验证码规范：从老后台排查到 Redis 一次性校验</title><link>https://xedczq.cn/post/web_logincaptchaspec/</link><pubDate>Sat, 20 Jun 2026 00:00:00 +0800</pubDate><guid>https://xedczq.cn/post/web_logincaptchaspec/</guid><description>&lt;h1 id="web-登录验证码规范从老后台排查到-redis-一次性校验"&gt;&lt;a href="#web-%e7%99%bb%e5%bd%95%e9%aa%8c%e8%af%81%e7%a0%81%e8%a7%84%e8%8c%83%e4%bb%8e%e8%80%81%e5%90%8e%e5%8f%b0%e6%8e%92%e6%9f%a5%e5%88%b0-redis-%e4%b8%80%e6%ac%a1%e6%80%a7%e6%a0%a1%e9%aa%8c" class="header-anchor"&gt;&lt;/a&gt;Web 登录验证码规范：从老后台排查到 Redis 一次性校验
&lt;/h1&gt;&lt;p&gt;这篇笔记记录我最近围绕 Web 管理端登录验证码做的一次完整排查和改造。事情的起点很简单：我想确认老服务器上的后台验证码到底是不是“真的在校验”，以及新版 &lt;code&gt;web_3&lt;/code&gt; 应该把验证码流程收敛成什么样，才算一套真正完整的登录安全链路。&lt;/p&gt;
&lt;p&gt;这次我主要做了三件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;排查老服务器 &lt;code&gt;yzdwx.yizhoudao.net/admin/&lt;/code&gt; 的验证码真实逻辑&lt;/li&gt;
&lt;li&gt;确认老 Web 是否真的启用了 Redis&lt;/li&gt;
&lt;li&gt;在本地 &lt;code&gt;web_3&lt;/code&gt; 里把管理端验证码改成“后端生成 + Redis 存储 + 后端一次性校验”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后的结论也很明确：&lt;strong&gt;老后台页面上虽然有验证码，但登录流程里没有真正校验它；新版 &lt;code&gt;web_3&lt;/code&gt; 已经改成标准的 Redis 验证码闭环。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="我为什么要单独整理这篇笔记"&gt;&lt;a href="#%e6%88%91%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a6%81%e5%8d%95%e7%8b%ac%e6%95%b4%e7%90%86%e8%bf%99%e7%af%87%e7%ac%94%e8%ae%b0" class="header-anchor"&gt;&lt;/a&gt;我为什么要单独整理这篇笔记
&lt;/h2&gt;&lt;p&gt;验证码这种东西很容易出现一种假安全状态：页面上明明有输入框，也有验证码图片，前端看起来一切都在，但真正决定安全性的不是页面上有没有控件，而是后端登录逻辑到底有没有做验证，以及验证结果有没有被一次性消费。&lt;/p&gt;
&lt;p&gt;这次排查让我更确定一件事：&lt;strong&gt;登录验证码必须按“生成、存储、提交、校验、销毁”这五步闭环来设计，缺任何一步都不算真正落地。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="老服务器后台验证码的真实逻辑"&gt;&lt;a href="#%e8%80%81%e6%9c%8d%e5%8a%a1%e5%99%a8%e5%90%8e%e5%8f%b0%e9%aa%8c%e8%af%81%e7%a0%81%e7%9a%84%e7%9c%9f%e5%ae%9e%e9%80%bb%e8%be%91" class="header-anchor"&gt;&lt;/a&gt;老服务器后台验证码的真实逻辑
&lt;/h2&gt;&lt;p&gt;这次我先通过 SSH 登录老服务器，只做了只读排查，没有改线上代码，也没有重启服务。&lt;/p&gt;
&lt;p&gt;我定位到的站点信息是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;域名 &lt;code&gt;yzdwx.yizhoudao.net&lt;/code&gt; 对应站点目录：&lt;code&gt;/www/wwwroot/yzdwx5.weitaibei.com/public_html&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;应用入口：&lt;code&gt;/www/wwwroot/yzdwx5.weitaibei.com/public_html/index.php&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;实际业务代码目录：&lt;code&gt;/www/wwwroot/yzdwx5.weitaibei.com/system/application&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;接着我去看后台登录页模板，确认页面上确实有验证码输入框和验证码图片。页面会请求：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/captcha
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;点击刷新时会请求：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;/captcha?random=...
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这个路由最终来自 ThinkPHP 的验证码包。继续往下追，我确认到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;验证码图片确实是后端生成的&lt;/li&gt;
&lt;li&gt;验证码答案会写入 &lt;code&gt;Session&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;不是写在前端，也不是直接写入 Redis&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;更关键的是，我继续追到后台登录控制器之后发现，老后台登录方法虽然接收了 &lt;code&gt;$verify&lt;/code&gt; 参数，但实际逻辑里只做了这些事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;用户名判空&lt;/li&gt;
&lt;li&gt;密码判空&lt;/li&gt;
&lt;li&gt;调用 &lt;code&gt;rbaclogin(...)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我没有看到任何真正的验证码校验逻辑，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;captcha_check($verify)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;validate(...captcha...)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;手动对比验证码答案&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以老服务器后台验证码的真实情况其实是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;页面上有验证码&lt;/li&gt;
&lt;li&gt;后端也会生成验证码图片&lt;/li&gt;
&lt;li&gt;验证码答案保存在 &lt;code&gt;session&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;但登录业务流程里没有真正校验它&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这意味着老版本后台的验证码大概率属于一种“界面还在，但校验已经失效或被漏掉”的状态。&lt;/p&gt;
&lt;h2 id="我是怎么确认这件事的"&gt;&lt;a href="#%e6%88%91%e6%98%af%e6%80%8e%e4%b9%88%e7%a1%ae%e8%ae%a4%e8%bf%99%e4%bb%b6%e4%ba%8b%e7%9a%84" class="header-anchor"&gt;&lt;/a&gt;我是怎么确认这件事的
&lt;/h2&gt;&lt;p&gt;这次排查不是靠猜，也不是只看前端页面，而是按一条比较稳的链路逐步确认的。&lt;/p&gt;
&lt;p&gt;我先确认 SSH 和站点目录：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;先通过本机 SSH 别名连到服务器&lt;/li&gt;
&lt;li&gt;查 Nginx 配置，确认 &lt;code&gt;yzdwx.yizhoudao.net&lt;/code&gt; 实际指向哪个目录&lt;/li&gt;
&lt;li&gt;查 PHP-FPM 和 Nginx 运行状态&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然后我再往应用层追：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;找后台登录模板&lt;/li&gt;
&lt;li&gt;找 &lt;code&gt;/captcha&lt;/code&gt; 路由来源&lt;/li&gt;
&lt;li&gt;找验证码生成逻辑&lt;/li&gt;
&lt;li&gt;找验证码校验逻辑&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最后我再去看后台登录控制器：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;看 &lt;code&gt;login()&lt;/code&gt; 方法是否真正调用了验证码校验&lt;/li&gt;
&lt;li&gt;结果是没有&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个顺序的好处是不会被页面表现误导。只要登录控制器里没有执行验证码校验，那页面上验证码画得再完整，也只是“视觉存在”，不是“安全生效”。&lt;/p&gt;
&lt;h2 id="老-web-到底有没有用-redis"&gt;&lt;a href="#%e8%80%81-web-%e5%88%b0%e5%ba%95%e6%9c%89%e6%b2%a1%e6%9c%89%e7%94%a8-redis" class="header-anchor"&gt;&lt;/a&gt;老 Web 到底有没有用 Redis
&lt;/h2&gt;&lt;p&gt;这部分我也专门确认了一遍，因为很多项目都会出现一种情况：代码里带着 Redis 封装类，但线上根本没启用。&lt;/p&gt;
&lt;p&gt;这次我的结论是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;代码仓里确实有 Redis 工具类&lt;/li&gt;
&lt;li&gt;但线上这套老 Web 没查到业务代码实际调用它&lt;/li&gt;
&lt;li&gt;服务器本身也没发现 Redis 服务在运行&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我当时主要查了几类证据：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ThinkPHP 的 session 配置&lt;/li&gt;
&lt;li&gt;PHP 的 &lt;code&gt;session.save_handler&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/tmp&lt;/code&gt; 下是否存在大量 &lt;code&gt;sess_*&lt;/code&gt; 文件&lt;/li&gt;
&lt;li&gt;服务器上是否有 &lt;code&gt;redis-server&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;是否有 &lt;code&gt;redis-cli&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;是否监听了 &lt;code&gt;6379&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最终确认到的事实是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Session 默认驱动不是 Redis&lt;/li&gt;
&lt;li&gt;PHP 当前会话处理器是 &lt;code&gt;files&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/tmp&lt;/code&gt; 下确实存在大量 &lt;code&gt;sess_*&lt;/code&gt; 文件&lt;/li&gt;
&lt;li&gt;服务器上没发现 Redis 进程&lt;/li&gt;
&lt;li&gt;没发现 &lt;code&gt;6379&lt;/code&gt; 监听&lt;/li&gt;
&lt;li&gt;也没发现 Redis 二进制&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以更准确的说法不是“老项目完全没有 Redis”，而是：&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;老项目带过 Redis 封装代码，但当前运行中的老站没有实际启用 Redis，后台验证码也不在 Redis 里。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;h2 id="老版本验证码的问题到底出在哪"&gt;&lt;a href="#%e8%80%81%e7%89%88%e6%9c%ac%e9%aa%8c%e8%af%81%e7%a0%81%e7%9a%84%e9%97%ae%e9%a2%98%e5%88%b0%e5%ba%95%e5%87%ba%e5%9c%a8%e5%93%aa" class="header-anchor"&gt;&lt;/a&gt;老版本验证码的问题到底出在哪
&lt;/h2&gt;&lt;p&gt;如果用一句话概括老后台的问题，我会说：&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;它的问题不是“验证码生成失败”，而是“验证码没有进入真正的登录校验链路”。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;也就是说，旧系统表面上像是有验证码防护，但真正的登录安全流程里并没有用到它。这个问题比“没有验证码”更隐蔽，因为产品、测试甚至开发自己都可能先被页面骗过去。&lt;/p&gt;
&lt;p&gt;从规范角度看，老版本至少有两个明显缺口：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;验证码答案存进了 Session，但登录接口没有校验&lt;/li&gt;
&lt;li&gt;验证码不是一次性消费，即使以后补校验，也容易留下复用问题&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="我在本地-web_3-改成了什么逻辑"&gt;&lt;a href="#%e6%88%91%e5%9c%a8%e6%9c%ac%e5%9c%b0-web_3-%e6%94%b9%e6%88%90%e4%ba%86%e4%bb%80%e4%b9%88%e9%80%bb%e8%be%91" class="header-anchor"&gt;&lt;/a&gt;我在本地 web_3 改成了什么逻辑
&lt;/h2&gt;&lt;p&gt;确认完老系统问题之后，我在本地 &lt;code&gt;web_3&lt;/code&gt; 管理端把验证码流程改成了完整的后端闭环。&lt;/p&gt;
&lt;p&gt;现在的流程是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;前端请求 &lt;code&gt;GET /api/admin/login/captcha&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;后端生成：
&lt;code&gt;captchaId&lt;/code&gt;
&lt;code&gt;4&lt;/code&gt; 位验证码
&lt;code&gt;Base64 PNG&lt;/code&gt; 图片&lt;/li&gt;
&lt;li&gt;后端把验证码答案写入 Redis&lt;/li&gt;
&lt;li&gt;前端只展示图片，同时保留 &lt;code&gt;captchaId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;用户登录时，前端提交：
&lt;code&gt;username&lt;/code&gt;
&lt;code&gt;userpwd&lt;/code&gt;
&lt;code&gt;captchaId&lt;/code&gt;
&lt;code&gt;captchaCode&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;后端先去 Redis 校验验证码&lt;/li&gt;
&lt;li&gt;校验时使用 &lt;code&gt;getAndDelete&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;读取成功后立即删除&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这个设计意味着一个验证码只有一次尝试机会。&lt;/p&gt;
&lt;p&gt;无论最终登录成功还是失败，这个验证码都会失效，不会继续留在系统里被重复提交。&lt;/p&gt;
&lt;h2 id="新版验证码链路的核心规范"&gt;&lt;a href="#%e6%96%b0%e7%89%88%e9%aa%8c%e8%af%81%e7%a0%81%e9%93%be%e8%b7%af%e7%9a%84%e6%a0%b8%e5%bf%83%e8%a7%84%e8%8c%83" class="header-anchor"&gt;&lt;/a&gt;新版验证码链路的核心规范
&lt;/h2&gt;&lt;p&gt;结合这次改造，我把 Web 登录验证码应该遵守的规范总结成下面这几条。&lt;/p&gt;
&lt;h3 id="规范-1验证码必须由后端生成"&gt;&lt;a href="#%e8%a7%84%e8%8c%83-1%e9%aa%8c%e8%af%81%e7%a0%81%e5%bf%85%e9%a1%bb%e7%94%b1%e5%90%8e%e7%ab%af%e7%94%9f%e6%88%90" class="header-anchor"&gt;&lt;/a&gt;规范 1：验证码必须由后端生成
&lt;/h3&gt;&lt;p&gt;验证码图片、验证码答案和 &lt;code&gt;captchaId&lt;/code&gt; 都必须由后端统一生成。前端只负责展示，不参与答案生成，也不保存正确答案。&lt;/p&gt;
&lt;p&gt;这样可以避免前端暴露正确结果，保证校验基准只存在于服务端。&lt;/p&gt;
&lt;h3 id="规范-2验证码答案必须保存在短期存储中"&gt;&lt;a href="#%e8%a7%84%e8%8c%83-2%e9%aa%8c%e8%af%81%e7%a0%81%e7%ad%94%e6%a1%88%e5%bf%85%e9%a1%bb%e4%bf%9d%e5%ad%98%e5%9c%a8%e7%9f%ad%e6%9c%9f%e5%ad%98%e5%82%a8%e4%b8%ad" class="header-anchor"&gt;&lt;/a&gt;规范 2：验证码答案必须保存在短期存储中
&lt;/h3&gt;&lt;p&gt;这次我选的是 Redis。它很适合这种短时、一次性、低价值但安全敏感的数据。&lt;/p&gt;
&lt;p&gt;相比 &lt;code&gt;session/file&lt;/code&gt;，Redis 更适合后续扩展：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;可以设置 TTL&lt;/li&gt;
&lt;li&gt;可以做原子删除&lt;/li&gt;
&lt;li&gt;可以支持多机共享&lt;/li&gt;
&lt;li&gt;可以承接登录风控类临时状态&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="规范-3登录接口必须在鉴权前先校验验证码"&gt;&lt;a href="#%e8%a7%84%e8%8c%83-3%e7%99%bb%e5%bd%95%e6%8e%a5%e5%8f%a3%e5%bf%85%e9%a1%bb%e5%9c%a8%e9%89%b4%e6%9d%83%e5%89%8d%e5%85%88%e6%a0%a1%e9%aa%8c%e9%aa%8c%e8%af%81%e7%a0%81" class="header-anchor"&gt;&lt;/a&gt;规范 3：登录接口必须在鉴权前先校验验证码
&lt;/h3&gt;&lt;p&gt;验证码校验不能放在一个“看起来有，但不影响主流程”的边缘位置。它必须在用户名密码真正进入登录鉴权前完成。&lt;/p&gt;
&lt;p&gt;也就是说，顺序应该是：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;先校验验证码
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;再校验账号密码
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;最后建立登录态
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;如果验证码失败，后面的账号密码逻辑就不应该继续执行。&lt;/p&gt;
&lt;h3 id="规范-4验证码必须一次性消费"&gt;&lt;a href="#%e8%a7%84%e8%8c%83-4%e9%aa%8c%e8%af%81%e7%a0%81%e5%bf%85%e9%a1%bb%e4%b8%80%e6%ac%a1%e6%80%a7%e6%b6%88%e8%b4%b9" class="header-anchor"&gt;&lt;/a&gt;规范 4：验证码必须一次性消费
&lt;/h3&gt;&lt;p&gt;这次我专门用了 &lt;code&gt;getAndDelete&lt;/code&gt; 这种读取即删除的方式，目的是确保验证码永远只能用一次。&lt;/p&gt;
&lt;p&gt;这个约束非常重要，因为如果验证码校验通过后不立刻销毁，就可能出现：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;同一验证码被重复利用&lt;/li&gt;
&lt;li&gt;自动化脚本反复撞同一个验证码&lt;/li&gt;
&lt;li&gt;失败重试时复用旧验证码&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一次性消费是验证码机制真正生效的关键之一。&lt;/p&gt;
&lt;h3 id="规范-5成功和失败都要失效"&gt;&lt;a href="#%e8%a7%84%e8%8c%83-5%e6%88%90%e5%8a%9f%e5%92%8c%e5%a4%b1%e8%b4%a5%e9%83%bd%e8%a6%81%e5%a4%b1%e6%95%88" class="header-anchor"&gt;&lt;/a&gt;规范 5：成功和失败都要失效
&lt;/h3&gt;&lt;p&gt;很多人只会在“登录成功”时删验证码，但这其实还不够。&lt;/p&gt;
&lt;p&gt;更稳的做法是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;校验成功后立即删除&lt;/li&gt;
&lt;li&gt;校验失败也要删除&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因为验证码本来就是一次挑战，不应该允许用户拿着同一个 &lt;code&gt;captchaId&lt;/code&gt; 连续试很多次。&lt;/p&gt;
&lt;h2 id="这次改成-redis-后我认为最实际的好处"&gt;&lt;a href="#%e8%bf%99%e6%ac%a1%e6%94%b9%e6%88%90-redis-%e5%90%8e%e6%88%91%e8%ae%a4%e4%b8%ba%e6%9c%80%e5%ae%9e%e9%99%85%e7%9a%84%e5%a5%bd%e5%a4%84" class="header-anchor"&gt;&lt;/a&gt;这次改成 Redis 后，我认为最实际的好处
&lt;/h2&gt;&lt;p&gt;这次改造给我的直接感受，不是“技术更高级了”，而是整个登录安全链路终于闭合了。&lt;/p&gt;
&lt;p&gt;主要好处有这些：&lt;/p&gt;
&lt;h3 id="逻辑闭环完整"&gt;&lt;a href="#%e9%80%bb%e8%be%91%e9%97%ad%e7%8e%af%e5%ae%8c%e6%95%b4" class="header-anchor"&gt;&lt;/a&gt;逻辑闭环完整
&lt;/h3&gt;&lt;p&gt;老版本是“有验证码页面，但登录不校验”。&lt;/p&gt;
&lt;p&gt;现在变成了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;后端生成&lt;/li&gt;
&lt;li&gt;后端存储&lt;/li&gt;
&lt;li&gt;前端展示&lt;/li&gt;
&lt;li&gt;后端校验&lt;/li&gt;
&lt;li&gt;一次性消费&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这才是一条完整的安全链路。&lt;/p&gt;
&lt;h3 id="不再依赖本地-session-文件"&gt;&lt;a href="#%e4%b8%8d%e5%86%8d%e4%be%9d%e8%b5%96%e6%9c%ac%e5%9c%b0-session-%e6%96%87%e4%bb%b6" class="header-anchor"&gt;&lt;/a&gt;不再依赖本地 session 文件
&lt;/h3&gt;&lt;p&gt;老站的验证码答案实际落在 &lt;code&gt;session/file&lt;/code&gt; 和 &lt;code&gt;/tmp sess_*&lt;/code&gt; 上。&lt;/p&gt;
&lt;p&gt;这套方案虽然在单机环境里能跑，但它天然更偏运行时实现，不够适合后续扩展。Redis 对这类短期临时数据更合适，也更容易观察和控制。&lt;/p&gt;
&lt;h3 id="一次性验证码更自然"&gt;&lt;a href="#%e4%b8%80%e6%ac%a1%e6%80%a7%e9%aa%8c%e8%af%81%e7%a0%81%e6%9b%b4%e8%87%aa%e7%84%b6" class="header-anchor"&gt;&lt;/a&gt;一次性验证码更自然
&lt;/h3&gt;&lt;p&gt;Redis 很适合实现这种需求：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;TTL 过期&lt;/li&gt;
&lt;li&gt;&lt;code&gt;getAndDelete&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;成功失败都删除&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些语义在验证码场景里非常自然，不需要绕很多额外逻辑。&lt;/p&gt;
&lt;h3 id="多机部署更友好"&gt;&lt;a href="#%e5%a4%9a%e6%9c%ba%e9%83%a8%e7%bd%b2%e6%9b%b4%e5%8f%8b%e5%a5%bd" class="header-anchor"&gt;&lt;/a&gt;多机部署更友好
&lt;/h3&gt;&lt;p&gt;如果以后不是单机，而是多台 Web 共同处理请求，那么 &lt;code&gt;session/file&lt;/code&gt; 很容易出现跨机器不一致问题。&lt;/p&gt;
&lt;p&gt;Redis 作为共享存储，可以让所有 Web 节点读到同一份验证码状态，这对后续扩容非常关键。&lt;/p&gt;
&lt;h3 id="后续风控能力更容易接上"&gt;&lt;a href="#%e5%90%8e%e7%bb%ad%e9%a3%8e%e6%8e%a7%e8%83%bd%e5%8a%9b%e6%9b%b4%e5%ae%b9%e6%98%93%e6%8e%a5%e4%b8%8a" class="header-anchor"&gt;&lt;/a&gt;后续风控能力更容易接上
&lt;/h3&gt;&lt;p&gt;验证码逻辑进入 Redis 之后，很多安全相关能力也更容易继续往上加，比如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;短信验证码&lt;/li&gt;
&lt;li&gt;登录失败次数限制&lt;/li&gt;
&lt;li&gt;风控计数&lt;/li&gt;
&lt;li&gt;临时 token&lt;/li&gt;
&lt;li&gt;AI/RAG 相关短期缓存&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从工程上看，这相当于把“登录临时态”统一收进了一个更适合治理的位置。&lt;/p&gt;
&lt;h2 id="这次我顺手做的配套工作"&gt;&lt;a href="#%e8%bf%99%e6%ac%a1%e6%88%91%e9%a1%ba%e6%89%8b%e5%81%9a%e7%9a%84%e9%85%8d%e5%a5%97%e5%b7%a5%e4%bd%9c" class="header-anchor"&gt;&lt;/a&gt;这次我顺手做的配套工作
&lt;/h2&gt;&lt;p&gt;为了让新版 &lt;code&gt;web_3&lt;/code&gt; 本地链路真正跑通，这次我还顺手处理了几项配套问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;本地临时启动了一个 Redis，监听 &lt;code&gt;127.0.0.1:6379&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;修了 &lt;code&gt;backend-java&lt;/code&gt; 启动时的 YAML 配置问题&lt;/li&gt;
&lt;li&gt;清理了后端端口占用&lt;/li&gt;
&lt;li&gt;把管理端前端改成支持局域网访问，监听 &lt;code&gt;0.0.0.0:9511&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;调整了登录页移动端和窄屏适配&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些改动虽然不是验证码主逻辑，但它们决定了整个方案能不能从“代码写好了”真正走到“本地完整跑通”。&lt;/p&gt;
&lt;h2 id="我最后得出的规范结论"&gt;&lt;a href="#%e6%88%91%e6%9c%80%e5%90%8e%e5%be%97%e5%87%ba%e7%9a%84%e8%a7%84%e8%8c%83%e7%bb%93%e8%ae%ba" class="header-anchor"&gt;&lt;/a&gt;我最后得出的规范结论
&lt;/h2&gt;&lt;p&gt;这次排查和改造之后，我对 Web 登录验证码的规范要求基本固定成下面这套：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;验证码必须由后端生成&lt;/li&gt;
&lt;li&gt;验证码答案必须只保存在后端&lt;/li&gt;
&lt;li&gt;前端只展示图片和提交 &lt;code&gt;captchaId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;登录接口必须先校验验证码，再校验账号密码&lt;/li&gt;
&lt;li&gt;验证码必须一次性消费&lt;/li&gt;
&lt;li&gt;成功失败都必须失效&lt;/li&gt;
&lt;li&gt;短期验证码优先放 Redis，而不是依赖本地 session 文件&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果少了其中任何一条，我都不会认为这套验证码是真正“规范落地”的。&lt;/p&gt;
&lt;h2 id="小结"&gt;&lt;a href="#%e5%b0%8f%e7%bb%93" class="header-anchor"&gt;&lt;/a&gt;小结
&lt;/h2&gt;&lt;p&gt;一句话总结这次工作：&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;老服务器后台验证码是 ThinkPHP 后端生成、保存到 Session，但后台登录代码里没有真正校验它；老服务器当前也没有实际运行 Redis。新版 &lt;code&gt;web_3&lt;/code&gt; 则已经改成标准的 Redis 验证码流程：后端生成、Redis 保存、前端展示、后端校验、一次性消费、成功失败都删除。&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;对我来说，这次最重要的收获不是“把验证码改成 Redis”这么简单，而是把验证码这件事从“页面上看起来有”推进到了“后端链路真正闭环”。只有做到这一点，验证码才不是装饰，而是登录安全的一部分。&lt;/p&gt;</description></item></channel></rss>