【项目实训#06】基于RAG检索增强生成系统的API问答系统基础实现

一、背景简介

在HarmonyOS应用开发中,开发者对API文档的高效查询和理解至关重要。我深入研究了检索增强生成(Retrieval-Augmented Generation,RAG)技术实现,并尝试构建了一个基于先期爬取并整理的HarmonyOS API文档的智能问答系统。该系统能够精准理解开发者的查询意图,从海量API文档中检索相关内容,并生成准确、有针对性的回答。本文将详细记录我在设计和实现这一系统过程中的技术方案、实现细节和解决方案。

二、RAG技术原理深度解析

2.1 RAG的基本原理

RAG(检索增强生成)是一种结合了检索系统和生成模型的混合架构,旨在解决大语言模型在知识时效性、专业领域知识和幻觉等方面的局限性。其核心思想可以分为三个关键步骤:

  1. 检索(Retrieval):根据用户查询,从知识库中检索相关文档或知识片段
  2. 增强(Augmentation):将检索到的信息与用户查询结合,构建增强的提示
  3. 生成(Generation):利用大语言模型基于增强提示生成准确、相关的回答

这种方法使模型能够访问外部知识,从而提供更准确、更可靠的回答,特别是对于专业领域的问题。

2.2 向量检索的数学基础

RAG系统的核心是基于向量相似度的检索,其数学基础主要包括:

  1. 文本嵌入(Text Embedding):使用预训练的语言模型将文本转换为高维向量空间中的点。在我们的实现中,使用了DeepSeek的BGE-M3模型进行文本嵌入。

  2. 相似度计算:常用的相似度计算方法包括余弦相似度、欧氏距离等。余弦相似度计算公式为:

    余弦相似度 = (A·B) / (||A|| × ||B||)

    其中A和B是两个向量,A·B是它们的点积,||A||和||B||是它们的范数。

  3. 最近邻搜索:在高维向量空间中找到与查询向量最相似的文档向量。为了提高搜索效率,可以使用近似最近邻算法,如HNSW、IVF等。

2.3 RAG与传统问答系统的比较

与传统的问答系统相比,RAG具有以下优势:

  1. 知识时效性:RAG可以访问最新的外部知识,而不仅限于模型训练数据
  2. 可溯源性:RAG的回答可以追溯到具体的知识来源,提高可信度
  3. 减少幻觉:通过提供事实依据,RAG显著减少了模型生成虚假或不准确信息的可能性
  4. 领域适应性:RAG可以针对特定领域构建专业知识库,提高回答质量

三、RAG系统的实现细节

3.1 系统架构设计

我们实现的RAG系统采用了模块化设计,主要包括以下组件:

  1. 文档处理模块:负责加载、解析和预处理API文档
  2. 向量化模块:将文档转换为向量表示
  3. 向量数据库:存储文档向量和原始文档
  4. 检索模块:根据用户查询检索相关文档
  5. 生成模块:基于检索结果生成回答
  6. API服务:提供RESTful API接口

这种模块化设计使系统具有良好的可扩展性和可维护性。

3.2 文档处理与向量化

文档处理是RAG系统的基础,我们的实现主要包括以下步骤:

  1. 文档加载:从JSON文件中加载API文档,并解析其结构
def load_api_docs(docs_dir: str) -> List[Dict[str, Any]]:
    """加载API文档"""
    api_docs = []
    for file_path in glob.glob(os.path.join(docs_dir, "*.json")):
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                doc_data = json.load(f)
                api_docs.append(doc_data)
        except Exception as e:
            print(f"加载文档失败: {file_path}, 错误: {str(e)}")
    return api_docs

这个函数使用Python的glob模块遍历指定目录下的所有JSON文件,然后逐一加载并解析。这里使用了异常处理机制,确保即使某个文件加载失败,整个过程也能继续进行,提高了系统的健壮性。

  1. 文本提取与清洗:提取文档中的标题、概述、内容等信息,并处理特殊内容
def prepare_text_for_embedding(doc_data: Dict[str, Any]) -> str:
    """准备用于嵌入的文本"""
    title = doc_data.get('title', '')
    overview = doc_data.get('overview', '')
    
    # 提取所有部分的标题、内容、表格和代码块
    sections_text = ""
    for section in doc_data.get('sections', []):
        # ... 处理各部分内容 ...
    
    # 组合文本
    text = f"{title}\n\n{overview}\n\n{sections_text}"
    
    return text

