自制遥控机械臂
自适应iframe高度
Passkey开发指南
一文搞懂Passkey
教育年金险
快返年金险
养老年金险
增额终身寿险
终身寿险
定期寿险
Web3全栈开发指南
期权交易
套利原理
永续合约
混合保证金
反向合约原理
期货交易
算法稳定币
Uniswap入门
做市指南
ETF指南
流动性概述
现货交易
元宇宙落地指南
用FPGA写Hello World
兵马俑
骊山游记
JDK11新特性解读
挑战2016北京马拉松
秋游红叶岭
纯CSS气泡效果
Python yield使用浅析
快速排序算法
JSON入门指南
J2ME概念解析

设计AI驱动的搜索引擎:基于语义的问答系统

廖雪峰
资深软件开发工程师,业余马拉松选手。

传统的搜索引擎,本质上就是一个倒排索引。当用户搜索若干关键字时,搜索引擎根据倒排索引给出包含关键字的网页,搜索结果依赖于用户给出的关键字,如站内搜索java撮合引擎,则给出结果Java教程/设计撮合引擎证券交易系统撮合引擎的设计等页面。

随着大模型的出现,用户可以直接以自然语言提问并获得结果,这种交互方式,将来会逐步取代基于关键字的搜索。

例如,用户使用GPT提问:用Java实现一个证券交易系统的撮合引擎,应该如何设计架构?

以下是GPT-3.5返回的结果:

设计一个证券交易系统的撮合引擎时,可以考虑以下几个方面的架构设计:

  1. 消息队列:使用消息队列来处理订单的流动和交易的撮合过程。消息队列可以是Kafka、RabbitMQ或者ActiveMQ等,用于接收和分发订单信息。
  2. 订单管理:设计一个订单管理模块,用于接收和处理用户提交的订单请求。该模块应包括验证订单的有效性、委托订单的处理和存储等功能。
  3. 撮合引擎:设计一个撮合引擎模块,用于撮合订单。该模块应根据订单的价格和数量等特征,匹配相应的买单和卖单,并生成交易成交信息。
  4. 数据存储:使用数据库或者其他数据存储方式存储订单信息、成交信息和交易市场数据等。
  5. 规则引擎:考虑引入一个规则引擎,用于处理复杂的交易规则和策略。规则引擎可以根据预定的规则对订单进行匹配和撮合。
  6. 自动化机制:设计一个自动化机制来监控交易系统的运行状态和风险控制。可以包括流控机制、防止恶意交易的规则等。
  7. 实时报告和余额管理:设计实时报告和余额管理模块,用于生成交易成交信息的报告和管理用户的余额信息。
  8. 可扩展性:考虑系统的可扩展性,可以将撮合引擎设计为分布式系统,允许多个撮合引擎实例并行处理订单流。
  9. 安全性:确保系统的安全性,包括数据加密、身份验证、权限控制以及防止恶意攻击等。
  10. 监控和日志:设计监控和日志模块,用于记录系统的运行状态和交易记录,便于故障排除和系统性能优化。

除了以上的基本架构设计,还可以根据具体需求和业务规则进行调整和优化,以满足系统的性能、可靠性和易用性等方面的要求。

这个回答非常的“GPT”,即什么都说了,但也等于什么都没说。你问它如何实现一个在线聊天系统,如何实现一个在线商城,上述答案也基本适用。

因为大模型训练使用的是大量的“通识”知识,对于不包含在它的训练数据里的特定行业知识、专业文档的内容,大模型只能给出泛泛的回答。

因此,参考《使用基于Embedding的搜索来实现问答》,我们在提问时嵌入《设计撮合引擎》一文的内容:

用Java实现一个证券交易系统的撮合引擎,应该如何设计架构?

请参考下文回答上述问题:

"""
在证券交易系统中,撮合引擎是实现买卖盘成交的关键组件。我们先分析撮合引擎的工作原理,然后设计并实现一个最简化的撮合引擎。

...

下一步是实现订单簿OrderBook的表示。一个直观的想法是使用List<Order>,并对订单进行排序。但是,在证券交易中,使用List会导致两个致命问题:

插入新的订单时,必须从头扫描List<Order>,以便在合适的地方插入Order,平均耗时O(N);
取消订单时,也必须从头扫描List<Order>,平均耗时O(N)。
更好的方法是使用红黑树,它是一种自平衡的二叉排序树,插入和删除的效率都是O(logN),对应的Java类是TreeMap。

所以我们定义OrderBook的结构就是一个TreeMap<OrderKey, OrderEntity>,它的排序根据OrderKey决定。由业务规则可知,负责排序的OrderKey只需要sequenceId和price即可:

...

本文讨论并实现了一个可工作的撮合引擎核心。实现撮合引擎的关键在于将业务模型转换为高效的数据结构。只要保证核心数据结构的简单和高效,撮合引擎的业务逻辑编写是非常容易的。
"""

