当前位置: 首页 > news >正文

《数据密集型应用系统设计2》--OLTP/OLAP/全文搜索的数据存储与查询

这篇文章是数据密集型应用系统设计2读书笔记的一部分。
目前包含的文章有:

  • 数据的存储与查询(本文)
  • 数据复制与数据分片
  • 事务一致性和隔离性

数据存储和查询

数据库的两个基本功能是1. 存用户指定的数据,2.快速地把之前存的数据读出来返回给用户。因此当我们了解了底层的数据存储,也能更好的进行数据库选型。本章主要分为三大块场景:

  • OLTP:主要服务于在线应用,要求低延迟地进行点查询/修改,存储结构主要有基于日志的LSMTree、基于树结构的BTree和内存型数据库,它们在读、写和持久化方面各有优劣;
  • OLAP:主要服务于分析场景,要求高效地扫描大量行记录,文件格式以列存为主,计算也通常有专门的执行计划,将计算过程分发到多个计算节点上执行;
  • 多维索引主要进行高效的地理位置搜索和文本搜索

OLTP数据库索引

LSM-Tree

日志存储:最方便写入的一种存储就是日志存储,每次都在存储文件的末尾,追加最新的修改。但是这种方式很不适合读取数据,而且很容易造成冗余的存储。对于冗余的日志(比如同一行数据多次改变,实际上只有最后一次代表了最新的状态),我们可以通过压缩日志来实现,但是它对读性能的提升有限。因此我们可以考虑构建索引。由于数据插入/修改的位置是随机的,所以我们只能使用哈希索引。

Hash索引:hash索引可以用O1的时间找到对应的存储位置,但是它也有个致命伤:它需要将整个hash表load到内存里才能发挥作用。这也是一般的OLTP数据库不使用hash索引的原因:通常数据库的key没法全部放在内存里,假如只读取一部分索引,没找到指定的数据的话,又要接着读下一块索引,因此最差情况下需要把全部索引都加载到内存中才能找到查询的数据,平均(即数学期望)需要加载一半的索引,因此查询效率不高。而最常使用的是排好序的索引,使用二分法可以把读取的数据量下降到logn,还能方便地进行范围查询,更别提BTree这种树类索引通常是分层的(那就不仅是二分了,而是K分,K是树的子节点数目),对于大型SQL数据库,只需读取常数次(通常小于5次)就可以找到查询的数据。

排序索引固然方便查找,可是写入的性能很差,因为每次写入都需要找到插入的位置,然后还不一定有可用的空间来插入一条记录。我们就陷入了一个既要又要的境地:怎么设计索引,能让写入和读取的综合性能比较好呢?著名的Log-Structed Merge-Tree(LSM-Tree)算法就是为此而生的。它在Google的BigTable论文中首次亮相,并且被HBase等多种开源引擎使用。它主要由磁盘上的SSTable和内存里的memTable组成。读写算法大致如下:

  1. 当需要写入数据库时,会先写入内存中一个排序的哈希表(我们知道树是可以用来做哈希表的)。
  2. 当内存表过大时,就会将其写入磁盘,成为一个新的SSTable文件,这样内存表就可以被清空
  3. 当读取数据时,会优先读取内存哈希表,再读取最新的SSTable,次新的SSTable,直到读取到需要查找的数据
  4. 定期进行SSTable的压缩任务,将多个SSTable合并、压缩成为一个大的SSTable。由于各个文件都是排序的,所以这里执行的是合并排序,速度还是比较快的
布隆过滤器

可以看到,LSM树在查找一个不存在的数据时,需要遍历所有SSTable,性能很差。因此引入布隆过滤器来筛选那些必定不存在的数据。

布隆过滤器通常被用于检查数据不存在。它可以用于优化点查询,减少一些昂贵的步骤,比方说LSM树中需要遍历多个log文件,比方说BTree需要查找多层等,比方说需要调用下游等。哈希索引一般不太需要布隆过滤器,因为哈希索引本身就可以快速定位一个数据是否存在。

BTree

是大部分关系型数据库的存储数据结构,可以参考我MySQL相关的文章,这里不再赘述。

BTree vs LSM Tree

从原理上看,BTree的写入是随机写入,LSMTree则是日志追加写入,因此看起来前者适合读多写少的场景,而后者适合写多读少的场景。但它们也在工程实现上进行了改良,比如LSMTree用布隆过滤器来减少读取不必要的数据段,BTree也有临时缓冲区来减少写回的次数。因此其性能取决于具体的实现和工作负载。