这个函数负责从文档数据中提取有意义的文本内容。它首先获取文档的标题和概述,然后遍历文档的各个部分,提取每个部分的标题、内容、表格和代码块。这里使用了Python的字典get方法,提供默认值以防某些字段不存在,增强了代码的健壮性。最后,将所有提取的文本组合成一个完整的字符串,用于后续的向量化处理。

  1. 文本向量化:使用DeepSeek的BGE-M3模型将文本转换为向量表示
def get_embedding(text: str) -> List[float]:
    """获取文本嵌入向量"""
    headers = {"Content-Type": "application/json"}
    if API_KEY:
        headers["Authorization"] = f"Bearer {API_KEY}"
    
    payload = {"model": MODEL, "input": text}
    
    try:
        response = requests.post(f"{API_URL}/embeddings", headers=headers, json=payload)
        response.raise_for_status()
        
        result = response.json()
        embedding = result.get("data", [{}])[0].get("embedding", [])
        
        return embedding
    except Exception as e:
        print(f"获取嵌入向量失败: {str(e)}")
        return []

这个函数通过调用DeepSeek的API将文本转换为向量表示。它首先构建请求头和请求体,然后发送POST请求到DeepSeek的embeddings接口。这里使用了requests库的raise_for_status方法,确保在API调用失败时能够抛出异常。然后,从响应中提取嵌入向量并返回。如果过程中出现任何异常,函数会捕获并打印错误信息,然后返回空列表,这样可以避免因为单个文档的向量化失败而导致整个处理流程中断。

3.3 向量数据库的实现

向量数据库是RAG系统的核心组件,我们的实现包括以下功能:

  1. 数据结构设计:使用Python字典和NumPy数组存储文档和向量
class VectorDB:
    def __init__(self):
        self.docs = []  # 存储文档
        self.embeddings = None  # 存储嵌入向量
        self.doc_ids = []  # 存储文档ID

这个类定义了向量数据库的基本结构。它使用Python列表存储文档和文档ID,使用NumPy数组存储嵌入向量。这种设计使得我们可以方便地进行向量操作,同时保持文档和向量之间的对应关系。

  1. 添加文档:将文档及其向量添加到数据库
def add_doc(self, doc: Dict[str, Any], embedding: List[float], doc_id: str = None) -> str:
    """添加文档到数据库"""
    if doc_id is None:
        doc_id = str(uuid.uuid4())
    
    self.docs.append(doc)
    
    # 添加嵌入向量
    if self.embeddings is None:
        self.embeddings = np.array([embedding])
    else:
        self.embeddings = np.vstack([self.embeddings, embedding])
    
    self.doc_ids.append(doc_id)
    
    return doc_id

这个方法负责将文档及其向量添加到数据库中。如果没有提供文档ID,它会生成一个UUID作为文档ID。然后,将文档添加到文档列表中,将向量添加到嵌入向量数组中,将文档ID添加到文档ID列表中。这里使用了NumPy的vstack函数,它可以高效地将新向量添加到现有的向量数组中。

  1. 持久化存储:将向量和文档保存到文件系统
def save(self, db_path: str):
    """保存数据库到文件"""
    # 确保目录存在
    os.makedirs(db_path, exist_ok=True)
    
    # 保存文档、嵌入向量和文档ID
    # ... 保存逻辑 ...

这个方法负责将数据库保存到文件系统中。它首先确保目标目录存在,然后将文档、嵌入向量和文档ID分别保存到不同的文件中。文档和文档ID通常保存为JSON格式,而嵌入向量则使用pickle保存为二进制格式,以保持数值精度并节省存储空间。

  1. 加载数据库:从文件系统加载向量和文档
def load(self, db_path: str) -> bool:
    """从文件加载数据库"""
    # 检查索引文件是否存在
    index_file = os.path.join(db_path, 'db_index.json')
    if not os.path.exists(index_file):
        print(f"数据库索引文件不存在: {index_file}")
        return False
    
    # 加载索引数据
    # ... 加载逻辑 ...
    
    return True

这个方法负责从文件系统中加载数据库。它首先检查索引文件是否存在,然后根据索引文件中的信息加载文档、嵌入向量和文档ID。这里使用了条件检查和错误处理,确保在文件不存在或格式不正确时能够给出明确的错误信息,并返回加载失败的标志。

