0d2c543eadf6b8a918b0ce53a0bb61e2.gif

作者 | Nikhil Garg,Navya Mehta 

译者 | 弯月  责编 | 苏宓

出品 | CSDN(ID:CSDNnews)

由于 AI 与机器学习的快速发展,如今向量数据库已无处不在。虽然向量搜索能够支持复杂的 AI 或机器学习应用程序,但这个概念本身并不难。在文本中,我们来一起看看向量数据库的工作原理,并使用不到 200 行 Rust 代码构建一个简单的向量搜索库。

GitHub 完整的代码:https://github.com/fennel-ai/fann/?ref=fennel.ai

本文使用的方法基于流行库 annoy 中使用的一种名叫“Locality Sensitive Hashing”(局部敏感哈希,LSH)的系列算法。本文的目的不是介绍一种新奇的算法或库,而是分享如何编写一个向量搜索。

在写代码之前,首先我们来介绍一下什么是向量搜索。

bfd339bf4f2a6c1eebd68f881e475195.png

向量(又名嵌入)介绍

我们很难使用传统的数据库表示和查询文档、图像、视频等复杂的非结构化数据,尤其是无法查找“相似”数据。那么,YouTube 又是如何选择下一个播放视频的呢?Spotify 又是如何根据当前歌曲自定义播放列表的呢?

2010 年代 AI 的进步(从 Word2Vec 和 GloVe 开始)使我们能够构建这些对象的语义表示,即使用笛卡尔空间中的点表示这些对象。假设一个视频被映射到点 [0.1, -5.1, 7.55],另一个被映射到点 [5.3, -0.1, 2.7]。这些机器学习算法的神奇之处在于,这些点的选择保留了语义信息:两个视频的相似度越高,它们的向量之间的距离越小。

请注意,这些向量(或更专业的叫法是“嵌入”)不一定是三维的,它们可以、而且通常确实位于更高维的空间中(比如 128 维或 750 维)。而且距离也不一定是欧几里得距离,其他形式的距离也可以,比如点积。无论采用哪种形式,重要的是它们之间的距离对应于相似性。

下面,假设我们可以访问所有 YouTube 视频的此类向量。那么我们如何找到与某个视频相似度最高的其他视频呢?很简单。遍历所有视频,计算它们之间的距离,并选择距离最小的视频,这个过程又称之为查找视频的“最近邻居”。这种方法确实有效,除了一个问题,线性O(N)搜索的成本过高。所以,我们需要一种更快的次线性方法来查找视频的最近邻居。这通常是不可能的,必须付出一些代价。

然而,在实际情况下,我们并不需要找到“最近”的视频,只要足够接近就可以了。这就是“近似最近邻”(Approximate Nearest Neighbor,ANN)算法,又称为向量搜索。我们的目标是通过次线性方法找到空间中任何足够近的最近邻。那么,实际如何实现呢?

4f9aed3e0f47aa2033cf294792e71c5b.png

如何找到近似最近邻?

向量搜索算法背后的基本思想都是相同的:通过一些预处理来识别彼此足够接近的点(有点类似于建立索引)。查询时,使用这个“索引”来排除大部分点。只留下少量点,然后再进行线性扫描。

然而,这个简单的想法有很多可以实现的方法。目前有几种最先进的向量搜索算法,例如 HNSW(Hierarchical Navigable Small World,分层的可导航小世界)是一种图,它连接了互相接近的顶点,而且还保存了距离固定入口点的长距离边。此外还有一些开源项目,例如 Facebook 的 FAISS,以及一些高可用向量数据库的 PaaS 产品,例如 Pinecone 和 Weaviate。

在本文中,假设有“N”个指定的点,我们要在这些点上构建一个简化的向量搜索索引,步骤如下:

  1. 随机抽取 2 个向量 A 和 B。

  2. 计算这两个向量之间的中点 C。

  3. 构建一个通过 C 并垂直于连接 A 和 B 的线段的超平面(即更高维度的“线”)。

  4. 根据相对于超平面的位置:“上方”或“下方”,将所有向量分为两组。

  5. 分别对两组向量做以下处理:如果组的大小大于可配置参数“最大节点数”,则在该组之上递归调用此过程,构建子树。否则,使用所有向量(或它们的唯一 ID)构建一个叶节点。

