掘金 人工智能 06月18日 11:34
使用 Ollama 和 Next.js 构建 AI 助理(使用 LangChain、Pinecone 和 Ollama 实现 RAG)
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文详细介绍了如何使用RAG(检索增强生成)技术,结合LangChain、Ollama和Pinecone,构建基于知识库的AI助手。文章涵盖了文档加载、预处理、向量化、存储和查询等关键步骤,并提供了Next.js、TailwindCSS等工具的配置指南。通过代码示例,读者可以了解到如何上传文档、进行文本拆分、嵌入向量,以及在Pinecone中存储和检索,最终实现智能问答功能。

📁 加载和预处理:首先,系统支持上传PDF、DOCX和TXT文件,并使用相应的加载器进行处理。接着,使用RecursiveCharacterTextSplitter将文本拆分成更小的片段,以便后续处理。

🧮 向量嵌入与存储:利用OllamaEmbeddings将文本片段转化为向量表示。这些向量随后被存储在Pinecone向量数据库中,以便进行高效的相似性搜索和检索。

❓ 查询与上下文构建:当用户提出问题时,系统在Pinecone中进行相似性搜索,找到与问题相关的文本片段。这些片段被组合成上下文,并作为系统消息注入到LLM的提示中,从而生成更准确的回答。

🤖 介绍

在前两部分中,我们介绍了如何使用 OllamaNext.js 和不同包集成来本地设置 AI 助理。在本文中,我们将深入探讨如何使用 RAG(检索增强生成)LangChainOllama 以及 Pinecone 构建基于知识库的 AI 助理。

我们将详细介绍:

🔧 使用工具

📘 什么是 RAG?

RAG 检索增强生成。它是一种结合了两种方法的混合 AI 方法,以提高响应的准确性:

🔁 流程概要

    加载 文件(PDF、DOCX、TXT)拆分 成可读片段嵌入 这些片段使用向量表示存储 在 Pinecone 中查询 Pinecone 并根据用户输入生成上下文相关答案

你可以在 LangChain 文档中阅读更多相关内容:js.langchain.com/docs/tutori…

🧩 关键包和文档

用途文档
langchainLLM 与工具链式集成框架文档
@pinecone-database/pineconePinecone 客户端文档
@langchain/pineconeLangChain-Pinecone 集成文档
@langchain/community/embeddings/ollamaOllama 为 LangChain 提供嵌入文档
pdf-parsemammoth用于加载和读取 PDF、DOCX 和 TXTpdf-parsemammoth

🧰 工具设置概览

🔧 1. 设置 Pinecone

你可以选择现有模型,也可使用自定义设置与项目中使用的模型一致,比如我选用了 mxbai-embed-large

🛠 2. 配置 .env

.env.local 中添加以下内容:

PINECONE_API_KEY=your-api-keyPINECONE_INDEX_NAME=database_namePINECONE_ENVIRONMENT=us-east-1-awsOLLAMA_MODEL=gemma3:1b

🚀 3. 启动 Ollama 和模型

确保已安装 Ollama 并在终端中运行以下命令启动该模型:

ollama run gemma3:1b

通过以下命令安装嵌入模型:

ollama pull mxbai-embed-large

LangChain 将通过以下方式本地引用该模型:

new OllamaEmbeddings({  model: 'mxbai-embed-large',  baseUrl: 'http://localhost:11434'});

注意:你可以在 js.langchain.com/docs/integr…ollama.com/search 中查看更多模型,也可以在 js.langchain.com/docs/integr… 中探索其他嵌入模型。

🧪 工作原理 — 步骤详解

下面我将详细说明我们要实现的目标,以及相应的代码片段。

第 1 步:上传和处理文档

第 2 步:嵌入并存储到 Pinecone 中

第 3 步:查询上下文

