前言

近期Deepseek大火,尤其于Deepseek R1模型以及通过其蒸馏出来的小模型,效果都让人十分惊叹。本文介绍使用llama_index框架 + Ollama + Chroma向量数据库,使用nomic-embed-text Embedding模型和deepseek-r1:1.5b推理模型,搭建一套可在Mac机器本地跑的RAG系统

前题信息

Ollama 是一个开源的本地化工具,专注于帮助用户快速在本地计算机上运行、管理和部署大型语言模型(LLMs)。它通过简化的命令行界面和本地服务,让用户无需复杂配置即可直接体验多种开源模型(如 Llama 2、Mistral、Phi-2 等)

环境信息

机器信息:Mac Mini4 最低配版本,10 核心CPU、10核 GPU、16GB统一内存(CPU+GPU共用)、56GB固态硬盘

Python运行环境: python 3.11 + poetry 2.0.1

软件安装

  • 安装Python环境 采用 brew 安装Python

  • poetry 安装

      curl -sSL https://install.python-poetry.org | python3 -
    
  • ollama 安装 直接在Ollama网站下载相应版本软件包,双击软件包安装即可,下载地址

ollama run deepseek-r1:1.5b
ollama run nomic-embed-text:latest
  • 安装python依赖软件包 通过poetry add xxx 方式安装Python软件包
      python = "^3.10"
      requests = "^2.32.3"
      ollama = "^0.4.7"
      chromadb = "^0.6.3"
      llama-index-core = "^0.12.16.post1"
      llama-index-llms-ollama = "^0.5.2"
      llama-index-readers-file = "^0.4.5"
      llama-index-embeddings-ollama = "^0.5.0"
      docx2txt = "^0.8"
      llama-index-vector-stores-chroma = "^0.4.1"
      llama-index-embeddings-nomic = "^0.6.0"
      nomic = "^3.4.1"
    

准备知识库

创建 data 目录,并把相关知识库文档放在这个目录下,当前支持 txt docx jsonl三类文档,如果要支持更多,可以修改下面的代码,可能需要增加相应的python软件包

核心代码

from pathlib import Path
import re
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.llms.ollama import Ollama
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb
from chromadb.config import Settings
import sys
from typing import Optional


class RAGSystem:
    def __init__(self, ollama_base_url: str = "http://localhost:11434"):
        # 初始化配置
        self.data_dir = Path("./data")
        self.db_dir = Path("./chroma_db")
        self.ollama_base_url = ollama_base_url
        self._init_components()

    def _init_components(self):
        # 初始化LLM和Embedding模型
        self.llm = Ollama(
            model="deepseek-r1:1.5b",
            base_url=self.ollama_base_url,
            temperature=0.1
        )

        self.embed_model = OllamaEmbedding(
            model_name="nomic-embed-text:latest",
            base_url=self.ollama_base_url
        )

        # 初始化ChromaDB
        self.chroma_client = chromadb.PersistentClient(
            path=str(self.db_dir),
            settings=Settings(allow_reset=True)
        )
        self.vector_store = ChromaVectorStore(
            chroma_collection=self.chroma_client.get_or_create_collection(
                "rag_demo")
        )

        # 配置文本分割器
        self.node_parser = SentenceSplitter(
            chunk_size=512,
            chunk_overlap=50
        )

    def build_index(self):
        # 加载文档
        documents = SimpleDirectoryReader(
            input_dir=self.data_dir,
            recursive=True,
            required_exts=[".txt", ".docx", ".jsonl"]
        ).load_data()

        print(f"成功加载 {len(documents)} 个文档")
        for doc in documents:
            print(f"- 文档ID: {doc.doc_id}, 长度: {len(doc.text)} 字符")

        # 处理文档并构建索引
        index = VectorStoreIndex.from_documents(
            documents=documents,
            embed_model=self.embed_model,
            transformations=[self.node_parser],
            vector_store=self.vector_store,
            show_progress=True
        )

        # 验证索引
        print("\n索引验证:")
        print(f"总节点数: {len(index.docstore.docs)}")
        sample_node_id = next(iter(index.docstore.docs.keys()))
        sample_node = index.docstore.get_node(sample_node_id)
        print(
            f"示例节点内容 ({len(sample_node.text)} 字符):\n{sample_node.text[:200]}...")

        return index

    def query_engine(self, index):
        return index.as_query_engine(
            llm=self.llm,
            similarity_top_k=3,
            response_mode="compact",
            timeout=60,  # 设置60秒超时
            # 优化检索参数
            # vector_store_query_mode="hybrid",  # 混合检索模式
            vector_store_kwargs={
                "search_kwargs": {"k": 50}  # 扩大候选集提升召回
            }
        )


if __name__ == "__main__":

    rag = RAGSystem()

    if "ingest" in sys.argv:
        print("Building vector index...")
        index = rag.build_index()
        index.storage_context.persist(persist_dir=rag.db_dir)
        print(f"Index persisted to {rag.db_dir}")

    if "query" in sys.argv:
        from llama_index.core import StorageContext
        from llama_index.core import load_index_from_storage  # 添加正确的导入

        # 加载已有索引
        # 修复后代码(从持久化目录加载)
        storage_context = StorageContext.from_defaults(
            persist_dir=rag.db_dir
        )
        # 修复后代码(使用load_index_from_storage)
        index = load_index_from_storage(
            storage_context=storage_context,
            embed_model=rag.embed_model
        )

        ##
        # 验证索引
        print("\n索引验证:")
        print(f"总节点数: {len(index.docstore.docs)}")
        sample_node_id = next(iter(index.docstore.docs.keys()))
        sample_node = index.docstore.get_node(sample_node_id)
        print(
            f"示例节点内容 ({len(sample_node.text)} 字符):\n{sample_node.text[:200]}...")

        # 获取查询问题
        if len(sys.argv) < 3:
            print("请提供查询问题,格式: python src/rag_pipeline.py query '你的问题'")
            sys.exit(1)

        query = sys.argv[2]
        qe = rag.query_engine(index)
        # 添加详细调试信息
        print("\n=== 查询调试信息 ===")
        print(f"查询问题: {query}")

        # 显示检索到的节点
        print("\n检索到的节点:")
        nodes = qe.retrieve(query)
        for i, node in enumerate(nodes):
            print(f"节点 {i+1}:")
            print(f"分数: {node.score:.4f}")
            print(f"内容: {node.text[:200]}...\n")

        # 执行查询
        response = qe.query(query)

        # 输出结果
        print("\n=== 最终答案 ===")
        # 过滤掉包含"<think>"的响应内容
        filtered_response = re.sub(
            r'<think>.*?</think>', '', response.response, flags=re.DOTALL).strip()
        final_answer = filtered_response if filtered_response else "未找到相关答案"
        print(final_answer)

        # 显示参考文档详情
        print("\n=== 参考文档 ===")
        if response.source_nodes:
            for node in response.source_nodes:
                print(f"- 相似度: {node.score:.4f}")
                print(f"  元数据: {node.metadata}")
                print(f"  内容: {node.text[:200]}...\n")
        else:
            print("未找到相关参考文档")

测试效果

  • 构建索引
poetry run python src/rag_pipeline.py ingest
  • 查询结果
  poetry run python src/rag_pipeline.py query "伽利略支持哪些SDK?"