为什么写这篇文章?最近在思考如果想要构建一个个人知识管理的Agent应该怎样设计才好,然后最近看到这样一个项目,就想剖析一下它的架构,看一下它的设计思想。然后一些剖析的过程就沉淀到本文当中。本文档主要从整体架构、dataflow的视角剖析khoj项目,分析应该一个知识管理Agent系统的架构是怎样的。如果你也在做类似的事,希望能有一定的启发作用。
简介
Khoj 是一款由人工智能驱动的个人知识管理系统,它通过智能搜索、聊天和智能体功能来扩展认知能力。它与个人文档和互联网集成,以提供上下文相关的答案,并协助进行研究和知识检索。从用户视角看,它为每个用户构建了多个可以使用联网搜索和本地知识库的Agent,从系统视角来看,它是一个拥有构建智能搜索、定制化Agent能力的平台。
那么这样一个系统是如何构建呢?它有哪些组成?接下来我们逐步进行探索。
整体架构
先看下整体的系统架构。架构中的Core包含了几个重要的子系统。APIRouter负责请求的转发和路由,将前端的请求路由到对应的后端接口中去,并通过中间件的能力做一些额外的处理;Authentication System负责请求的认证,只有通过请求验证的请求才会继续被后端系统处理。SearchAndIndexing系统则是负责构建本地知识库并提供搜索能力;Conversation System则是提供系统中对话能力的基本支持;Agent系统则是提供Agent的定制化能力,和Conversation System紧密集成。
值得一提的是,在khoj中,后端系统采用混合架构搭建。API层(API Routers)使用FastAPI,而数据库和认证系统则是使用Django。为什么这样设计呢?一个猜想是这样设计可能是想结合两种框架的优势:对于FastAPI, 它能提供现代、高性能的API层,支持异步处理和自动API文档; 对于Django,它能提供成熟的ORM、认证系统和管理界面。这样系统即有高性能的API处理能力,又有强大的数据库和认证功能。
接下来我们逐个来看每一个子系统:
API Routers
API Routers负责处理所有发往服务器的 HTTP 请求,通过适当的中间件对其进行处理,并将响应返回给客户端。API Routers采用模块化的设计,每个模块都处理特定的功能。
当接收到一个请求时,系统的标准处理流程如下:
API Router和数据库之间的交互如下,通过Adapter实现和数据库的隔离。Adapter层的设计意图是提供一个抽象层,将API层与数据库模型层分离,主要目的是:
- 封装数据库操作:简化API层的代码,隐藏数据库操作的复杂性提供业务逻辑:在适配器中实现业务规则和逻辑增强可测试性:通过适配器模式使得单元测试更容易编写提高代码复用性:适配器中的方法可以被多个API端点调用
处理过程中的一段代码如下:
def chat_history(request: Request, common: CommonQueryParams, conversation_id: Optional[str] = None, n: Optional[int] = None): user = request.user.object validate_chat_model(user) # Load Conversation History using adapter conversation = ConversationAdapters.get_conversation_by_user( user=user, client_application=request.user.client_app, conversation_id=conversation_id ) # Logic using the retrieved model...
Authentication System
Authentication System的整体架构如下:
当收到一个具体接口的请求时,首先要对请求进行身份认证。这里的身份认证是通过中间件来实现的。
// 中间件注册,指定使用UserAuthenticationBackend后端app.add_middleware(AuthenticationMiddleware, backend=UserAuthenticationBackend())
更具体一些的认证流程如下:
- 请求首先通过
SessionMiddleware
,它处理会话cookie然后通过AuthenticationMiddleware
,它使用UserAuthenticationBackend
来验证用户UserAuthenticationBackend.authenticate
方法检查各种认证方式(会话、令牌等)如果认证成功,它返回AuthCredentials
和AuthenticatedKhojUser
然后请求继续到API路由,路由可以通过request.user
访问已认证的用户通过这种方式来完成请求的认证。
认证系统支持多种认证方法,并且支持多种类型的客户端。通过中间件和UserAuthenticationBackend
来为请求提供认证。
Search and Indexing
Khoj 的搜索和索引系统将各种内容源(org 文件、Markdown、PDF 等)转换为可搜索的向量表示形式,从而实现语义搜索功能。该系统由几个关键组件协同工作,以提供快速且相关的搜索结果。这里主要分成两个阶段:索引和搜索。
索引
在索引阶段需要做的事情是将预先准备好的不同源的文件进行处理,转换成对搜索有利的形态。在上图中对应的主要是数据处理层和数据处理管道。
数据处理
数据处理包括了图中的数据输入层和数据处理管道的部分。数据输入层的主要目标是将不同源的数据进行收集,便于后面内容的统一处理。在fs_syncer.py文件中的collect_files()方法会基于用户配配置来进行文件收集。之后会调用对应类型的文本处理方法如:get_org_files()
, get_markdown_files()
等从不同类型的文件中提取出文本内容。最后会将这些文件内容转换成统一的数据结构entries,方便后续进行统一的处理。之后,进入embedding阶段。
Embedding生成
在embeding阶段需要完成的是对文本内容进行embedding,这里有两种embedding的方式。这里使用BiEncoder对entries数据进行embedding。(关于不同类型的model,在下面搜索流程中继续说明)
搜索
当用户进行搜索时,搜索的处理流程如下:
- 查询编码:使用 EmbeddingsModel 将用户的自然语言查询编码为向量嵌入。相似性搜索:通过 EntryAdapters.search_with_embeddings(),将查询嵌入与存储的文档嵌入进行比较,以找到语义相似的内容。(此时使用BiEncoder模型)重新排序:可以选择使用功能更强大的 CrossEncoderModel 对结果进行重新排序,该模型直接比较查询 - 文档对,以提高精度。(此时使用CrossEncoderModel )结果整理:在将结果返回给用户之前,对结果进行去重和格式化处理。
这里涉及到两个编码模型,BiEncoder和CrossEncoder。BiEncoder模型通常包含两个独立的编码器,两个编码器通常是独立训练的,分别用于处理两个不同的输入模态或输入序列。由于两个编码器是独立的,可以并行处理输入,这使得模型在推理阶段的速度非常快。但同时,由于两个编码器是独立的,它们生成的向量表示可能无法捕捉到输入之间的复杂交互关系一般,BiEncoder一般用于从大量数据中找到语义相似的数据的粗筛。CrossEncoder模型通常使用一个编码器来处理两个输入的组合,编码器的输出是一个固定维度的向量,表示两个输入的联合语义信息。由于CrossEncoder模型可以同时处理两个输入的组合,能够捕捉到输入之间的复杂交互关系。但同时于由于CrossEncoder模型需要同时处理两个输入的组合,推理速度相对较慢,因此经常用精排。在上面的流程中,就是使用BiEncoder进行粗筛,再由CrossEncoder进行精排。
Conversation System
对话系统是整个项目中重要的一部分,当用户开始对话时,会经历以下流程。
接下来我们来针对每个部分深入看下。
生成查询
在对知识检索之前,首先要理解用户的真实意图是什么,要查询哪些内容。这一步通常通过extra_questions来完成,在实现上通常由LLM来进行实现。每个LLM provider都需要实现这个方法。
搜索上下文内容
在获取查询之后,使用这个查询内容按前面提到的搜索步骤的内容进行检索,得到和当前检索最相关的内容作为上下文,之后提交给LLM生成回复。这个流程和传统的RAG流程类似。上下文内容有多种类型,比如之前已经进行了索引的内容,在线搜索的内容,以及其他内容等,在这一步需要完成需要搜索的相关上下文内容的获取。
数据数据源的选择也是一个动态决策过程。类似Agent选择tools的逻辑,由LLM来决定哪些数据源更加匹配当前的查询。
流式响应
在经历过上面的处理之后,基本上能够获取到以下上下文信息,此时可以将这些内容都传递给LLM,以生成最终的响应。
最终的结果以流式返回,这里的流式响应主要是通过ThreadedGenerator来异步的返回生成内容。响应流程如下:
在开始处理时,由converse_function发起llm的调用流程,并创建一个ThreadedGenerator。LLM流式返回chunks,并将chunks发送给ThreadedGenerator, ThreadedGenerator通过yield方法来流式返回chunk。在LLM thread中,每一种LLM provider都会实现自己独特的流程。
Agent System
Agent System和Conversation System是紧密集成的。当开始新对话时,用户可以选择一个智能体(匿名隐藏智能体, 该智能体允许特定于对话的设置),用户可以在对话过程中切换智能体,每个对话都可以关联到一个特定的智能体。Agent System和Conversation System的集成关系如下:
来看下Agent System的架构,其中部分模块是可以定制化的。
在Agent系统中,最核心的是Agent的数据模型:
其中各字段的含义如下:
属性 | 描述 |
---|---|
slug | 代理的唯一标识符 |
name | 显示名称 |
personality | 定义代理行为的指令/提示 |
privacy_level | 控制可见性(私有、受保护、公开) |
style_icon | 用于视觉识别的图标 |
style_color | 代理的颜色主题 |
chat_model | 此代理使用的LLM模型 |
input_tools | 代理可以访问的工具(例如,搜索、网络、代码) |
output_modes | 代理可以使用的响应格式(例如,文本、语音) |
is_hidden | 是否为特定对话隐藏的代理 |
creator | 创建代理的用户 |
在Agent系统中,代理有两种类型: 常规代理和隐藏代理。常规代理可以在多个对话中,隐藏代理则是特定于单个对话,不显示在Agent目录中。常规代理可以设置privacy_level,而隐藏代理则始终是私有的。
最后
至此,我们分析了khoj的系统架构以及架构中的子系统的实现,但对于其中更多的细节,这里没有更进一步的深入。本文档期望能让你对个人知识管理系统的设计从架构设计层面有个基本的认知。关于其中的更多细节感兴趣的话可以去看看项目源码。个人感觉如果我们业务中也想要设计类似的系统,这个项目还是比较有参考性的。