🚙 一条腿走路会摔:ES 关键词检索、混合召回与 ReRank 裁判

写在前面:前三篇我们搭了 RAG、打了三个补丁(分诊、拆题、联网兜底)。但有一块硬伤还没修------"高血糖"和"低血糖"在向量数据库里几乎是同一个东西。 readme 把这个问题的本质说得非常准:"专业术语、精确实体更适合关键词检索,纯语义检索容易匹配不准。" 更狠的是 readme 里那句点评------"Mysql 是原始数据,es 是特种兵(关键词搜索)。" 这篇我们看三件事:ES 的 CRUD 怎么用、为什么需要"关键词 + 语义"两条腿走路、以及 ReRank 这个裁判是干嘛的。以下所有代码和概念均来自课堂真实文件。


一、病根:为什么"高血糖"和"低血糖"几乎一样

先把这个病讲透,因为它决定了后面所有方案。

Embedding 的视角

向量检索的原理是把文字转成 1024 维的坐标点,然后找"距离最近"的。那么:

arduino 复制代码
"高血糖" → [0.12, -0.34, 0.56, ..., 0.78]
"低血糖" → [0.13, -0.33, 0.55, ..., 0.77]
                    ↑
              几乎重合

两个意思完全相反的词,向量坐标几乎一样。

为什么会这样?因为 Embedding 模型学的是"这些词在什么语境里出现":

  • 两个词都跟"血糖""胰岛素""糖尿病""饮食控制"这些概念强关联
  • 出现的位置、搭配的动词、所在的句子结构都极度相似
  • 唯一的区别是那个"高"和"低"

模型眼里的世界是"语义场",它不太擅长捕捉"一字之差"的方向性差异。

readme 直接把这个例子写进了笔记:

"高血糖、低血糖 自然语义相似度 相近"

这在专业场景里有多致命

领域 差一个字的后果
医疗 高血糖要控糖,低血糖要补糖------方案完全相反
法律 "有期徒刑三年" vs "十年"
代码 useState vs useEffect
金融 买入建议 vs 卖出建议
化学 一氧化碳 vs 二氧化碳

在通用闲聊场景,"差不多就行"能接受。但在专业场景,"差不多"等于"错了"。

readme 给的解药

"解决方案:同时结合关键词检索和语义检索,由模型统一融合多路结果,提升专业场景准确率。"

翻译一下------别指望一个检索器包打天下,让两个各有长短的检索器同时上,再把结果合起来。

而关键词检索这一路的选手,就是 ElasticSearch。


二、ES 的两把武器:text 和 keyword

create.mjs 是一个真实可跑的 ES 建索引脚本。我们从它入手。

先建索引:这里没有"表"

javascript 复制代码
import { Client } from '@elastic/elasticsearch';

const client = new Client({
    node: 'http://localhost:9200',
})
const INDEX_NAME = 'travel-journal';

这就是 ES 的"数据库连接"。然后:

javascript 复制代码
async function createIndex() {
    const exists = await client.indices.exists({
        index: INDEX_NAME,
    })
    if (!exists) {
        console.log(`索引已存在 ${INDEX_NAME}`)
        return;
    }

    await client.indices.create({
        index: INDEX_NAME,
        mappings: {
            properties: {
                // ... 字段定义
            }
        }
    });
    console.log(`索引创建成功 ${INDEX_NAME}`)
}

对比一下 MySQL 的建表:

MySQL ES
CREATE DATABASE 索引(Index)
CREATE TABLE 索引(Index)下的 mappings
字段名 类型 properties: { 字段名: { type: ... } }

注意 ES 里没有"库-表"两级------一个 Index 既是"表"也是"库"。 这也解释了 readme 里那句容易看懵的话:

javascript 复制代码
// mysql 行列,milvus 语义 es 关键词索引 id

三种存储各管一段:MySQL 管行列结构化数据、Milvus 管语义、ES 管关键词和 ID 索引。

mappings:字段类型决定"怎么搜"

javascript 复制代码
mappings: {
    properties: {
        note_title: {
            type: 'text',
            analyzer: 'ik_max_word',
            search_analyzer: 'ik_max_smart',
        },
        note_body: {
            type: 'text',
            analyzer: 'ik_max_word',
            search_analyzer: 'ik_max_smart',
        },
        tags: {type: 'keyword'},
        mood: {type: 'keyword'},
        priority: {type: "integer"},
        create_time: {type: 'date'},
        update_time: {type: 'date'},
    }
}

