logo蛋烘糕.

不写博客的工程师不是好的产品经理

AI 技术演进与核心算法实战 | 第十三篇:混合检索实战:BM25 关键词检索 + 向量检索的融合算法与权重调优

Cover Image for AI 技术演进与核心算法实战 | 第十三篇:混合检索实战:BM25 关键词检索 + 向量检索的融合算法与权重调优
蛋烘糕
蛋烘糕

单一检索就像只用一种感官感知世界——要么只有视觉(语义),要么只有触觉(关键词),都不完整。真正的理解需要多感官融合。

上一篇 中,我们构建了高质量的数据流水线。现在让我们进入检索阶段的核心问题:如何找到最相关的文档?

想象这个真实场景:

某电商公司的 RAG 客服系统遇到了瓶颈:

  • 用户问:“iPhone 15 Pro Max 256GB 多少钱?”
  • 纯向量检索返回了"iPhone 14 的价格"、“安卓手机推荐” —— 语义相关但不精确
  • 纯 BM25 返回了"iPhone 15 Pro Max 评测"、“256GB 存储卡介绍” —— 关键词匹配但答非所问

问题出在哪里?

答案是:单一的检索方式无法同时满足"精确匹配"和"语义理解"的双重需求。

本篇是 《AI 技术演进与核心算法实战》第三模块的第三篇。我们将深入探讨 混合检索(Hybrid Search) 技术,结合 BM25 的精确匹配能力和向量检索的语义理解能力。

根据我们的实践经验:

  • 混合检索可以将召回率(Recall@10)提升 30-50%
  • MRR(Mean Reciprocal Rank)提升 25% 以上
  • 用户满意度提升 40%+

这就是为什么说:混合检索是生产级 RAG 系统的标配。


1. 为什么单纯的向量检索不够用?

1.1 一个直觉类比:找人问路 vs 查地图

向量检索就像找人问路

  • 你描述:“我想找一个有红色屋顶、旁边有小溪的餐厅”
  • 当地人凭印象告诉你:“哦,你说的是山那边那家农家乐吧?”
  • 优点:能理解你的语义描述,即使你说的不准确
  • 缺点:对于具体名称、数字不敏感

BM25 检索就像查地图

  • 你搜索:“北京市海淀区中关村大街 1 号”
  • 地图精确地定位到这个地址
  • 优点精确匹配地名、街道号
  • 缺点:如果你说"中关村附近好吃的川菜馆",它就懵了

关键洞察:向量检索擅长语义理解但缺乏精确性,BM25 擅长精确匹配但缺乏灵活性。混合检索结合两者优势,实现互补。

1.2 真实案例分析:当向量检索遇到"专业术语"

让我们看几个典型的失败案例:

案例 1:产品型号混淆

用户查询:"特斯拉 Model Y 续航多少公里?"

向量检索 Top-3 结果:
1. "特斯拉 Model 3 续航里程测试" (余弦相似度:0.89) ❌ 型号错误
2. "比亚迪汉 EV 续航能力详解" (余弦相似度:0.85) ❌ 品牌错误
3. "电动汽车续航排行榜" (余弦相似度:0.82) ❌ 太泛化

BM25 Top-3 结果:
1. "Model Y 车主必看!这些功能你必须知道" (BM25: 2.35) ❌ 无关内容
2. "特斯拉全系车型降价公告" (BM25: 2.12) ❌ 答非所问
3. "Model Y 续航实测:高速 vs 市区" (BM25: 1.98) ✅ 正确答案但在第 3 位

问题诊断

  • 向量检索把 “Model Y” 和 “Model 3” 编码成相似的向量(都是特斯拉 SUV)
  • 但用户关心的是具体型号,一字之差就是完全不同的车

案例 2:数字敏感度不足

用户查询:"预算 5000 元左右的笔记本电脑推荐"

向量检索 Top-3:
1. "6000 元价位笔记本性价比之王" (相似度:0.91) ❌ 超预算
2. "4000-7000 元笔记本选购指南" (相似度:0.88) ⚠️ 范围太大
3. "游戏本推荐:高性能不等于高价格" (相似度:0.85) ❌ 无具体价格