根据我们给出的参考文档,GPT回答如下:

根据给出的设计原则,可以设计一个证券交易系统的撮合引擎的架构如下:

  1. 定义核心数据结构:撮合引擎的核心数据结构是OrderBook(买卖盘),其中包含一个买盘(BuyBook)和一个卖盘(SellBook)。买盘按价格从高到低排序,卖盘按价格从低到高排序。
  2. 使用TreeMap实现OrderBook:为了保证插入、删除和查找操作的效率,可以使用TreeMap作为OrderBook的底层数据结构。TreeMap是一种自平衡的二叉排序树,插入和删除的效率都是O(logN)。
  3. 定义OrderKey和Comparator:为了对OrderBook进行排序,可以定义OrderKey作为排序的依据,OrderKey包含sequenceId(全局唯一的订单ID)和price(价格)。为了实现不同的排序,可以编写两个Comparator分别用于排序买盘和卖盘。
  4. 实现OrderBook的插入、删除和查找操作:通过OrderKey和Comparator,可以实现OrderBook的插入、删除和查找操作。可以提供以下方法:
    • getFirst(): 获取买卖盘的第一个订单。
    • remove(order): 从买卖盘中删除指定的订单。
    • add(order): 添加新的订单到买卖盘。
  5. 设计MatchEngine:MatchEngine是撮合引擎的核心组件,包含一个买盘和一个卖盘的OrderBook,以及一个最新成交价。处理订单的流程如下:
    • 根据订单的方向(买入或卖出),选择买盘或卖盘作为MakerBook,另一个作为TakerBook。
    • 从MakerBook中按价格优先的顺序匹配价格合适的挂单(Maker)。如果匹配成功,则成交并更新挂单的数量,直到成交双方有一方完全成交或成交条件不满足。
    • 如果Taker订单未完全成交,则将其作为新的Maker挂到TakerBook中。
  6. 定义MatchResult:MatchResult包含撮合结果的信息,包括Taker订单、匹配的Maker订单、成交价格、成交数量等。
  7. 实现撮合引擎的处理方法:根据订单的方向,选择合适的买盘或卖盘作为MakerBook,然后调用MatchEngine的处理方法。处理方法根据价格匹配挂单,直到成交双方有一方完全成交或成交条件不满足。如果Taker订单未完全成交,则将其作为新的Maker挂到买盘或卖盘中。
  8. 实现多个交易对:如果需要实现多个交易对,可以创建一个MatchEngineGroup来管理多个MatchEngine实例,每个MatchEngine实例处理一个交易对。在处理订单时,根据订单的交易对ID选择合适的MatchEngine实例进行处理。

通过以上架构设计,可以实现一个简化的证券交易系统的撮合引擎。根据具体需求,还可以进一步优化和扩展该架构。

把专业的文档内容连同提问喂给GPT,我们得到的回答质量就有了质的提升。

现在只剩下一个问题需要解决:当用户提问“用Java实现一个证券交易系统的撮合引擎,应该如何设计架构?”,我们就把《设计撮合引擎》的内容连同提问扔给GPT,下次用户提问“如何用Java实现一个证券交易系统的清算系统?”,我们应该扔啥文档给GPT?

这个需求其实就是:

当用户提问:“如何实现xyz?”,我们怎么根据xyz查找到关联度最高的文档比如“xyz设计指南”?这就要用到Embedding和Vector数据库。

Embedding问答系统架构

一个预训练的大模型包含通识知识,但它无法访问很多不对外公开的专业文档、实时更新的数据等,因此,为了让大模型根据专业内容回答用户提问,我们需要使用Vector Embedding(向量嵌入)。

什么是Vector?Vector是一个由若干浮点数表示的数组,可以将任意的文本、图片、视频等转换为Vector,无论输入的数据是啥,Vector输出为固定大小,这一点有点像哈希,但与哈希不同的是,通过比较Vector的相似度,我们就可以找到与指定输入最相似的若干文本。

所以,Vector DB最近很火,我们要使用Vector Embedding,就需要使用一个Vector DB。

OpenAI官方列出了如下Vector DB:

我们使用Redis作为Vector DB来存储和比较Vector。