连续和随机读取性能

二者的随机读取性能比较接近,虽然BTree使用少量几次页读取就能读到数据,但是LSMTree用布隆过滤器减少不必要读取的数据段。

对于连续读取负载,BTree的数据是全局有序的,因此大部分时候只用连续读取数据页即可;LSMTree的数据是局部有序,因此范围查找必须读取所有数据段,判断是否有合适的数据,而布隆过滤器并不能加速范围查询(哈希表的范围查询和点查询是一样的)。因此范围查询的负载上,BTree胜利。

连续和随机写入性能

不论是HDD还是SSD,连续写入通常都比随机写入快。当负载进行随机写入时,日志追加的方式肯定获胜,因为日志将随机写入转化成了顺序写入,可以获得更高的吞吐量。

为什么SSD的随机写入还是远远慢于HDD?其中一个重要原因是因为写入时的垃圾回收会占据很大一部分性能。通常SSD一次最小能写4KB的块,但是擦除的大小可能是512KB。在写入数据时,驱动器的垃圾回收功能需要找到一块足够的空间来存放数据,可能导致一些数据搬运、压缩过程;而在连续写入负载中,通常文件被删除时会空闲出大量连续的块,可以直接使用,无需搬运数据。因此随机写入负载会比连续写入负载更消耗磁盘的写入性能。

写放大

每种存储引擎在真正写入变更的数据时,通常要进行多次写入。比如不论BTree还是LSMTree,出于持久化目的首先要先把修改写到日志里,然后再修改对应的索引结构,还可能需要进行后续的压缩步骤。

写放大系数越大,意味着每次写入的的实际消耗越大,因此在相同硬件条件下,写放大系数越小的吞吐量越大。

总体来说LSMTree每次写入需要进行的磁盘操作少于BTree,因为LSMTree通常无需将页写回,而是一次性写一个数据段。因此这一角度也可以说明LSMTree更适合写入负载重的场景。

磁盘碎片化

BTree以页的方式组织数据,虽然分配数据时的额外开销更小,但是存储的碎片化严重,也可能由于存储空间填不满一个磁盘页而造成空间浪费。

二级索引

二级索引,也是我们通常说的“加索引”里“索引”这一名词的官方表达。通常二级索引是用于加速查询的,而且可以不必是唯一的。

存储引擎可以与索引存储在一起,也可以只在索引里存储指向数据区的指针。具体来说有以下三种情况

  1. 索引页存储数据,通常也称为聚集索引:比如MySQL InnoDB引擎的主索引,叶子节点也直接存储数据。
  2. 索引页存储指向数据区的指针:具体来说有两种情况,一种是有单独数据区(也成为堆区)的情况,不论主索引还是二级索引都存储指向堆区的指针,堆区可以利用顺序写入来优化写入性能;另一种是主索引采用聚集索引的情况,则可以存储主键id,比如InnoDB二级索引的实现方式。
  3. 二者的混合,也成为覆盖(cover)索引:可以在二级索引页存储被索引的列的值,这样假如只需查找单列时,可以避免去数据区或者聚集索引中拿数据,大大减少读取二级索引的IO次数。缺点是加剧了写放大问题,每次写入都需要更新二级索引里的值

再说一点“利用堆区存储数据”的优劣。由于当更新的值所需空间不会大于原来的空间时,可以直接进行原地替换,无需修改数据区指针。但是当更新的值比原来的空间大(比方说替换成了一个更大的字符串),那就需要写入到堆区的另一位置,并更新所有索引里的指针。

内存数据库

另一种OLTP中常见的数据库是内存型数据库,或者说叫“缓存”,比如常见的MemCache,Redis等。它们的特点是

  1. 存储的数据量相对较小,一定可以完全放在内存中
  2. 由于无需过分考虑持久化存储问题,可以支持复杂数据结构,比如Redis的排序集合

内存数据库并不意味着完全不和磁盘进行交互,出于持久化和快速恢复的需求,内存数据库也需要向磁盘写入持久化数据。而且对于通数据库来说,当内存足够大时也可以不和磁盘进行交互,因为操作系统有缓存,数据库可以完全从缓存中读取数据。所以内存型数据库和其它数据库的差别在于,所有数据都放在内存中,读请求一定只访问内存;访问磁盘的频率并非其根本差别。

有的内存数据库持久化等级很高,因此会使用同步追加日志或同步到副本或专用硬件(比如带电池的内存)的方式来保证不丢失数据;有的内存数据库则容忍数据丢失,因此可以异步写入来提高性能。