BM25 Top-3:
1. "5000 元笔记本电脑推荐排行榜" (BM25: 3.21) ✅ 精确命中
2. "4500-5500 元笔记本购买攻略" (BM25: 2.89) ✅ 范围精准
3. "2024 年笔记本价格大全" (BM25: 1.45) ❌ 太泛化

关键洞察

  • 向量空间中,$5000 和 $6000 的表示非常接近(都是"几千元"的概念)
  • 但用户的预算约束是硬性的,不能模糊处理

1.3 数学本质:两种检索的评分机制差异

让我们从数学角度理解两者的根本差异:

BM25 的评分公式(简化版):

ScoreBM25(q,d)=i=1nIDF(qi)TF(qi,d)(k1+1)TF(qi,d)+k1(1b+bdavgdl)\text{Score}_{\text{BM25}}(q, d) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{\text{TF}(q_i, d) \cdot (k_1 + 1)}{\text{TF}(q_i, d) + k_1 \cdot (1 - b + b \cdot \frac{|d|}{\text{avgdl}})}

其中:

  • TF(qi,d)\text{TF}(q_i, d):词频(Term Frequency)
  • IDF(qi)\text{IDF}(q_i):逆文档频率(Inverse Document Frequency)
  • d|d|:文档长度
  • avgdl\text{avgdl}:平均文档长度

物理意义:BM25 计算的是词汇重叠程度,关注的是"形似"。

向量检索的评分公式

ScoreVector(q,d)=cos(vq,vd)=vqvdvqvd\text{Score}_{\text{Vector}}(q, d) = \cos(\vec{v_q}, \vec{v_d}) = \frac{\vec{v_q} \cdot \vec{v_d}}{|\vec{v_q}| \cdot |\vec{v_d}|}

其中 vq\vec{v_q}vd\vec{v_d} 分别是查询和文档的 Embedding 向量。

物理意义:向量检索计算的是语义空间中的夹角,关注的是"神似"。

总结

  • BM25 关注字面重合度(有多少相同的词)
  • 向量检索关注语义相关性(在向量空间中的方向是否一致)
  • 两者互补才能实现既精确又灵活的检索

2. BM25 算法详解:经典搜索引擎的基石

2.1 从 TF-IDF 到 BM25:信息检索的演进

要理解 BM25,我们先从经典的 TF-IDF 说起。

TF-IDF 的直觉理解

假设你在写一篇关于"人工智能"的文章,哪些词最能代表你的主题?

  • “的”、“了”、“是”:出现频率很高 → 但不能代表主题(停用词)
  • “人工智能”、“机器学习”:频繁出现 → 能代表主题(关键词)
  • “神经网络”、“深度学习”:偶尔出现 → 也有一定代表性

TF-IDF 的核心思想:

TF-IDF(t,d)=TF(t,d)×IDF(t)\text{TF-IDF}(t, d) = \text{TF}(t, d) \times \text{IDF}(t)

其中:

  • TF(t,d)\text{TF}(t, d):词 tt 在文档 dd 中出现的频率
  • IDF(t)=logNdf(t)\text{IDF}(t) = \log\frac{N}{\text{df}(t)}:逆文档频率(NN 是总文档数,df(t)\text{df}(t) 是包含词 tt 的文档数)

物理意义

  • 如果一个词在当前文档中频繁出现(TF 高)
  • 在其他文档中很少见(IDF 高)
  • 那么这个词很可能很重要

TF-IDF 的问题

  1. 没有考虑文档长度:长文档天然 TF 更高,不公平
  2. 线性增长假设:TF 越高分数越高,但实际上存在边际效应递减
  3. 二元相关性:只考虑词是否出现,不考虑位置、邻近度等因素

2.2 BM25 的改进:更科学的评分函数

BM25(Best Matching 25)在 TF-IDF 的基础上做了三个关键改进:

改进 1:词频饱和(TF Saturation)

BM25 认为,词频的贡献应该逐渐饱和,而不是线性增长:

TFBM25=TF(k1+1)TF+k1\text{TF}_{\text{BM25}} = \frac{\text{TF} \cdot (k_1 + 1)}{\text{TF} + k_1}

其中 k1k_1 是饱和参数(通常取 1.2-2.0)。

