【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
<think>好的,我现在需要帮助用户寻找关于RAGFlow的代码示例实现教程。首先,我得回顾一下用户提供的引用内容。引用[1][2]介绍了RAGFlow的基本情况,说明它是一个基于深度文档理解的开源RAG引擎,适用于企业个人的RAG工作流程。引用[3]则展示了一个多模态RAG的代码片段,但看起来是另一个项目的例子,可能与RAGFlow无关。 用户的需求很明确:寻找RAGFlow的代码示例教程。我需要先检查用户提供的引用是否有直接相关的代码。根据引用[1][2],项目地址在gitcode的镜像仓库,但用户提供的引用中没有具体的代码示例,反而是引用[3]中的代码是关于多模态RAG的实现,可能来自不同的项目。 接下来,我需要考虑用户可能没有直接访问RAGFlow的官方文档或仓库。根据最佳实践,开源项目通常会在其代码仓库中提供示例文档。因此,我应该建议用户查看官方文档Git仓库,如安装步骤、配置文件API使用示例。例如,安装步骤可能涉及Docker,配置文件可能需要调整模型路径,而核心功能如文件解析检索生成流程可能有示例代码。 另外,用户可能对多模态RAG感兴趣,引用[3]中的代码虽然不属于RAGFlow,但可以建议用户参考类似思路在RAGFlow中实现。需要区分开不同项目的代码,避免混淆。 最后,生成相关问题时要围绕RAGFlow的安装、配置、扩展多模态实现等方面,帮助用户进一步探索。</think>根据RAGFlow的文档公开代码资源,其核心实现主要围绕文档解析、向量检索生成流程构建。以下是关键代码实现思路教程指引: ### 一、基础环境安装 建议通过Docker部署,需修改`docker-compose.yml`中的模型路径配置: ```yaml services: ragflow: environment: - EMBED_MODEL_PATH=/app/pretrained_models/bge-large-zh-v1.5 - LLM_MODEL_PATH=/app/pretrained_models/Qwen1.5-7B-Chat ``` 完整安装步骤参考官方文档[^1] ### 二、核心功能实现代码 1. **文档解析模块**(支持PDF/Word/PPT等格式): ```python # 示例:PDF解析器核心逻辑 from ragflow.parser import PDFParser parser = PDFParser( chunk_size=512, table_parser="paddle", ocr_engine="paddleocr" ) chunks = parser.parse("/path/to/document.pdf") ``` 2. **检索增强流程**: ```python from ragflow.workflow import RAGWorkflow workflow = RAGWorkflow( embedding_model="bge-large", retriever_type="semantic+keyword", rerank_model="bge-reranker" ) results = workflow.search("查询问题", top_k=5) ``` 3. **生成应答接口**: ```python from ragflow.api import generate_response response = generate_response( query="如何优化RAG系统?", context=results, llm_model="Qwen-7B", temperature=0.3 ) print(response["answer"]) ``` ### 三、多模态扩展实现 虽然当前版本未直接支持多模态,但可参考以下扩展思路: ```python # 图像特征提取(需集成视觉模型) from transformers import ViTFeatureExtractor vit_extractor = ViTFeatureExtractor.from_pretrained('google/vit-base-patch16-224') image_features = vit_extractor(images=image, return_tensors="pt") ``` 然后将特征向量与文本向量共同存入向量库[^3]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

zstar-_

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

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

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

打赏作者

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

抵扣说明:

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

余额充值