1. 概述

Rete 算法是卡内基梅隆大学的 Charles L. Forgy 博士在 1974 年发表的论文中所阐述的算法,该算法为专家系统提供了一个高效实现。

Rete 在拉丁语中译为"net"(即网络)。它是一种进行大量模式集合和大量对象集合间比较的高效方法,通过网络筛选的方式找出所有匹配各个模式的对象和规则。

其核心思想是用分离的匹配项构造匹配网络,同时缓存中间结果,即“以空间换时间”。整个过程主要分为规则编译(Rule Compilation)和运行时执行(Runtime Execution)两个阶段。

2. 规则编译(Rule Compilation)

规则编译是指根据规则集生成高效推理网络的过程。

2.1. 相关概念

  • Fact(事实):对象之间及对象属性之间的关系。
  • Rule(规则):由条件和结论构成的推理语句,一般表示为 if…Then。一个规则的 if 部分称为 LHS(Left-Hand-Side),then 部分称为 RHS(Right-Hand-Side)。
  • Module(模式):指 IF 语句的条件。这里 IF 条件可能是由几个更小的条件组成的大条件。模式指的是不能再继续分割下去的最小的原子条件。

2.2. RETE 网络节点类型

RETE 网络示意图如下:

13416824241499e42e8e218c.png

  • Root Node:所有对象进入网络的入口,在一个网络中只有一个根节点。
  • 1-input Node:可分为 ObjectTypeNode、AlphaNode、LeftInputAdapterNode 等。

    • Object Type Node:事实从根节点进入 Rete 网络后,会立即进入 Object Type Node 节点。该节点提供了按对象类型过滤对象的能力,通过此类节点可使规则引擎不做额外的工作。例如,Cheese 类型的事实进入网络后,只需经过类型为 Cheese 的 Object Type Node 之后的节点。如下图:

    13416824520f19a1d3b1abbc.png

    • Alpha Node:Alpha 节点是规则条件部分的一个模式,通常用于评估字面条件。如下图,两个 Alpha Node 分别评估了 Cheese 事实的 namestrength 属性。

    1341682440b8f67eb6f193a0.png

    • Left Input Adapter Node:作用是将输入的单对象适配为单对象列表(元组),以便传入 Beta 节点进行后续处理。
  • 2-input Node(Beta Node):拥有两个输入的节点。Beta Node 用于比较两个对象,这两个对象可能是相同或不同的类型。Beta Node 主要包含 Join NodeNot Node 两种类型。

    • Join Node:用作连接(join)操作的节点,相当于数据库的表连接操作。
    • Not Node:根据右边输入对左边输入的对象数组进行过滤,两个 NotNode 可以完成 exists 检查。
  • Terminal Node:到达一个终端节点,表示单条规则匹配了所有的条件。网络中有多个终端节点,当单条规则中有 or 时,也会产生多个终端节点。

完整示例:

  • 规则文件
rule1  
when  
Cheese($cheddar : name == "cheddar" )  
$person : Person( favouriteCheese == $cheddar )  
then  
System.out.println($person.getName() + " likes cheddar" );  
end

rule2  
when  
Cheese( $cheddar : name == "cheddar" )  
$person : Person( favouriteCheese != $cheddar )  
then  
System.out.println($person.getName() + " does not like cheddar" );  
end

Rete 网络结构:

134168244573bbcc0037a1cd.png

从图上可以看到,编译后的 RETE 网络中,AlphaNode 是共享的,而 BetaNode 不是共享的。两条规则的 BetaNode 不同,且这两条规则有各自的 Terminal Node。

2.3. 创建 Rete 网络

RETE 算法通过构建一个网络进行匹配,具体过程如下:

  1. 创建 Root 节点(根节点),作为推理网络的入口。
  2. 拿到规则 1,从规则 1 中取出模式 1(模式就是最小的原子条件)。

    • a) 检查模式 1 中的参数类型,如果是新类型,添加一个类型节点。
    • b) 检查模式 1 对应的 Alpha 节点是否存在,如果存在记录下节点的位置;如果没有,将模式 1 作为一个 Alpha 节点加入到网络中。同时根据 Alpha 节点建立 Alpha 内存表
    • c) 重复 b,直到处理完所有模式。
    • d) 组合 Beta 节点:Beta(2) 左输入节点为 Alpha(1),右输入节点为 Alpha(2)Beta(i) 左输入节点是 Beta(i-1),右输入节点为 Alpha(i),并将两个父节点的内存表内联成为自己的内存表。
    • e) 重复 d,直到所有 Beta 节点处理完毕。
    • f) 将动作 Then 部分封装成最后节点做为 Beta(n)
  3. 重复步骤 2,直到所有规则处理完毕。