七个字段,三种类型,各有分工:

字段 类型 为什么
note_title text 标题要能被搜到关键词 → 分词
note_body text 正文要全文检索 → 分词
tags keyword 标签是精确值 → 不分词
mood keyword 心情是个枚举值 → 不分词
priority integer 数字能比大小 → 用数值类型
create_time / update_time date 时间能排序、能范围查 → 用日期类型

text 和 keyword 的区别是这篇最重要的知识点之一。

readme 里那句话点得很清楚:

"title,content 分词 type: text 全文检索。author 不分词 精确匹配。"

用《天龙八部》举例,假设标题是"虚竹破珍珑棋局":

arduino 复制代码
text 类型(分词后存入):
  ["虚竹", "破", "珍珑", "棋局", "珍珑棋局"]
  → 搜"虚竹"能搜到,搜"棋局"也能搜到 ✅

keyword 类型(整体存入):
  ["虚竹破珍珑棋局"]
  → 搜"虚竹"搜不到,必须整串完全匹配 ❌

反过来,标签用 keyword 就对了:

vbnet 复制代码
tags: ["旅行", "周末", "杭州"]

keyword 类型 → 存成 ["旅行", "周末", "杭州"]
  → 精确匹配"杭州",一击即中 ✅

如果 tags 用 text → 存成 ["旅", "行", "周", "末", "杭", "州"]
  → 搜"杭"也能命中,全是噪音 ❌

记住这个判断口诀:要"搜内容"用 text,要"精确匹配"用 keyword。

ik 分词器:存用细的,查用粗的

上面那个 ik_max_word 和 search_analyzer 是什么?

readme 有一句很精准的总结:

"ik_max_word + ik_smart 中文友好分词器的两种。存的时候 ik_max_word 尽量多的存索引,粒度更细。ik_smart 去的时候,粒度粗一点。"

场景 分词器 粒度 为什么
存入时 ik_max_word 细(尽可能多切词) 切得越细,能被搜到的机会越多
查询时 ik_smart 粗(切最合理的) 查询词切太细会引入噪音

用"中华人民共和国"举例:

markdown 复制代码
ik_max_word(存):中华人民共和国 / 中华人民 / 中华 / 华人 / 人民 / 共和国 / 共和 / 国
                                                          ↑ 切得很碎,各种搜法都能命中

ik_smart(查):中华人民共和国 / 中华 / 人民 / 共和国
                                                          ↑ 切得克制,避免噪音

"存的时候撒网,查的时候收网" ------这个不对称设计很有讲究:

  • 存得细 → 用户用任何切法搜,都可能命中(提高召回率)
  • 查得粗 → 避免查询词被切碎后匹配到一堆无关内容(提高准确率)

这就是 readme 说的"存的时候尽量多存索引"的用意------用存储空间换检索命中率。

一个容易记混的提醒 :ES 的中文分词器只有两个名字------ik_max_word 和 ik_smart。课堂这份配置里 search_analyzer 写成了 ik_max_smart,这个名字在 ES 里不存在------两个分词器的名字很像,改配置时容易串,建议直接查官方文档核对。

三条种子数据

javascript 复制代码
async function seedData() {
    const now = new Date().toISOString();// 当前时间
    const docs = [
        {
            note_title: '杭州西湖半日游',
            note_body: '早上绕湖慢跑,中午吃片儿川,下午在断桥拍照放松。',
            tags: ['旅行', '周末', '杭州'],
            mood: 'relaxed',
            priority: 2,
            create_time: now,
            update_time: now
        },
        {
            note_title: '城市骑行计划',
            note_body: '周六沿江骑行 20 公里,带上水和简易修车工具。',
            tags: ['运动', '骑行'],
            mood: 'energetic',
            priority: 3,
            create_time: now,
            update_time: now
        },
        {
            note_title: '雨天宅家阅读',
            note_body: '下雨天在家看书,整理本周笔记并做晚餐。',
            tags: ['生活', '阅读'],
            mood: 'calm',
            priority: 1,
            create_time: now,
            update_time: now
        }
    ];

三条"旅行日志"------西湖慢跑、城市骑行、雨天阅读。数据设计得很贴近真实场景:有标签、有心情、有优先级、有时间戳。

new Date().toISOString() 生成标准 ISO 时间格式(2026-09-23T10:30:00.000Z)------这正好匹配 mappings 里声明的 type: 'date'。

bulk 批量插入:一个巧妙的数组构造

javascript 复制代码
    const operations =
        docs.flatMap((doc) => [{index: {_index: INDEX_NAME}}, doc])
    // 批量插入
    console.log(operations);
    await client.bulk({ // 批量插入
        operations,
        refresh: true, // 立即刷新索引
    })
}

