【大模型本地部署】手把手教你打造本地化部署的LLM+RAG知识库(2)

1. 前言

在前一篇文章中,我们已经实现了本地化LLM接入知识库实现了RAG搜索。不过上篇我们只是走完整个流程而已,还有很多需优化的环节。

本文将在前篇基础之上,引入数据库和向量索引持久化机制,embedding优化技巧,提供会话、历史消息的支持、打造可定制的可视化交互UI界面

待优化部分

1)每次启动服务,都要重新构建知识库向量索引(build_vector_index方法),耗时造成不必要的性能浪费,如果每次只针对新加入的知识库文件做向量索引,只做增量索引,这样就好很多了。

2)embedding有待优化,模型选择,分词器选择怎样才适合?

3)打造一个UI界面进行交互,为交付奠定基础

4)引入数据库,为后续会话管理、知识库管理打好基础

接下去我们就针对以上待优化部分进行实现:

2. 向量索引持久化

我们引入pickle模块来进行向量的序列化与反序列化操作。通过pickle.dump将通过FAISS构建好的向量索引持久化到文件中。

# 保存向量化数据和文件列表的文件路径
VECTOR_INDEX_FILE = '../data/vector_index.pkl'
FILE_LIST_FILE = '../data/file_list.pkl'

增加一个方法:load_or_build_vector_index(directory): 目的是先检查是否存在索引持久化文件,不存在的话,从头开始创建(调用之前的build_vector_index方法),如果存在,就只查找新增的知识库文件并增量索引。

已经序列化过的文件列表,会存储在FILE_LIST_FILE中。

# 异步加载或构建向量索引
async def load_or_build_vector_index(directory):
    if os.path.exists(VECTOR_INDEX_FILE) and os.path.exists(FILE_LIST_FILE):
        # 加载之前保存的向量索引和文件列表
        with gzip.open(VECTOR_INDEX_FILE, 'rb') as f:
            docsearch = pickle.load(f)
        with open(FILE_LIST_FILE, 'rb') as f:
            old_file_list = pickle.load(f)

        # 获取当前知识库目录下的文件列表
        current_file_list = []
        for root, dirs, files in os.walk(directory):
            for file in files:
                current_file_list.append(os.path.join(root, file))

        # 找出新增的文件
        new_files = [file for file in current_file_list if file not in old_file_list]

        if new_files:
            logger.info(f"Found {len(new_files)} new files. Incrementally building vector index...")
            # 对新增的文件进行向量化处理
            embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL_NAME)
            new_documents = []

            # 改进:捕获单个文件处理异常
            for file in new_files:
                try:
                    if file.endswith('.pdf'):
                        loader = PyPDFLoader(file)
                        new_documents.extend(loader.load())
                    elif file.endswith('.docx'):
                        loader = Docx2txtLoader(file)
                        new_documents.extend(loader.load())
                    elif file.endswith('.md'):
                        loader = TextLoader(file, encoding='utf-8')
                        doc = loader.load()[0]
                        hospital_name = get_hospital_name_from_md(file)
                        doc.metadata = {"hospital_name": hospital_name}
                        new_documents.append(doc)
                except Exception as e:
                    logger.error(f"Error processing file {file}: {e}")
                    # 继续处理其他文件

            # 检查是否有有效文档
            if not new_documents:
                logger.warning("No valid documents found in new files. Keeping existing index.")
                return docsearch

            text_splitter = MarkdownTextSplitter(chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP)
            new_texts = text_splitter.split_documents(new_documents)

            # 检查拆分后是否还有文本
            if not new_texts:
                logger.warning("No valid text chunks after splitting. Keeping existing index.")
                return docsearch

            # 打印调试信息
            logger.info(f"Creating embeddings for {len(new_texts)} text chunks")

            # 尝试创建索引
            try:
                # 合并新的向量索引到已有的索引中
                docsearch.add_documents(new_texts)

                # 更新文件列表
                old_file_list.extend(new_files)
                with open(FILE_LIST_FILE, 'wb') as f:
                    pickle.dump(old_file_list, f)

                # 将 GPU 索引转换回 CPU 索引
                if torch.cuda.is_available():
                    docsearch.index = faiss.index_gpu_to_cpu(docsearch.index)

                # 保存更新后的向量索引
                # 使用 gzip 和 pickle 进行压缩并保存
                with gzip.open(VECTOR_INDEX_FILE, 'wb') as f:
                    pickle.dump(docsearch, f)

                # 如果有 GPU,再将索引迁移回 GPU
                if torch.cuda.is_available():
                    res = faiss.StandardGpuResources()
                    docsearch.index = faiss.index_cpu_to_gpu(res, 0, docsearch.index)
            except Exception as e:
                logger.error(f"Error creating FAISS index: {e}")
                # 如果发生错误,返回现有索引
                logger.warning("Using existing index due to error")
                return docsearch
    else:
        logger.info("No existing vector index found. Building vector index from scratch...")
        try:
            docsearch = await build_vector_index(directory)

            # 将 GPU 索引转换回 CPU 索引
            if torch.cuda.is_available():
                docsearch.index = faiss.index_gpu_to_cpu(docsearch.index)

            # 保存向量索引和文件列表
            file_list = []
            for root, dirs, files in os.walk(directory):
                for file in files:
                    file_list.append(os.path.join(root, file))
            with gzip.open(VECTOR_INDEX_FILE, 'wb') as f:
                pickle.dump(docsearch, f)
            with open(FILE_LIST_FILE, 'wb') as f:
                pickle.dump(file_list, f)

            # 如果有 GPU,再将索引迁移回 GPU
            if torch.cuda.is_available():
                res = faiss.StandardGpuResources()
                docsearch.index = faiss.index_cpu_to_gpu(res, 0, docsearch.index)
        except Exception as e:
            logger.error(f"Error building vector index from scratch: {e}")
            # 创建一个空的FAISS索引作为后备
            embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL_NAME)
            docsearch = FAISS(embedding_function=embeddings)

    return docsearch