内存数据库的高性能来源,除了主要在内存中处理数据之外,更重要的是它们无需处理繁杂的数据结构转换逻辑,所有更改基本都以日志的方式追加写入,不用将树、哈希表、跳表等结构进行序列化,或者即使要进行序列化,也只用进行简单的序列化。

OLAP数据存取

在线事务场景通常注重低延迟的点查询,而数据分析场景则注重少量维度分析、聚合等计算,因此二者的数据存储结构差别很大。

OLAP分析组件

我记得在学习OLTP数据库时,通常只用看看MySQL就够了,虽然也有SQL Server、Oracle等数据库产品,每个数据库产品也都有各自的特点,比如SQL方言、索引优化、数据存储格式等,但它们都属于某个数据库产品的范畴,而且一般也都是私有的实现。但在OLAP,或者说大数据计算的世界,可选组件的数量和需要了解的概念就爆炸了。从数仓、数据湖等OLAP系统,到Spark这种计算引擎,到Parquet这种存储格式……每种组件还都有名有姓,看起来想搭建一个可用的数据OLAP平台并不像安装一个MySQL这么简单。

为什么OLAP组件的分工更细呢?我觉得这是因为OLTP只需处理固定的负载(SQL)并提供快速响应,而OLAP系统的数据源通常很复杂,可能来源于SQL数据库、日志、事件流等;而不同的数据源也需要不同的计算方式,使用不同的计算引擎;用户对数据的时效性要求也不一样,有的希望使用简单的逻辑但计算的延迟很低,而有的则希望对历史数据进行详尽的分析。这就使得很难有一个大而全的OLAP系统能将适应所有负载,再加上现在开源软件的发展很快,每一块都有几款开源软件可供选择。所以选型工作就从OLTP世界的数据库选型,到了OLAP世界对每个组件都要选型。

目前主要有以下这些组件

  • 文件格式:底层文件格式,如parquet,avro,csv,json等,通常负责存储结构相近的或者有关联的数据,是数据真正存储在磁盘上的物理数据。这些文件里虽然包含丰富的数据,但是由于没有metadata,没有人知道这些数据从哪里来、具体代表什么含义
  • 表格式:存储着与“表”相关的metadata和数据,经过正确的程序可以构建出类似SQL中表的概念。表可以提供文件格式中无法记录的额外metadata,提供历史版本管理、行记录插入删除等功能。
  • 数据目录:存储着表的相关信息,比如有什么表,表格式是什么,表数据在哪,表的描述等。可以认为它将零散的表都集中到一处,便于管理和发现
  • 计算引擎:对于结构化数据,通常提供高效的内部格式来操作数据,可以与其它插件集成来从各种数据源中读取数据并处理,比方说可以与数据目录交互来读取某个指定的表,也可以直接读取表格式、文件格式等

除此之外,还有一些数据源接入组件(从各种各样的数据源获取数据并转换为计算引擎可计算的格式)等,也都是OLAP的组件之一。

文件格式:列存

行存指的是每一行的完整数据按行序写入文件,列存指的是将每一列的数据存放在一起。举个例子,假如有两行数据中国,iPhone,老年中国,Android,少年,行存的写入结果就是中国,iPhone,老年;中国,Android,少年,列存的写入结果就是中国,中国;iPhone,Android;老年,少年。其中逗号相连的数据必须连续按序写入;分号相隔的数据可以分开存储、不按顺序存储,也不影响读取的正确性。

Tips:从列存的存储结构可以看到,每一列的所存行数据的行号顺序必须是一样的,这样才可以保证当取出第x行数据时,只要分别从每一列取出第x个数据拼在一起即可。

在分析场景中,通常进行的操作是,按照一些维度筛选出数据,然后计算一些指标。比如产品团队经常要在意的指标DAU(daily active user,日活跃用户量),就指的是当天使用过APP的用户数量。而且通常要将DAU按照多个维度进行细粒度分析,比如产品团队如果发现“中国区域使用iPhone老年用户的DAU”下降了,那可能是最近上线的某些功能没有考虑到他们的需求等。分析场景中,通常数据源的列数非常多(至少几十,几百也很常见),但是分析程序关心的列通常很少;而且需要计算的行数通常很多,比如百万行。将各列的数据拆开分别存储,可以方便地过滤掉不需要的列,减少需要读取的数据量。

数据的组织方式