物理意义:当一个词出现 5 次时,再增加 1 次的边际贡献应该比只出现 1 次时要小得多。

改进 2:文档长度归一化

BM25 引入了长度惩罚因子:

LengthNorm=1b+bdavgdl\text{LengthNorm} = 1 - b + b \cdot \frac{|d|}{\text{avgdl}}

其中:

  • d|d|:当前文档长度
  • avgdl\text{avgdl}:所有文档的平均长度
  • bb:长度惩罚系数(通常取 0.75)

物理意义

  • 如果文档长度 = 平均长度 → 惩罚因子 = 1(无影响)
  • 如果文档很长 → 惩罚因子 > 1(降低 TF 的贡献)
  • 如果文档很短 → 惩罚因子 < 1(提高 TF 的贡献)

改进 3:IDF 的平滑处理

BM25 使用更稳健的 IDF 计算:

IDFBM25(qi)=log(Ndf(qi)+0.5df(qi)+0.5)\text{IDF}_{\text{BM25}}(q_i) = \log\left(\frac{N - \text{df}(q_i) + 0.5}{\text{df}(q_i) + 0.5}\right)

加 0.5 是为了避免除以 0,并且对罕见词给予更高的权重。

2.3 手写实现 BM25

让我们从零实现一个 BM25 检索引擎:

import math
from typing import List, Dict, Tuple
from collections import defaultdict

class BM25Searcher:
    """BM25 检索器实现"""
    
    def __init__(self, k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.documents: List[str] = []
        self.doc_lengths: List[int] = []
        self.avg_doc_length: float = 0.0
        self.df: Dict[str, int] = defaultdict(int)
        self.tf: Dict[str, Dict[int, int]] = defaultdict(lambda: defaultdict(int))
        
    def index(self, documents: List[str]) -> None:
        self.documents = documents
        self.doc_lengths = [len(doc.split()) for doc in documents]
        self.avg_doc_length = sum(self.doc_lengths) / len(documents)
        
        for doc_id, doc in enumerate(documents):
            words = doc.lower().split()
            word_count = defaultdict(int)
            
            for word in words:
                word_count[word] += 1
            
            for word, count in word_count.items():
                self.tf[word][doc_id] = count
                self.df[word] += 1
    
    def _compute_idf(self, word: str) -> float:
        n_docs = len(self.documents)
        df = self.df.get(word, 0)
        return math.log((n_docs - df + 0.5) / (df + 0.5))
    
    def _compute_tf(self, word: str, doc_id: int) -> float:
        tf = self.tf[word].get(doc_id, 0)
        doc_len = self.doc_lengths[doc_id]
        numerator = tf * (self.k1 + 1)
        denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_doc_length)
        return numerator / denominator if denominator > 0 else 0
    
    def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
        scores = defaultdict(float)
        query_words = query.lower().split()
        
        for word in query_words:
            idf = self._compute_idf(word)
            for doc_id in range(len(self.documents)):
                tf = self._compute_tf(word, doc_id)
                scores[doc_id] += idf * tf
        
        sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return sorted_results[:top_k]

# 使用示例
documents = [
    "人工智能是计算机科学的一个分支,致力于创建能够执行智能任务的系统",
    "机器学习是人工智能的子领域,通过数据训练模型来实现预测和决策",
    "深度学习基于人工神经网络,在图像识别和自然语言处理中表现出色",
]

searcher = BM25Searcher(k1=1.5, b=0.75)
searcher.index(documents)
query = "机器学习和深度学习有什么区别"
results = searcher.search(query, top_k=3)

for doc_id, score in results:
    print(f"[{score:.3f}] {documents[doc_id]}")

参数调优建议

参数 取值范围 物理意义 调优策略
k1k_1 1.2-2.0 控制 TF 饱和速度 短文本用较大值(1.8-2.0),长文本用较小值(1.2-1.5)
bb 0.5-1.0 长度惩罚强度 文档长度差异大时用较大值(0.8-1.0),长度均匀时用较小值(0.5-0.7)

3. 混合检索的融合策略:如何让 1+1>2?

3.1 直观理解:为什么不能简单相加?

假设我们有两个检索结果:

BM25 检索结果(分数范围:0-5):