# 异步加载本地知识库
docsearch = asyncio.run(load_or_build_vector_index(KNOWLEDGE_BASE_DIR))

3. Embedding优化

此处我们优化了build_vector_index方法,将CharacterTextSplitter替换成了MarkdownTextSplitter,因为知识库文件是Markdown格式,采用MarkdownTextSplitter更适合。

 async def build_vector_index(directory):
    # 加载 BAAI/bge-large-zh-v1.5 嵌入模型
    embeddings = HuggingFaceEmbeddings(model_name=EMBEDDING_MODEL_NAME)
    documents = []
    for root, dirs, files in os.walk(directory):
        for file in files:
            file_path = os.path.join(root, file)
            if file.endswith('.pdf'):
                loader = PyPDFLoader(file_path)
                documents.extend(loader.load())
            elif file.endswith('.docx'):
                loader = Docx2txtLoader(file_path)
                documents.extend(loader.load())
            elif file.endswith('.md'):
                loader = TextLoader(file_path, encoding='utf-8')
                doc = loader.load()[0]
                # 从 md 文件中获取公司名称
                company_name= get_company_name_from_md(file_path)
                doc.metadata = {"company_name": company_name}
                documents.append(doc)

    text_splitter = MarkdownTextSplitter(chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP)
    texts = text_splitter.split_documents(documents)
    docsearch = FAISS.from_documents(texts, embeddings)
    # 将索引迁移到 GPU
    if torch.cuda.is_available():
        res = faiss.StandardGpuResources()  # 创建 GPU 资源
        index_gpu = faiss.index_cpu_to_gpu(res, 0, docsearch.index)  # 将 CPU 索引迁移到 GPU
        docsearch.index = index_gpu
    return docsearch

MarkdownTextSplitter的两个重要参数,往往会根据知识库内容的差异会有不同的经验值(这里我设置的是chunk_size=400,chunk_overlap=80):

chunk_size:这是一个参数,由 CHUNK_SIZE 变量指定。它表示每个分割后的文本块的最大字符数。例如,如果 CHUNK_SIZE 设为 500,那么每个分割后的文本块的字符数不会超过 500。

chunk_overlap:这也是一个参数,由 CHUNK_OVERLAP 变量指定。它表示相邻文本块之间重叠的字符数。例如,如果 CHUNK_OVERLAP 设为 50,那么相邻的两个文本块会有 50 个字符是相同的。设置重叠部分可以确保在分割文本时不会丢失重要信息,并且在后续的检索和处理中可以更好地捕捉上下文。


关于Embedding模型的选择

推荐3个模型,请根据自己的场景选择:

模型名称类型优点缺点适用场景资源需求
sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2多语言模型- 支持多种语言
- 轻量高效,推理速度快
- 预训练数据广泛,语义理解强
- 中文任务表现一般
- 模型容量有限,长文本处理受限
多语言场景(如跨国文档检索、国际新闻分类)、资源有限的实时应用(如聊天机器人)较低(适合轻量级设备)
BAAI/bge-large-zh-v1.5中文优化模型- 中文任务表现优异
- 大模型参数,复杂语义理解强
- 融合最新技术
- 计算资源需求高
- 不支持多语言
中文专业领域(医学、法律)、高精度任务(问答系统、语义相似度计算)高(需GPU支持)
shibing624/text2vec-base-chinese中文基础模型- 中文适配性好
- 易用性强,部署简单
- 社区支持丰富
- 模型能力有限,复杂任务效果一般
- 功能扩展性不足
快速搭建中文系统(文本分类、情感分析)、初学者或轻量级应用中等(无GPU可运行)

4. 引入数据库

我们引入SQLAlchemy和Pydantic ,同时使用 SQLAlchemy 和 Pydantic 是为了分别处理数据库交互和数据验证与序列化,它们在不同层面发挥重要作用,结合使用能提高开发效率和代码的可维护性。