由于OLAP场景的数据量很大,需要分布式计算,为了减少冲突增加并行度,列存格式的数据组织方式通常是这样:

  1. 行组(RowGroup),每个行组保存特定行的完整数据,比方说从1-1000行的数据。每个分区内部通常有多个行组
  2. 列数据:某个列的,包含行组内所有行的数据。行组内的每个列的列数据都按照相同的顺序存储,确保相同位置存储的是同一行的数据,这样取出相应位置的数据时,就能恢复成完整的一行数据

因此可以看到,相比于行存格式只能剪枝无关的行数据(即按照排序键进行剪枝),列存格式既能剪枝无关的行,也能剪枝无关的列。因此在剪枝无关数据、减少读取数据量这一点上,列存格式完胜,这也是列存格式最大的优势。

Tips:我们通常会看到分区(partition)的概念,比如Spark就可以指定数据的分区数量,甚至还能指定分区内分桶(bucket)的数量。分区和分桶通常是计算引擎引入的概念,为了将一个大数据集进一步划分为小文件,便于做谓词下推等优化,但它们与列存格式的定义无关。

排序(sort)和分区(partition)

我们知道OLTP数据库中可以有很多二级索引,但是OLAP一般做不到。这是因为OLAP通常处理的数据量巨大(而不是一两行),而且计算都是分布式执行的,在分布式计算中维护一个全局二级索引的开销远远高于它能带来的增益。实际上在OLAP文件存储格式中,能充当索引的基本只有排序键,所以类比MySQL数据库,OLAP存储格式可以看作是只支持主键索引的数据库。因此排序键的选择很重要,通常选择最常使用的where条件里的第一个维度。

另一个和排序键有点类似的概念是分区键。分区指的是一个数据集可以按照某个维度(即列名)切分为多个文件,每个文件叫做一个分区,有点像分布式数据库中的“分片(shading)”概念:

  1. 当需要读取的数据范围筛选条件包含分区键时,计算引擎可以只读取包含所需数据的这部分文件,减少读取的数据量
  2. 计算引擎可以分工,让每个节点只读取一部分文件,增加并行度

分区方式可以是范围分区,也可以是哈希分区。

分区和排序是可以相互配合工作的,比如即使同一分区内,每个列也可能存储为多个数据段,每个数据段包含一定行数的数据,这样当按照排序键筛选数据时,可以更好地过滤不需要的数据段。

当使用范围分区,且排序键和分区键的前缀相同时,计算引擎可以工作得更好,因为这意味着数据是按排序键全局排序的,剪枝机制既可以直接排除不需要的分区文件,也可以排除每个分区中不需要的数据段;反之,只能保证分区内的数据是按排序键排序的,但是多个分区的数据并不会排序。举个例子,假如非要按照时间戳分区但是按用户ID排序,那实际数据的组织可能是第一个分区保存了第一个小时的用户数据,第二个分区保存了第二个小时的用户数据,两个分区内都会有很多重叠的用户数据,虽然查询指定用户id范围的数据也会利用上部分排序索引(比如只需读取每个分区内的一小部分数据),但是如果需要频繁按照用户id查询数据的话,最好按照用户id分区且排序,因为这样既可以保证只读取所需分区,还可以保证只读取分区内所需的数据段。

写入逻辑

将行数据计算出来后写入列存格式的缓冲区,积累够一定行数之后一次性写入文件。

列存的优劣

优点:

  1. 减少读取的数据量:列存可以只读取所需列的数据,而如果使用行存,即使只需要10%的列,每次读出的也是一整行,也就是不管读取多少列,都需要从头到尾读完完整的数据。
  2. 更好的压缩比:不同数据存储格式的优劣对比中,压缩比也是很重要的指标。更好的压缩比意味着可以用更小的空间存储相同的数据量。将相同的列存储在一起,它们更容易有相似的值,因此也更方便进行压缩,尤其是列值有限的情况下(比如性别、国籍、操作系统等)

列存也不是万能的,比如它就不适合OLTP场景,OLTP需要快速定位到某一行的位置,假如使用列存,计算引擎就得读取每个列的存储文件,根据行标定位到每一列的值,最后将其组装成一行;而对于行存格式来说,只需按照主键定位到存有该行的文件并读取指定偏移量的数据即可取出整行。也就是在OLTP场景中,行存点查询的执行效率基本是列存的N倍,N指的是查询的列的数量。

执行查询语句