Doc A: 3.2
Doc B: 2.8
Doc C: 1.5

向量检索结果(余弦相似度:0-1):

Doc B: 0.92
Doc A: 0.85
Doc D: 0.78

问题来了:如果直接相加,会发生什么?

Doc A: 3.2 + 0.85 = 4.05
Doc B: 2.8 + 0.92 = 3.72
Doc C: 1.5 + 0 = 1.5
Doc D: 0 + 0.78 = 0.78

问题

  1. 量纲不统一:BM25 分数是 0-5,向量是 0-1,BM25 主导了结果
  2. 分布不同:BM25 分数可能无限增长,向量相似度有上限
  3. 未命中的文档得 0 分:Doc D 只在向量检索中出现,总分反而最低

解决方案:我们需要归一化融合策略

3.2 融合策略 1:加权求和法(Weighted Sum)

这是最直观的方法:

Scorefinal=αNorm(ScoreBM25)+βNorm(ScoreVector)\text{Score}_{\text{final}} = \alpha \cdot \text{Norm}(\text{Score}_{\text{BM25}}) + \beta \cdot \text{Norm}(\text{Score}_{\text{Vector}})

其中 α+β=1\alpha + \beta = 1,用于控制两种检索的权重。

关键步骤

Step 1: 分数归一化

def min_max_normalize(scores: List[float]) -> List[float]:
    """Min-Max 归一化到 [0, 1] 区间"""
    min_score = min(scores)
    max_score = max(scores)
    
    if max_score == min_score:
        return [0.5] * len(scores)
    
    return [(s - min_score) / (max_score - min_score) for s in scores]

Step 2: 加权融合实现

class HybridSearcher:
    """混合检索器:加权求和法"""
    
    def __init__(self, bm25_searcher, vector_searcher, alpha: float = 0.5):
        self.bm25 = bm25_searcher
        self.vector = vector_searcher
        self.alpha = alpha
        self.beta = 1 - alpha
    
    def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
        # Step 1: 分别检索
        bm25_results = self.bm25.search(query, top_k=top_k * 2)
        vector_results = self.vector.search(query, top_k=top_k * 2)
        
        # Step 2: 归一化分数
        bm25_scores = {doc_id: score for doc_id, score in bm25_results}
        vector_scores = {doc_id: score for doc_id, score in vector_results}
        
        bm25_norm = min_max_normalize(list(bm25_scores.values()))
        vector_norm = min_max_normalize(list(vector_scores.values()))
        
        bm25_dict = {doc_id: s for (doc_id, _), s in zip(bm25_results, bm25_norm)}
        vector_dict = {doc_id: s for (doc_id, _), s in zip(vector_results, vector_norm)}
        
        # Step 3: 融合分数
        all_doc_ids = set(bm25_scores.keys()) | set(vector_scores.keys())
        final_scores = {}
        
        for doc_id in all_doc_ids:
            bm25_part = self.alpha * bm25_dict.get(doc_id, 0)
            vector_part = self.beta * vector_dict.get(doc_id, 0)
            final_scores[doc_id] = bm25_part + vector_part
        
        # Step 4: 排序返回
        sorted_results = sorted(final_scores.items(), key=lambda x: x[1], reverse=True)
        return sorted_results[:top_k]

α参数的物理意义

α值 BM25 权重 向量权重 适用场景
0.0 0% 100% 纯向量检索(语义理解优先)
0.3 30% 70% 语义为主,精确为辅(通用问答)
0.5 50% 50% 平衡模式(推荐默认值)
0.7 70% 30% 精确为主,语义为辅(专业术语查询)
1.0 100% 0% 纯 BM25 检索(关键词匹配)

3.3 融合策略 2:倒数排名融合(Reciprocal Rank Fusion, RRF)

RRF 是一种不依赖分数绝对值的融合方法,只利用排名信息。

核心公式

RRF_Score(d)=rR1k+rankr(d)\text{RRF\_Score}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}

其中:

  • RR:所有检索结果的集合
  • rankr(d)\text{rank}_r(d):文档 dd 在结果集 rr 中的排名
  • kk:平滑常数(通常取 60)

为什么用倒数?