SQLAlchemy 是Python的一个持久层框架,具有ORM,多数据库支持、支持事务的特性。有点类似Java后端的Hibernate和Mybatis。

首先导入必要的库:

import datetime
from typing import Optional

from sqlalchemy import Column, Integer, String, TIMESTAMP, ForeignKey, Text, func
from sqlalchemy.orm import declarative_base
from pydantic import BaseModel

然后使用一个单独的文件(entities.py)来定义实体类模型(这里已经给出了本系列教程所有需要用到的实体类,在系列3将不再重复描述):

import datetime
from typing import Optional

from sqlalchemy import Column, Integer, String, TIMESTAMP, ForeignKey, Text, func
from sqlalchemy.orm import declarative_base
from pydantic import BaseModel

Base = declarative_base()

# 定义数据库模型
class SessionModelDB(Base):
    __tablename__ = "session"
    id = Column(Integer, primary_key=True, index=True)
    session_id = Column(String(255), nullable=False)
    created_date = Column(TIMESTAMP, server_default=func.now())


class MessageDB(Base):
    __tablename__ = "message"
    id = Column(Integer, primary_key=True, index=True)
    content = Column(Text, nullable=False)
    type = Column(String(10), nullable=False)
    created_date = Column(TIMESTAMP, server_default=func.now())
    session_id = Column(Integer, ForeignKey("session.id", ondelete="CASCADE"))  # 会话删除,对话记录自动删除

# 定义 kb_files 表
class KbFile(Base):
    __tablename__ = "kb_files"
    id = Column(Integer, primary_key=True, index=True)
    name = Column(String(255), index=True)
    path = Column(String(255))
    created_date = Column(TIMESTAMP, server_default=func.now())

# #=========================================================================

# 定义 Pydantic 模型
class SessionModel(BaseModel):
    id: int
    session_id: str
    created_date: datetime

    model_config = {
        "from_attributes": True,
        "arbitrary_types_allowed": True
    }

class Message(BaseModel):
    id: int
    think: str # 思考部分,从<think>标签里拆解出来
    final_content: str # 正式回答部分
    type: str
    created_date: datetime
    session_id: int

    model_config = {
        "from_attributes": True,
        "arbitrary_types_allowed": True
    }

# 新增 Pydantic 的 KbFile 模型
class KbFileModel(BaseModel):
    id: int
    name: str
    path: str

    model_config = {
        "from_attributes": True,
        "arbitrary_types_allowed": True
    }

这样,在每次交互问答时,只需要将问答记录保存到数据库即可,实现了保存会话历史的功能:

# 保存问题和回答到数据库
question_message = MessageDB(content=question, type='question', session_id=session_id)
answer_message = MessageDB(content=answer, type='answer', session_id=session_id)
db.add(question_message)
db.add(answer_message)
db.commit()

服务端修改后的generate_answer方法完整代码:

# 异步生成回答(修改后的函数)
async def generate_answer(question, session_id, db: Session):
    global docsearch
    if docsearch is None:
        docsearch = await load_or_build_vector_index(KNOWLEDGE_BASE_DIR)
    try:
        logger.info(f"Received question: {question}")

        # 获取历史对话(排除当前尚未保存的问题)
        history_messages = db.query(MessageDB).filter(
            MessageDB.session_id == session_id,
            MessageDB.type.in_(['question', 'answer'])
        ).order_by(MessageDB.created_date).all()

        # 构造历史对话字符串
        history_str = ""
        for msg in history_messages:
            if msg.type == 'question':
                history_str += f"用户: {msg.content}\n"
            elif msg.type == 'answer':
                history_str += f"助手: {msg.content}\n"

        hospital_name = extract_hospital_name(question)
        logger.info(f"你要查询的医院是: {hospital_name}")

        # candidates = docsearch.similarity_search(question, k=10)
        candidates = docsearch.similarity_search_with_score(question, k=10)

        if candidates:
            logger.info(f"Found relevant documents in the knowledge base.{len(candidates)}")
            if hospital_name:
                # candidates = [doc for doc in candidates if doc.metadata.get("hospital_name") == hospital_name]
                # 当使用similarity_search_with_score时,它返回的是(document, score)形式的元组列表,而不是单纯的文档列表。您需要修改代码以提取元组中的文档部分。
                candidates = [doc_tuple[0] for doc_tuple in candidates if
                              doc_tuple[0].metadata.get("hospital_name") == hospital_name]


            ranked_candidates = await rerank(question, candidates)
            final_results = ranked_candidates[:3]
            logger.info(f"Final results: {final_results}")

            context = "\n".join([doc.page_content for doc in final_results]) if final_results else ""
        else:
            logger.info("No relevant documents found in the knowledge base.")
            context = ""

        # 构造完整提示(包含历史对话和上下文)
        full_prompt = prompt.format(
            history=history_str.strip(),
            context=context,
            question=question
        )

        # 流式请求Ollama(保持原有逻辑)
        url = 'http://localhost:11434/api/generate'
        headers = {'Content-Type': 'application/json'}
        data = {
            "model": OLLAMA_MODEL_NAME,
            "prompt": full_prompt,
            "parameters": {
                "max_tokens": 100,
                "temperature": 0.2
            },
            "stream": True
        }

        async with aiohttp.ClientSession() as session:
            async with session.post(url, headers=headers, json=data) as res:
                answer = ""
                async for line in res.content:
                    if line:
                        try:
                            chunk = json.loads(line)
                            result = await process_ollama_stream(chunk)
                            if result:
                                answer_chunk = result.decode('utf-8')
                                answer += answer_chunk
                                # 流式返回数据
                                yield answer_chunk.encode('utf-8')
                        except json.JSONDecodeError as e:
                            logger.error(f"JSON decoding error: {e}, line: {line}")

        # 保存问题和回答到数据库
        question_message = MessageDB(content=question, type='question', session_id=session_id)
        answer_message = MessageDB(content=answer, type='answer', session_id=session_id)
        db.add(question_message)
        db.add(answer_message)
        db.commit()

    except Exception as general_exception:
        error_message = f"General error occurred: {str(general_exception)}"
        logger.error(error_message)
        error_message_obj = MessageDB(
            content=error_message,
            type='error',
            session_id=session_id
        )
        db.add(error_message_obj)
        db.commit()
        yield error_message.encode('utf-8')
        return