utils/documentProcessing.tsimport { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';import { Document } from '@langchain/core/documents';import { PineconeStore } from '@langchain/pinecone';import { Pinecone } from '@pinecone-database/pinecone';import { DocxLoader } from 'langchain/document_loaders/fs/docx';import { PDFLoader } from 'langchain/document_loaders/fs/pdf';import { TextLoader } from 'langchain/document_loaders/fs/text';import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });const embeddings = new OllamaEmbeddings({ model: 'mxbai-embed-large', baseUrl: 'http://localhost:11434' });const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 1000, chunkOverlap: 200 });export async function processDocument(file: File | Blob, fileName: string): Promise<Document[]> {  let documents: Document[];  if (fileName.endsWith('.pdf')) documents = await new PDFLoader(file).load();  else if (fileName.endsWith('.docx')) documents = await new DocxLoader(file).load();  else if (fileName.endsWith('.txt')) documents = await new TextLoader(file).load();  else throw new Error('Unsupported file type');  return await textSplitter.splitDocuments(documents);}export async function storeDocuments(documents: Document[]): Promise<void> {  const pineconeIndex = pinecone.Index(process.env.PINECONE_INDEX_NAME!);  await PineconeStore.fromDocuments(documents, embeddings, {    pineconeIndex,    maxConcurrency: 5,    namespace: 'your_namespace', //可选  });}export async function queryDocuments(query: string): Promise<Document[]> {  const pineconeIndex = pinecone.Index(process.env.PINECONE_INDEX_NAME!);  const vectorStore = await PineconeStore.fromExistingIndex(embeddings, {    pineconeIndex,    maxConcurrency: 5,    namespace: 'your_namespace', //可选  });  return await vectorStore.similaritySearch(query, 4);}
api/chat/upload/route.tsimport { processDocument, storeDocuments } from '@/utils/documentProcessing';import { NextResponse } from 'next/server';export async function POST(req: Request) {  const formData = await req.formData();  const file = formData.get('file') as File;  if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 });  const documents = await processDocument(file, file.name);  await storeDocuments(documents);  return NextResponse.json({    message: 'Document processed and stored successfully',    fileName: file.name,    documentCount: documents.length  });}
api/chat/route.tsimport { queryDocuments } from '@/utils/documentProcessing';import { Message, streamText } from 'ai';import { NextRequest } from 'next/server';import { createOllama } from 'ollama-ai-provider';const ollama = createOllama();const MODEL_NAME = process.env.OLLAMA_MODEL || 'gemma3:1b';export async function POST(req: NextRequest) {  const { messages } = await req.json();  const lastMessage = messages[messages.length - 1];  const relevantDocs = await queryDocuments(lastMessage.content);  const context = relevantDocs.map((doc) => doc.pageContent).join('\n\n');  const systemMessage: Message = {    id: 'system',    role: 'system',    content: `You are a helpful AI assistant with access to a knowledge base.    Use the following context to answer the user's questions:\n\n${context}`,  };  const promptMessages = [systemMessage, ...messages];  const result = await streamText({    model: ollama(MODEL_NAME),    messages: promptMessages  });  return result.toDataStreamResponse();}

以下是 UI 部分的代码片段