3. 运行时执行(Runtime Execution)

推理引擎在进行模式匹配时,先对事实进行断言,为每一个事实建立 WME (Working Memory Element),然后将 WME 从 RETE 鉴别网络的根结点开始匹配。因为 WME 传递到的结点类型不同采取的算法也不同,所以需要对 Alpha 结点和 Beta 结点处理 WME 的不同情况而分开讨论。

1) 如果 WME 的类型和根节点的后继结点 TypeNode (Alpha 结点的一种) 所指定的类型相同,则会将该事实保存在该 TypeNode 结点对应的 alpha 存储区中,该 WME 被传到后继结点继续匹配,否则会放弃该 WME 的后续匹配;

2) 如果 WME 被传递到 Alpha 结点,则会检测 WME 是否和该结点对应的模式相匹配,若匹配,则会将该事实保存在该 Alpha 结点对应的存储区中,该 WME 被传递到后继结点继续匹配,否则会放弃该 WME 的后续匹配;

3) 如果 WME 被传递到 Beta 结点的右端,则会加入到该 Beta 结点的 right 存储区,并和 left 存储区中的 Token 进行匹配 (匹配动作根据 Beta 结点的类型进行,例如:join,projection,selection),匹配成功,则会将该 WME 加入到 Token 中,然后将 Token 传递到下一个结点,否则会放弃该 WME 的后续匹配;

4) 如果 Token 被传递到 Beta 结点的左端,则会加入到该 Beta 结点的 left 存储区,并和 right 存储区中的 WME 进行匹配 (匹配动作根据 Beta 结点的类型进行,例如:join,projection,selection),匹配成功,则该 Token 会封装匹配到的 WME 形成新的 Token,传递到下一个结点,否则会放弃该 Token 的后续匹配;

5) 如果 WME 被传递到 Beta 结点的左端,将 WME 封装成仅有一个 WME 元素的 WME 列表做为 Token,然后按照 4) 所示的方法进行匹配;

6) 如果 Token 传递到终结点,则和该根结点对应的规则被激活,建立相应的 Activation,并存储到 Agenda 当中,等待激发;

7) 如果 WME 被传递到终结点,将 WME 封装成仅有一个 WME 元素的 WME 列表做为 Token,然后按照 6) 所示的方法进行匹配。

4. 一些实践

由于从事运输行业,故以业内的典型场景作为实践示例。
例如:我们需要将提供“机票 + 酒店”、“机票 + 酒店 + 贵宾休息室”两种类型的产品给旅客。
机票、酒店、贵宾休息室需要满足一些基本的限制条件。并且:

  • “机票 + 酒店”产品要保障:酒店位于目的地且到达当天可以入住。
  • “机票 + 酒店 + 贵宾休息室”产品要保障:酒店位于目的地且到达当天可以入住,且贵宾休息室位于出发城市。

下图展示了打包规则所构成的 RETE 网络:

1341682465b456a664322229.png

基于 Drools 实现相关规则:

  • 数据模型
package com.myspace.packagedproduct;

public class Location implements java.io.Serializable {
    static final long serialVersionUID = 1L;
    @org.kie.api.definition.type.Label(value = "\u56FD\u5BB6")
    private java.lang.String country;
    @org.kie.api.definition.type.Label(value = "\u7701\u4EFD")
    private java.lang.String province;
    @org.kie.api.definition.type.Label(value = "\u57CE\u5E02")
    private java.lang.String city;
    // ...Getter、Setter 方法...
}

public class Segment implements java.io.Serializable {
    static final long serialVersionUID = 1L;
    @org.kie.api.definition.type.Label("产品编码")
    private java.lang.String proCode;
    @org.kie.api.definition.type.Label("产品名称")
    private java.lang.String proName;
    @org.kie.api.definition.type.Label("出发城市")
    private java.lang.String startCity;
    @org.kie.api.definition.type.Label("到达城市")
    private java.lang.String arriveCity;
    @org.kie.api.definition.type.Label("舱位")
    private java.lang.String cabin;
    @org.kie.api.definition.type.Label("航班日期")
    private java.util.Date flightDate;
    // ...Getter、Setter 方法...
}