5. 打造UI交互界面

对于熟悉前端的同学来说,搭建一个交互界面应该很容易,就算你对前端一窍不通,这个前端页面的构建任务只需要几句提示词就能让AI帮你完成:

笔者也是很久没写前端了,为了快速实现,让AI快速帮我生成了基于Vue的前端工程。

前端工程核心架构

让后我告诉AI我需要什么样的页面,通过提示词引导,一步步让AI帮我绘制出来,我全程没写过一行代码:

告诉AI你需要什么样的布局

告诉AI你需要什么样的CSS风格

经过几步调整,最终AI帮我绘制了这样的界面,已经够用了:

告诉UI统一修改所有的CSS风格

因为LLM返回的内容是Markdown格式,所以在右侧流式输出结果栏中,需要识别Markdown格式内容并正确渲染(包括对公式的支持):

渲染Markdown文本,支持数学公式

输入问题后,直接按回车即可与本地的AI进行交互了,按回车触发的核心前端方法如下:

async handleSubmit() {
            if (!this.question) return;
  
            if (!this.currentSessionId) {
                await this.startNewSession();
            }
  
            const currentSession = this.currentSession;
            const question = this.question;
  
            // 确保 currentSession 和 currentSession.messages 存在
            if (currentSession &&!currentSession.messages) {
                currentSession.messages = [];
            }
  
            if (currentSession && currentSession.messages) {
                // 推送用户问题消息
                currentSession.messages.push({
                    type: 'question',
                    final_content: question,
                    think: '',
                    id: Date.now()
                });
                this.scrollToBottom();
            }
  
            this.isLoading = true;
            this.answer = '';
  
            try {
                const response = await fetch(
                    `http://localhost:8000/ask?question=${encodeURIComponent(question)}&session_id=${this.currentSessionId}`,
                    {
                        method: 'GET',
                        headers: {
                            Accept: 'text/plain'
                        }
                    }
                );
  
                console.log('Response Status:', response.status);
                console.log('Response Headers:', response.headers);
                console.log('Response Type:', response.type);
  
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
  
                const reader = response.body.getReader();
                const decoder = new TextDecoder();
                let result = '';
  
                try {
                    let botMessageId = Date.now(); // 创建一个新的Bot消息ID
                    let thinkContent = ''; // 用于存储思考过程
                    let contentPart = ''; // 用于存储正式内容
  
                    if (currentSession && currentSession.messages) {
                        currentSession.messages.push({
                            type: 'answer',
                            final_content: '',
                            think: '',
                            id: botMessageId
                        });
                    }
  
                    for (;;) {
                        const { done, value } = await reader.read();
                        if (done) {
                            result += decoder.decode();
                            break;
                        }
                        const chunk = decoder.decode(value, { stream: true });
                        result += chunk;
  
                        // 动态拆分思考过程和正式内容
                        const parts = result.split(/<think>|<\/think>/);
                        if (parts.length >= 3) {
                            thinkContent = parts[1];
                            contentPart = parts[2];
                        } else if (parts.length === 2 && parts[0] === '') {
                            thinkContent = parts[1];
                            contentPart = '';
                        } else {
                            thinkContent = '';
                            contentPart = result;
                        }
  
                        // 更新Bot消息的内容
                        if (currentSession && currentSession.messages) {
                            const botMessageIndex = currentSession.messages.findIndex(
                                (msg) => msg.id === botMessageId
                            );
                            if (botMessageIndex !== -1) {
                                currentSession.messages[botMessageIndex].think = thinkContent;
                                currentSession.messages[botMessageIndex].final_content = contentPart;
                                this.scrollToBottom(); // 每次更新消息内容后滚动到最底部
                            }
                        }
                    }
                } finally {
                    reader.releaseLock();
                }
            } catch (error) {
                console.error('请求出错:', error);
                this.answer = '请求出错,请稍后再试。';
  
                if (currentSession && currentSession.messages) {
                    const botMessageId = Date.now(); // 创建一个新的Bot消息ID
                    currentSession.messages.push({
                        type: 'answer',
                        think: '',
                        final_content: '请求出错,请稍后再试。',
                        id: botMessageId
                    });
                    this.scrollToBottom(); // 出现错误时也滚动到最底部
                }
            } finally {
                this.isLoading = false;
                this.question = '';
                this.$nextTick(async () => {
                    await this.safeMathJaxTypeset();
                });
            }
        },