由于OLAP计算量通常很大,而且逻辑也不仅限于SQL,因此通常会将计算先划分为多个执行操作(比如过滤、投影、JOIN等),然后将编排执行计划,最后将执行操作分发到不同的机器上并行执行。

执行引擎通常可以进行如下的一些优化来提高性能:

  1. 谓词下推:对比数据文件中的metadata和条件语句的匹配程度,来避免读取不包含目标数据的文件
  2. 选取合适的节点执行计算:比如让存有数据的节点执行计算,减少网络传输开销,这通常在第一代Hadoop引擎中比较常见。目前计算与存储节点通常是分离的,因此也不存在让存储节点执行计算的说法,但是可以将计算任务分配到同一数据中心或者机架上的计算节点,它们之间的网络传输速率通常很快,可以提高性能
  3. 执行顺序:比如优先执行where语句减少需要传输的数据量,再进行其它操作

通常计算引擎会生成逻辑计算图,并且根据节点的特点来应用以上的优化方法。

在执行计算逻辑时,由于需要扫描巨大的数据量,通常要避免一行一行解释执行计算逻辑。通常有如下两种优化方式:

  • 即时编译:将计算程序通过类似JIT的技术编译为机器代码
  • 解释执行:预先定义一系列的算子,并且每个算子都已经编译为机器码。解释执行时,解释程序将多行数据送入算子批处理计算。相当于通过提高每个算子的处理量来减少解释执行控制流带来的开销

预计算视图与维度

通常视图(view)只在读取时才会被计算。假如一个视图会频繁被读取,那可以将其结果缓存起来,这样之后读取时就不用重复计算了。

与之相同,假如一些维度的聚合结果查看得也很多,那也可以预先计算出各种维度组合的结果,可以看作是用空间换时间。

预计算(或者缓存)的缺点是存储的数据量会增加,不过对于预计算聚合来说通常聚合后的数据远远小于源数据,因此也可以容忍;另外就是当源数据修改时需要相应地更新这些预计算的结果。

多维索引与全文搜索

多维索引

不论是上面介绍OLTP还是OLAP的存储结构,都没法高效支持多维索引查询——你可能会说,OLAP只有主键索引确实没法多维索引,但OLTP不是有联合索引吗?

拿我们熟悉的经纬度信息为例,假如我们希望查询一个经纬度矩形内所有的餐馆,不管你如何建立BTree索引——简单起见,我们经度在前,纬度在后——也只能做到先按照索引找到经度范围内的所有餐馆,再遍历这些餐馆,把纬度合适的筛选出来。因为在BTree里数据会这么组织:(1.0, 0), (1.0, 0.1), (2.0, 0), (2.0, 0.1), (2.0, 0.5), (2.0, 1.0), ...,只有联合索引的首字段会按序排序,从第二个字段开始就是不停地从最小值到最大值循环,忽大忽小。

有一些简单的方式来加强增强多维搜索,比如zOrder,但在一些情况下容易出现两点很远,但距离很近的情况。

可以使用特别的空间索引,比如H3库,R-Tree等,基本都是将二维空间划分为有限数量的多边形。这样当查询时,可以只查询特定几个多边形内的点集。

全文搜索

全文搜索工具可以用于搜索引擎中,找到包含指定关键字的文本。除了为关键字与文本建立索引之外,全文搜索还包含一系列预处理步骤,比如将文本分割为关键字、去除无用的语气词/停止词、统一词语形式、匹配同义词等。

在预处理得到关键字之后,通常会使用倒排索引(inverted index)存储关键字到原文的索引。因此在查询时,就可以分别查询具有每个关键字的文章,然后用逻辑运算得到最终的搜索结果,并且计算相关度。

如果将一个词在哪些文章中出现看成一个存有位图的维度,那全文索引可以看成是多维索引的一种,只不过解决的是多维位图数据的联合查询。

常见的全文搜索引擎有大名鼎鼎的ElasticSearch,它底层使用Lucene全文索引引擎。

词嵌入(embedding)

全文搜索可以算是较为传统的搜索方式,随着机器学习、AI的兴起,语义(semantic)搜索在搜索文本中也很常用。词嵌入是语义搜索中最常见的一种技术。与搜索准确的关键字并对关键字建立索引不同,语义搜索通常是对文字段落的语义建立索引。大概包含以下两个步骤:

  1. 将文章按照某种方式划分为多个段落,比如按照换行符、字符数量、语句数量等;
  2. 将段落文字用自然语言模型编码(最常见的模型有text-embedding-ada-002)成一个向量,比如说是1536维
  3. 将向量和段落id存入向量数据库

