RAGFlow部分文件下载功能实现

491 阅读4分钟

RAGFlow 目前已经有完整文件下载功能,通过 api/apps/document_app.py 中的 get 方法实现,这个方法允许用户通过文档 ID 获取整个文档的内容。但目前没有直接支持获取文件部分内容的 API。所以需要我们用户自己手动实现, 在此记录一下

实现方案

可以扩展现有的 API,添加一个新的端点,允许用户指定要获取的文件范围。这个功能对于大型文件特别有用,用户可能只需要文件的特定部分。

方案设计

  • 创建一个新的 API 端点,接受文档 ID 和范围参数
  • 根据文件类型和范围参数,返回文件的指定部分
  • 支持不同类型的范围指定(页码范围、字节范围、块范围等)

代码实现示例

添加新端点的逻辑:

@manager.route('/get_partial/<doc_id>', methods=['GET'])  
def get_partial(doc_id):  
    try:  
        # 获取范围参数  
        start_pos = request.args.get('start', type=int, default=0)  
        end_pos = request.args.get('end', type=int)  # 如果不指定,则到文件末尾  
        range_type = request.args.get('range_type', default='bytes')  # 范围类型:bytes, pages, chunks  
          
        # 获取文档信息  
        e, doc = DocumentService.get_by_id(doc_id)  
        if not e:  
            return get_data_error_result(message="Document not found!")  
  
        # 获取文件存储地址  
        b, n = File2DocumentService.get_storage_address(doc_id=doc_id)  
          
        # 根据不同的范围类型处理  
        if range_type == 'bytes':  
            # 字节范围处理  
            file_content = STORAGE_IMPL.get_partial(b, n, start_pos, end_pos)  
        elif range_type == 'pages' and doc.type == FileType.PDF.value:  
            # PDF页面范围处理  
            file_content = get_pdf_pages(b, n, start_pos, end_pos)  
        elif range_type == 'chunks':  
            # 获取特定的块范围  
            chunks = get_document_chunks(doc_id, start_pos, end_pos)  
            return get_json_result(data=chunks)  
        else:  
            return get_data_error_result(message=f"Unsupported range type: {range_type} for file type: {doc.type}")  
          
        # 创建响应  
        response = flask.make_response(file_content)  
          
        # 设置适当的Content-Type  
        ext = re.search(r".([^.]+)$", doc.name)  
        if ext:  
            if doc.type == FileType.VISUAL.value:  
                response.headers.set('Content-Type', 'image/%s' % ext.group(1))  
            else:  
                response.headers.set('Content-Type', 'application/%s' % ext.group(1))  
                  
        # 设置Content-Range头  
        if range_type == 'bytes' and end_pos:  
            response.headers.set('Content-Range', f'bytes {start_pos}-{end_pos}')  
              
        return response  
    except Exception as e:  
        return server_error_response(e)  
  
# 辅助函数:获取PDF的特定页面  
def get_pdf_pages(bucket, name, start_page, end_page):  
    from io import BytesIO  
    import PyPDF2  
      
    # 获取完整PDF  
    pdf_data = STORAGE_IMPL.get(bucket, name)  
    pdf_stream = BytesIO(pdf_data)  
      
    # 创建PDF读取器和写入器  
    reader = PyPDF2.PdfReader(pdf_stream)  
    writer = PyPDF2.PdfWriter()  
      
    # 验证页码范围  
    max_page = len(reader.pages)  
    start_page = max(0, start_page)  
    end_page = min(max_page, end_page) if end_page is not None else max_page  
      
    # 添加指定范围的页面  
    for page_num in range(start_page, end_page):  
        writer.add_page(reader.pages[page_num])  
      
    # 写入新的PDF  
    output = BytesIO()  
    writer.write(output)  
    output.seek(0)  
      
    return output.getvalue()  
  
# 辅助函数:获取文档的特定块  
def get_document_chunks(doc_id, start_chunk, end_chunk):  
    # 获取文档的所有块  
    tenant_id = DocumentService.get_tenant_id(doc_id)  
    if not tenant_id:  
        return []  
      
    # 从文档存储中获取块  
    chunks = settings.docStoreConn.search(  
        {"doc_id": doc_id},   
        search.index_name(tenant_id),   
        None,  # kb_id  
        0,     # offset  
        1000,  # limit,可以根据需要调整  
        None,  # sort_by  
        None   # sort_order  
    )  
      
    # 验证块范围  
    total_chunks = len(chunks)  
    start_chunk = max(0, start_chunk)  
    end_chunk = min(total_chunks, end_chunk) if end_chunk is not None else total_chunks  
      
    # 返回指定范围的块  
    return chunks[start_chunk:end_chunk]