3.4 检索与生成模块

检索与生成模块负责根据用户查询检索相关文档,并生成最终回答:

  1. 查询向量化:将用户查询转换为向量表示
def get_query_embedding(self, query: str) -> List[float]:
    """获取查询的嵌入向量"""
    return get_embedding(query)

这个方法调用前面定义的get_embedding函数,将用户查询转换为向量表示。这样,用户查询和文档就可以在同一个向量空间中进行比较,从而计算它们之间的相似度。

  1. 相似度计算:计算查询向量与文档向量的余弦相似度
def compute_similarity(self, query_embedding: List[float], doc_embeddings: np.ndarray) -> np.ndarray:
    """计算查询与文档的相似度"""
    # 将查询嵌入向量转换为numpy数组
    query_embedding = np.array(query_embedding)
    
    # 计算余弦相似度
    query_norm = np.linalg.norm(query_embedding)
    if query_norm > 0:
        query_embedding = query_embedding / query_norm
    
    # 计算点积
    similarities = np.dot(doc_embeddings, query_embedding)
    
    return similarities

这个方法计算查询向量与所有文档向量的余弦相似度。它首先将查询向量转换为NumPy数组,然后计算查询向量的范数,并对查询向量进行归一化处理。最后,使用NumPy的点积运算计算查询向量与所有文档向量的余弦相似度。这种向量化的计算方式比循环计算每个文档的相似度要高效得多,特别是在文档数量较大的情况下。

  1. 检索相关文档:根据相似度排序,选择最相关的文档