查询时,也将用户的问题用同样的模型编码为向量,然后在向量数据库中查询与之最相近(比如用余弦距离)的几个段落id。

词嵌入在AI搜索中被广泛使用,比如RAG(Retrieval-augmented generation)搜索中,最常见的方法之一就是先找出最匹配的N个段落,然后让大模型总结这N个段落得到答案。

最后的一个问题就是如何为向量建立索引了。其中最常见的两种技术是

  1. 不做索引:检索时遍历所有向量计算距离,找出最接近的一些向量。最精确,但是计算复杂度是O(N)
  2. Inverted file (IVF):大致思想是将向量空间划分为不同的区域,搜索时只查找与目标向量最近的几个区域,然后再一一比较这些区域内向量的距离。它会有边界问题,即相近的向量不一定属于同一区域。理论上来说当分区数目确定时,它的计算复杂度也是O(N),但是可以将搜索的向量数量下降多个数量级(取决于分了多少区域以及区域是否均匀)
  3. Hierarchical Navigable Small World(HNSW):它为完整的向量空间创建了多层缩略图,比如完整向量共有1000个,缩略图有两层,那可以中间层有100个向量,顶层有10个向量。查找时,先从顶层的10个点里找到最接近目标向量的点;然后在中层,沿着这个点的边找到最接近目标向量的点;再前往下一层,沿着上一层的中间结果点的边找到最接近目标向量的点……它的思想类似于分层地图,当要在世界地图里找到上海的某个地方,我们要顺着这个顺序找:中国-上海-X区-Y路。HNSW的结果也是近似的,因为沿着一个点的边贪婪地访问最接近目标点的方向,可能导致局部最优;而且需要调整的参数也比较多,比如顶层点的数量、多少层、每个点和多少个相邻点相连等。

总结

这一章介绍了OLTP、OLAP、多维索引和全文搜索这几块内容。其中OLTP主要服务于在线应用,要求低延迟地进行点查询/修改,存储结构主要有BTree和内存数据库;OLAP主要服务于分析场景,要求高效地扫描大量行记录,文件格式以列存为主,计算也是分布式执行;多维索引主要进行高效的地理位置搜索和文本搜索。

http://www.dtcms.com/a/442056.html

相关文章:

  • 【ROS2学习笔记】RViz 三维可视化
  • 如何实现理想中的人形机器人
  • 【深度学习|学习笔记】神经网络中有哪些损失函数?(一)
  • AP2协议与智能体(Intelligent Agents)和电商支付
  • Upload-labs 文件上传靶场
  • 江苏省网站备案查询系统天津做网站找津坤科技专业
  • 虚幻版Pico大空间VR入门教程 05 —— 原点坐标和项目优化技巧整理
  • AI绘画新境界:多图融合+4K直出
  • 云图书馆平台网站建设方案柴沟堡做网站公司
  • 第67篇:AI+农业:精准种植、智能养殖与病虫害识别
  • GitPuk入门到实战(5) - 如何进行标签管理
  • 特征工程中平衡高频与低频数据的权重分配试错
  • 做网站需要买企业网站icp备案
  • 兰亭妙微QT软件开发经验:跨平台桌面端界面设计的三大要点
  • 大数据工程师认证项目:汽车之家数据分析系统,Hadoop分布式存储+Spark计算引擎
  • 【AI4S】DrugChat:迈向在药物分子图上实现类似ChatGPT的功能
  • 构建基于Hexo、Butterfly、GitHub与Cloudflare的高性能个人博客
  • 自动驾驶中的传感器技术64——Navigation(1)
  • RAG技术全栈指南学习笔记------基于Datawhale all-in-rag开源项目
  • 哪里有免费服务器南京seo域名
  • 网站公众号建设工具中国建筑集团有限公司有几个局
  • K230基础-几种图像处理方式
  • 鸿蒙NEXT网络管理:从“能用”到“智能”的架构演进
  • UE HTML5开发一:构建引擎以及项目发布踩坑
  • DaYe-PhotoStudio-2 v2.0.0 安装教程(64位/AMD64)详细步骤
  • 【计算机视觉】分水岭实现医学诊断
  • SAP HANA2.0数据库升级实录
  • Java-141 深入浅出 MySQL Spring事务失效的常见场景与解决方案详解(3)
  • 多功能集成工具软件,图片音视频处理一体化
  • 大型网络建站公司响应式网站的意义