创建Vector

使用Embedding之前,我们要为现有的每一个文档创建对应的Vector。假设存储文档的数据库表如下:

doc_id doc_title doc_content
123 设计撮合引擎 在证券交易系统中,撮合引擎是...
456 设计清算系统 清算系统就是处理撮合结果...
789 安装JDK Install JDK我们第一件事情就是安装JDK...

如果关系数据库支持Vector,那么我们可以直接加一个存储Vector的列:

doc_id doc_title doc_content doc_vector
123 设计撮合引擎 在证券交易系统中,撮合引擎是... [-0.02809206, -0.00365088, -0.00299650, ...]
456 设计清算系统 清算系统就是处理撮合结果... [-0.00990966, 0.00471535, -0.00117799, ...]
789 安装JDK Install JDK我们第一件事情就是安装JDK... [0.00966310, -0.01571467, 0.00255492, ...]

只是现在关系数据库还没有对Vector的支持,而我们选择Redis存储Vector,所以先准备Redis环境:

启动Redis Stack

Redis Stack包含的Redisearch支持Vector,使用Redis Stack最简单的方法是通过docker启动:

$ docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest

创建Vector索引

用Python通过redis库创建索引的代码如下:

from redis import Redis
from redis.exceptions import ResponseError

from redis.commands.search.field import VectorField, TagField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType

# redis连接:
redis = Redis('127.0.0.1', 6379)

def create_index(redis, index_name, vector_dim):
    tag_field = TagField('doc_id')
    embedding_field = VectorField('embedding', 'FLAT', {
        'TYPE': 'FLOAT32',
        'DIM': vector_dim,
        'DISTANCE_METRIC': 'COSINE',
    })
    try:
        redis.ft(index_name).create_index(
            fields=[tag_field, embedding_field],
            definition=IndexDefinition(
                prefix=['doc:'],
                index_type=IndexType.HASH
            )
        )
    except ResponseError as err:
        if str(err) == 'Index already exists':
            pass
        else:
            raise err

create_index(redis, 'idx_doc', 1536)

我们创建的索引包含一个TagField和一个名为embeddingVectorFieldVectorField有很多选项,其中,DIM是Vector数组的大小,这里传入1536,是因为GPT返回的Vector大小就是1536

上述代码对应的Redis命令如下:

127.0.0.1:6379> FT.CREATE idx_doc ON HASH PREFIX 1 doc: SCORE 1.0 SCHEMA doc_id TAG SEPARATOR , embedding VECTOR FLAT 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE

将文档内容转换为Vector

紧接着,我们用OpenAI提供的Embedding API把文档内容转换为Vector。假定我们要转换以下3个文档,均以字符串表示:

docMatchEngine = '在证券交易系统中,撮合引擎是实现买卖盘成交的关键组件...'
docClearing = '...要把撮合结果最终实现为买卖双方的资产交换,就需要清算...'
docInstallJDK = '因为Java程序必须运行在JVM之上,所以,我们第一件事情就是安装JDK...'

调用API获得Vector结果并打印:

import openai

for doc in [docMatchEngine, docClearing, docInstallJDK]:
    resp = openai.Embedding.create(input=doc, model='text-embedding-ada-002')
    embedding = resp['data'][0]['embedding']
    print(embedding)

我们得到3组Embedding:

[-0.028092065826058388, -0.003650880651548505, -0.002996509661898017, ...]
[-0.00990966334939003, 0.004715353716164827, -0.0011779952328652143, ...]
[0.009663100354373455, -0.015714673325419426, 0.002554928883910179, ...]

索引文档的Vector

当我们获得了文档对应的Vector后,就可以将它们添加到Redis的索引:

from redis import Redis
import numpy as np

# 上一步获得的Vector:
vecMatchEngine = [-0.028092065826058388, -0.003650880651548505, -0.002996509661898017, ...]
vecClearing = [-0.00990966334939003, 0.004715353716164827, -0.0011779952328652143, ...]
vecInstallJDK = [0.009663100354373455, -0.015714673325419426, 0.002554928883910179, ...]

def add_doc(redis, doc_id, embedding):
     key = f'doc:{doc_id}'
     obj = dict(doc_id=doc_id, embedding=np.array(embedding, dtype=np.float32).tobytes())
     redis.hset(key, mapping=obj)

add_doc(redis, 'id_123_match_engine', vecMatchEngine)
add_doc(redis, 'id_456_clearing', vecClearing)
add_doc(redis, 'id_789_install_jdk', vecInstallJDK)