存储实现扩展

# 在存储实现类中添加获取部分内容的方法  
def get_partial(self, bucket, name, start_pos=0, end_pos=None):  
    """  
    获取对象的部分内容  
      
    Args:  
        bucket: 存储桶名称  
        name: 对象名称  
        start_pos: 起始位置(字节)  
        end_pos: 结束位置(字节),如果为None则到文件末尾  
      
    Returns:  
        部分文件内容的字节数据  
    """  
    try:  
        # 构建范围字符串  
        range_header = f'bytes={start_pos}-'  
        if end_pos is not None:  
            range_header = f'bytes={start_pos}-{end_pos}'  
              
        # 获取部分内容  
        response = self.client.get_object(  
            Bucket=bucket,  
            Key=name,  
            Range=range_header  
        )  
          
        # 读取内容  
        return response['Body'].read()  
    except Exception as e:  
        logging.error(f"Error getting partial object {bucket}/{name}: {e}")  
        raise

使用示例

HTTP API 调用示例

# 获取文档的前1000字节  
GET /api/document/get_partial/doc_123?start=0&end=999&range_type=bytes  
  
# 获取PDF文档的第2-5页  
GET /api/document/get_partial/doc_123?start=1&end=5&range_type=pages  
  
# 获取文档的第10-20个块  
GET /api/document/get_partial/doc_123?start=10&end=20&range_type=chunks  

优化方案

为了避免将整个文件加载到内存中,可以采用以下几种策略,根据不同的文件类型和范围类型进行优化:

1. 字节范围请求的优化

对于字节范围请求,我们可以直接利用底层存储系统的范围请求功能,这样就不需要先将整个文件加载到内存中:

def get_partial(self, bucket, name, start_pos=0, end_pos=None):  
    """  
    获取对象的部分内容,直接使用存储系统的范围请求功能  
    """  
    try:  
        # 构建范围字符串  
        range_header = f'bytes={start_pos}-'  
        if end_pos is not None:  
            range_header = f'bytes={start_pos}-{end_pos}'  
              
        # 使用MinIO的范围请求功能直接获取部分内容  
        response = self.client.get_object(  
            Bucket=bucket,  
            Key=name,  
            Range=range_header  
        )  
          
        # 返回响应流,而不是读取全部内容到内存  
        return response['Body']  
    except Exception as e:  
        logging.error(f"Error getting partial object {bucket}/{name}: {e}")  
        raise

2. PDF页面范围请求的优化

对于PDF文件,我们可以使用支持流式处理的库,如PyPDF2或pdfrw,并结合范围请求来只加载需要的页面:

def get_pdf_pages_streaming(bucket, name, start_page, end_page):  
    """  
    流式获取PDF的特定页面,避免将整个PDF加载到内存  
    """  
    from io import BytesIO  
    import PyPDF2  
      
    # 创建一个新的PDF写入器  
    writer = PyPDF2.PdfWriter()  
      
    # 使用RAGFlow现有的PDF解析器,它支持页面级别的处理  
    pdf_parser = RAGFlowPdfParser()  
      
    # 验证页码范围  
    total_pages = pdf_parser.total_page_number(name, STORAGE_IMPL.get_head(bucket, name, 1024))  
    start_page = max(0, start_page)  
    end_page = min(total_pages, end_page) if end_page is not None else total_pages  
      
    # 逐页处理,每次只加载一页到内存  
    for page_num in range(start_page, end_page):  
        # 计算当前页面在文件中的大致位置(这需要文件的索引信息)  
        page_data = pdf_parser.get_page(bucket, name, page_num)  
          
        # 从页面数据创建PDF页面对象  
        reader = PyPDF2.PdfReader(BytesIO(page_data))  
        if len(reader.pages) > 0:  
            writer.add_page(reader.pages[0])  
      
    # 写入新的PDF  
    output = BytesIO()  
    writer.write(output)  
    output.seek(0)  
      
    return output

3. 块范围请求的优化

对于文档块,我们可以使用分页查询来避免一次性加载所有块:

def get_document_chunks_paged(doc_id, start_chunk, end_chunk, page_size=100):  
    """  
    分页获取文档的特定块,避免一次性加载所有块  
    """  
    # 获取文档的租户ID  
    tenant_id = DocumentService.get_tenant_id(doc_id)  
    if not tenant_id:  
        return []  
      
    # 计算需要获取的块数量  
    chunk_count = end_chunk - start_chunk if end_chunk is not None else None  
      
    # 如果没有指定结束块,先获取总块数  
    if chunk_count is None:  
        total_count = settings.docStoreConn.count(  
            {"doc_id": doc_id},   
            search.index_name(tenant_id)  
        )  
        chunk_count = total_count - start_chunk  
      
    # 分页获取块  
    chunks = []  
    current_offset = start_chunk  
    remaining = chunk_count  
      
    while remaining > 0:  
        # 计算当前页大小  
        current_page_size = min(page_size, remaining)  
          
        # 获取当前页的块  
        page_chunks = settings.docStoreConn.search(  
            {"doc_id": doc_id},   
            search.index_name(tenant_id),   
            None,  # kb_id  
            current_offset,  # offset  
            current_page_size,  # limit  
            None,  # sort_by  
            None   # sort_order  
        )  
          
        # 添加到结果中  
        chunks.extend(page_chunks)  
          
        # 更新偏移量和剩余数量  
        current_offset += len(page_chunks)  
        remaining -= len(page_chunks)  
          
        # 如果返回的块数少于请求的数量,说明已经到达末尾  
        if len(page_chunks) < current_page_size:  
            break  
      
    return chunks

API端点实现

结合上述优化策略,我们可以修改API端点实现:

@manager.route('/get_partial/<doc_id>', methods=['GET'])  
def get_partial(doc_id):  
    try:  
        # 获取范围参数  
        start_pos = request.args.get('start', type=int, default=0)  
        end_pos = request.args.get('end', type=int)  # 如果不指定,则到文件末尾  
        range_type = request.args.get('range_type', default='bytes')  # 范围类型:bytes, pages, chunks  
          
        # 获取文档信息  
        e, doc = DocumentService.get_by_id(doc_id)  
        if not e:  
            return get_data_error_result(message="Document not found!")  
  
        # 获取文件存储地址  
        b, n = File2DocumentService.get_storage_address(doc_id=doc_id)  
          
        # 根据不同的范围类型处理  
        if range_type == 'bytes':  
            # 字节范围处理 - 流式返回  
            file_stream = STORAGE_IMPL.get_partial(b, n, start_pos, end_pos)  
              
            # 创建流式响应  
            response = flask.Response(  
                stream_with_context(iter_file(file_stream)),  
                mimetype='application/octet-stream'  
            )  
              
            # 设置适当的Content-Type  
            ext = re.search(r".([^.]+)$", doc.name)  
            if ext:  
                if doc.type == FileType.VISUAL.value:  
                    response.headers.set('Content-Type', f'image/{ext.group(1)}')  
                else:  
                    response.headers.set('Content-Type', f'application/{ext.group(1)}')  
                      
            # 设置Content-Range头  
            if end_pos:  
                response.headers.set('Content-Range', f'bytes {start_pos}-{end_pos}')  
                  
            return response  
              
        elif range_type == 'pages' and doc.type == FileType.PDF.value:  
            # PDF页面范围处理 - 流式处理  
            pdf_stream = get_pdf_pages_streaming(b, n, start_pos, end_pos)  
              
            # 创建流式响应  
            response = flask.Response(  
                stream_with_context(iter_file(pdf_stream)),  
                mimetype='application/pdf'  
            )  
              
            # 设置下载文件名  
            filename = f"{doc.name.split('.')[0]}_pages_{start_pos}-{end_pos}.pdf"  
            response.headers.set('Content-Disposition', f'attachment; filename="{filename}"')  
              
            return response  
              
        elif range_type == 'chunks':  
            # 获取特定的块范围 - 分页处理  
            chunks = get_document_chunks_paged(doc_id, start_pos, end_pos)  
            return get_json_result(data=chunks)  
              
        else:  
            return get_data_error_result(message=f"Unsupported range type: {range_type} for file type: {doc.type}")  
              
    except Exception as e:  
        return server_error_response(e)  
  
# 辅助函数:迭代文件流  
def iter_file(file_stream, chunk_size=8192):  
    """  
    迭代文件流,每次返回指定大小的块  
    """  
    while True:  
        chunk = file_stream.read(chunk_size)  
        if not chunk:  
            break  
        yield chunk

存储实现扩展

为了支持上述优化,我们需要在存储实现中添加一些辅助方法:

# 在MinIO存储实现中添加获取文件头部的方法  
def get_head(self, bucket, name, size=1024):  
    """  
    获取对象的头部数据  
      
    Args:  
        bucket: 存储桶名称  
        name: 对象名称  
        size: 要获取的字节数  
      
    Returns:  
        对象头部的字节数据  
    """  
    try:  
        response = self.client.get_object(  
            Bucket=bucket,  
            Key=name,  
            Range=f'bytes=0-{size-1}'  
        )  
        return response['Body'].read()  
    except Exception as e:  
        logging.error(f"Error getting object head {bucket}/{name}: {e}")  
        raise

针对PDF文件的特殊优化

对于PDF文件,我们可以利用RAGFlow现有的PDF解析器进行更高效的处理。从代码中可以看到,RAGFlowPdfParser类 pdf_parser.py:48-52 已经实现了PDF文件的解析功能。

我们可以扩展这个类,添加一个方法来获取特定页面的内容:

# 在RAGFlowPdfParser类中添加获取特定页面的方法  
def get_page(self, bucket, name, page_num):  
    """  
    获取PDF文件的特定页面  
      
    Args:  
        bucket: 存储桶名称  
        name: 对象名称  
        page_num: 页码(从0开始)  
      
    Returns:  
        页面的PDF数据  
    """  
    # 使用pdfplumber打开文件  
    with sys.modules[LOCK_KEY_pdfplumber]:  
        file_data = STORAGE_IMPL.get(bucket, name)  
        with pdfplumber.open(BytesIO(file_data)) as pdf:  
            if page_num < 0 or page_num >= len(pdf.pages):  
                raise ValueError(f"Page number {page_num} out of range (0-{len(pdf.pages)-1})")  
              
            # 提取单页  
            page = pdf.pages[page_num]  
              
            # 创建一个新的PDF,只包含这一页  
            from pikepdf import Pdf, Page  
            new_pdf = Pdf.new()  
            src_pdf = Pdf.open(BytesIO(file_data))  
            new_pdf.pages.append(Page(src_pdf.pages[page_num]))  
              
            # 保存到内存  
            output = BytesIO()  
            new_pdf.save(output)  
            output.seek(0)  
              
            return output.getvalue()

与任务执行系统的集成

从代码中可以看到,RAGFlow使用任务执行系统来处理文档 task_service.py:292-307 。我们可以扩展这个系统,使其支持部分文件处理:

# 在TaskService中添加创建部分处理任务的方法  
def queue_partial_tasks(doc, bucket, name, start, end, range_type, priority=0):  
    """  
    创建并排队处理文档部分内容的任务  
      
    Args:  
        doc: 文档字典  
        bucket: 存储桶名称  
        name: 文件名  
        start: 起始位置  
        end: 结束位置  
        range_type: 范围类型(bytes, pages, chunks)  
        priority: 任务优先级  
    """  
    task = {  
        "id": get_uuid(),  
        "doc_id": doc["id"],  
        "progress": 0.0,  
        "range_type": range_type,  
        "start": start,  
        "end": end  
    }  
      
    # 根据范围类型设置特定参数  
    if range_type == 'pages':  
        task["from_page"] = start  
        task["to_page"] = end  
      
    # 计算任务摘要  
    chunking_config = DocumentService.get_chunking_config(doc["id"])  
    hasher = xxhash.xxh64()  
    for field in sorted(chunking_config.keys()):  
        if field == "parser_config":  
            for k in ["raptor", "graphrag"]:  
                if k in chunking_config[field]:  
                    del chunking_config[field][k]  
        hasher.update(str(chunking_config[field]).encode("utf-8"))  
    for field in ["doc_id", "range_type", "start", "end"]:  
        hasher.update(str(task.get(field, "")).encode("utf-8"))  
    task["digest"] = hasher.hexdigest()  
    task["priority"] = priority  
      
    # 插入任务到数据库  
    bulk_insert_into_db(Task, [task], True)  
      
    # 将任务加入队列  
    REDIS_CONN.queue_product(  
        get_svr_queue_name(priority), message=task  
    )  
      
    return task

完整的API实现

结合上述所有优化,以下是完整的API实现:

@manager.route('/get_partial/<doc_id>', methods=['GET'])  
def get_partial(doc_id):  
    """  
    获取文档的部分内容  
      
    参数:  
        doc_id: 文档ID  
        start: 起始位置(默认为0end: 结束位置(如果不指定,则到文件末尾)  
        range_type