为了区分AI思考链内容和正式反馈的结果,前端需要通过两个变量分别来存储:

let thinkContent = ''; // 用于存储思考过程
let contentPart = ''; // 用于存储正式内容

前面提到的ChatView.vue代码:

<template>
    <div id="app">
        <div class="sidebar">
            <button class="custom-btn" @click="startNewSession">开启新会话</button>
            <button class="custom-btn" @click="goToKnowledgeBaseManagement">管理知识库</button>
            <div id="sessionList" class="session-list">
                <div
                    v-for="session in sessions"
                    :key="session.id"
                    class="session-item"
                    :class="{ 'active': String(session.id) === String(currentSessionId) }"
                    @click="loadSessionMsgs(session.id)"
                >
                    会话 {{ session.id }} - {{ formatDate(session.created_date) }}
                     <!-- 添加删除按钮 -->
                    <button class="delete-session-btn" @click="deleteSession(session.id)">删除</button>                  
                </div>
            </div>
        </div>
        <div class="chat-container">
            <div class="chat-header">
                <h1>RAG知识库搜索 + 智能对话</h1>
            </div>
            <div class="chat-messages" id="chatMessages" ref="chatMessages">
                <div
                    v-for="message in currentSession.messages"
                    :key="message.id"
                    class="message"
                    :class="{ 'user-message': message.type === 'question', 'bot-message': message.type === 'answer' }"
                >
                    <div class="avatar" :class="{ 'user-avatar': message.type === 'question', 'bot-avatar': message.type === 'answer' }">
                        <img :src="message.type === 'question' ? require('@/assets/user-avatar.png') : require('@/assets/bot-avatar.png')" alt="Avatar">
                        <span>{{ message.created_date }}</span>
                    </div>
                    <div class="message-content" :class="{ 'user-message-content': message.type === 'question', 'bot-message-content': message.type === 'answer' }">
                        <div class="think-content" v-html="renderMarkdown(message.think)"> </div>
                        <div class="content-text" v-html="renderMarkdown(message.final_content)">
                        </div>
                        <button
                            v-if="message.type === 'question'"
                            class="copy-button"
                            @click="copyToClipboard(message.final_content)"
                        >
                            复制
                        </button>
                    </div>
                </div>
            </div>
            <div class="chat-input">
                <input
                    type="text"
                    id="question-input"
                    placeholder="请输入你的问题"
                    v-model="question"
                    @keyup.enter="handleSubmit"
                />
                <button type="button" id="submit-button" @click="handleSubmit" class="custom-btn">
                    提交
                </button>
            </div>
        </div>
    </div>
  </template>
  
  <script>
  import { marked } from 'marked';
  import DOMPurify from 'dompurify';
  import { useRouter } from 'vue-router';
  
  // 配置 marked
  marked.setOptions({
      gfm: true,
      breaks: true,
      pedantic: false,
      smartLists: true,
      smartypants: false
  });
  
  // 配置 DOMPurify 允许数学相关字符
  DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
    if (data.attrName === 'class' && data.attrValue === 'math') {
      return true; // 允许保留 math 类
    }
  });
  
  DOMPurify.addHook('uponSanitizeElement', (node) => {
    if(node.tagName === 'MATH') return false; // 保留MathJax元素
  });
  
  DOMPurify.setConfig({
    ALLOWED_ATTR: ['class', 'style'], // 允许 class 和 style
    ALLOWED_TAGS: ['span', 'div', 'p', 'br', 'strong', 'em', 'code'] // 添加必要标签
  });
  
  export default {
    name: 'ChatView',
    data() {
        return {
            question: '',
            answer: '',
            isLoading: false,
            currentSessionId: null,
            sessions: [], // 用于存储会话历史
            mathJaxLoaded: false
        };
    },
  
    setup() {
      const router = useRouter();  
      const goToKnowledgeBaseManagement = () => {
        router.push({ name: 'KnowledgeBaseManagement' });
      };
  
      return {
        goToKnowledgeBaseManagement
      };
    }, 
  
    computed: {
        currentSession() {
            return this.sessions.find((session) => session.id === this.currentSessionId) || {};
        }
    },
    methods: {
      async safeMathJaxTypeset() {
       
      }, 
      
        async loadSessions() {
             //加载历史会话的代码

        },
  
        // 其他方法保持不变
        async deleteSession(sessionId) {
           //删除会话
        },
  
        async handleSubmit() {
            if (!this.question) return;
  
            if (!this.currentSessionId) {
                await this.startNewSession();
            }
  
            const currentSession = this.currentSession;
            const question = this.question;
  
            // 确保 currentSession 和 currentSession.messages 存在
            if (currentSession &&!currentSession.messages) {
                currentSession.messages = [];
            }
  
            if (currentSession && currentSession.messages) {
                // 推送用户问题消息
                currentSession.messages.push({
                    type: 'question',
                    final_content: question,
                    think: '',
                    id: Date.now()
                });
                this.scrollToBottom();
            }
  
            this.isLoading = true;
            this.answer = '';
  
            try {
                const response = await fetch(
                    `http://localhost:8000/ask?question=${encodeURIComponent(question)}&session_id=${this.currentSessionId}`,
                    {
                        method: 'GET',
                        headers: {
                            Accept: 'text/plain'
                        }
                    }
                );
  
                console.log('Response Status:', response.status);
                console.log('Response Headers:', response.headers);
                console.log('Response Type:', response.type);
  
                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }
  
                const reader = response.body.getReader();
                const decoder = new TextDecoder();
                let result = '';
  
                try {
                    let botMessageId = Date.now(); // 创建一个新的Bot消息ID
                    let thinkContent = ''; // 用于存储思考过程
                    let contentPart = ''; // 用于存储正式内容
  
                    if (currentSession && currentSession.messages) {
                        currentSession.messages.push({
                            type: 'answer',
                            final_content: '',
                            think: '',
                            id: botMessageId
                        });
                    }
  
                    for (;;) {
                        const { done, value } = await reader.read();
                        if (done) {
                            result += decoder.decode();
                            break;
                        }
                        const chunk = decoder.decode(value, { stream: true });
                        result += chunk;
  
                        // 动态拆分思考过程和正式内容
                        const parts = result.split(/<think>|<\/think>/);
                        if (parts.length >= 3) {
                            thinkContent = parts[1];
                            contentPart = parts[2];
                        } else if (parts.length === 2 && parts[0] === '') {
                            thinkContent = parts[1];
                            contentPart = '';
                        } else {
                            thinkContent = '';
                            contentPart = result;
                        }
  
                        // 更新Bot消息的内容
                        if (currentSession && currentSession.messages) {
                            const botMessageIndex = currentSession.messages.findIndex(
                                (msg) => msg.id === botMessageId
                            );
                            if (botMessageIndex !== -1) {
                                currentSession.messages[botMessageIndex].think = thinkContent;
                                currentSession.messages[botMessageIndex].final_content = contentPart;
                                this.scrollToBottom(); // 每次更新消息内容后滚动到最底部
                            }
                        }
                    }
                } finally {
                    reader.releaseLock();
                }
            } catch (error) {
                console.error('请求出错:', error);
                this.answer = '请求出错,请稍后再试。';
  
                if (currentSession && currentSession.messages) {
                    const botMessageId = Date.now(); // 创建一个新的Bot消息ID
                    currentSession.messages.push({
                        type: 'answer',
                        think: '',
                        final_content: '请求出错,请稍后再试。',
                        id: botMessageId
                    });
                    this.scrollToBottom(); // 出现错误时也滚动到最底部
                }
            } finally {
                this.isLoading = false;
                this.question = '';
                this.$nextTick(async () => {
                    await this.safeMathJaxTypeset();
                });
            }
        },
        renderMarkdown(rawText) {
          // 匹配 $...$ 和 $$...$$ 包裹的公式
          const latexBlocks = rawText.match(/\$\$.*?\$\$|\$.*?\$/g) || [];
          let processed = rawText;
          latexBlocks.forEach(formula => {
              // 保留原始LaTeX内容
              processed = processed.replace(formula, `<div class="math">${formula}</div>`);
          });
          
          const html = marked(processed);
          
          return DOMPurify.sanitize(html, { 
              ADD_TAGS: ['math'], 
              ADD_ATTR: ['xmlns'] 
          });
       },
        async startNewSession() {
             //开启一个新的会话
        },
        async loadSessionMsgs(sessionId) { 

        },
        copyToClipboard(text) {
            navigator.clipboard
              .writeText(text)
              .then(() => {
                    alert('已复制到剪贴板');
                })
              .catch((err) => {
                    console.error('复制失败:', err);
                    alert('复制失败,请重试。');
                });
        },
        scrollToBottom() {
          this.$nextTick(() => {
              const chatMessages = this.$refs.chatMessages;
              if (chatMessages) {
                  chatMessages.scrollTop = chatMessages.scrollHeight;
                  this.safeMathJaxTypeset(); // 滚动后触发公式渲染
              }
          });
        },
        formatDate(date) {
            const d = new Date(date);
            const year = d.getFullYear();
            const month = String(d.getMonth() + 1).padStart(2, '0');
            const day = String(d.getDate()).padStart(2, '0');
            return `${year}/${month}/${day}`;
        },
  
    },
    mounted() {
     
  </script>
  
  <style scoped>
  @import '@/assets/styles.css';
  
  </style>

除此之外,可以按需添加自己需要的内容,此处不再赘述。

6. 总结

这个环节主要基于第(1)篇做了进一步优化,包括增量索引的支持、embedding的优化和模型选择、引入数据库、以及增加了UI交互界面。此篇已经将会话(SessionModelDB)、消息(MessageDB)、知识库(KbFile)对象提前建立,为后续功能完善做好铺垫。

在系列(3)我们将实现会话管理、历史消息的加载与删除、知识库的预览与管理操作。

 如何系统的去学习大模型LLM ?

大模型时代,火爆出圈的LLM大模型让程序员们开始重新评估自己的本领。 “AI会取代那些行业?”“谁的饭碗又将不保了?”等问题热议不断。

事实上,抢你饭碗的不是AI,而是会利用AI的人。

科大讯飞、阿里、华为等巨头公司发布AI产品后,很多中小企业也陆续进场!超高年薪,挖掘AI大模型人才! 如今大厂老板们,也更倾向于会AI的人,普通程序员,还有应对的机会吗?

与其焦虑……

不如成为「掌握AI工具的技术人」,毕竟AI时代,谁先尝试,谁就能占得先机!

但是LLM相关的内容很多,现在网上的老课程老教材关于LLM又太少。所以现在小白入门就只能靠自学,学习成本和门槛很高。

基于此,我用做产品的心态来打磨这份大模型教程,深挖痛点并持续修改了近70次后,终于把整个AI大模型的学习门槛,降到了最低!

在这个版本当中:

第一您不需要具备任何算法和数学的基础
第二不要求准备高配置的电脑
第三不必懂Python等任何编程语言

您只需要听我讲,跟着我做即可,为了让学习的道路变得更简单,这份大模型教程已经给大家整理并打包,现在将这份 LLM大模型资料 分享出来:包括LLM大模型书籍、640套大模型行业报告、LLM大模型学习视频、LLM大模型学习路线、开源大模型学习教程等, 😝有需要的小伙伴,可以 扫描下方二维码领取🆓↓↓↓

一、LLM大模型经典书籍

AI大模型已经成为了当今科技领域的一大热点,那以下这些大模型书籍就是非常不错的学习资源。

在这里插入图片描述

二、640套LLM大模型报告合集

这套包含640份报告的合集,涵盖了大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。(几乎涵盖所有行业)
在这里插入图片描述

三、LLM大模型系列视频教程

在这里插入图片描述

四、LLM大模型开源教程(LLaLA/Meta/chatglm/chatgpt)

在这里插入图片描述

五、AI产品经理大模型教程

在这里插入图片描述

LLM大模型学习路线 

阶段1:AI大模型时代的基础理解

  • 目标:了解AI大模型的基本概念、发展历程和核心原理。

  • 内容

    • L1.1 人工智能简述与大模型起源
    • L1.2 大模型与通用人工智能
    • L1.3 GPT模型的发展历程
    • L1.4 模型工程
    • L1.4.1 知识大模型
    • L1.4.2 生产大模型
    • L1.4.3 模型工程方法论
    • L1.4.4 模型工程实践
    • L1.5 GPT应用案例

阶段2:AI大模型API应用开发工程

  • 目标:掌握AI大模型API的使用和开发,以及相关的编程技能。

  • 内容

    • L2.1 API接口
    • L2.1.1 OpenAI API接口
    • L2.1.2 Python接口接入
    • L2.1.3 BOT工具类框架
    • L2.1.4 代码示例
    • L2.2 Prompt框架
    • L2.3 流水线工程
    • L2.4 总结与展望

阶段3:AI大模型应用架构实践

  • 目标:深入理解AI大模型的应用架构,并能够进行私有化部署。

  • 内容

    • L3.1 Agent模型框架
    • L3.2 MetaGPT
    • L3.3 ChatGLM
    • L3.4 LLAMA
    • L3.5 其他大模型介绍

阶段4:AI大模型私有化部署

  • 目标:掌握多种AI大模型的私有化部署,包括多模态和特定领域模型。

  • 内容

    • L4.1 模型私有化部署概述
    • L4.2 模型私有化部署的关键技术
    • L4.3 模型私有化部署的实施步骤
    • L4.4 模型私有化部署的应用场景

这份 LLM大模型资料 包括LLM大模型书籍、640套大模型行业报告、LLM大模型学习视频、LLM大模型学习路线、开源大模型学习教程等, 😝有需要的小伙伴,可以 扫描下方二维码领取🆓↓↓↓

 

<think>好的,用户想学习如何手动搭建知识库系统,需要手把手程和方法。首先,我需要根据提供的引用内容来整理出搭建知识库的关键步骤和注意事项。 首先,引用[2]和[3]提到了RAG系统,也就是检索增强生成,将外部知识库大模型结合。这应该涉及到使用向量数据库存储非参数化知识,然后通过检索来增强模型的回答。用户可能需要了解如何构建这样的系统,包括知识处理、向量化、存储和检索。 然后,引用[1]和[3]提到了Word2Vec的训练和知识库应用的代码,这可能涉及到文本的向量化表示,也就是Embeddings。这部分需要详细说明如何将文本转化为向量,并选择合适的模型,比如BERT或Sentence-BERT。 引用[4]提到了中文简繁转换的工具,可能在处理中文文本时需要用到,比如统一简繁体,确保知识库的一致性。这也提醒我在预处理阶段需要考虑文本清洗和格式标准化。 引用[5]是关于前端Vue实现知识库系统的,用户可能需要前后端结合的指导,但当前问题可能更关注后端和数据处理部分,但或许也需要简要提及前端展示。 接下来,整理搭建步骤。根据引用内容,大概分为五个步骤:知识收集、预处理、向量化、构建数据库、检索与集成。每个步骤需要具体说明方法和工具,例如预处理使用OpenCC进行简繁转换,分词使用jieba;向量化选择Sentence-BERT;数据库使用Milvus或FAISS;最后集成到大模型LangChain。 还需要注意实际应用中的问题,比如数据更新、多语言支持、混合检索(关键词+向量)以及性能优化,如引用[2]提到的混合检索和RAG-Fusion,这些可以作为优化点。 最后,生成相关问题,可能涉及步骤的细节、工具选择、优化方法、应用场景等,帮助用户深入理解。 需要确保回答结构清晰,分步骤说明,引用相关的引用来源,并在末尾添加引用标识。同时,按照用户要求,使用中文,数学公式正确应用,但这个问题可能涉及较少数学内容,但如果有的话,比如向量距离计算,需要用$$...$$格式。</think>### 手把手搭建知识库系统方法 #### 一、知识库系统核心原理 知识库系统通过将结构化/非结构化数据转化为机器可理解的语义表示(如向量),结合检索增强生成(RAG)技术扩展大模型能力[^2]。其核心公式为: $$ \text{知识库系统} = \text{数据向量化} + \text{向量数据库} + \text{LLM推理} $$ #### 二、搭建步骤详解 1. **知识采集与清洗** - 使用网络爬虫获取网页数据或导入本地文档(PDF/Word/TXT) - 文本清洗:移除HTML标签、特殊符号,统一编码格式(UTF-8) - 中文处理:使用OpenCC进行简繁转换[^4] ```python import opencc converter = opencc.OpenCC('t2s.json') # 繁转简 cleaned_text = converter.convert(raw_text) ``` 2. **知识向量化处理** - 选择Embedding模型: - 中文推荐:`text2vec-base-chinese` - 多语言推荐:`sentence-transformers/all-mpnet-base-v2` - 批量生成向量: $$ \mathbf{v} = f_{\text{model}}(\text{文档内容}) \in \mathbb{R}^{768} $$ ```python from sentence_transformers import SentenceTransformer model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2') vectors = model.encode(documents) ``` 3. **向量数据库构建** - 数据库选型对比: | 类型 | 特点 | 适用场景 | |------------|-------------------------------|------------------| | Milvus | 支持分布式部署,高并发 | 企业级生产环境 | | FAISS | 轻量级,内存运行 | 开发测试环境 | | Pinecone | 全托管云服务 | 无运维团队场景 | - 创建索引示例(FAISS): ```python import faiss index = faiss.IndexFlatL2(768) # 创建L2距离索引 index.add(vectors) # 添加向量数据 ``` 4. **检索增强实现** - 混合检索策略: ```python def hybrid_search(query, k=5): # 向量检索 vector_results = vector_search(query) # 关键词检索 keyword_results = bm25_search(query) return rerank(vector_results + keyword_results) ``` - 检索结果评分公式: $$ \text{综合评分} = 0.7 \times \text{余弦相似度} + 0.3 \times \text{BM25得分} $$ 5. **系统集成开发** - 使用LangChain框架对接大模型: ```python from langchain.chains import RetrievalQA qa_chain = RetrievalQA.from_chain_type( llm=ChatGLM(), chain_type="stuff", retriever=vector_db.as_retriever() ) ``` #### 三、实际应用建议 1. **数据更新机制**:设置定时任务增量更新向量数据库[^3] 2. **多语言支持**:配置多Embedding模型路由策略 3. **性能优化**:对超过512字符的文档进行分块处理,块大小推荐256-512字符 4. **混合检索**:结合传统BM25算法提升召回率 #### 四、完整技术栈示例 ``` 前端:Vue3 + Element Plus [^5] 后端:FastAPI 向量数据库:Milvus 2.3 Embedding模型:bge-large-zh-v1.5 大模型:ChatGLM3-6B ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值