AI相关-RAG开发实例(二)

用户头像
作者:新鲜噩梦
简介:little笔记全栈作者
创建于:2025-09-24 22:03:09字数:34650

RAG 开发实例

根据AI相关-RAG开发实例(一)的理论,可以把实践拆分为3部分:
  1. 部署向量数据库。
  2. 使用nestjs开发服务端(链接向量数据库,提供sse接口)。
  3. 前端根据输入展示sse输出内容。
所以,以下内容需要你已经熟悉nestjs和docker的基本使用。
最终效果展示:
存入向量数据库4条数据:
json
[ "有一只小狗,体重为10公斤", "有一只老狗,体重为18公斤", "有一个石头,重量为2吨", "有一个人,他位高权重,却以百姓为刍狗" ]
image
1. 部署向量数据库
首先需要确定什么向量数据库,市面上有哪些数据库呢?问AI。
因为我们不可能把市面上所有的向量数据数据库全部实践一遍,没那个精力,也没必要,可以使用AI工具(豆包或者deepseek)搜索,搜索出的数据库需要去npm上看下下载量,选择下载量大、经常更新的。我找到的数据库有:Milvus、Weaviate、Qdrant,他们的下载量都还行,但是我看中了Qdrant对TS支持好和使用简单的特性,所以选择了它。
使用dcker部署Qdrant向量数据库,项目根目录创建 docker-compose.yml 文件。
yml
# docker-compose.yml volumes: notes-qdrant-data: services: # Qdrant 向量数据库服务 qdrant: container_name: notes-qdrant image: qdrant/qdrant:latest # 端口映射:6333为REST API端口,6334为gRPC端口 ports: - '6333:6333' - '6334:6334' # 数据持久化 volumes: - notes-qdrant-data:/qdrant/storage # 环境变量配置 environment: # 注意:这里可以设置API密钥来保护向量数据库 QDRANT__SERVICE__API_KEY: '123456' # 配置集群模式(单节点) QDRANT__CLUSTER__ENABLED: 'false' # 配置日志级别 QDRANT__LOG_LEVEL: 'INFO' # 重启策略 restart: always
部署命令
bash
docker compose -f docker-compose.yml up -d --build
2. nestjs开发服务端
nestjs的项目初始化AI一问就都出来了,使用最新版的nest就可以。
这里只展示必要的vector模块的三个文件:
vector.controller.tsvector.service.tsvector.module.ts
需要提前安装的包:
bash
npm i @qdrant/js-client-rest openai uuid
vector.controller.ts
ts
import { Controller, Get, Res, Query, Post, Body, Sse } from '@nestjs/common'; import { Observable } from 'rxjs'; import { VectorService } from './service'; @Controller('vector') export class VectorController { constructor(private readonly vectorService: VectorService) {} // 添加向量 @Post('addVector') async addVector(@Body('texts') texts: string[]) { const data = await this.vectorService.addVector(texts); return data; } // 搜索向量 @Post('searchVectors') async searchVector(@Body('text') text: string) { const data = await this.vectorService.searchVectors({ text }); return data; } // 删除向量集合 @Get('deleteVectorCollection') async deleteVectorCollection() { await this.vectorService.deleteCollection(); return { success: true, message: '向量集合删除成功', }; } // 获取所有向量点 @Get('getAllPoints') async getAllPoints() { const data = await this.vectorService.getAllPoints({}); return data; } // 获取所有集合列表 @Get('getAllCollections') async getAllCollections() { const data = await this.vectorService.getAllCollections(); return data; } // 流式返回向量点 @Sse('sseChatTrue') stream(@Res() res: MyRes, @Query() query: { prompt: string }) { const { prompt } = query; return new Observable((observer) => { const run = async () => { try { const { stream, stop } = await this.vectorService.littleNoteStream({ prompt, }); // 检测客户端断开连接 res.on('close', async () => { console.log('客户端断开连接'); await stop(); observer.complete(); // 正确关闭 SSE 连接 }); for await (const chunk of stream) { if (chunk) { const content = chunk.choices[0].delta.content; if (content) { observer.next({ done: false, value: content }); } } } observer.next({ done: true, data: null }); observer.complete(); // 流结束时正确关闭连接 } catch (error: any) { observer.next({ error: error.message || '传输错误', done: true }); observer.error(error); // 发生错误时关闭连接 } }; run(); // 返回清理函数,当客户端断开连接时会被调用 return () => { // 这里可以添加额外的清理逻辑 }; }); } }
vector.service.ts
ts
import { Inject, Injectable, Logger } from '@nestjs/common'; import { QdrantClient } from '@qdrant/js-client-rest'; import { v4 as uuidv4 } from 'uuid'; import { OpenAI } from 'openai'; interface PointsType { id: string; vector: number[]; payload?: Record<string, any>; } @Injectable() export class VectorService { private readonly logger = new Logger(VectorService.name); private readonly DIMENSIONS = 1024; // 维度 private readonly COLLECTION_NAME = 'home'; // 集合名称 private openai: OpenAI; private isCollectionInitialized: Promise<boolean>; // 集合是否初始化 aiModel = { LLM: 'Doubao-1.5-pro-32k', Embedding: 'text-embedding-3-large', url: 'https://api.302.ai/v1', key: '你自己在302AI申请的key', }; constructor(@Inject('QDRANT_CLIENT') private readonly client: QdrantClient) { this.openai = new OpenAI({ apiKey: this.aiModel.key, baseURL: this.aiModel.url, }); this.isCollectionInitialized = this.initVectorCollection(); } async initVectorCollection(): Promise<boolean> { try { const result = await this.createCollection({ collection_name: this.COLLECTION_NAME, vector_size: this.DIMENSIONS, distance: 'Cosine', }); if (result) { this.logger.log(`向量集合 ${this.COLLECTION_NAME} 初始化成功`); return true; } return false; } catch (error: any) { this.logger.error(`初始化向量集合失败: ${error.message}`, error); return false; } } async createCollection(params: { collection_name: string; vector_size: number; distance?: 'Cosine' | 'Euclid' | 'Dot'; }) { const { collection_name, vector_size, distance = 'Cosine' } = params; try { // 检查集合是否已存在 const collections = await this.client.getCollections(); this.logger.log('collections', JSON.stringify(collections)); const exists = collections.collections.some( (col) => col.name === collection_name, ); if (exists) { this.logger.log(`集合 ${collection_name} 已存在`); return true; // 返回成功状态 } const result = await this.client.createCollection(collection_name, { vectors: { size: vector_size, distance, }, }); this.logger.log(`集合 '${collection_name}' 创建成功`); return result; } catch (error: any) { // 如果是集合已存在的错误,也视为成功 if (error.status === 409 || error.message?.includes('already exists')) { this.logger.log(`集合 ${collection_name} 已存在`); return true; } this.logger.error(`创建集合 '${collection_name}' 失败: ${error.message}`); throw error; } } /** * 通过模型获取文本的向量 * @param content 文本 * @returns 返回向量嵌入 */ async getEmbedding(content: string) { const embedding = await this.openai.embeddings.create({ model: this.aiModel.Embedding, input: content, dimensions: this.DIMENSIONS, }); console.log('embedding', embedding); const arr = embedding.data[0].embedding; this.logger.log( `获取向量成功, text: ${content}, vector: ${JSON.stringify(arr)}, length: ${arr.length}`, ); return arr; } async addVector(texts: string[]) { await this.isCollectionInitialized; const vectors = await Promise.all( texts.map(async (text) => { const vector = await this.getEmbedding(text); return { id: uuidv4(), vector, payload: { text, }, }; }), ); await this.upsertPoints(vectors); } /** * 插入或更新向量点 * @param points 向量点数据 * @returns 返回操作结果 */ async upsertPoints(points: PointsType[]) { const pointsData = points.map((point) => ({ id: uuidv4(), vector: point.vector, payload: point.payload || {}, })); const handleChunk = <T = any>(arr: T[], size = 10) => { const result: T[][] = []; for (let i = 0; i < arr.length; i += size) { result.push(arr.slice(i, i + size)); } return result; }; const sleep = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); }; // 每次40个有概率会报错,减少并发 const chunks = handleChunk(pointsData, 20); const upload = async (chunk: PointsType[], tryMaxCount = 3) => { try { const result = await this.client.upsert(this.COLLECTION_NAME, { wait: true, points: chunk, }); return result; } catch (error: any) { this.logger.error( `向集合 '${this.COLLECTION_NAME}' 插入/更新点失败, 重试剩余 ${tryMaxCount} 次, chunk: ${JSON.stringify(chunk.map((item) => item.payload))}, error: ${JSON.stringify(error)}`, ); if (tryMaxCount > 0) { await sleep(1000); return upload(chunk, tryMaxCount - 1); } } }; try { let result: any; for (const chunk of chunks) { result = await upload(chunk); } this.logger.log( `向集合 '${this.COLLECTION_NAME}' 插入/更新了 ${points.length} 个点`, ); return result; } catch (error: any) { this.logger.error( `向集合 '${this.COLLECTION_NAME}' 插入/更新点失败: ${error.message}`, ); } } /** * 搜索相似向量 * @param params 参数对象 * @param params.text 查询文本 * @param params.limit 返回结果数量限制,默认为 10 * @param params.score_threshold 相似度阈值,默认为 0.5 * @param params.filter 过滤条件 * @returns 返回搜索结果 */ async searchVectors(params: { text: string; limit?: number; score_threshold?: number; filter?: Record<string, any>; }) { const { text, limit = 10, score_threshold = 0.1, filter } = params; try { const vector = await this.getEmbedding(text); const result = await this.client.search(this.COLLECTION_NAME, { vector, limit, score_threshold, filter, with_payload: true, with_vector: false, }); return result; } catch (error: any) { this.logger.error( `在集合 '${this.COLLECTION_NAME}' 中搜索向量失败: ${error.message}`, ); throw error; } } /** * 删除集合 * @returns 返回操作结果 */ async deleteCollection() { try { const result = await this.client.deleteCollection(this.COLLECTION_NAME); this.logger.log(`集合 '${this.COLLECTION_NAME}' 删除成功`); return result; } catch (error: any) { this.logger.error( `删除集合 '${this.COLLECTION_NAME}' 失败: ${error.message}`, ); throw error; } } /** * 获取集合中的所有向量点 * @param params 参数对象 * @param params.limit 返回结果数量限制,默认为 1000 * @param params.offset 偏移量,用于分页 * @param params.with_vector 是否包含向量数据,默认为 false * @returns 返回所有向量点数据 */ async getAllPoints(params: { limit?: number; offset?: number; with_vector?: boolean; }) { const { limit = 1000, offset = 0, with_vector = false } = params; try { const result = await this.client.scroll(this.COLLECTION_NAME, { limit, offset, with_payload: true, with_vector, }); this.logger.log( `从集合 '${this.COLLECTION_NAME}' 获取了 ${result.points.length} 个点`, ); return { points: result.points, next_page_offset: result.next_page_offset, }; } catch (error: any) { this.logger.error( `从集合 '${this.COLLECTION_NAME}' 获取所有点失败: ${error.message}`, ); throw error; } } /** * 获取所有集合列表 * @returns 返回集合列表 */ async getAllCollections() { try { const result = await this.client.getCollections(); return result.collections; } catch (error: any) { this.logger.error(`获取集合列表失败: ${error.message}`); throw error; } } /** * 创建笔记助手的流式聊天 * @param params 参数对象 * @param params.prompt 用户输入的提示文本 * @returns 返回的流对象 */ async littleNoteStream(params: { prompt: string }) { const { prompt } = params; // 搜索相似向量 const searchResult = await this.searchVectors({ text: prompt, limit: 5, score_threshold: 0.4, }); // 格式化搜索结果 const data = searchResult.map((item) => { const chunk = (item.payload?.text as string) || ''; return { score: item.score, chunk, }; }); // 组装prompt const promptTemplate = ` <data>${JSON.stringify(data)}</data> <question>${prompt}</question> 根据以上数据回答用户问题。 **回答要求:** 1. 回答要简洁明了,不要过于复杂。 2. 回答要符合用户问题,不要偏离主题。 3. 禁止使用“数据”“chunk”“score”“数组”“字段”等与原始数据结构相关的词汇; 4. 如果数据中没有相关信息,回答“根据已知信息无法回答”。 `; console.log('promptTemplate', promptTemplate); // 调用OpenAI API // @ts-ignore const stream = await this.openai.chat.completions.create({ model: this.aiModel.LLM, // 模型名称 messages: [ { role: 'user', content: promptTemplate, }, ], hide_thoughts: true, // 隐藏思考过程 stream: true, // 流式输出 }); const stop = async () => { await stream.controller.abort(); }; return { stream, stop, }; } }
vector.module.ts
ts
import { Global, Module } from '@nestjs/common'; import { QdrantClient } from '@qdrant/js-client-rest'; import { VectorService } from './service'; import { VectorController } from './controller'; @Global() @Module({ controllers: [VectorController], providers: [ VectorService, { provide: 'QDRANT_CLIENT', async useFactory() { try { const client = new QdrantClient({ host: '127.0.0.1', port: 6333, https: false, apiKey: '123456', timeout: 30000, // 30秒超时 checkCompatibility: false, // 跳过版本兼容性检查 }); // 测试连接 - 添加更详细的错误信息 await client.getCollections(); return client; } catch (error: any) { throw new Error(`无法连接到 Qdrant 服务器,请确保服务器正在运行`); } }, }, ], exports: [VectorService], }) export class VectorModule {}
以上就是向量模块,注意在service文件中需要把 key 改成自己在302AI申请的key,什么平台都可以,这个自己选择。
使用postman调试接口
先把下面的内容保存为json文件,放在本地。
json
{ "info": { "_postman_id": "cd54cfbf-9f5d-4fd7-860d-1e69e6de2eb8", "name": "vector", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_exporter_id": "17779284", "_collection_link": "https://solar-star-708793-2808.postman.co/workspace/%25E5%2590%2591%25E9%2587%258F~8c394ee0-0b44-471f-905c-b8277939d299/collection/17779284-cd54cfbf-9f5d-4fd7-860d-1e69e6de2eb8?action=share&source=collection_link&creator=17779284" }, "item": [ { "name": "addVector", "request": { "method": "POST", "header": [], "body": { "mode": "raw", "raw": "{\n \"texts\": [\n \"有一只小狗,体重为10公斤\",\n \"有一只老狗,体重为18公斤\",\n \"有一个石头,重量为2吨\",\n \"有一个人,他位高权重,却以百姓为刍狗\"\n ]\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "http://localhost:8020/vector/addVector", "protocol": "http", "host": ["localhost"], "port": "8020", "path": ["vector", "addVector"] } }, "response": [] }, { "name": "searchVectors", "request": { "method": "POST", "header": [], "body": { "mode": "raw", "raw": "{\n \"text\": \"找出所有的狗\"\n}", "options": { "raw": { "language": "json" } } }, "url": { "raw": "http://localhost:8020/vector/searchVectors", "protocol": "http", "host": ["localhost"], "port": "8020", "path": ["vector", "searchVectors"] } }, "response": [] }, { "name": "deleteVectorCollection", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "", "options": { "raw": { "language": "json" } } }, "url": { "raw": "http://localhost:8020/vector/deleteVectorCollection", "protocol": "http", "host": ["localhost"], "port": "8020", "path": [ "vector", "deleteVectorCollection" ] } }, "response": [] }, { "name": "getAllPoints", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "", "options": { "raw": { "language": "json" } } }, "url": { "raw": "http://localhost:8020/vector/getAllPoints", "protocol": "http", "host": ["localhost"], "port": "8020", "path": ["vector", "getAllPoints"] } }, "response": [] }, { "name": "sseChatTrue", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [], "body": { "mode": "raw", "raw": "", "options": { "raw": { "language": "json" } } }, "url": { "raw": "http://localhost:8020/vector/sseChatTrue?prompt=找出所有的狗", "protocol": "http", "host": ["localhost"], "port": "8020", "path": ["vector", "sseChatTrue"], "query": [ { "key": "prompt", "value": "找出所有的狗" } ] } }, "response": [] }, { "name": "getAllCollections", "request": { "method": "GET", "header": [], "url": { "raw": "http://localhost:8020/vector/getAllCollections", "protocol": "http", "host": ["localhost"], "port": "8020", "path": ["vector", "getAllCollections"] } }, "response": [] } ] }
按照如下步骤,把保存的文件导入到postman
image
image
先执行 addVector 的接口,向向量数据库添加4条文本向量数据。
image
然后使用 searchVectors 接口验证查询结果
image
相似度最高值是1,图中相似度为0.1的都被找出来了,这个并不是想要的数据,可以在 service 的 searchVectors 方法中修改 score_threshold 阈值。
ts
const { text, limit = 10, score_threshold = 0.1, filter } = params;
改成
ts
const { text, limit = 10, score_threshold = 0.4, filter } = params;
查看修改后的效果:
image
开发中,可以根据业务需要修改这个阈值。
3. 前端根据输入展示sse输出内容
这一部分直接让AI输出,使用的claude4的模型,基本没有修改啥,效果不错,以下为实际效果:
image
首先需要在main.ts中添加静态访问目录 public
ts
import { NestFactory } from '@nestjs/core'; import { ValidationPipe, Logger } from '@nestjs/common'; import { NestExpressApplication, ExpressAdapter, } from '@nestjs/platform-express'; import * as cookieParser from 'cookie-parser'; import { join } from 'path'; import { AppModule } from 'src/modules/app.module'; async function bootstrap() { const app = await NestFactory.create<NestExpressApplication>( AppModule, new ExpressAdapter(), ); app.useStaticAssets(join(__dirname, '..', 'public')); app.use(cookieParser()); app.useGlobalPipes(new ValidationPipe({ transform: true })); await app.listen(8020); } bootstrap().catch((error) => { console.error('应用启动失败:', error); process.exit(1); });
然后再根目录创建 public 目录,在目录中创建 ai-chat.html 文件,文件内容为:
html
<!doctype html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>AI问答助手</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } .chat-container { background: white; border-radius: 20px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); width: 100%; max-width: 800px; height: 600px; display: flex; flex-direction: column; overflow: hidden; } .chat-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; font-size: 24px; font-weight: 600; } .chat-messages { flex: 1; padding: 20px; overflow-y: auto; background: #f8f9fa; } .message { margin-bottom: 15px; display: flex; align-items: flex-start; } .message.user { justify-content: flex-end; } .message-content { max-width: 70%; padding: 12px 16px; border-radius: 18px; word-wrap: break-word; line-height: 1.4; } .message.user .message-content { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; } .message.ai .message-content { background: white; border: 1px solid #e1e5e9; color: #333; } .chat-input { padding: 20px; background: white; border-top: 1px solid #e1e5e9; } .input-container { display: flex; gap: 10px; align-items: center; } .input-field { flex: 1; padding: 12px 16px; border: 2px solid #e1e5e9; border-radius: 25px; font-size: 16px; outline: none; transition: border-color 0.3s ease; } .input-field:focus { border-color: #667eea; } .send-button { padding: 12px 24px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; border-radius: 25px; font-size: 16px; font-weight: 600; cursor: pointer; transition: transform 0.2s ease; } .send-button:hover { transform: translateY(-2px); } .send-button:disabled { opacity: 0.6; cursor: not-allowed; transform: none; } .typing-indicator { display: none; padding: 12px 16px; background: white; border: 1px solid #e1e5e9; border-radius: 18px; max-width: 70%; } .typing-dots { display: flex; gap: 4px; } .typing-dot { width: 8px; height: 8px; background: #667eea; border-radius: 50%; animation: typing 1.4s infinite; } .typing-dot:nth-child(2) { animation-delay: 0.2s; } .typing-dot:nth-child(3) { animation-delay: 0.4s; } @keyframes typing { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-10px); } } .error-message { background: #fee; color: #c33; border: 1px solid #fcc; } @media (max-width: 768px) { .chat-container { height: 100vh; border-radius: 0; } .message-content { max-width: 85%; } } </style> </head> <body> <div class="chat-container"> <div class="chat-header">🤖 AI问答助手</div> <div class="chat-messages" id="chatMessages"> <div class="message ai"> <div class="message-content"> 你好!我是AI助手,有什么问题可以问我哦~ </div> </div> </div> <div class="chat-input"> <div class="input-container"> <input type="text" class="input-field" id="messageInput" placeholder="输入你的问题..." maxlength="500" /> <button class="send-button" id="sendButton">发送</button> </div> </div> </div> <script> class AIChat { constructor() { this.messagesContainer = document.getElementById('chatMessages'); this.messageInput = document.getElementById('messageInput'); this.sendButton = document.getElementById('sendButton'); this.currentEventSource = null; this.isStreaming = false; this.init(); } init() { this.sendButton.addEventListener('click', () => this.sendMessage()); this.messageInput.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); } async sendMessage() { const message = this.messageInput.value.trim(); if (!message || this.isStreaming) return; // 添加用户消息 this.addMessage(message, 'user'); this.messageInput.value = ''; // 显示输入状态 this.setInputState(true); // 添加AI消息容器 const aiMessageElement = this.addMessage('', 'ai', true); try { await this.streamAIResponse(message, aiMessageElement); } catch (error) { console.error('发送消息失败:', error); this.addMessage( '抱歉,发生了错误,请稍后重试。', 'ai', false, true, ); } finally { this.setInputState(false); } } streamAIResponse(prompt, messageElement) { return new Promise((resolve, reject) => { const messageContent = messageElement.querySelector('.message-content'); let fullResponse = ''; // 创建EventSource连接 const eventSource = new EventSource( `/vector/sseChatTrue?prompt=${encodeURIComponent(prompt)}`, ); this.currentEventSource = eventSource; eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.error) { messageContent.textContent = `错误: ${data.error}`; messageContent.classList.add('error-message'); eventSource.close(); reject(new Error(data.error)); return; } if (data.done) { eventSource.close(); this.currentEventSource = null; resolve(); return; } if (data.value) { fullResponse += data.value; messageContent.textContent = fullResponse; this.scrollToBottom(); } } catch (error) { console.error('解析响应数据失败:', error); messageContent.textContent = '解析响应失败'; messageContent.classList.add('error-message'); eventSource.close(); reject(error); } }; eventSource.onerror = (error) => { console.error('EventSource错误:', error); messageContent.textContent = '连接失败,请检查网络或稍后重试'; messageContent.classList.add('error-message'); eventSource.close(); this.currentEventSource = null; reject(error); }; eventSource.onopen = () => { console.log('EventSource连接已建立'); }; }); } addMessage(content, type, isStreaming = false, isError = false) { const messageDiv = document.createElement('div'); messageDiv.className = `message ${type}`; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; if (isError) { contentDiv.classList.add('error-message'); } if (isStreaming) { contentDiv.textContent = ''; } else { contentDiv.textContent = content; } messageDiv.appendChild(contentDiv); this.messagesContainer.appendChild(messageDiv); this.scrollToBottom(); return messageDiv; } setInputState(isDisabled) { this.isStreaming = isDisabled; this.sendButton.disabled = isDisabled; this.messageInput.disabled = isDisabled; if (isDisabled) { this.sendButton.textContent = '发送中...'; } else { this.sendButton.textContent = '发送'; } } scrollToBottom() { this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight; } // 清理方法,在页面卸载时调用 cleanup() { if (this.currentEventSource) { this.currentEventSource.close(); this.currentEventSource = null; } } } // 初始化聊天应用 const aiChat = new AIChat(); // 页面卸载时清理资源 window.addEventListener('beforeunload', () => { aiChat.cleanup(); }); </script> </body> </html>

其他问题

通过以上的步骤,可以得到一个可以运行的简易知识库问答系统,其中还有一些东西需要探究一下
1. 向量数据库在初始化时需要确定维度,这个值后期可以动态修改吗?
这个值确定后就不能修改了,除非删除重建这个仓库,这不难理解,如果一个向量数据库初始时1024维度,后面你再存入100维度的数据,这两种维度描述的事物特征是不一样的,检索出的数据肯定是不准确的。
2. 可以向一个向量数据库存入同一维度不同厂商的 Embedding 的向量数据吗?
不可以,不同厂商定义的事物维度是不一样的,比如A,B两个厂商都提供了2维向量:
A厂商两个维度的定义是:[是否是生物,年龄]
B厂商两个维度的定义是:[长度,重量]
这肯定是不能存在一起的,因为维度定义不一样。
3. 多次 Embedding 同一句话,得到的向量数据是否完全相同?
使用的是 openAI 的 text-embedding-3-large 模型测试。
多次得到的向量数据不完全一致,但这不会影响什么,比如“所有的狗”和“有一只小狗,体重为10公斤” Embedding 之后的相似度为4.3,即使再多次重复操作,他们的相似度也不会有明显变化,几乎可以忽略不计。
4. 维度的选择会对相似度匹配造成很大影响吗
使用的是 openAI 的 text-embedding-3-large 模型测试。
分别使用100维度和1024为度作对比,如下:
prompt与“有一只小狗,
体重为10公斤”相似度
与“有一只老狗,
体重为18公斤”相似度
找出所有的狗0.3818805 (100维)
0.43531436 (1024维)
0.27178484 (100维)
0.40916687 (1024维)
关于狗的内容0.45164186 (100维)
0.43591332 (1024维)
0.43328193 (100维)
0.41741773 (1024维)
你好,帮我找出
关于狗的内容
0.37944922 (100维)
0.3759594 (1024维)
0.2893664 (100维)
0.3401547 (1024维)
所有的狗0.5181277 (100维)
0.47305614 (1024维)
0.50474775 (100维)
0.44513088 (1024维)
0.4424646 (100维)
0.42782572 (1024维)
0.42330262 (100维)
0.4014681 (1024维)
体重0.41686216 (100维)
0.42644602 (1024维)
0.49011505 (100维)
0.38479653 (1024维)
公斤0.43894738 (100维)
0.413822 (1024维)
0.48421618 (100维)
0.35434955 (1024维)
狗的体重0.6909904 (100维)
0.698545 (1024维)
0.67722666 (100维)
0.68216 (1024维)
有一只小狗,
体重为10公斤
0.99999994 (100维)
0.99999905 (1024维)
0.80675054 (100维)
0.8170248 (1024维)
有一只老狗,
体重为18公斤
0.80675054 (100维)
0.8170315 (1024维)
1 (100维)
1.0000001 (1024维)
根据以上的数据可知,综合下来还是高纬度效果更好一些。
这里,有一个问题没有深入探究,就是判断两段文本相似度的值准不准确的标准是什么,比如图中所示:“找出所有的狗” 与 “有一只小狗,体重为10公斤” 在1024维度上的相似度是 0.43531436,这是在 openAI 的 text-embedding-3-large 模型基础下下得出的相似度,其他的模型得出的结论是否和他一致?
以下两个为其他厂商的 Embedding 后得出的相似度结果:
厂商A:
prompt有一只小狗,
体重为10公斤
(1024维度)
有一只老狗,
体重为18公斤
(1024维度)
找出所有的狗0.42836380.4423697
关于狗的内容0.442775730.4427782
你好,帮我找出
关于狗的内容
0.573913460.5698793
所有的狗0.39306070.413149
0.495839450.50600433
体重0.488137870.50087357
公斤0.470480080.46403456
狗的体重0.56529570.5581999
有一只小狗,
体重为10公斤
10.74792135
有一只老狗,
体重为18公斤
0.747921350.9999999
厂商B:
prompt有一只小狗,
体重为10公斤
(1024维度)
有一只老狗,
体重为18公斤
(1024维度)
我出所有的狗0.468508930.45931795
关于狗的内容0.489425780.4830734
你好,帮我找出
关于狗的内容
0.48339260.44802487
所有的狗0.39306070.413149
0.495839450.50600433
体重0.488137870.50087357
公斤0.470480080.46403456
狗的体重0.56529570.5581999
有一只小狗,
体重为10公斤
10.74792135
有一只老狗,
体重为18公斤
0.747921350.9999999
通过以上数据,可以看出 “ 你好,帮我找出关于狗的内容” 和 “有一只小狗,体重为10公斤” 的相似度出入比较大。
openAI厂商A厂商B
0.37595940.573913460.4833926
那他们三个那个更准?这个评测的标准是啥?
这方面我咨询了一下deepseek,结论是:
核心结论
在这个具体的例子中,OpenAI的相似度分数(0.376)更准,更符合人类的语义直觉
为什么?
  • 第一句 “你好,帮我找出关于狗的内容” 是一个查询指令,意图是寻找信息。
  • 第二句 “有一只小狗,体重为10公斤” 是一个事实陈述,描述了一个具体的狗。
  • 从语义上看,这两句话的意图和主题相关,但并非高度相似。它们不是在说同一件事。一句是“找资料”,另一句是“一个具体的资料”。所以,一个中等偏低(但在0.3-0.5之间)的相似度是合理的。
  • 厂商A(0.574)和厂商B(0.483)的分数明显偏高,这使得它们看起来比实际更相似,这可能会在后续应用(如搜索)中引入噪声。
评测标准是什么?
评测文本嵌入模型的好坏,绝对不是看一两个句子的结果,而是需要通过一个大规模、有标注的基准测试集来进行综合评估。常用的评测标准和方法如下:
...(巴拉巴拉一大堆,有兴趣的自己去问问AI)
说实话,我觉得0.5的相似度更接近我的预期,但是我的感觉就只是我的感觉而已,没有理论支撑,你也会有你心目中的答案,都会不同,所以需要把这个事情交个专业的厂商去做,我们要做的就是选择一个靠谱的厂商而已,而且要选择尽可能权威的厂商接口,比如我选择的就是openAI,并且我也吧刚才的问题问了豆包,结论都是 openAI 的评分更准确。
5. 如何提高召回率?
召回率(Recall)是衡量模型在所有真实正例中,成功识别出正例数量的指标,核心是 “不漏检”。
召回率 = TP / (TP + FN)
TP = 真实正例(True Positive, TP):模型预测为正例,且实际确实是正例的数量。
FN = 假负例(False Negative, FN):模型预测为负例,但实际是正例的数量(即被遗漏的正例)。
这个问题可以提问AI,然后结合上面的代码进行测试。
我说几个我认为比较靠谱的做法:
  • 简化用户的问题。 比如 “你好,帮我找出关于狗的内容” 这句话,可以想让模型简化语句,以便于用于向量数据搜索,可以简化为 “找狗相关内容”,然后在生成向量数据去搜索。
  • 多语义搜索。 比如 “你好,帮我找出关于狗的内容” 这句话,可以让模型生成多条类似的语句,然后再去搜索,生成的结果例句:检索狗相关内容、查找关于狗的信息、获取狗相关的内容、搜索和狗有关的信息、提取狗相关的内容
  • 多维度搜索。 除了向量数据库搜索,还要使用传统的sql数据库搜索,因为有的用户的问题可能就是关键词,比如用户提问”狗“,这样的提问在sql数据库搜索可能比向量数据库更全面。
  • chunk分割 把文本数据拆分成chunk是有一些方法论的,比如一般200到300个字符拆分一段,拆分时,chunk的头部需要和前一段的结尾有一些重复,尾部需要和后一段的前部有重复,目的是防止段落分割语义太割裂,LangChain 就有类似的工具方法。
  • 找回数据的清洗 就是找回的数据很有可能有一些和用户意图相违背的信息,需要更具业务或者算法来排序,提取排名前几的数据作为回到那个户问题的依据。

总结

说的不一定对,毕竟也是在学习当中,可以作为参考。
最后编辑于:2025-10-07 20:09:41
©著作权归作者所有,转载或内容合作请联系作者。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,little笔记系信息发布平台,仅提供信息存储服务。