其中,hset()命令指定的key必须以doc:开头,因为创建索引时指定了prefix=doc:,只有以doc:开头的数据才会被加入索引。doc_id可视为文档在数据库中的主键,这里用类似id_123_match_engine这样的字符串表示是为了后续查看较方便。

上述代码对应的Redis命令如下:

127.0.0.1:6379> HSET doc:id_123_match_engine doc_id id_123_match_engine embedding U!\xe6\xbc\x9dCo\xbb...\xea\xbb

其中,embedding字段需要用numpy将数组转换为字节再传入。

搜索相关文档

当我们准备好Redis的索引后,就可以根据用户提问搜索相似度最高的文档。这个过程分两步:

  1. 将用户提问转换成Vector;
  2. 根据此Vector在Redis中搜索相关文档。

Python代码如下:

def query_doc(redis, q):
    print(q)
    # 把q转换成Vector:
    q_resp = openai.Embedding.create(input=q, model=EMBEDDING_MODEL)
    q_embedding = q_resp['data'][0]['embedding']
    # 返回文档数量:
    q_results = 2
    # 检索的VectorField名称:
    q_vector_field = 'embedding'
    # 检索的Vector参数:
    q_params={'vec': np.array(q_embedding, dtype=np.float32).tobytes()}
    # 构造Query:
    str_query = f'*=>[KNN {q_results} @{q_vector_field} $vec AS score]'
    query = Query(str_query).sort_by('score').return_fields('doc_id', 'score').paging(0, q_results).dialect(2)
    # 执行Query:
    results = redis.ft('idx_doc').search(query, query_params=q_params)
    for doc in results.docs:
        print(doc)

query_doc(redis, '如何用Java实现撮合引擎?')

当用户提问“如何用Java实现撮合引擎?”时,返回结果如下:

如何用Java实现撮合引擎?
[-0.019103992730379105, -0.008535079658031464, -0.011284259147942066, ...]
Document {'id': 'doc:id_123_match_engine', 'payload': None, 'score': '0.171741425991', 'doc_id': 'id_123_match_engine'}
Document {'id': 'doc:id_789_install_jdk', 'payload': None, 'score': '0.212927222252', 'doc_id': 'id_789_install_jdk'}

首先将提问转换为Vector,然后,我们根据KNN相似度算法搜索,得到相关度最高的两个文档doc:id_123_match_enginedoc:id_789_install_jdk(注意score越小说明相关度越高),根据返回的doc_id从数据库中读出文档内容,后续流程就是将文档内容嵌入到提问中发给GPT获得回答了。

上述Python代码对应的Redis命令如下:

127.0.0.1:6379> FT.SEARCH idx_doc *=>[KNN 2 @embedding $vec AS score] RETURN 2 doc_id score SORTBY score ASC DIALECT 2 LIMIT 0 2 params 2 vec \xaf\x9a\xb2...\xd1M\xe0

如果SQL数据库支持Vector,那么上述整个流程其实就相当于一个SQL查询:

SELECT doc_id, doc_content FROM docs WHERE doc_vector LIKE text2vec(?) ORDER BY doc_vector LIMIT ?

以上就是使用Embedding实现一个AI驱动的基于语义的问答系统的步骤。后续工程开发需要考虑的要点如下:

  1. 文档在数据库中创建、修改、删除时,要相应地更新Redis的索引;
  2. 受GPT输入限制,一个非常大的文档需要先分割成若干块,给每一个文本块生成Vector并索引;
  3. 对用户请求做限流等。

通过大模型的Embedding实现语义搜索功能十分强大,因为Vector存储的是语义相关性,即使用户使用另一种语言提问,也可以轻松根据语义找出相关性最高的文档:

>>> query_doc(redis, '環境変数を設定するにはどうすればよいですか?')
環境変数を設定するにはどうすればよいですか?
[0.01713828556239605, -0.012635081075131893, 0.00681354571133852...]
Document {'id': 'doc:id_789_install_jdk', 'payload': None, 'score': '0.217503964901', 'doc_id': 'id_789_install_jdk'}
Document {'id': 'doc:id_456_clearing', 'payload': None, 'score': '0.322280108929', 'doc_id': 'id_456_clearing'}

如果我们选择其他大模型,例如ChatGLM或LLaMa,那么就把OpenAI提供的Embedding API替换为其他大模型的接口即可。

小结

使用Embedding能轻松实现基于语义的问答系统,非常适合检索内部文档、用于客服系统等。




Comments

Loading comments...