public class Hotel implements java.io.Serializable {
    static final long serialVersionUID = 1L;
    @org.kie.api.definition.type.Label("产品编码")
    private java.lang.String proCode;
    @org.kie.api.definition.type.Label("产品名称")
    private java.lang.String proName;
    @org.kie.api.definition.type.Label("房型")
    private java.lang.String roomType;
    @org.kie.api.definition.type.Label("入住日期")
    private java.util.Date checkInDate;
    @org.kie.api.definition.type.Label("位置")
    private com.myspace.packagedproduct.Location location;
    @org.kie.api.definition.type.Label(value = "\u662F\u5426\u53EF\u6253\u5305\u9500\u552E")
    private java.lang.Boolean ifCanPackageSale;
    // ...Getter、Setter 方法...
}

public class ReservedLounge implements java.io.Serializable {
    static final long serialVersionUID = 1L;
    @org.kie.api.definition.type.Label("产品编码")
    private java.lang.String proCode;
    @org.kie.api.definition.type.Label("产品名称")
    private java.lang.String proName;
    @org.kie.api.definition.type.Label("位置")
    private com.myspace.packagedproduct.Location location;
    @org.kie.api.definition.type.Label("是否自营")
    private boolean selfSupport;
    // ...Getter、Setter 方法...
}

public class PackagedProduct implements java.io.Serializable {
    static final long serialVersionUID = 1L;
    @org.kie.api.definition.type.Label(value = "\u6210\u5458\u4EA7\u54C1ID\u5217\u8868")
    private java.util.List<java.lang.String> itemProductCodes;
    // ...Getter、Setter 方法...
}
  • 规则
package com.myspace.packagedproduct;

import java.util.Date;
import java.text.SimpleDateFormat;
import java.math.BigDecimal;
import java.util.List;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONArray;
import com.myspace.packagedproduct.*;
import java.util.ArrayList;

global java.util.Date now;
global java.text.SimpleDateFormat dateFormat;

rule "segment_hotel"
    when
        seg : Segment( startCity in ( "XMN", "PEK", "FOC", "HGH", "TSN", "JJN" ) , cabin == "Y" )
        hotel : Hotel( ifCanPackageSale == true , location != null , location.city == seg.arriveCity )
    then
        System.out.println("【机 + 酒产品】"+seg.getProCode()+" + "+hotel.getProCode());
end

rule "segment_hotel_lounge"
    dialect "java"
    when
        seg : Segment( startCity in ( "XMN", "PEK", "FOC", "HGH", "TSN", "JJN" ) , cabin == "Y" )
        hotel : Hotel( ifCanPackageSale == true , location != null , location.city == seg.arriveCity )
        lounge: ReservedLounge(selfSupport==true, location.city == seg.startCity)
    then
        System.out.println("【机 + 酒 + 休息室产品】"+seg.getProCode()+" + "+hotel.getProCode()+" + "+lounge.getProCode());
end
  • 规则调用语句