直觉理解:

  • 第 1 名:160+1=0.0164\frac{1}{60+1} = 0.0164
  • 第 2 名:160+2=0.0161\frac{1}{60+2} = 0.0161
  • 第 10 名:160+10=0.0143\frac{1}{60+10} = 0.0143
  • 第 50 名:160+50=0.0091\frac{1}{60+50} = 0.0091

关键洞察

  • 排名越靠前,分数差异越大(区分度高)
  • 排名越靠后,分数差异越小(不重要)
  • 这样设计是为了突出头部结果

RRF 的优势

  1. 无需归一化:不同检索的分数分布可能完全不同,但排名是可比的
  2. 鲁棒性强:不受异常值影响
  3. 实现简单:不需要调参(只有一个固定的 kk

代码实现

def reciprocal_rank_fusion(
    result_lists: List[List[Tuple[int, float]]],
    k: int = 60,
    top_k: int = 10
) -> List[Tuple[int, float]]:
    rrf_scores = defaultdict(float)
    
    for results in result_lists:
        for rank, (doc_id, _) in enumerate(results):
            rrf_scores[doc_id] += 1.0 / (k + rank + 1)
    
    sorted_results = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results[:top_k]

# 使用示例
bm25_results = bm25.search(query, top_k=20)
vector_results = vector.search(query, top_k=20)

final_results = reciprocal_rank_fusion(
    result_lists=[bm25_results, vector_results],
    k=60,
    top_k=10
)

3.4 RRF vs 加权求和:实战对比

关键差异

  • 加权求和:倾向于给在某一侧得分极高的文档更高排名
  • RRF:更青睐在两侧都表现稳定的文档(共识)

选择建议

  • 如果你相信某个检索的绝对分数(如经过精心调优)→ 用加权求和
  • 如果你更看重共识(两边都认为重要)→ 用 RRF
  • 生产环境推荐:先试 RRF(更稳健,不需要调 α)

4. 权重调优实验:如何找到最优的 α?

4.1 评估指标:如何衡量检索质量?

常用评估指标

1. Precision@K(查准率)

P@K=Top-K 中相关的文档数K\text{P@K} = \frac{\text{Top-K 中相关的文档数}}{K}

2. Recall@K(召回率)

R@K=Top-K 中相关的文档数总共相关的文档数\text{R@K} = \frac{\text{Top-K 中相关的文档数}}{\text{总共相关的文档数}}

3. NDCG@K(归一化折损累积增益) 考虑排名顺序的指标——相关文档排在越前面越好。

4. MRR(Mean Reciprocal Rank,平均倒数排名)

MRR=1Qq=1Q1rankq\text{MRR} = \frac{1}{Q} \sum_{q=1}^{Q} \frac{1}{\text{rank}_q}

其中 rankq\text{rank}_q 是查询 qq第一个相关文档的排名。

4.2 网格搜索:系统地尝试所有 α组合

import numpy as np

def tune_alpha(bm25, vector, train_queries, test_queries, alpha_range=None):
    if alpha_range is None:
        alpha_range = np.arange(0.0, 1.05, 0.05)
    
    all_results = []
    
    for alpha in alpha_range:
        hybrid_searcher = HybridSearcher(bm25, vector, alpha=alpha)
        metrics = evaluate_retrieval(hybrid_searcher, train_queries)
        metrics['alpha'] = alpha
        metrics['composite_score'] = (
            0.4 * metrics['ndcg'] + 
            0.4 * metrics['mrr'] + 
            0.2 * metrics['precision']
        )
        
        all_results.append(metrics)
        print(f"α={alpha:.2f}: NDCG={metrics['ndcg']:.3f}, MRR={metrics['mrr']:.3f}")
    
    best_result = max(all_results, key=lambda x: x['composite_score'])
    best_alpha = best_result['alpha']
    
    print(f"\n最优 α = {best_alpha:.2f}")
    return best_alpha, all_results

典型输出:

α=0.00: NDCG=0.652, MRR=0.589, P@10=0.620
α=0.40: NDCG=0.745, MRR=0.682, P@10=0.712  ← 峰值
α=1.00: NDCG=0.623, MRR=0.551, P@10=0.595

最优 α = 0.40

典型趋势分析

  1. NDCG/MRR 先升后降:α太小过于依赖向量,α太大过于保守
  2. 不同场景的最优 α不同
    • 产品搜索(型号敏感):α ≈ 0.6-0.7(BM25 主导)
    • FAQ 问答(语义多样):α ≈ 0.3-0.4(向量主导)
    • 通用搜索:α ≈ 0.5(平衡)

5. 总结与实践建议

5.1 核心要点回顾

  1. 为什么需要混合检索?

    • 向量检索擅长语义理解,但对专有名词和数字不敏感
    • BM25 擅长精确匹配,但无法理解同义词和语义关联
    • 两者结合可以实现 1+1 > 2 的效果
  2. 两种主流融合策略

    • 加权求和:需要归一化,可调 α控制权重
    • RRF(倒数排名融合):无需归一化,更稳健,推荐使用
  3. 权重调优方法论

    • 使用网格搜索系统地尝试不同 α
    • 评估指标优先选择 NDCG 和 MRR
    • 考虑根据查询类型动态调整 α

5.2 给初学者的实践路线

第一阶段:快速验证(1-2 天)

  • 使用 RRF 融合,无需调参
  • BM25 和向量检索各取 Top-50,融合后返回 Top-10
  • 目标:验证混合检索是否比单一检索效果好

第二阶段:参数调优(1 周)

  • 准备 50-100 个测试查询和标准答案
  • 网格搜索最优 α(如果用加权求和)
  • 对比 RRF vs 加权求和的效果
  • 目标:找到适合你业务场景的最优配置

第三阶段:生产优化(2-4 周)

  • 实现并行检索(降低延迟)
  • 添加缓存层(热门查询直接返回)
  • 集成 Re-Rank 模型(Cross-Encoder)
  • 建立在线评估和 A/B 测试机制

5.3 常见陷阱与避坑指南

错误 1:不做归一化就直接相加

# 错误示范
final_score = bm25_score + vector_score  # ❌ 量纲不同!

正确做法

# 先归一化再融合
bm25_norm = (bm25_score - min_bm25) / (max_bm25 - min_bm25)
vector_norm = (vector_score - min_vector) / (max_vector - min_vector)
final_score = 0.5 * bm25_norm + 0.5 * vector_norm  # ✅

错误 2:α 参数拍脑袋决定

  • “我觉得语义更重要,所以 α=0.3”
  • “论文里用的是 0.5,我们也用 0.5”

正确做法

  • 准备标注数据集(查询 + 相关文档)
  • 用网格搜索找到最优 α
  • 定期重新评估(数据分布可能变化)

错误 3:忽略延迟问题

  • 串行执行 BM25 和向量检索 → 延迟翻倍

正确做法

# 并行检索
import asyncio

async def hybrid_search(query):
    bm25_task = asyncio.create_task(bm25_search(query))
    vector_task = asyncio.create_task(vector_search(query))
    
    bm25_results, vector_results = await asyncio.gather(
        bm25_task, vector_task
    )
    
    return fuse(bm25_results, vector_results)

📚 参考文献与延伸阅读

  1. Introduction to Information Retrieval (Manning et al., 2008)

  2. The Probabilistic Relevance Framework: BM25 and Beyond (Robertson & Zaragoza, 2009)

  3. Dense Passage Retrieval for Open-Domain Question Answering (Karpukhin et al., 2020)

  4. Hybrid Search for Fun and Profit (博客)

  5. Reciprocal Rank Fusion outperforms Condorcet and Individual Rank Learning Methods (Cormack et al., SIGIR 2009)

  6. Elasticsearch: The Definitive Guide

  7. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks (Reimers & Gurevych, 2019)

  8. LangChain Hybrid Search Implementation

  9. Advanced RAG Techniques Survey (2024)

  10. Qdrant Hybrid Search Documentation


下一篇预告重排序(Re-Rank)技术:Cross-Encoder 模型原理及其在提升检索精度中的关键作用 —— 混合检索之后,如何进一步提升结果质量?我们将探讨 Cross-Encoder 的精排策略。

博客日历
2026年04月
SuMoTuWeThFrSa
29
30
31
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
01
02
03
04
05
06
07
08
09
更多
--
--
--
--