ChatInput.tsx'use client'interface ChatInputProps {  input: string;  handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;  handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;  isLoading: boolean;}export default function ChatInput({ input, handleInputChange, handleSubmit, isLoading }: ChatInputProps) {  return (    <form onSubmit={handleSubmit} className="flex gap-4">    <textarea      value={input}  onChange={handleInputChange}  placeholder="Ask a question about the documents..."  className="flex-1 p-4 border border-gray-200 dark:border-gray-700 rounded-xl    bg-white dark:bg-gray-800  placeholder-gray-400 dark:placeholder-gray-500  focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400  resize-none min-h-[50px] max-h-32  text-gray-700 dark:text-gray-200"  rows={1}  required  disabled={isLoading}    />    <button      type="submit"      disabled={isLoading}  className={`px-6 py-2 rounded-xl font-medium transition-all duration-200          ${isLoading            ? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'            : 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white shadow-sm hover:shadow'          }`}>{isLoading ? (  <span className="flex items-center gap-2">    <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">    <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>    </svg>    Processing    </span>    ) : 'Send'}    </button>  </form>    );}
ChatMessage.tsx'use client'import { Message } from 'ai';import ReactMarkdown from 'react-markdown';interface ChatMessageProps {  message: Message;}export default function ChatMessage({ message }: ChatMessageProps) {  return (    <div      className={`flex items-start gap-4 p-6 rounded-2xl shadow-sm transition-colors ${        message.role === 'assistant'        ? 'bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700'        : 'bg-blue-50 dark:bg-blue-900/30 border border-blue-100 dark:border-blue-800'      }`}      >      <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${        message.role === 'assistant'        ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-300'        : 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-300'      }`}>        {message.role === 'assistant' ? '🤖' : '👤'}      </div>      <div className="flex-1 min-w-0">        <div className="font-medium text-sm mb-2 text-gray-700 dark:text-gray-300">          {message.role === 'assistant' ? 'AI Assistant' : 'You'}        </div>        <div className="prose dark:prose-invert prose-sm max-w-none">          <ReactMarkdown>{message.content}</ReactMarkdown>        </div>      </div>    </div>  );}
FileUpload.tsx"use client"import React, { useState } from 'react';export default function FileUpload() {  const [isUploading, setIsUploading] = useState(false);  const [message, setMessage] = useState('');  const [error, setError] = useState('');  const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {    const file = e.target.files?.[0];    if (!file) return;    // Reset states    setMessage('');    setError('');    setIsUploading(true);    try {      const formData = new FormData();      formData.append('file', file);      const response = await fetch('/api/chat/upload', {        method: 'POST',        body: formData,      });      const data = await response.json();      if (!response.ok) {        throw new Error(data.error || 'Error uploading file');      }      setMessage(`Successfully uploaded ${file.name}`);    } catch (err) {      setError(err instanceof Error ? err.message : 'Error uploading file');    } finally {      setIsUploading(false);    }  };  return (    <div className="mb-6">      <div className="flex flex-col sm:flex-row items-center gap-4">        <label          className={`flex items-center gap-2 px-6 py-3 rounded-xl border-2 border-dashed            transition-all duration-200 cursor-pointer            ${isUploading              ? 'border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50'              : 'border-blue-300 hover:border-blue-400 hover:bg-blue-50 dark:border-blue-700 dark:hover:border-blue-600 dark:hover:bg-blue-900/30'            }`}          >          <svg            className={`w-5 h-5 ${isUploading ? 'text-gray-400' : 'text-blue-500'}`}            fill="none"            stroke="currentColor"            viewBox="0 0 24 24"            >            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />          </svg>          <span className={`font-medium ${isUploading ? 'text-gray-400' : 'text-blue-500'}`}>            {isUploading ? 'Uploading...' : 'Upload Document'}          </span>          <input            type="file"            className="hidden"            accept=".pdf,.docx"            onChange={handleFileUpload}            disabled={isUploading}            />        </label>        <span className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />          </svg>          Supported: PDF, DOCX        </span>      </div>      {message && (      <div className="mt-4 p-4 bg-green-50 dark:bg-green-900/30 rounded-xl border border-green-100 dark:border-green-800">        <p className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />            </svg>            {message}          </p>        </div>      )}      {error && (        <div className="mt-4 p-4 bg-red-50 dark:bg-red-900/30 rounded-xl border border-red-100 dark:border-red-800">          <p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-2">            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />            </svg>            {error}          </p>        </div>      )}    </div>  );}
ChatPage.tsx"use client"import { useChat } from 'ai/react';import ChatInput from './ChatInput';import ChatMessage from './ChatMessage';import FileUpload from './FileUpload';export default function ChatPage() {  const { input, messages, handleInputChange, handleSubmit, isLoading } = useChat({    api: '/api/chat',    onError: (error) => {      console.error('Chat error:', error);      alert('Error: ' + error.message);    }  });  return (    <div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-900">      <div className="flex-1 max-w-5xl mx-auto w-full p-4 md:p-6 lg:p-8">        <div className="flex-1 overflow-y-auto mb-4 space-y-6">          <h1 className="text-3xl font-bold text-gray-900 dark:text-white text-center mb-8">            RAG-Powered Knowledge Base Chat          </h1>          <div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">            <FileUpload />          </div>          <div className="space-y-6">            {messages.map((message) => (      <ChatMessage key={message.id} message={message} />    ))}          </div>        </div>        <div className="sticky bottom-0 bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4">          <ChatInput            input={input}            handleInputChange={handleInputChange}            handleSubmit={handleSubmit}            isLoading={isLoading}            />        </div>      </div>    </div>  );}

好啦!现在你可以运行代码了

npm run dev

点击 “上传文档” 按钮上传你想要存储的文档。上传成功后,你的 Pinecone 仪表盘将如下图所示:

成功加载文档后,你可以向 AI 助理询问与文档内容相关的问题,并获取正确回答。以下是我的测试截图:

dev.to/abayomijohn…

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

RAG LangChain Ollama Pinecone AI助手
相关文章