这是 ES bulk API 的规定格式,值得单独讲。

ES 的批量接口要求的是"动作行 + 数据行"交替出现的扁平数组:

javascript 复制代码
[
  { index: { _index: 'travel-journal' } },   // ← 动作:要写入
  { note_title: '杭州西湖半日游', ... },      // ← 数据:写什么
  { index: { _index: 'travel-journal' } },
  { note_title: '城市骑行计划', ... },
  { index: { _index: 'travel-journal' } },
  { note_title: '雨天宅家阅读', ... },
]

flatMap 干的就是这个------一条数据变两行。 它比 map 多一步"拍平":

css 复制代码
map:     [{doc1}] → [[动作1, doc1], [动作2, doc2], [动作3, doc3]]   ← 嵌套数组
flatMap: [{doc1}] → [动作1, doc1, 动作2, doc2, 动作3, doc3]         ← 拍平成一维

一次 bulk 请求,三条数据一次写完------比循环调三次单条插入快得多,也少两次网络往返。

refresh: true 是另一个关键参数:

javascript 复制代码
refresh: true, // 立即刷新索引

ES 的写入默认是"准实时"的------数据先写到内存缓冲,隔一段时间(默认 1 秒)才真正"可见"。加 refresh: true 强制立即刷新,保证脚本跑完马上就能搜到数据。

开发调试时这个参数很有用(不然你会以为"插入失败了");但生产环境批量写入时要慎用------频繁 refresh 会严重影响性能。


三、CRUD:ES 的操作跟想的一样

operate.mjs 演示了四个基本操作。

查:get 一个文档

javascript 复制代码
async function getDocument(docId) {
    const res = await client.get({
        index: INDEX_NAME,
        id: docId,
    });
    console.log('查询结果', res._source);
}

res._source ------ ES 里存的那份原始 JSON 就叫 _source。 这个名字很形象:ES 会给数据加一堆元信息(索引、版本号、分数等),_source 是"你当初存进去的那份原始数据"。

增:index 一个文档

javascript 复制代码
async function createDocument() {
    const now = new Date().toISOString();
    const res = await client.index({
        index: INDEX_NAME,
        document: {
            note_title: '夜跑复盘',
            note_body: '昨晚夜跑 10 公里,感觉很累。',
            tags: ['运动', '夜跑'],
            mood: 'focused',
            priority: 2,
            create_time: now,
            update_time: now
        }
    });
    console.log(`新增 成功,ID=`, res._id);
    return res._id;
}

ES 里没有 insert,用的是 index。

这个命名差异很有意思------index 的语义是"建立索引条目",它同时覆盖了"新增"和"覆盖"两种情况:给定 ID 就是替换,不给 ID 就新建。

res._id 是 ES 自动生成的 ID。代码里注释掉的那行长这样:

javascript 复制代码
// const docId = 'R3bgvaABnL1oJIcDzsG6'

这就是 ES 自动 ID 的真实长相 ------一长串看似随机的字符(内部是 URL-safe 的 base64 编码 UUID)。它唯一,但不携带任何语义,这正是我们上一篇讲"手动生成可读 ID"时对比的那种反面例子。

改:update 局部字段

javascript 复制代码
async function updateDocument(docId) {
    const res = await client.update({
        index: INDEX_NAME,
        id: docId,
        doc: {
            note_body:'昨晚夜跑 6 公里,状态不错,拉伸后恢复很快',
            tags: ['运动', '夜跑', '训练'],
            update_time: new Date().toISOString(),
        },
        refresh: true
    });
    console.log('更新成功', res);
}

注意 doc 里只写了三个字段------这是局部更新(partial update)。 没写的字段(note_title、mood、priority)保持原样。

对比一下前后数据的变化:

字段 原值 新值
note_body 昨晚夜跑 10 公里,感觉很累。 昨晚夜跑 6 公里,状态不错,拉伸后恢复很快
tags '运动', '夜跑' '运动', '夜跑', **'训练'**
update_time 创建时间 当前时间

