【Ragflow】2. rag检索原理和效率解析

概述

本文是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中,检索内容主要分以下几个步骤:

  1. 用户查询处理 :用户的问题首先被处理成关键词和向量表示
  2. 检索执行 :使用处理后的查询在知识库中检索相关内容
  3. 结果处理 :对检索结果进行处理和重排序
  4. 提示词构建 :将检索到的内容构建成提示词

这部分的核心代码为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
### RAGFlow 二次开发教程资源 #### 开发环境搭建 为了进行有效的二次开发,开发者需先设置好开发环境。考虑到RAGFlow依赖于特定的数据库架构以及模型组件,建议按照官方文档指引完成基础框架部署[^1]。 #### 深入理解系统架构 深入研究RAGFlow的设计理念及其内部运作机制对于后续定制化修改至关重要。该平台旨在通过改进传统检索增强生成(Retrieval-Augmented Generation, RAG)方法中存在的不足之处,在此基础上构建更加高效稳定的解决方案。因此,熟悉GraphRAG与其他关键技术模块之间的交互方式将是重点之一。 #### 利用现有工具集扩展功能 针对不同应用场景下的特殊需求,可以通过集成第三方API接口或是调整预训练语言模型参数等方式实现个性化定制。例如,在处理多模态输入时可引入专门用于解析PDF、Word等文件类型的库函数;而在优化查询效率方面,则可能涉及到索引策略的选择与调优等问题[^2]。 #### 社区交流与贡献反馈 积极参与开源社区讨论不仅有助于获取最新资讯技术支持,同时也是促进项目持续进步的有效途径。鉴于RAGFlow自发布以来便受到了广泛关注支持,相信这里将成为寻求灵感及解决问题的理想场所。此外,任何有价值的改进建议或代码提交都将受到欢迎,并有可能被纳入未来版本之中。 ```bash # 安装必要的Python包 pip install ragflow==0.9.* graphrag transformers datasets faiss-cpu ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

zstar-_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值