def retrieve_relevant_docs(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
    """检索与查询相关的文档"""
    # 获取查询的嵌入向量
    query_embedding = self.get_query_embedding(query)
    
    # 计算相似度
    similarities = self.compute_similarity(query_embedding, self.db.embeddings)
    
    # 获取最相关的文档索引
    top_indices = np.argsort(similarities)[-top_k:][::-1]
    
    # 获取最相关的文档
    relevant_docs = [self.db.get_doc_by_index(i) for i in top_indices]
    
    # 添加相似度分数
    for i, doc in enumerate(relevant_docs):
        doc['similarity'] = float(similarities[top_indices[i]])
    
    return relevant_docs

这个方法负责检索与用户查询最相关的文档。它首先获取查询的嵌入向量,然后计算查询向量与所有文档向量的相似度。接着,使用NumPy的argsort函数获取相似度最高的top_k个文档的索引,并根据这些索引获取相应的文档。最后,将相似度分数添加到文档中,以便后续使用。这里使用了Python的列表推导式和NumPy的高效排序函数,使得代码简洁而高效。

  1. 回答生成:使用DeepSeek-R1模型生成最终回答
def generate_answer(self, query: str, relevant_docs: List[Dict[str, Any]]) -> str:
    """生成回答"""
    # 构建提示
    context = ""
    for i, doc in enumerate(relevant_docs):
        doc_content = self.extract_doc_content(doc)
        context += f"文档{i+1}:\n{doc_content}\n\n"
    
    prompt = f"""请基于以下API文档内容回答用户的问题。如果文档中没有相关信息,请直接说明无法回答。

文档内容:
{context}

用户问题: {query}

请提供准确、简洁的回答,并尽可能引用文档中的相关内容。"""
    
    # 使用DeepSeek-R1模型生成回答
    messages = [{"role": "user", "content": prompt}]
    answer = self.client.chat_completion(messages)
    
    return answer

这个方法负责生成最终回答。它首先从检索到的文档中提取内容,并构建一个包含这些内容的上下文字符串。然后,构建一个提示模板,将上下文和用户查询结合起来。最后,使用DeepSeek-R1模型生成回答。这里的提示工程非常关键,它指导模型如何使用检索到的文档来回答用户的问题,包括如何处理文档中没有相关信息的情况,以及如何引用文档中的内容。

3.5 API服务实现

为了方便前端调用,我们实现了一个RESTful API服务:

@app.route('/api/answer', methods=['POST'])
def answer():
    """生成回答"""
    global retriever
    
    # 确保检索器已初始化
    if retriever is None:
        if not load_retriever(current_retriever_name):
            return jsonify({'error': '检索器未初始化'}), 500
    
    # 获取请求数据
    data = request.json
    query = data.get('query', '')
    
    if not query:
        return jsonify({'error': '查询不能为空'}), 400
    
    # 生成回答
    try:
        answer = retriever.generate_answer(query)
        
        # 同时返回检索到的相关文档
        contexts = retriever.retrieve(query)
        
        return jsonify({
            'answer': answer,
            'contexts': contexts,
            'retriever': current_retriever_name
        })
    except Exception as e:
        return jsonify({'error': str(e)}), 500

这个函数使用Flask框架实现了一个API端点,用于接收用户查询并返回回答。它首先确保检索器已经初始化,然后从请求中获取用户查询。如果查询为空,则返回400错误。否则,调用检索器的generate_answer方法生成回答,并同时返回检索到的相关文档。如果过程中出现任何异常,则返回500错误。这个API设计遵循了RESTful风格,使用HTTP状态码表示请求的处理结果,使用JSON格式返回数据,便于前端解析和展示。

四、实现挑战与解决方案

在实现RAG系统的过程中,我遇到了一些挑战,并采取了相应的解决方案:

4.1 文档预处理挑战

挑战:API文档中包含大量HTML格式的表格和代码块,直接提取文本会丢失结构信息。

解决方案:使用BeautifulSoup库解析HTML内容,保留表格和代码块的结构信息,并在向量化前进行适当处理。

def extract_text_from_html(html_content: str) -> str:
    """从HTML内容中提取纯文本"""
    soup = BeautifulSoup(html_content, 'html.parser')
    return soup.get_text(separator=' ', strip=True)

这个函数使用BeautifulSoup库解析HTML内容,并提取其中的纯文本。它使用空格作为分隔符,确保不同元素之间有适当的间隔,同时去除文本前后的空白字符。这样,我们就可以保留HTML中的结构信息,同时去除HTML标签,得到适合向量化的纯文本。

在实际应用中,我们还可以根据不同类型的HTML元素进行更复杂的处理。例如,对于表格,我们可以提取表头和单元格内容,并按照一定的格式组织;对于代码块,我们可以保留其缩进和换行,以保持代码的可读性。这些处理可以帮助我们更好地保留文档的结构信息,提高向量化的质量。

4.2 向量化效率问题

挑战:大量文档的向量化过程耗时较长,且容易导致内存溢出。

解决方案:采用批处理策略,每次处理一小批文档,并在批次之间进行垃圾回收,释放内存。

# 分批处理文档
for batch_start in range(0, total_docs, BATCH_SIZE):
    # 获取当前批次的文档ID
    batch_end = min(batch_start + BATCH_SIZE, total_docs)
    batch_doc_ids = doc_ids[batch_start:batch_end]
    
    print(f"处理批次 {batch_start//BATCH_SIZE + 1}/{(total_docs+BATCH_SIZE-1)//BATCH_SIZE},文档 {batch_start+1}-{batch_end} / {total_docs}")
    
    # 处理当前批次的文档
    for doc_id in tqdm(batch_doc_ids, desc="处理文档"):
        # ... 处理文档 ...
    
    # 强制垃圾回收
    gc.collect()

这段代码实现了批处理策略。它使用range函数生成批次的起始索引,然后计算每个批次的结束索引,并获取当前批次的文档ID。接着,使用tqdm库显示处理进度,逐一处理当前批次的文档。最后,在每个批次处理完成后,调用gc.collect()函数强制进行垃圾回收,释放内存。

这种批处理策略有几个优点:首先,它可以避免一次性加载所有文档导致的内存溢出问题;其次,它可以在处理过程中显示进度,让用户了解处理的状态;最后,它可以在批次之间释放内存,避免内存占用持续增长。

在实际应用中,我们可以根据系统的内存大小和文档的大小调整批次大小。如果文档较小,可以增大批次大小,减少批次数量,提高处理效率;如果文档较大,可以减小批次大小,避免内存溢出。

4.3 相似度计算优化

挑战:简单的余弦相似度计算在大规模向量集上效率较低。

解决方案:使用NumPy的向量化操作进行批量计算,并对向量进行归一化处理,提高计算效率和准确性。

def compute_similarity(self, query_embedding: List[float], doc_embeddings: np.ndarray) -> np.ndarray:
    """计算查询与文档的相似度"""
    # 将查询嵌入向量转换为numpy数组
    query_embedding = np.array(query_embedding)
    
    # 计算余弦相似度
    # 首先对向量进行归一化
    query_norm = np.linalg.norm(query_embedding)
    if query_norm > 0:
        query_embedding = query_embedding / query_norm
    
    # 计算点积
    similarities = np.dot(doc_embeddings, query_embedding)
    
    return similarities

这个方法使用NumPy的向量化操作计算余弦相似度。它首先将查询向量转换为NumPy数组,然后计算查询向量的范数,并对查询向量进行归一化处理。最后,使用NumPy的点积运算计算查询向量与所有文档向量的余弦相似度。

这种向量化的计算方式比循环计算每个文档的相似度要高效得多,特别是在文档数量较大的情况下。NumPy的点积运算是高度优化的,可以利用CPU的SIMD指令进行并行计算,大大提高计算效率。

在实际应用中,我们还可以考虑使用近似最近邻算法,如HNSW、IVF等,进一步提高检索效率。这些算法可以在牺牲一定准确性的情况下,大大减少计算量,适合处理大规模向量集。

4.4 提示工程优化

挑战:简单的提示模板可能导致生成的回答质量不高。

解决方案:设计更加结构化的提示模板,明确指导模型如何使用检索到的文档生成回答。

prompt = f"""请基于以下API文档内容回答用户的问题。如果文档中没有相关信息,请直接说明无法回答。

文档内容:
{context}

用户问题: {query}

请遵循以下要求:
1. 回答必须基于提供的文档内容,不要使用其他知识
2. 如果文档中包含代码示例,请在回答中引用相关代码
3. 保持专业、简洁的语言风格
4. 如果文档内容不足以回答问题,请明确指出
5. 不要复制整个文档,而是提取关键信息进行回答"""

这个提示模板明确指导模型如何使用检索到的文档生成回答。它首先提供了文档内容和用户问题,然后列出了五个具体的要求,包括基于文档内容回答、引用代码示例、保持专业简洁的语言风格、明确指出文档内容不足的情况,以及提取关键信息而不是复制整个文档。

这种结构化的提示模板可以帮助模型更好地理解任务要求,生成更高质量的回答。在实际应用中,我们可以根据不同类型的查询设计不同的提示模板,如API用法查询、概念解释查询等,以提高回答质量。

提示工程是RAG系统中非常重要的一环,它直接影响到生成回答的质量。通过精心设计提示模板,我们可以引导模型生成更准确、更相关、更有用的回答,提高用户体验。

五、未来改进方向

尽管当前RAG系统已经取得了不错的效果,但仍有以下几个方面可以进一步改进:

5.1 文档分块策略优化

目前系统以整个API文档为单位进行向量化,未来可以尝试更细粒度的分块策略,如按章节或段落进行分块,以提高检索精度。

5.2 混合检索策略

可以结合关键词检索和语义检索的混合策略,进一步提高检索准确性,特别是对于包含专业术语的查询。

5.3 提示模板优化

可以针对不同类型的查询设计不同的提示模板,如API用法查询、概念解释查询等,以提高回答质量。

5.4 模型微调

可以使用HarmonyOS API文档对大语言模型进行微调,使其更好地理解和生成与HarmonyOS相关的内容。

六、总结与反思

通过本次RAG系统的实现,我深入理解了检索增强生成技术的原理和实践方法。RAG技术通过结合检索系统和生成模型的优势,有效解决了大语言模型在专业领域知识方面的局限性,为构建更智能、更可靠的问答系统提供了有效途径。

在实现过程中,我不仅掌握了向量数据库构建、相似度计算、提示工程等技术要点,还深刻认识到数据预处理、系统优化和评估方法的重要性。这些经验和技能将对我未来在AI应用开发方面的工作产生积极影响。

RAG技术实现为HarmonyOS开发者提供了强大的API文档智能查询工具,不仅提高了开发效率,也为开发者学习和使用HarmonyOS提供了更便捷的途径。这种结合专业领域知识库与大语言模型的方案,代表了开发辅助工具的重要发展方向,为构建更智能、更专业的开发者生态提供了有力支持。后续开发过程可能继续优化RAG的部分,并将RAG与知识图谱等其他方式结合,以实现更精确、更智能的API文档智能查询功能。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