这次修改很有意思------跑量从 10 公里改成了 6 公里。 大概是跑完一查,发现记错了。用一个"更正记录"的场景来演示 update,比用"改个标题"自然得多。

搜:match 查询 + ik_smart

javascript 复制代码
async function searchDocument() {
    const res = await client.search({
        index: INDEX_NAME,
        query: {
            match: {
                note_body: {
                    query: '夜跑',
                    analyzer: 'ik_smart',
                },
            }
        }
    });
    console.log(res);
    const rows = res.hits.hits.map(item => ({
        id: item._id,
        ...item._source
    }));
    console.log('查询结果', rows);
}

这就是查询时的分词器------analyzer: 'ik_smart'。 前面 mappings 里那个 search_analyzer 是"这个字段默认用什么查询分词器",这里则是在具体查询里显式指定一次。

查询结构层数有点多,拆开看:

lua 复制代码
client.search
├── index: 'travel-journal'        在哪个索引里搜
└── query
    └── match                      match 查询(会分词)
        └── note_body              搜哪个字段
            ├── query: '夜跑'       搜什么
            └── analyzer: ik_smart  用哪个分词器

为什么用 match 而不是 term?

查询类型 行为 适合
match 先分词,再匹配 搜 text 字段(全文本)
term 不分词,精确匹配 搜 keyword 字段(标签、枚举)

你要搜"夜跑"这两个字出现在正文里,就得用 match------因为它会先把查询词分词,再跟正文的分词结果比对。

结果放在 res.hits.hits 里------两层 hits:

css 复制代码
res.hits.total   有多少条命中
res.hits.hits    命中的文档数组
    └── [0]._id       文档 ID
        [0]._score    相关度分数 ← BM25 打分!
        [0]._source   原始数据

注意 _score ------ES 的全文检索结果天然带相关性分数 ,这个分数用的是 BM25 算法 (readme 里提到的)。这意味着 ES 不只是"筛出含这个词的文档",还会按相关程度排序。

代码把结果整理成扁平对象:

javascript 复制代码
const rows = res.hits.hits.map(item => ({
    id: item._id,
    ...item._source
}));

...item._source 展开原始数据,加上 id------又是一个"展开合并"的用法 (前面在 Python 的 **model_dump() 里见过对称写法)。

一个值得注意的细节

javascript 复制代码
async function main() {
    // const docId = await createDocument();
    // const docId = 'R3bgvaABnL1oJIcDzsG6'
    // console.log(docId);
    // await getDocument(docId);
    await searchDocument();
    // await updateDocument(docId);
}
main()
    .catch(console.error)

增、查、改三个操作全被注释掉了,只保留了搜索。

这不是偷懒------这是调试现场的真实样子。 先把数据准备好(跑一遍 create.mjs),然后集中验证"检索"这一个动作。用完了就把其他操作注掉,避免每次跑都重复写入数据。

读代码的时候,被注释掉的部分往往比留下的部分信息量更大------它记录了调试过程。


四、两条腿走路:混合检索

ES 学会了,现在回到那个病根------为什么要两个检索器。

readme 有一句极简的公式,值得全文抄下:

"混合检索 = es 关键词搜索 + milvus 相似度检索"

还有一句补充:

"混合检索RAG = Milvus 向量检索(语义,缺点是不准确)+ ES 文本检索(关键词,缺点语义不够丰富)"

注意括号里的"缺点"------两边都有短板,而且是互补的短板。

检索器 强在哪 弱在哪
Milvus(向量) 语义理解------"马铃薯"能搜到"土豆" 不精确------高血糖/低血糖混淆
ES(关键词) 精确匹配------差一个字都不算命中 不聪明------换个说法就搜不到

readme 用三组词把这层关系画了出来:

diff 复制代码
=== 关键词 es 土豆 马铃薯 跨语言 tomato
+
like embedding 语义相近

同一个概念的两条检索路径:

arduino 复制代码
用户搜"土豆"

ES 关键词路径:  字面匹配 → 命中所有含"土豆"的文档
                 ❌ 搜不到只写了"马铃薯"的文档

Milvus 语义路径: 向量相似 → 命中"马铃薯"(语义相近)
                 ✅ 跨词、跨语言都能找到(连 tomato 都有机会)

合起来,才是"既找得全、又找得准"。