我们将使用这个随机过程来构建一棵树,其中的每个节点都是一个超平面定义,左边的子树是超平面“下方”的所有向量,右边的子树是超平面“上方”的所有向量。向量集不断递归拆分,直到叶节点包含的向量不超过“最大节点数”。下图是包含5个点的示例:

20c3fa42ddcb88ed8dbef17b552528ce.png

图1:利用随机超平面分割空间

我们随机选择向量 A1=(4,2)和 B1=(5,7),二者的中点是 (4.5,4.5),我们构建一条线,要求通过中点且垂直于线 (A1, B1)。这条线 x + 5y=27(图中蓝色的线)给了我们两组向量:一组包含 2 个向量,另一组包含 4 个。假设“最大节点数”设置为 2。那么,第一组向量不需要进一步拆分,而第二组向量需要进一步递归——如图所示,我们又构建了一个红色超平面。对于大型数据集,如此重复拆分可以将超空间拆分为几个不同的区域,如下所示。

b14d93dc871a5f9dc8bc9d3849e9ac87.png

图2:被许多超平面分割的空间(来自 https://t.co/K0Xlht8GwQ,作者:Erik Bernhardsson)

此处的每个区域代表一个叶节点,而且感觉足够接近的点很可能最终会出现在同一个叶节点中。接下来,给定一个查询点,我们可以在对数时间内自上而下遍历树,找到它所属的叶子,然后对叶节点中的所有(数量不多)点进行线性扫描。很明显,这个算法并不是万无一失的,即便是距离非常近的两个点也完全有可能被一个超平面分开,最终导致二者彼此相距很远。但是,这个问题可以通过构建多棵独立的树来解决,这样,如果两个点之间的距离足够近,它们出现在某些树同一个叶节点中的概率就很高。在查询时,我们自上而下遍历所有树,定位相关的叶节点,然后求所有叶节点的所有候选节点的并集,并对所有节点进行线性扫描。

好了,理论的介绍到此为止。下面,我们来编写代码。

首先,我们为 Rust 的 Vector 类型定义一些工具函数,包括求点积、均值、哈希值以及平方 L2 距离。感谢 Rust 的优雅的类型系统,我们可以传递泛型类型参数 N,以在编译时强制索引中的所有向量具有相同的维度。

#[derive(Eq, PartialEq, Hash)]
pub struct HashKey<const N: usize>([u32; N]);

#[derive(Copy, Clone)]
pub struct Vector<const N: usize>(pub [f32; N]);
impl<const N: usize> Vector<N> {
    pub fn subtract_from(&self, vector: &Vector<N>) -> Vector<N> {
        let mapped = self.0.iter().zip(vector.0).map(|(a, b)| b - a);
        let coords: [f32; N] = mapped.collect::<Vec<_>>().try_into().unwrap();
        return Vector(coords);
    }
    pub fn avg(&self, vector: &Vector<N>) -> Vector<N> {
        let mapped = self.0.iter().zip(vector.0).map(|(a, b)| (a + b) / 2.0);
        let coords: [f32; N] = mapped.collect::<Vec<_>>().try_into().unwrap();
        return Vector(coords);
    }
    pub fn dot_product(&self, vector: &Vector<N>) -> f32 {
        let zipped_iter = self.0.iter().zip(vector.0);
        return zipped_iter.map(|(a, b)| a * b).sum::<f32>();
    }
    pub fn to_hashkey(&self) -> HashKey<N> {
        // f32 in Rust doesn't implement hash. We use bytes to dedup. While it
        // can't differentiate ~16M ways NaN is written, it's safe for us
        let bit_iter = self.0.iter().map(|a| a.to_bits());
        let data: [u32; N] = bit_iter.collect::<Vec<_>>().try_into().unwrap();
        return HashKey::<N>(data);
    }
    pub fn sq_euc_dis(&self, vector: &Vector<N>) -> f32 {
        let zipped_iter = self.0.iter().zip(vector.0);
        return zipped_iter.map(|(a, b)| (a - b).powi(2)).sum();
    }
}

构建好这些核心工具函数之后,下面我们来定义超平面:

struct HyperPlane<const N: usize> {
    coefficients: Vector<N>,
    constant: f32,
}

impl<const N: usize> HyperPlane<N> {
    pub fn point_is_above(&self, point: &Vector<N>) -> bool {
        self.coefficients.dot_product(point) + self.constant >= 0.0
    }
}

接下来,我们来生成随机超平面,构建最近邻树的森林。我们应该如何表示树中的点呢?

我们可以直接将 D 维向量存储在叶节点中。但是,如果这个维度(D)非常大,那么会导致内存碎片化(严重影响到性能),而且当多棵树引用同一个向量时,还会造成森林重复保存。因此,我们将向量存储在全局可访问的连续位置中,并在叶节点中保存类型为 usize 的索引(在 64 位系统上仅占用 8 字节,相反如果直接保存四维向量,每个维度的 f32 类型就会占用 4 字节)。以下是用于表示树内部节点和叶节点的数据类型。

enum Node<const N: usize> {
    Inner(Box<InnerNode<N>>),
    Leaf(Box<LeafNode<N>>),
}
struct LeafNode<const N: usize>(Vec<usize>);
struct InnerNode<const N: usize> {
    hyperplane: HyperPlane<N>,
    left_node: Node<N>,
    right_node: Node<N>,
}
pub struct ANNIndex<const N: usize> {
    trees: Vec<Node<N>>,
    ids: Vec<i32>,
    values: Vec<Vector<N>>,
}

那么,我们应该如何找到正确的超平面呢?

我们从索引中随机采样两个,分别对应于向量 A 和 B,计算 n = A - B,并找到 A 和 B 的中点(point_on_plane)。存储超平面使用的结构包含系数(向量 n)和常量(n 和 point_on_plane 的点积),形式为:n(x-x0) = nx - nx0。我们可以求向量和 n 之间的点积,然后减去常数,就可以将向量放在超平面的“上方”或“下方”。由于树中的内部节点包含超平面定义,叶节点包含向量 ID,因此我们可以使用 ADT 对树进行类型检查:

impl<const N: usize> ANNIndex<N> {
    fn build_hyperplane(
        indexes: &Vec<usize>,
        all_vecs: &Vec<Vector<N>>,
    ) -> (HyperPlane<N>, Vec<usize>, Vec<usize>) {
        let sample: Vec<_> = indexes
            .choose_multiple(&mut rand::thread_rng(), 2)
            .collect();
        // cartesian eq for hyperplane n * (x - x_0) = 0
        // n (normal vector) is the coefs x_1 to x_n
        let (a, b) = (*sample[0], *sample[1]);
        let coefficients = all_vecs[a].subtract_from(&all_vecs[b]);
        let point_on_plane = all_vecs[a].avg(&all_vecs[b]);
        let constant = -coefficients.dot_product(&point_on_plane);
        let hyperplane = HyperPlane::<N> {
            coefficients,
            constant,
        };
        let (mut above, mut below) = (vec![], vec![]);
        for &id in indexes.iter() {
            if hyperplane.point_is_above(&all_vecs[id]) {
                above.push(id)
            } else {
                below.push(id)
            };
        }
        return (hyperplane, above, below);
    }
}

接下来,我们定义递归过程,根据索引时的“最大节点数”构建树:

impl<const N: usize> ANNIndex<N> {
    fn build_a_tree(
        max_size: i32,
        indexes: &Vec<usize>,
        all_vecs: &Vec<Vector<N>>,
    ) -> Node<N> {
        if indexes.len() <= (max_size as usize) {
            return Node::Leaf(Box::new(LeafNode::<N>(indexes.clone())));
        }
        let (plane, above, below) = Self::build_hyperplane(indexes, all_vecs);
        let node_above = Self::build_a_tree(max_size, &above, all_vecs);
        let node_below = Self::build_a_tree(max_size, &below, all_vecs);
        return Node::Inner(Box::new(InnerNode::<N> {
            hyperplane: plane,
            left_node: node_below,
            right_node: node_above,
        }));
    }
}

请注意,由于该算法不允许重复,构建两点之间的超平面要求这两个点是唯一的,因此在建立索引之前,我们必须去除向量集中的重复项。

因此整个索引(树的森林)可以这样构建:

impl<const N: usize> ANNIndex<N> {
    fn deduplicate(
        vectors: &Vec<Vector<N>>,
        ids: &Vec<i32>,
        dedup_vectors: &mut Vec<Vector<N>>,
        ids_of_dedup_vectors: &mut Vec<i32>,
    ) {
        let mut hashes_seen = HashSet::new();
        for i in 1..vectors.len() {
            let hash_key = vectors[i].to_hashkey();
            if !hashes_seen.contains(&hash_key) {
                hashes_seen.insert(hash_key);
                dedup_vectors.push(vectors[i]);
                ids_of_dedup_vectors.push(ids[i]);
            }
        }
    }

    pub fn build_index(
        num_trees: i32,
        max_size: i32,
        vecs: &Vec<Vector<N>>,
        vec_ids: &Vec<i32>,
    ) -> ANNIndex<N> {
        let (mut unique_vecs, mut ids) = (vec![], vec![]);
        Self::deduplicate(vecs, vec_ids, &mut unique_vecs, &mut ids);
        // Trees hold an index into the [unique_vecs] list which is not
        // necessarily its id, if duplicates existed
        let all_indexes: Vec<usize> = (0..unique_vecs.len()).collect();
        let trees: Vec<_> = (0..num_trees)
            .map(|_| Self::build_a_tree(max_size, &all_indexes, &unique_vecs))
            .collect();
        return ANNIndex::<N> {
            trees,
            ids,
            values: unique_vecs,
        };
    }
}

f6b1b51908ef226268c940a24ef3f744.png

查询时间

索引建立之后,下一步我们如何使用它搜索某棵树中输入向量的 K 个近似最近邻?我们的超平面存储在非叶节点处,因此我们可以从树的根开始搜索:“这个向量是在这个超平面的上方还是下方?”我们可以用点积计算,复杂度为 O(D)。接下来,我们可以根据响应,递归搜索左子树或右子树,直到找到叶节点。请记住,叶节点中存储的向量数最大为“最大节点数”,这些向量位于输入向量的近似邻域中(它们将落入所有超平面下超空间的同一分区中)。如果这个叶节点的向量索引数超过 K,我们就需要根据到输入向量的 L2 距离,对所有这些向量进行排序,并返回最接近的 K 个向量。

假设我们的索引最后得到了一棵平衡树,对于维度 D、向量数量 N 和最大节点数 M << N,搜索耗费的时间为 O(Dlog(N) + DM + Mlog(M)),最坏的情况下我们需要比较 log(N) 个超平面(即树的高度)才能找到叶节点,每次比较耗费的时间为 O(D) 个点积,计算一个叶节点的所有候选向量的 L2 距离需要 O(DM),最后在 O(Mlog(M)) 时间内对它们进行排序,并返回 前 K 个。

但是,如果我们找到的叶节点少于 K 个向量,会发生什么情况?如果最大节点数太小,或超平面分割不均匀,则会导致某个子树中的向量非常少。为了解决这个问题,我们可以在树搜索中添加一些简单的回溯功能。例如,如果返回的候选向量数量不足,我们可以在内部节点再递归调用一次,访问另一个分枝。代码如下:

1impl<const N: usize> ANNIndex<N> {
 2    fn tree_result(
 3        query: Vector<N>,
 4        n: i32,
 5        tree: &Node<N>,
 6        candidates: &mut HashSet<usize>,
 7    ) -> i32 {
 8        // take everything in node, if still needed, take from alternate subtree
 9        match tree {
10            Node::Leaf(box_leaf) => {
11                let leaf_values = &(box_leaf.0);
12                let num_candidates_found = min(n as usize, leaf_values.len());
13                for i in 0..num_candidates_found {
14                    candidates.insert(leaf_values[i]);
15                }
16                return num_candidates_found as i32;
17            }
18            Node::Inner(inner) => {
19                let above = (*inner).hyperplane.point_is_above(&query);
20                let (main, backup) = match above {
21                    true => (&(inner.right_node), &(inner.left_node)),
22                    false => (&(inner.left_node), &(inner.right_node)),
23                };
24                match Self::tree_result(query, n, main, candidates) {
25                    k if k < n => {
26                        k + Self::tree_result(query, n - k, backup, candidates)
27                    }
28                    k => k,
29                }
30            }
31        }
32    }
33}

请注意,为了进一步优化递归调用,我们还可以保存子树中的向量总数,并在每个内部节点中保存指向所有叶节点的指针列表,但为了简单起见,此处略过。

将此搜索扩展到一片森林非常简单,只需从所有树木中单独收集前 K 个候选者,按距离对它们进行排序,然后返回总体的前 K 个匹配项。请注意,随着树的数量增加,内存的使用和搜索时间都会呈线性增长,但我们可以获得更好的“更近”邻居,因为我们收集了来自不同树的候选者。

1impl<const N: usize> ANNIndex<N> {
 2    pub fn search_approximate(
 3        &self,
 4        query: Vector<N>,
 5        top_k: i32,
 6    ) -> Vec<(i32, f32)> {
 7        let mut candidates = HashSet::new();
 8        for tree in self.trees.iter() {
 9            Self::tree_result(query, top_k, tree, &mut candidates);
10        }
11        candidates
12            .into_iter()
13            .map(|idx| (idx, self.values[idx].sq_euc_dis(&query)))
14            .sorted_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
15            .take(top_k as usize)
16            .map(|(idx, dis)| (self.ids[idx], dis))
17            .collect()
18    }
19}

以上就是一个简单的向量搜索索引的 200 行 Rust 代码!

bb4e847f4e782aa5b7e4bdbadafab4de.png

基准测试

其实,这个实现非常简单,以至于我们一度怀疑相较于最先进的算法,这个算法非常拙略。因此,我们做了一些基准测试来证实我们的怀疑。

算法可以通过延迟和质量来评估。质量通常通过召回率来衡量:求近似最近邻搜索返回结果与线性搜索返回结果的百分比。严格来说,有时返回的结果并不在前 K,但非常接近 K,因此也没关系,为了量化这一点,我们可以计算邻居的平均欧几里德距离,并将其与平均距离进行比较。

测量延迟很简单,我们可以检查执行查询所需的时间。

所有的基准测试都是在搭载了 2.3 GHz 四核英特尔酷睿 i5 处理器的机器上运行的,使用了 999,994 条维基百科数据 FastText 嵌入,300 个维度。我们设置返回“前 K 个”查询结果。

作为参考,我们拿 FAISS HNSW 索引(ef_search=16、ef_construction=40、max_node_size=15)与 Rust 索引的小型版本(num_trees=3、max_node_size=15)进行了比较。我们使用 Rust 实现了穷举搜索,而 FAISS 库有 HNSW 的 C++ 源代码。原始延迟数突出了近似搜索的优势:

算法

延迟

QPS

穷举搜索

675.25 毫秒

1.48

FAISS HNSW 索引

355.36 微秒

2814.05

自定义 Rust 索引

112.02 微秒

8926.98

两种近似最近邻方法快了三个数量级。看起来在这个微型基准测试中,我们的 Rust 实现比 HNSW 快 3 倍。

在分析质量时,我们的测试数据为:返回“river”的 10 个最近的邻居。

穷举搜索

FAISS HNSW 索引

自定义 Rust 索引

单词

欧几里得距离

单词

欧几里得距离

单词

欧几里得距离

river

0

river

0

river

0

River

1.39122

River

1.39122

creek

1.63744

rivers

1.47646

river-

1.58342

river.

1.73224

river-

1.58342

swift-flowing

1.62413

lake

1.75655

swift-flowing

1.62413

flood-swollen

1.63798

sea

1.87368

creek

1.63744

river.The

1.68156

up-river

1.92088

flood-swollen

1.63798

river-bed

1.68510

shore

1.92266

river.The

1.68156

unfordable

1.69245

brook

2.01973

river-bed

1.68510

River-

1.69512

hill

2.03419

unfordable

1.69245

River.The

1.69539

pond

2.04376

再来一个例子,这次我们搜索“war”。

穷举搜索

FAISS HNSW 索引

自定义 Rust 索引

单词

欧几里得距离

单词

欧几里得距离

单词

欧几里得距离

war

0

war

0

war

0

war--

1.38416

war--

1.38416

war--

1.38416

war--a

1.44906

war--a

1.44906

wars

1.45859

wars

1.45859

wars

1.45859

quasi-war

1.59712

war--and

1.45907

war--and

1.45907

war-footing

1.69175

war.It

1.46991

war.It

1.46991

check-mate

1.74982

war.In

1.49632

war.In

1.49632

ill-begotten

1.75498

unwinable

1.51296

unwinable

1.51296

subequent

1.76617

war.And

1.51830

war.And

1.51830

humanitary

1.77464

hostilities

1.54783

Iraw

1.54906

execution

1.77992

我们使用的这个语料库包含 999,994 个单词,我们可视化了在 HNSW 和我们的自定义 Rust 索引下,每个单词与其前 K=20 个近似邻居的平均欧几里得距离的分布:

e45524d236f9a60ecb76ea3a47f71c22.png

最先进的 HNSW 索引提供的邻居确实比我们的示例索引更近,其平均距离和中值距离分别为 1.31576 和 1.20230(而与我们的示例索引分别为 1.47138 和 1.35620)。在大小为 1 万的子集上,HNSW 的前 K=20 的召回率为 58.2%,而我们的示例索引在不同配置下的测试结果如下:

树的数量

最大节点数

平均运行时间

QPS

召回率

3

5

129.48微秒

7723

0.11465

3

15

112.02微秒

8297

0.11175

3

30

114.48微秒

8735

0.09265

9

5

16.77毫秒

60

0.22095

9

15

1.54毫秒

649

0.20985

9

30

370.80微秒

2697

0.16835

15

5

35.45毫秒

28

0.29825

15

15

7.34毫秒

136

0.28520

15

30

2.19毫秒

457

0.23115


60b28291ad7b4eed949e8b507eab0be8.png

为什么我们的算法如此之快?

通过上面的数字可以看出,虽然我们的算法在质量上无法与尖端技术相媲美,但它的速度非常快。为什么会这样?

老实说,在构建这个算法时,我们兴奋得昏了头脑,性能优化也只是为了好玩。下面是一些重要的优化:

  • 将去除文档的重复数据的过程转移到索引冷路径上。通过索引(而不是浮点数组)引用向量可以大幅提高搜索速度,因为跨树查找唯一候选者只需要对 8 字节索引进行散列(而不是 300 维 f32 数据)。

  • 在将点添加到全局候选列表之前,散列并查找唯一向量,通过递归搜索调用中的可变引用传递数据,以避免栈帧之间和栈帧内的复制。

  • 将 N 作为通用类型参数传递进去,这样类型检查就会将 300 维的数据当作长度为 300 的 f32 数组,这不仅可以改善缓存局部性,还可以减少内存占用。

  • 我们怀疑内部操作(例如点积)被 Rust 编译器向量化了,但我们没有检查。

e8850f558dbcdfa6547d26151f6c9cf9.png

一些真实世界应用的考虑

有一些问题这个示例没有考虑到,但在生产环境的向量搜索中非常关键:

  • 当搜索涉及多个树时应当使用并行。不要顺序收集候选者,而是应该并行进行,因为每个树都会访问完全不同的内存,因此每个树都可以在各自的线程上单独运行,候选者通过通道发送到主线程。线程可以在创建索引时建立,并使用模拟搜索进行预热(将树加载到缓存),从而减小搜索的额外开销。这样搜索就不会根据树的数量线性增长了。

  • 大型树可能无法完整地加载到内存中,可能需要从磁盘中读取,所以可以将特定的子图放到磁盘上,并精心设计算法,保证搜索正常工作的同时,尽可能减小文件I/O。

  • 继续深入,如果树无法保存在一个实例的磁盘上,可以将子树分布到多个实例中,并让递归搜索在本地数据不存在时发出RPC请求。

  • 树包含许多内存重定向(基于指针的树对L1缓存不友好)。平衡树可以写成数组,但我们的树只是接近平衡,其超平面是随机的。是否可能采用新的数据结构?

  • 上述问题的解决方案,对于新数据即时编制索引也是成立的(可能需要对大型树进行分片)。如果特定索引序列会导致非常不平衡的树,那么是否应该重建树?

这些都是我们未来该深刻思考的问题。

原文链接:https://fennel.ai/blog/vector-search-in-200-lines-of-rust/

声明:本文为 CSDN 翻译,未经允许,禁止转载。

推荐阅读:

▶低质量软件的最大“祸根”:虚构问题!

▶ChatGPT 也能用来造芯,说说英语就可以了?!

▶“从开发 GitHub Copilot 到上线,我拿到了 2 万美元的奖金!”

Logo

苏州本地的技术开发者社区,在这里可以交流本地的好吃好玩的,可以交流技术,可以交流招聘等等,没啥限制。

更多推荐