@Test
public void testPackagedProduct() throws ParseException {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
    Date date1 = sdf.parse("2018-09-30");
    Date date2 = sdf.parse("2018-10-01"); // 修正日期逻辑错误,09-31 不存在
    KieSession kieSession = this.getKieSessionBySessionName("mapAndList-rules");
    
    Segment seg = new Segment();
    seg.setArriveCity("PEK");
    seg.setStartCity("XMN");
    seg.setFlightDate(date1);
    seg.setCabin("Y");
    seg.setProCode("seg1");
    
    Segment seg2 = new Segment();
    seg2.setArriveCity("PEK");
    seg2.setStartCity("XMN");
    seg2.setFlightDate(date1);
    seg2.setCabin("T");
    seg2.setProCode("seg2");
    
    Segment seg3 = new Segment();
    seg3.setArriveCity("XMN");
    seg3.setStartCity("TSN");
    seg3.setFlightDate(date1);
    seg3.setCabin("Y");
    seg3.setProCode("seg3");
    
    Hotel hotel = new Hotel();
    hotel.setCheckInDate(date1);
    hotel.setIfCanPackageSale(true);
    hotel.setLocation(new Location("", "", "XMN"));
    hotel.setProCode("hotel1");
    
    Hotel hotel2 = new Hotel();
    hotel2.setCheckInDate(date2);
    hotel2.setIfCanPackageSale(true);
    hotel2.setLocation(new Location("", "", "XMN"));
    hotel2.setProCode("hotel2");
    
    Hotel hotel3 = new Hotel();
    hotel3.setCheckInDate(date1);
    hotel3.setIfCanPackageSale(true);
    hotel3.setLocation(new Location("", "", "NRT"));
    hotel3.setProCode("hotel3");
    
    Hotel hotel4 = new Hotel();
    hotel4.setCheckInDate(date1);
    hotel4.setIfCanPackageSale(true);
    hotel4.setLocation(new Location("", "", "PEK"));
    hotel4.setProCode("hotel4");

    ReservedLounge lounge = new ReservedLounge();
    lounge.setLocation(new Location("", "", "XMN"));
    lounge.setSelfSupport(true);
    lounge.setProCode("lounge1");
    
    ReservedLounge lounge2 = new ReservedLounge();
    lounge2.setLocation(new Location("", "", "PEK"));
    lounge2.setSelfSupport(true);
    lounge2.setProCode("lounge2");
    
    ReservedLounge lounge3 = new ReservedLounge();
    lounge3.setLocation(new Location("", "", "XMN"));
    lounge3.setSelfSupport(false);
    lounge3.setProCode("lounge3");
    
    kieSession.insert(seg);
    kieSession.insert(seg2);
    kieSession.insert(seg3);
    kieSession.insert(hotel);
    kieSession.insert(hotel2);
    kieSession.insert(hotel3);
    kieSession.insert(hotel4);
    kieSession.insert(lounge);
    kieSession.insert(lounge2);
    kieSession.insert(lounge3);
    
    kieSession.fireAllRules();
    kieSession.dispose();
}
  • 执行结果
【机 + 酒产品】seg1 + hotel4  
【机 + 酒产品】seg3 + hotel1  
【机 + 酒产品】seg3 + hotel2  
【机 + 酒 + 休息室产品】seg1 + hotel4 + lounge1

5. 为何 RETE 算法效率高

5.1. RETE 算法优于普通代码逻辑

借用上面的示例,如 SegmentHotelReservedLounge 类型的产品分别有 10 个。按照一般的程序处理逻辑,我们要写三个 For 循环去处理三类产品的打包操作,计算次数为三类产品数目的笛卡尔积级别,即:10 * 10 * 10 = 1000 次。

而 RETE 算法采用空间换时间的策略,将中间的计算结果缓存下来(Alpha Memory,Beta Memory)。计算次数约为 10 + 10 + 10(Alpha 节点计算次数)加上 2 次 join/projection 操作(Beta 节点计算次数)。基于内存中的数据做 join/projection/selection 操作效率很高。

5.2. RETE 算法优于传统的模式匹配算法

  1. 节点共享:Rete 算法是一种启发式算法,不同规则之间往往含有相同的模式,因此在 Beta-network 中可以共享 BetaMemory 和 BetaNode。如果某个 BetaNode 被 N 条规则共享,则算法在此节点上效率会提高 N 倍。
  2. 状态缓存:Rete 算法由于采用 AlphaMemory 和 BetaMemory 来存储事实,当事实集合变化不大时,保存在 Alpha 和 Beta 节点中的状态不需要太多变化,避免了大量的重复计算,提高了匹配效率。
  3. 无关规则过滤:从 Rete 网络可以看出,Rete 匹配速度与规则数目无直接关系,这是因为事实只有满足本节点才会继续向下沿网络传递。

6. RETE 算法的缺点

RETE 算法使用了存储区存储已计算的中间结果,以空间换取时间,从而加快系统的速度。然而存储区根据规则的条件与事实的数目成指数级增长,极端情况下会耗尽系统资源。

7. RETE 算法的衍生

KIE 团队在原生的 Rete 算法基础上进行了改良,提出了 ReteOO 算法,以更好地支持面向对象特性及复杂事件处理。


说明:本文示例代码基于 Drools 6.x/7.x 版本(org.kie.api),部分 API 在新版本中可能有所调整,请以官方文档为准。