概述
本文是ragflow内容解析系列的第二篇。本文将详细解析Ragflow是进行一轮信息检索的过程,并通过实验对比 Elasticsearch和 Infinity在检索效率上的差异。
1. 前文错误更正
首先更正一下前文Ragflow技术栈分析及二次开发指南中存在的一处错误,文中错误将ragflow-infinity
写做为前端系统
,实际上,infinity是ragflow官方开发的高效向量数据库和搜索引擎,和elasticsearch功能类似。
2. Elasticsearch简介与可视化
2.1 Elasticvue可视化
Elasticsearch 是一个开源的分布式搜索和分析引擎。
前文中,并没有对Elasticsearch进行可视化,在此节中,使用比Kibana
更轻量化的Elasticvue
可视化工具对Elasticsearch内容进行查看。
Elasticvue下载链接:https://elasticvue.com/installation
docker中,启动Elasticsearch容器。
使用Elasticvue连接本地1200
端口:
默认用户名:elastic
,默认密码:infini_rag_flow
进入管理首页,可以看到Elasticsearch中,存在1个节点(nodes),2个分片(shards),1个索引(indices)
2.2 集群
在Elasticsearch中,集群(Cluster)是最大的单位,集群是由一个或多个节点(Node)组成的分布式系统。集群可以自动进行负载均衡,将搜索请求和索引请求分配到各个节点上,以实现数据的均衡存储和处理[1]。
连接的1200端口,就是默认的一个集群。
2.3 节点
集群的下一级单位是节点(Node),每个节点都是一个独立的工作单元,负责存储数据、参与数据处理(如索引、搜索、聚合等)。
节点主要有以下类型[1][2]:
- 主节点(Master Node):负责集群范围内的元数据管理和变更,如索引创建、删除、分片分配等。
- 主节点候选(Master-eligible Node):每一个节点启动后,默认就是一个主节点候选, 可以参加选主流程,成为主节点。当第一个节点启动时候,它会将自己选举成主节点。
- 数据节点(Data Node):负责保存分片上存储的所有数据,当集群无法保存现有数据的时候,可以通过增加数据节点来解决存储上的问题。
- 协调节点(Coordinating Node):负责接收 Client 的请求,将请求分发到合适的节点,最终把结果汇集到一起返回给客户端,每个节点默认都起到了协调节点的职责。
- 冷热节点(Hot & Warm Node) :热节点(Hot Node)就是配置高的节点,可以有更好的磁盘吞吐量和更好的 CPU,冷节点(Warm Node)存储一些比较久的节点,这些节点的机器配置会比较低。
- 预处理节点(Ingest Node):预处理操作允许在索引文档之前,即写入数据之前,通过事先定义好的一系列的 processors(处理器)和 pipeline(管道),对数据进行某种转换。
在此项目中,只有一个节点,可以看到,它既是主节点,也是数据节点和预处理节点。
2.4 索引
每个节点可以包含多个索引,索引相当于关系型数据库中的数据表,在 ES 中,索引是一类文档的集合。
在此项目中,只有一个索引,里面有8个分段(ES将数据分成多个段,提升搜索性能),57个文档(类似于数据库的记录),这里的文档记录是我上传了一个文件,然后切分成了57个chunk。
索引中有很多字段,比较主要的有以下几个字段:
- title_tks:文档标题的分词结果,用于标题的精确匹配和相关性排序
- title_sm_tks:文档标题的细粒度分词结果,捕获标题中的更细粒度语义单元,提高召回率
- content_with_weight:原始内容文本,存储原始文本内容,用于结果展示和后处理
- content_ltks:文档内容的标准分词结果,用于全文检索的主要字段
- content_sm_ltks:文档内容的细粒度分词结果,提高内容检索的召回率
- page_num_int:文档的页码信息,在搜索结果中展示页码信息
- position_int:文档或段落在集合中的位置信息,在搜索结果中作为返回字段之一
- top_int:文档的优先级,用于自定义排序
- q_1024_vec:1024维的密集向量表示,存储文档的语义向量表示,用于向量相似度搜索
2.5 分片
不同于前面几个概念,分片更多是物理层面上的优化。由于单台机器无法存储大量数据,ES 可以将一个索引中的数据切分为多个分片(Shard),分布在多台服务器上存储。有了分片就可以横向扩展,存储更多数据,让搜索和分析等操作分布到多台服务器上去执行,提升吞吐量和性能[2]。
分片可分成主分片(Primary Shard)和副本分片(Replica Shard),主分片用于将数据进行扩展,副本分片是主分片的拷贝,当主分片故障时,保证数据不会丢失。
在此项目中,es的相关设定在conf/mapping.json
文件中进行设置,默认只指定了2个主分片,未设定副本分片。
"index": {
"number_of_shards": 2,
"number_of_replicas": 0,
}
3. 检索过程解析
3.1 检索过程分解
在RAGFlow中,检索内容主要分以下几个步骤:
- 用户查询处理 :用户的问题首先被处理成关键词和向量表示
- 检索执行 :使用处理后的查询在知识库中检索相关内容
- 结果处理 :对检索结果进行处理和重排序
- 提示词构建 :将检索到的内容构建成提示词
这部分的核心代码为rag/nlp/search.py
,其主要包括以下几个函数:
-
get_vector
这个方法用于获取文本的向量表示,并创建一个 MatchDenseExpr 对象用于向量搜索。它接收文本、嵌入模型、topk和相似度阈值作为参数。 -
search
这是核心搜索方法,处理搜索请求并返回搜索结果。它支持纯关键词搜索和混合搜索(关键词+向量)。 -
rerank
对搜索结果进行重排序,结合关键词相似度和向量相似度。 -
rerank_by_model
使用专门的重排序模型对搜索结果进行重排序。 -
retrieval
这是一个高级搜索方法,整合了搜索、重排序和结果处理的完整流程。
3.2 检索过程模拟
为了更好地说清楚整个检索流程,我选取了部分核心代码,重构了单独的检索py脚本。
尽管在此搜索过程中,并未用到 infinity,但在相关依赖初始化中,import infinity,对于此依赖,直接通过pip uninstall infinity
安装会出现版本问题,正确版本安装方式为:
pip install infinity-sdk==0.6.0.dev3
为了方便环境构建,后面我会将使用的环境依赖打包出一个requirements.txt
,放在本文附录,方便读者用pip install -r requirements.txt
进行安装。
脚本内容如下:
import sys
import os
import logging
import json
import numpy as np
from timeit import default_timer as timer
from collections import defaultdict
# 添加项目根目录到路径
sys.path.append(os.path.abspath("."))
# 导入必要的模块
from rag.utils.doc_store_conn import MatchTextExpr, MatchDenseExpr, FusionExpr, OrderByExpr
from rag.utils.es_conn import ESConnection
from rag.nlp.query import FulltextQueryer
from rag.nlp import rag_tokenizer
from rag.llm.embedding_model import DefaultEmbedding
from rag import settings
# NLTK资源下载
import nltk
try:
nltk.data.find('tokenizers/punkt_tab')
except LookupError:
nltk.download('punkt_tab')
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger('retrieval_test')
class RetrievalProcess:
"""复刻 RAGFlow 中的检索过程"""
def __init__(self, tenant_id="default", kb_ids=None, embedding_model_name=None):
"""初始化检索过程"""
self.tenant_id = tenant_id
self.kb_ids = kb_ids or []
self.es_conn = ESConnection()
self.queryer = FulltextQueryer()
# 获取嵌入模型
if embedding_model_name:
# 修改嵌入模型的初始化方式
self.embd_mdl = DefaultEmbedding(key=None, model_name=embedding_model_name)
else:
# 使用默认嵌入模型
self.embd_mdl = None
# 向量相似度权重 (1-keywords_similarity_weight)
self.vector_similarity_weight = 0.3
# 关键词相似度权重
self.keywords_similarity_weight = 0.7
# 相似度阈值
self.similarity_threshold = 0.2
# 检索结果数量
self.top_n = 5
def process_query(self, question):
"""处理用户查询"""
logger.info(f"处理用户查询: {
question}")
# 1. 文本处理和分词
start = timer()
match_expr, keywords = self.queryer.question(question)
end = timer()
logger.info(f"查询处理耗时: {
(end - start) * 1000:.2f}ms")
logger.info(f"提取的关键词: {
keywords}")
# 2. 生成向量表示
if self.embd_mdl:
start = timer()
query_vector, _ = self.embd_mdl.encode([question])
query_vector = query_vector[0]
end = timer()
logger.info(f"向量编码耗时: {
(end - start) * 1000:.2f}ms")
else:
query_vector = None
logger.warning("未配置嵌入模型,将仅使用关键词检索")
return match_expr, keywords, query_vector
def build_search_query(self, match_expr, query_vector=None):
"""构建搜索查询"""
# 准备查询条件
select_fields = ["id", "title", "content", "content_ltks", "content_sm_ltks", "kb_id", "doc_id", "docnm_kwd"]
highlight_fields = ["title", "content"]
condition = {
"kb_id": self.kb_ids} if self.kb_ids else {
}
# 构建匹配表达式列表
match_exprs = []
# 添加文本匹配表达式
if match_expr:
match_exprs.append(match_expr)
# 添加向量匹配表达式
if query_vector is not None:
# 确定向量字段名称
vector_field = f"q_{
len(query_vector)}_vec"
# 添加向量字段到选择字段
select_fields.append(vector_field)
# 创建向量匹配表达式
vector_expr = MatchDenseExpr(
vector_column_name=vector_field,
embedding_data=query_vector.tolist(),
embedding_data_type='float',
distance_type='cosine',
topn=self.top_n,
extra_options={
"similarity": 0.1}
)
match_exprs.append(vector_expr)
# 添加融合表达式
fusion_expr = FusionExpr(
"weighted_sum",
self.top_n,
{
"weights": f"{
self.keywords_similarity_weight}, {
self.vector_similarity_weight}"}
)
match_exprs.append(fusion_expr)
return select_fields, highlight_fields, condition, match_exprs
def search(self, question, index_names):
"""执行搜索"""
# 处理查询
match_expr, keywords, query_vector = self.process_query(question)
# 构建搜索查询
select_fields, highlight_fields, condition, match_exprs = self.build_search_query(match_expr, query_vector)
# 执行搜索
start = timer()
search_response = self.es_conn.search(
selectFields=select_fields,
highlightFields=highlight_fields,
condition=condition,
matchExprs=match_exprs,
orderBy=None,
offset=0,
limit=self.top_n,
indexNames=index_names,
knowledgebaseIds=self.kb_ids
)
# 从响应中提取实际的结果列表
results = search_response.get("hits", {
}).get("hits", [])
end = timer()
logger.info(f"搜索耗时: {
(end - start) * 1000:.2f}ms")
logger.info(f"搜索结果数量: {
len(results)}")
# 如果结果为空且使用了向量搜索,尝试降低匹配阈值重新搜索
if len(results) == 0 and query_vector is not None:
logger.info("搜索结果为空,尝试降低匹配阈值重新搜索")
match_expr, _ = self.queryer.question(question, min_match=0.1) # 降低min_match
select_fields, highlight_fields, condition, match_exprs = self.build_search_query(match_expr, query_vector)
# 修改向量匹配表达式的相似度阈值
for expr in match_exprs:
if isinstance(expr, MatchDenseExpr):
expr.extra_options["similarity"] = 0.17 # 提高similarity阈值
# 重新执行搜索
start = timer()
search_response = self.es_conn.search(
selectFields=select_fields,
highlightFields=highlight_fields,
condition=condition,
matchExprs=match_exprs,
orderBy=None,
offset=0,
limit=self.top_n,
indexNames=index_names,
knowledgebaseIds=self.kb_ids
)
results = search_response.get("hits", {
}).get("hits", [])
end = timer()
logger.info(f"重新搜索耗时: {
(end - start) * 1000:.2f}ms")
logger.info(f"重新搜索结果数量: {
len(results)}")
# 如果有向量和分词,计算混合相似度并重新排序
if query_vector is not None and len(results) > 0:
results = self.rerank_results(results, query_vector, keywords)
return results
def rerank_results(self, results, query_vector, query_tokens):
"""重新排序搜索结果"""
start = timer()
# 提取文档向量和分词
doc_vectors = []
doc_tokens = []
for result in results:
# 提取向量
doc_vectors.append(np.array(result["_source"]["q_1024_vec"]))
# 提取分词
doc_tokens.append(result["_source"]["content_ltks"])
# 计算混合相似度
query_tokens_str = " ".join(query_tokens)
hybrid_scores, token_scores, vector_scores = self.queryer.hybrid_similarity(
query_vector, doc_vectors, query_tokens_str, doc_tokens,
tkweight=self.keywords_similarity_weight,
vtweight=self.vector_similarity_weight
)
# 为结果添加分数
for i, result in enumerate(results):
result["hybrid_score"] = float(hybrid_scores[i])
result["token_score"] = float(token_scores[i])
result["vector_score"] = float(vector_scores[i])
# 查看各分数信息
logger.info(f"混合分数: {
hybrid_scores}")
logger.info(f"关键词分数: {
token_scores}")
logger.info(f"向量分数: {
vector_scores}")
# 按混合分数重新排序
results.sort(key=lambda x: x["hybrid_score"], reverse=True)
# 过滤低于阈值的结果
results[:] = [r for r in results if r["hybrid_score"] >= self.similarity_threshold]
end = timer()
logger.info(f"重排序耗时: {
(end - start) * 1000:.2f}ms")
logger.info(f"重排序后结果数量: {
len(results)}")
return results
def build_llm_prompt(self, question, results, system_prompt=None):
"""构建输入到LLM的提示"""
if system_prompt is None:
system_prompt = """你是一个智能助手。请基于提供的上下文信息回答用户的问题。
如果上下文中没有足够的信息来回答问题,请说明你无法回答,不要编造信息。
回答时请引用相关的上下文信息,并标明引用的来源。"""
# 按文档组织内容
doc2chunks = defaultdict(lambda: {
"chunks": []})
# 调试信息:打印结果结构
# logger.info(f"搜索结果结构示例: {json.dumps(results[0] if results else {}, ensure_ascii=False, indent=2)[:500]}...")
for i, result in enumerate(results):
# 获取文档名称 - 从_source字段中提取
source = result.get("_source", {
})
doc_name = source.get("docnm_kwd", f"文档{
i+1}")
# 提取内容
content = source.get("content_with_weight", "")
# 调试信息
# logger.info(f"文档 {doc_name} 内容长度: {len(content)} 字符")
# if content:
# logger.info(f"内容预览: {content[:100]}...")
# else:
# logger.info("警告: 内容为空!")
# 只有当内容不为空时才添加到对应文档的chunks中
if content:
doc2chunks[doc_name]["chunks"].append(content)
# 构建上下文信息
context_parts = []
for doc_name, doc_info in doc2chunks.items():
if not doc_info["chunks"]: # 跳过没有内容的文档
continue
txt = f"Document: {
doc_name}\n"
txt += "Relevant fragments as following:\n"
for i, chunk in enumerate(doc_info["chunks"], 1):
txt += f"{
i}. {
chunk}\n"
context_parts.append(txt)
# 合并上下文
context = "\n\n".join(context_parts)
# 如果没有有效的上下文,添加提示信息
if not context_parts:
context = "未找到与问题相关的文档内容。"
logger.warning("警告: 没有找到有效的文档内容!")
# 构建完整提示
messages = [
{
"role": "system", "content": system_prompt},
{
"role": "user", "content": f"我需要回答以下问题:\n\n{
question}\n\n以下是相关的上下文信息:\n\n{
context}"}
]
return messages
def format_llm_prompt(self, messages):
"""格式化LLM提示以便于查看"""
formatted = []
for msg in messages:
role = msg["role"]
content = msg["content"]
# 格式化不同角色的消息
if role == "system":
formatted.append(f"### 系统指令\n