readme 那句比喻也很传神:

"Mysql 原始数据的,es 是特种兵(关键词搜索)"

MySQL 是守仓库的(存原始数据),ES 是特种兵(精确打击关键词)。


五、裁判登场:ReRank 重排

两条腿都上了,新问题来了------腿多了,路也乱了。

召回多了反而更糟

readme 把这个矛盾写得很清楚:

"混合检索给到大模型特别多 document,我们需要筛选一下,混合召回的文档做一次重排序(rerank)把最相关、最有用、最能支撑回答的文档筛选出来,再增强 Prompt。"

为什么"召回更多"反而变成问题? readme 列了四条原因:

原因 后果
混合召回带来大量冗余信息 重复、相似的文档堆在一起
大模型上下文窗口有限 塞不下
噪声太多会让模型答非所问、逻辑混乱,幻觉增加 答得反而更差
先过滤,再精简,才能回答准确 结论

第三条是核心------信息多了不一定更好,噪声会干扰判断。 这跟前面"重复文档被误读为强调"是同一个道理:模型对"哪些内容更重要"的判断,会被无关内容稀释。

ReRank 是什么

readme 对 ReRank 模型的描述非常清晰:

"重排模型就是输入用户问题 + 一段文档,输出一个相关度分数。专门来给 RAG 做去噪、筛选,重新排序。体积小,推理快,成本极低。"

它的工作模式:

复制代码
输入:{ 用户问题, 一段文档 }
  ↓
输出:一个分数(0.87)

然后按分数排序,取前 N 条。

几个关键特点:

特点 含义
输入是"问题+文档"成对 能看到两边的交互,比单纯的向量相似度更准
输出一个分数 结果是可比较、可排序的
体积小、推理快 100 条文档打 100 次分,也扛得住
成本极低 适合放在召回后做过滤

为什么它比向量相似度更准? 因为向量检索是"提前算好、之后只比对距离"------它不知道你今天问的是什么 。而 ReRank 是"现场把问题和文档放一起过一遍模型"------能捕捉到上下文相关的细微匹配。

readme 把这个分工总结得很到位:

模型类型 干什么
AIGC 模型 生成内容
embedding 模型 把文本转成向量
ReRank 模型 输入问题+文档,输出相关度分数
jev 模型 100 倍速度、1/100 价格(课堂笔记里的另一类效率优化模型)

注意 ES 那边的分工也顺带被点明了 ------readme 说 ES 全文检索的排序"BM25 给查询出来的文档打分"。

所以一条完整的检索链上,其实有三层打分:

层次 谁在打分 打什么分
第一层 ES 的 BM25 关键词相关度
第一层 Milvus 的 COSINE 语义相似度
第二层 ReRank 模型 问题+文档的精细相关度

前两层是"粗筛"(快但糙),第三层是"精排"(慢但准)。 这就是搜索引擎领域经典的"召回-排序"两阶段架构。


六、完整的混合检索流水线

readme 把整个流程写成了清晰的步骤:

erlang 复制代码
用户输入 query
  ↓
拆分不同角度,三个子问题
  ↓
多路召回
  ├── 向量检索(Milvus)
  └── 关键词检索(ES)
  ↓
结果合并 → 去重
  ↓
重排序、相关性打分(ReRank)
  ├── 最相关的排前面
  └── 取 N 条
  ↓
把高质量文档送入大模型
  ↓
生成最终回答

还有一个更简练的版本:

"全新 Agentic RAG 流程检索逻辑:ES 关键词召回一批;Milvus 向量召回一批;合并去重;丢给 ReRank 模型打分排序;只取前几条"

以及那个更完整的版本(带查询改写):

"用户问题 → 大模型改写 → 生成 3-5 个不同角度问题(召回更多文档)→ 每个问题都去 ES + Milvus 检索 → 合并 → 去重 → 重排模型 ReRank 排序 → 增强 Prompt → 最终输出"

注意流程里那个"改写"环节------它跟前面那篇的"子问题拆解"是同一个思路的两种用法:

手段 目的
多跳拆解(第 2 篇) 拆成有顺序的子问题,一步步推理
查询改写(本篇) 拆成不同角度的问题,扩大召回面

readme 把这两个目标总结成了两个问题:

"rag 中怎么提升召回率?改写 3-5 个不同角度问题,更多的文档。"

"rag 中怎么提升召回质量?es + milvus,ReRank。"

