DAG概述
在图论中,图是由顶点和连接这些顶点的边所构成,如果一个有向图从任意顶点出发无法经过若干条边回到该点,则这个图是一个有向无环图(英语:Directed Acyclic Graph,缩写:DAG)
每条边都带有从一个顶点指向另一个顶点的方向的图为有向图。有向图中的道路为一系列的边,系列中每条边的终点都是下一条边的起点。如果一条路径的起点是这条路径的终点,那么这条路径就是一个环。有向无环图即为没有环出现的有向图。
组成结构:
- 顶点(vertices)
- 边(edges)
- 出度:从一个顶点出发的边的总数。
- 入度:指向一个顶点的边的总数。
基本表示方法
简单一点来说,DAG实现状态流的核心在于描述单个任务(Task)他完成了什么样的动作(Action),以及他依赖什么样的任务(Task)。
TaskA:
Actions:
○ 动作 A
Dependencies:
TaskB:
Actions:
○ 动作 B
Dependencies:
○ TaskA
TaskC:
Actions:
○ 动作 C
Dependencies:
○ TaskB
TaskD:
Actions:
○ 动作 D
Dependencies:
○ TaskB
下图为依赖图
A -> B -> C
|
-> D
调度器设计
调度是指如何让节点一个一个的执行;一个节点执行完成,如何让其他节点感知并开始执行,常见的思路是,节点的分层调度,它的含义及特点如下:
- 依赖分层算法(如广度优先遍历)提前计算好每一层需要执行的节点;节点一批一批的调度,无需任何通知和驱动机制。
- 在同批次多节点时,由于各节点执行时间不同,容易出现长板效应。
- 在多串行节点的图调度时,有较好的性能优势。
另一种常见的思路是,基于流水线思想的队列通知驱动模式:
- 某节点执行完成后,立即发送信号给消息队列;消费侧在收到信号后,执行后续节点。如上图DAG中,B执行完成后,D/E收到通知开始执行,不需要关心C的状态。
- 由于不关心兄弟节点的执行状态,不会出现分层调度的长板效应。
- 在多并行节点的图调度时,有非常好的并行性能;但在多串行节点的图中,由于额外存在线程切换和队列通知开销,性能会稍差。
在大模型检索增强中的应用
检索增强是提升大模型生成内容准确性和时效性的成熟手段,在实践中,检索源往往来自多个,包括开放的搜索引擎以及自建库,汇总的各个库信息在输出给大模型进行推理之前需要经过粗排、精排、干预等一系列聚合逻辑。这种场景业务上有两个特点:
- 检索、排序等能力具有高度可复用性
- 绝大部分场景都是在1的基础能力之上进行自定义组合。
因此检索增强的业务逻辑可以很好的抽象为DAG,大大提升业务迭代过程的人效 检索流程可以大致抽象为干预->多路召回->[粗排]->精排-> 过滤 ->[答案抽取] 这样一个DAG模型
3.1 节点抽象
其中【过滤】就是平时一般说的【策略】,其逻辑是频繁变化的,对它的抽象是方案的核心。广义上上【粗排】、【精排】、【答案抽取】等也符合过滤操作的特征,可以统一抽象为输入输出均为召回列表的处理单元。
【干预】、【召回】可以抽象为输入是query,输出为召回列表的另一类处理单元
3.2 节点调度
- DAG框架:负责流程配置解析和执行
- processor:
- 上下文:
3.3 流程
DAG分为静态和动态两种
静态DAG以文件形式维护在欧若拉代码库中,在线上运行;
动态DAG由用户通过接口将其配置传入,实时解析运行,使用场景是线下测试,为确保稳定性,线上不开放这个能力
静态DAG执行
动态DAG执行
4 DAG框架
框架在实现上有以下几个要求
- 一个节点的所有子节点能够并行执行
- 子节点需等待所有父节点执行完成后执行
- 无环
4.1 描述
需要定义DSL来描述DAG,语法可以用yaml、json等,简单起见先用json来描述,以下示例表达了 【note库】->【排序】 这个简单的DAG
{
"name": "base",
"nodes": [
{
"name": "note",
"processor": "ann_es",
"processor_conf": {
"index": ""
},
},
{
"name": "satisfy_rank",
"processor": "rank"
"processor_conf": {
"model": "rank-v5"
},
},
],
"edges": [
{
"source": "A",
"target": "B",
"condition": "",
}
]
}
4.2 DAG
4.2.1 DAG存储
使用邻接表作为DAG的存储方式,但维护指向子节点的指针的同时,还需要维护指向父节点的指针,原因是运行时子节点需要等待所有父节点完成后才能执行
type Node struct {
Name string
Processor
Pre []*Node
Next []*Node
Conditions map[string]Condition
}
4.2.2 校验
- 不能有环
- 不能引用未知节点,因为所有处理节点都必须编码好的,目前不开放自定义节点的能力
- 校验的同时完成拓扑排序并保存起来
4.2.3 调度
要求:
- 子节点等待所有父节点执行完成后才能执行,并且merge所有父节点的hits
- 一个节点的所有子节点能并行执行
实现要点
- 使用无缓冲的channel作为父节点向子节点结果传递方式,同时能保证所有父节点执行完成后子节点才能执行
- 使用waitgroup进行流程控制
func (d *Flow) Execute(c *ExecContext) []Hit {
nodeRuntimeMap := make(map[string]*NodeRuntime)
wg := sync.WaitGroup{}
// 根据拓扑排序遍历DAG
for _, name := range d.order {
wg.Add(1)
node := d.nodeMap[name]
nodeRT := &NodeRuntime{Node: node, HitsChan: make(chan []Hit)}
nodeRuntimeMap[name] = nodeRT
go func() {
defer wg.Done()
defer close(nodeRT.HitsChan)
hits := []Hit{}
for _, pre := range node.Pre {
// channel是无缓冲的,当父节点没有将结果写入时子节点将在此处阻塞
hits = append(hits, (<- nodeRuntimeMap[pre.Name].HitsChan)...)
}
hits = node.Process(c, hits)
for _, next := range node.Next {
nodeRuntimeMap[next.Name].HitsChan <- hits
}
}()
}
wg.Wait()
lastNodeName := d.order[len(d.order) - 1]
hits := <- nodeRuntimeMap[lastNodeName].HitsChan
return hits
}
4.2.4 Processor
processor是一个节点的执行器,包含具体的处理逻辑。
type Processor interface {
func Process(c *ExecContext, hits []Hit) []Hit
}
降级策略: 所有节点都看作非关键节点,因此processor若出现失败情况则视为直接跳过
4.2.5 边规则
边规则是一类输出为布尔值的表达式,用来在运行时动态决策执行流程
规则引擎使用开源的expr,内置常用操作函数,支持用.访问属性值,因此规则可以写成len(Hits) == 0 或者 isSafe == 1的形式,开发和使用成本均比较低
type Condition struct {
rule string
}
func (c *ExprCondition) IsSatisfied(env any) (bool, error) {}