RAG 开发实例
- 部署向量数据库。
- 使用nestjs开发服务端(链接向量数据库,提供sse接口)。
- 前端根据输入展示sse输出内容。
所以,以下内容需要你已经熟悉nestjs和docker的基本使用。
最终效果展示:
存入向量数据库4条数据:
1. 部署向量数据库
首先需要确定什么向量数据库,市面上有哪些数据库呢?问AI。
因为我们不可能把市面上所有的向量数据数据库全部实践一遍,没那个精力,也没必要,可以使用AI工具(豆包或者deepseek)搜索,搜索出的数据库需要去npm上看下下载量,选择下载量大、经常更新的。我找到的数据库有:Milvus、Weaviate、Qdrant,他们的下载量都还行,但是我看中了Qdrant对TS支持好和使用简单的特性,所以选择了它。
使用dcker部署Qdrant向量数据库,项目根目录创建 docker-compose.yml 文件。
部署命令
2. nestjs开发服务端
nestjs的项目初始化AI一问就都出来了,使用最新版的nest就可以。
这里只展示必要的vector模块的三个文件:
vector.controller.ts、vector.service.ts、vector.module.ts
需要提前安装的包:
vector.controller.ts
vector.service.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
以上就是向量模块,注意在service文件中需要把 key 改成自己在302AI申请的key,什么平台都可以,这个自己选择。
使用postman调试接口
先把下面的内容保存为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
先执行 addVector 的接口,向向量数据库添加4条文本向量数据。
然后使用 searchVectors 接口验证查询结果
相似度最高值是1,图中相似度为0.1的都被找出来了,这个并不是想要的数据,可以在 service 的 searchVectors 方法中修改 score_threshold 阈值。
改成
查看修改后的效果:
开发中,可以根据业务需要修改这个阈值。
3. 前端根据输入展示sse输出内容
这一部分直接让AI输出,使用的claude4的模型,基本没有修改啥,效果不错,以下为实际效果:
首先需要在main.ts中添加静态访问目录 public:
然后再根目录创建 public 目录,在目录中创建 ai-chat.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.4283638 | 0.4423697 |
| 关于狗的内容 | 0.44277573 | 0.4427782 |
你好,帮我找出 关于狗的内容 | 0.57391346 | 0.5698793 |
| 所有的狗 | 0.3930607 | 0.413149 |
| 狗 | 0.49583945 | 0.50600433 |
| 体重 | 0.48813787 | 0.50087357 |
| 公斤 | 0.47048008 | 0.46403456 |
| 狗的体重 | 0.5652957 | 0.5581999 |
有一只小狗, 体重为10公斤 | 1 | 0.74792135 |
有一只老狗, 体重为18公斤 | 0.74792135 | 0.9999999 |
厂商B:
| prompt | 有一只小狗, 体重为10公斤 (1024维度) | 有一只老狗, 体重为18公斤 (1024维度) |
|---|
| 我出所有的狗 | 0.46850893 | 0.45931795 |
| 关于狗的内容 | 0.48942578 | 0.4830734 |
你好,帮我找出 关于狗的内容 | 0.4833926 | 0.44802487 |
| 所有的狗 | 0.3930607 | 0.413149 |
| 狗 | 0.49583945 | 0.50600433 |
| 体重 | 0.48813787 | 0.50087357 |
| 公斤 | 0.47048008 | 0.46403456 |
| 狗的体重 | 0.5652957 | 0.5581999 |
有一只小狗, 体重为10公斤 | 1 | 0.74792135 |
有一只老狗, 体重为18公斤 | 0.74792135 | 0.9999999 |
通过以上数据,可以看出 “ 你好,帮我找出关于狗的内容” 和 “有一只小狗,体重为10公斤” 的相似度出入比较大。
| openAI | 厂商A | 厂商B |
|---|
| 0.3759594 | 0.57391346 | 0.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 就有类似的工具方法。
-
找回数据的清洗 就是找回的数据很有可能有一些和用户意图相违背的信息,需要更具业务或者算法来排序,提取排名前几的数据作为回到那个户问题的依据。
总结
说的不一定对,毕竟也是在学习当中,可以作为参考。