召回率 = 找得全不全;召回质量 = 找得准不准。

  • 想找得全 → 多角度改写问题、多路召回
  • 想找得准 → 关键词+语义双路 + ReRank 精排

这是两个不同的问题,需要两套不同的手段。 混在一起谈"怎么提升检索效果",往往就说不清了。

readme 里还有一句看似不起眼但很重要的备注:

"类似的内容存入 es 和 Milvus"

同一份内容,要同时写进两个库。 因为一个管关键词、一个管语义------这是"混合检索"的前提条件。 数据管道的设计要从此多一条分支。


七、三次迭代,一张完整的图

现在把四篇文章串起来,看 RAG 这个系统是怎么长起来的:

版本 结构 能力
朴素 RAG retrieve → generate 能查能答
+ 路由 `route → (直答 检索)`
+ 多跳 拆解 → 循环检索 → 决策 会拆题、会循环
+ 评估 评估 → 联网 → 再评估 知道自己不知道
+ 混合检索 ES + Milvus → 去重 → ReRank 又全又准

readme 最后那句总结,现在读起来完整了:

"Agentic RAG,基于 LangGraph 实现大模型决策的 RAG 闭环系统。"

五个要点,对应五篇文章:

diff 复制代码
- RAG 线性                          ← 第 1 篇(起点)
- 所有问题都走检索,区分出简单问题,省资源   ← 第 2 篇(路由)
- 多步检索的复杂问题 拆分问题           ← 第 2 篇(拆解)
- 评估机制                          ← 第 3 篇(评估+兜底)
- 网络搜索兜底                       ← 第 3 篇(联网)
- 多路检索 重排 提升了召回率和质量       ← 第 4 篇(混合检索+ReRank)

readme 末尾还列了几个后续方向,都是这个系统继续长大的路:

方向 是什么
PSQL = mysql + milvus 关系数据 + 向量共存的方案
LangSmith 全链路 Agent 观测
DeepAgents 开箱即用的 skill、上下文压缩等中间件
Redis 后端缓存 Agent 短期记忆
LangFuse 部署 Agent

八、这一篇的三个结论

结论一:没有万能的检索器。

向量检索擅长"懂你意思",但栽在"一字之差";关键词检索擅长"精确命中",但栽在"换个说法"。专业场景里两者缺一不可------这就是 readme 说"提升专业场景准确率"必须混合检索的原因。

结论二:召回和排序是两件事。

混合召回阶段追求"宁可多找"(召回率),ReRank 阶段追求"只留最准"(召回质量)。分开设计、各司其职,比试图用一个检索器同时满足两个目标现实得多。

结论三:ES 的 mappings 是"提前决定未来能不能搜到"。

text 还是 keyword、用 ik_max_word 还是别的分词器------这些在建索引时就定死了。 上线后再想改,往往要重建索引、重新灌数据。

建 mappings 时多想十分钟"以后会怎么搜",能省下上线后十个小时的返工。


PS:四篇写完,回看整个系列,最有趣的是那个"高血糖 vs 低血糖"的例子------它朴素到不像个技术问题,但它精准地把向量检索的边界划了出来。所有技术方案都有它的"高血糖时刻":看起来无所不能,直到遇到一个一字之差的case。承认边界、补上短板,比吹嘘一个"全能方案"实用得多。

相关推荐
YIAN1 小时前
从跨域代理到 WebSocket:梳理前端实时通信的底层逻辑与完整实践
前端·websocket
一曲终散1 小时前
创建 SVG 图标预览页面:从零实现到解决 CORS 问题
前端
静默回滚1 小时前
iPhone照片电脑上打不开:HEIC解码从原理到WASM
前端
dsyyyyy11012 小时前
Vue 3 Watch 监视完全指南
前端·javascript·vue.js
航飞光电市场经理2 小时前
人员定位系统“全栈自研”技术解析:从射频前端到定位引擎的架构拆解
前端·架构
Apifox2 小时前
Apifox 9 月更新|CLI 能力升级、GitLab 私有化部署接入与产品体验优化
前端·后端·测试
deli0072 小时前
随机乱跳为什么能画出完美三角形:混沌游戏分形实验室
前端
Gizzap_Tech2 小时前
国内官网增加英文版:URL、语言切换与 hreflang 怎么配置?
前端
剪刀石头布啊2 小时前
泛洪DFS、BFS
前端