AI通关笔记第一章:RAG 技术揭秘 —— 从0到1带你手撸原生RAG!

一、技术背景

1.1 LLM 局限性

在介绍 RAG 之前,我们先思考,现在的 LLM 有什么问题?

知识的时效性

  • 大模型自身的知识完全源于训练数据,而现有的主流大模型的训练集基本都是构建于网络公开的数据。
  • 对于一些实时性的、非公开的或私域的数据是没有。

数据的安全性

  • 对于企业来说,数据安全至关重要,没有企业愿意承担数据泄露的风险,尤其是大公司,没有人将私域数据上传第三方平台进行训练会推理。
  • 这也导致完全依赖通用大模型自身能力的应用方案不得不在数据安全和效果方面进行取舍。

幻觉的不可靠性

  • 因为 LLM 本身是基于从大量数据中训练出来的概率模型来一个个生成 token,也就是它并没有逻辑和事实基线。
  • 所以我们说 LLM 的智能是涌现性的智能,是基于概率产生的 "伪智能" ,而不是底层基于逻辑和推理能力"真智能"。

我们用一个经典的例子说明。我们给猴子一个打字机,让它随便打字,如果这个 实验拉长到时间是无限 的。有没有一种可能,总有一天他会打出一部完整的莎士比亚的小说? 答案是肯定的,因为时间是无限的,而且它是随机打字,那就一定会在某个时间点所有概率都碰巧了,成了一本莎士比亚的小说。那一个问题来了,猴子到底懂不懂莎士比亚?那肯定是完全不懂的,它不具备逻辑,只是一切概率性的巧合凑到一起了罢了。而 LLM 可以理解成为一个更大概率打出莎士比亚的猴子。 它不理解输出文本的逻辑,更不理解内容背后的逻辑。但因为它的模型足够大,训练数据集足够大,他输出正确内容的概率也足够大。所以,从外界看来,他就像真正理解内容一样,也就像具有真正的逻辑和推理能力。

二、基本概念

2.1 基本定义

RAG 是 Retrieval-Augmented Generation 的缩写,也就 检索增强生成 的意思。 它主要是为了解决大模型本身知识匮乏的问题,主要流程包括索引、检索和生成。

RAG 将传统生成模型的 记忆式回答 转变为 查资料后回答 ,从而:

  • 降低幻觉。
  • 支持知识实时更新。

2.2 核心思想

RAG 的核心思想是 先检索,后生成

  1. 检索阶段
    • 从外部知识库(如文档、数据库、代码库)中动态查找与用户输入相关的信息。
    • 解决生成模型依赖静态训练数据的问题。
  1. 生成阶段
    • 将检索到的内容作为上下文,指导 LLM 生成更准确的回答。
    • 解决纯检索系统无法灵活组织语言的问题。

三、工作流程

RAG(检索 增强 生成) = Embedding 检索技术 + LLM 增强生成

3.1 检索阶段

知识库构建

  • 数据源:文档、代码、数据库等。
  • 预处理:分块(如按 段落/函数/语义 拆分)、清洗(去噪、格式化)。

GPT 3.5 的上下文窗口是 16k,GPT 4 上下文窗口是 128k,而我们很多数据源都很容易比这个大,而且用户的提问经常涉及多个数据源。所以我们需要对数据集进行语意化的切分。根据内容的特点和目标大模型的特点、上下文窗口等,对数据源进行合适的切分。

文本向量化

  • 向量化也就是 Embedding 阶段
  • 使用 Embedding 模型 将文本转换为 向量,目的是为了让计算机能够用数学方式理解和比较语义的相似度。

向量这个概念比较抽象,我们先从二维向量来说起。

  • 向量 A:[1, 2] 表示一个点
  • 向量 B:[2, 2] 是另一个点

这两个点在二维平面中靠得近,就表示它们意义接近。语言中的向量:是高维向量,其实就是高维的数字数组。

  • LLM 会把一句话变成一个 512维 / 768维 的向量:
  • "我想休假" → [0.1, -0.3, 0.6, ..., 0.2] (共 768 个数字)
  • 维其实是为了更加精确的区分不同的向量,维度越高,语义表达就越丰富。

向量存储

  • 存入向量数据库(如FAISS、Milvus),支持快速相似性搜索。

生成向量之后,就需要 把这些向量保存起来,方便后面检索向量在存哪儿呢? → 向量数据库!

  • FAISS(本地,开源)
  • Milvus(国产,支持分布式)
  • Weaviate、Pinecone(商用 SaaS)

查询检索

  • 将用户输入 Query 同样向量化,从知识库中召回 Top-K 相关文档。

向量检索 最常见的指标: 余弦相似度(cosine similarity) 它衡量两个向量之间夹角有多小 ------ 角度越小,语义越接近 假设我们有B、C 两个向量 和 A 向量做比较:

  • A = [1, 2]
  • B = [1.1, 2.1] 、C = [-2, 0.3]

cos_sim(A, B) ≈ 0.999 → 非常相似cos_sim(A, C) ≈ -0.2 → 完全不同

3.2 生成阶段

构造提示

  • 检索到的这些相关文本块会与用户的原始查询一起,被整合到一个结构化的 Prompt 中 。
css 复制代码
请基于以下信息回答问题:
* [检索到的文档1]
* [检索到的文档2]
用户问题:{Query}

生成回答

  • 调用 LLM Chat 模型 生成回答。
  • 参数控制:temperature=0.3(降低随机性)、max_tokens=500 等

3.3 知识库更新

  • 为了保持信息的时效性,知识库需要定期或实时地更新。
  • 当外部数据源发生变化时(如公司政策更新、新产品发布),相关的文档需要被重新处理(切块、嵌入、索引),以替换或补充向量数据库中的旧信息 。
  • 这个更新过程可以自动化,确保 RAG 系统总能获取到最新的知识。

3.4 全流程拆解

第一步:知识库构建

将资料文档拆成小段 Chuck(例如每 100 字),然后:

  • 对每段文字使用 Embedding 模型生成向量
  • 存进一个向量数据库

第二步:用户提问

用户提问如:「 21 世纪诞生了哪些人工智能相关技术 ?」

  1. 把这个问题也用同样的 Embedding 模型转成向量:q = [1, 1, ..., 1]
  2. 在知识库中计算与 q 的余弦相似度
  3. 找出 Top-K(比如前 5 条)最相似的段落,作为 检索结果

第三步:生成回答

把这些 Top-K 段落作为"上下文资料",和用户的问题一起输入大语言模型

css 复制代码
Prompt 示例:

你是一个 xxx 的知识问答助手。
你的任务是根据给定的文档回答用户问题,并且回答时仅根据给定的文档,尽可能回答用户问题。
如果你不知道,你可以回答"我不知道"。

这是文档:
{docs}

用户的提问是:
{question}

四、实战案例

4.1 前置准备

下面我们分别使用 原生 JSLangChain 框架 实现 RAG

  • 由 NodeJS 原作者 使用 Rust 重写,基于 V8 引擎 ,,并且内置对 TypeScript 的支持。
  • 无需手动安装 NPM,标准库内置,模块直接通过 URL 导入。
  • 使用 ES Module(import/export)而非 CommonJS
  • 默认安全,文件、网络、环境访问都需要显式授权。
arduino 复制代码
// 安装 Deno
curl -fsSL https://deno.land/install.sh | sh

使用的本地部署的 Ollama 开源模型

  • Chat 模型:deepseek-r1:8b
  • Embedding 模型:dengcao/Qwen3-Embedding-0.6B:Q8_0
arduino 复制代码
// 安装 Ollama
curl -fsSL https://ollama.com/install.sh | sh

// 下载 模型
ollama pull deepseek-r1:8b
ollama pull dengcao/Qwen3-Embedding-0.6B:Q8_0

// 运行 模型
ollama run deepseek-r1:8b
ollama run dengcao/Qwen3-Embedding-0.6B:Q8_0

知识库文档样例,用于检索用户相关问题

markdown 复制代码
## RAG 检索增强生成

  RAG (Retrieval-Augmented Generation) 是一种结合了信息检索和文本生成的技术:

  ### 工作原理
  1. 文档索引:将文档切分成小块并向量化
  2. 检索阶段:根据查询检索相关文档片段  
  3. 生成阶段:结合检索到的信息生成回答

  ### 优势
  - 提供最新和准确的信息
  - 减少模型幻觉问题
  - 支持领域特定知识

  ### 应用场景
  - 智能客服系统
  - 文档问答系统
  - 知识库检索
  - 企业内部知识管理

  ## 向量数据库

  向量数据库是RAG系统的重要组成部分:

  ### 常用向量数据库
  - Chroma: 轻量级向量数据库
  - FAISS: Facebook开源的相似性搜索库
  - Pinecone: 商业向量数据库服务
  - Qdrant: 高性能向量搜索引擎

  ### 向量化技术
  - 文本嵌入模型
  - 相似度计算算法
  - 索引优化策略

环境配置和依赖导入

ini 复制代码
// 使用 Deno 内置的 fetch API 和文件系统 API
const { readTextFile, readDir, stat } = Deno;

// 配置 Ollama 服务地址
const OLLAMA_BASE_URL = "http://localhost:11434";

// 支持的文件格式
const SUPPORTED_EXTENSIONS = ['.txt'];

// 存储配置
const STORAGE_PATH = "./storage";
const DATA_PATH = "./data";

4.2 原生实现

我们先使用 JS 来原生实现一个简易的 RAG

完整流程

javascript 复制代码
/**
 * 完整的 RAG 系统流程演示
 * 
 * 演示如何使用上述定义的类来构建一个完整的 RAG 系统:
 * 1. 加载和切分文档
 * 2. 文档向量化,构建向量数据库
 * 3. 问答检索
 */

// 主要的 RAG 流程函数
async function runRAGDemo() {
        
    // 0. 初始化组件
    const embedding = new OllamaEmbedding();
    const chat = new OllamaChat();
    const fileReader = new ReadFiles(DATA_PATH);
    
    // 1. 加载和切分文档
    const documents = await fileReader.getContent(600, 150);
    
    // 2. 初始化向量数据库
    const vectorStore = new VectorStore(documents);
    
    // 2.1 文档向量化
    await vectorStore.getVector(embedding);
    
    // 2.2 保存到本地
    await vectorStore.persist(STORAGE_PATH);
    
    // 3. 问答检索
    
    // 3.1 测试问题
    const questions = "虚拟环境的作用是什么?";
    
    try {
        // 3.2 问题向量化, 检索相关文档
        const relevantDocs = await vectorStore.query(question, embedding, 1);
        const context = relevantDocs.join('\n\n');

        // 3.3 生成回答
        const answer = await chat.chat(question, [], context);

        console.log(`💡 回答: ${answer}`);
    } catch (error) {
        console.error(`❌ 处理问题失败: ${error.message}`);
    }
}

// 执行 RAG 演示
await runRAGDemo();

对话模型

javascript 复制代码
/**
 * Chat 对话模型实现类
 * 使用本地 Ollama 服务进行智能问答。
 */
class OllamaChat {
    /**
     * 构造函数
     * @param {string} baseUrl - Ollama 服务的基础 URL
     * @param {string} model - 使用的对话模型名称
     */
    constructor(baseUrl = OLLAMA_BASE_URL, model = "deepseek-r1:8b") {
        this.baseUrl = baseUrl;
        this.model = model;
        this.apiUrl = `${baseUrl}/api/chat`;
    }

    /**
     * 使用 Ollama API 生成回答
     * @param {string} prompt - 用户的提问
     * @param {Array} history - 对话历史记录(可选)
     * @param {string} content - 可参考的上下文信息(可选)
     * @returns {Promise<string>} 生成的回答
     */
    async chat(prompt, history = [], content = '') {
        try {
            // 构建完整的提示信息
            const fullPrompt = this.buildPrompt(prompt, content);
            
            // 构建消息数组
            const messages = this.buildMessages(fullPrompt, history);
            
            // 调用 Ollama API
            const response = await this.makeRequest({
                model: this.model,
                messages: messages,
                stream: false // 非流式响应
            });
            
            if (response && response.message && response.message.content) {
                const answer = response.message.content.trim();
                console.log(`回答生成完成,长度: ${answer.length} 字符`);
                return answer;
            } else {
                throw new Error("API 响应格式错误");
            }
            
        } catch (error) {
            console.error(`生成回答失败: ${error.message}`);
            throw error;
        }
    }

    /**
     * 构建提示信息,整合问题和上下文
     * @param {string} prompt - 用户问题
     * @param {string} content - 上下文内容
     * @returns {string} 完整的提示信息
     */
    buildPrompt(prompt, content) {
        // 使用 RAG 提示模板
        const template = `
                      下面有一个或许与这个问题相关的参考段落,若你觉得参考段落能和问题相关,则先总结参考段落的内容。
                      若你觉得参考段落和问	题无关,则使用你自己的原始知识来回答用户的问题,并且总是使用中文来进行回答。
                      问题: {question}
                      可参考的上下文:
                      ···
                      {context}
                      ···
                      有用的回答:`;

        return template
            .replace('{question}', prompt)
            .replace('{context}', content || '无相关上下文信息');
    }

    /**
     * 构建消息数组,包含系统提示和对话历史
     * @param {string} prompt - 完整的提示信息
     * @param {Array} history - 对话历史
     * @returns {Array} 消息数组
     */
    buildMessages(prompt, history) {
        const messages = [];
      
        // 添加系统消息
        messages.push({
            role: "system",
            content: "你是一个智能助手,能够根据提供的上下文信息回答用户问题。请遵循以下原则:1. 如果上下文信息与问题相关,优先使用上下文信息回答;2. 如果上下文信息不相关,使用你的知识回答;3. 始终使用中文回答;4. 回答要准确、简洁、有帮助。"
        });
        
        // 添加历史对话(如果有)
        if (Array.isArray(history) && history.length > 0) {
            for (const item of history) {
                if (typeof item === 'object' && item.role && item.content) {
                    messages.push({
                        role: item.role,
                        content: item.content
                    });
                }
            }
        }
        
        // 添加当前用户问题
        messages.push({
            role: "user",
            content: prompt
        });
        
        return messages;
    }

    /**
     * 发送请求到 Ollama API
     * @param {Object} data - 请求数据
     * @returns {Promise<Object>} API 响应
     */
    async makeRequest(data) {
            try {
                const response = await fetch(this.apiUrl, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(data),
                });
                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
                }
                const result = await response.json();
                return result;
            } catch (error) {
				throw new Error('Ollama Chat API 调用失败,请稍后再试');
            }
    
   }
}

console.log("OllamaChat 对话模型类定义完成");

嵌入模型

javascript 复制代码
/**
 * Embeddings 模型实现类
 * 使用本地 Ollama 服务进行文本向量化。
 */
class OllamaEmbedding {
    /**
     * 构造函数
     * @param {string} baseUrl - Ollama 服务的基础 URL
     * @param {string} model - 使用的嵌入模型名称
     */
    constructor(baseUrl = OLLAMA_BASE_URL, model = "dengcao/Qwen3-Embedding-0.6B:Q8_0") {
        this.baseUrl = baseUrl;
        this.model = model;
        this.apiUrl = `${baseUrl}/api/embeddings`;
    }

    /**
     * 使用 Ollama API 获取文本的向量表示
     * @param {string} text - 需要向量化的文本
     * @param {string} model - 模型名称(可选,使用默认模型)
     * @returns {Promise<number[]>} 文本的向量表示
     */
    async getEmbedding(text, model = this.model) {
        // 清理文本,去除换行符
        const cleanText = text.replace(/\n/g, " ").trim();
        try {
            const response = await this.makeRequest({
                model: model,
                input: cleanText
            });
            return response.embedding;
        } catch (error) {
            console.error(`获取文本向量失败: ${error.message}`);
            throw error;
        }
    }
  
  	/**
     * 计算两个向量之间的余弦相似度
     * 
     * 余弦相似度公式:cos(θ) = (A·B) / (|A|×|B|)
     * 其中 A·B 是向量点积,|A|、|B| 是向量的模长
     * 
     * @param {number[]} vector1 - 第一个向量
     * @param {number[]} vector2 - 第二个向量
     * @returns {number} 余弦相似度值,范围从 -1 到 1,越接近 1 表示越相似
     */
    static cosineSimilarity(vector1, vector2) {
        if (vector1.length !== vector2.length) {
            throw new Error("向量维度不匹配");
        }

        // 计算向量点积
        const dotProduct = vector1.reduce((sum, a, i) => sum + a * vector2[i], 0);
        
        // 计算向量的模长
        const magnitude1 = Math.sqrt(vector1.reduce((sum, a) => sum + a * a, 0));
        const magnitude2 = Math.sqrt(vector2.reduce((sum, b) => sum + b * b, 0));
        
        // 避免除零错误
        if (magnitude1 === 0 || magnitude2 === 0) {
            return 0;
        }
        
        // 返回余弦相似度
        return dotProduct / (magnitude1 * magnitude2);
    }

    /**
     * @param {Object} data - 请求数据
     * @returns {Promise<Object>} API 响应
     */
    async makeRequest(data) {
            try {
                const response = await fetch(this.apiUrl, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json'
                    },
                    body: JSON.stringify(data),
                });
                if (!response.ok) {
                    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
                }
                const result = await response.json();
                return result;
            } catch (error) {
				throw new Error('Ollama Embedding API 调用失败,请稍后再试');
            }
    }
}

console.log("Ollama Embedding 嵌入模型类定义完成");

文档加载和切片

ini 复制代码
/**
 * 文档读取和切分类
 * 负责从指定路径读取支持的文件类型(TXT、Markdown、PDF),
 * 并将长文档智能切分为适合向量化的小片段。
 */
class ReadFiles {
    /**
     * 构造函数
     * @param {string} path - 文档文件夹路径
     */
    constructor(path) {
        this.path = path;
        this.fileList = [];
    }

    /**
     * 获取指定目录下所有支持的文件
     * @returns {Promise<string[]>} 文件路径列表
     */
    async getFiles() {
        this.fileList = [];
        await this.walkDirectory(this.path);
        return this.fileList;
    }

    /**
     * 递归遍历目录,查找支持的文件类型
     * @param {string} dirPath - 目录路径
     */
    async walkDirectory(dirPath) {
        const entries = await readDir(dirPath);
        for await (const entry of entries) {
            const fullPath = `${dirPath}/${entry.name}`;
            if (entry.isDirectory) {
                // 递归处理子目录
                await this.walkDirectory(fullPath);
            } else if (entry.isFile) {
                // 检查文件扩展名
                const ext = entry.name.substring(entry.name.lastIndexOf('.')).toLowerCase();
                if (SUPPORTED_EXTENSIONS.includes(ext)) {
                    this.fileList.push(fullPath);
                }
            }
        }
    }

    /**
     * 读取文件内容并进行切分
     * @param {number} maxTokenLen - 每个文档片段的最大 Token 长度
     * @param {number} coverContent - 片段间重叠的 Token 长度
     * @returns {Promise<string[]>} 切分后的文档片段列表
     */
    async getContent(maxTokenLen = 600, coverContent = 150) {
        const allFiles = await this.getFiles();
        const docs = [];
        console.log(`📚 找到 ${allFiles.length} 个文件`);
        for (const file of allFiles) {
            try {
              	// 读取纯文本文件
                const content = await await readTextFile(file);
                if (content && content.trim()) {
                    // 将文档切分为多个小块
                    const chunks = this.getChunk(content, maxTokenLen, coverContent);
                    docs.push(...chunks);
                }
            } catch (error) {
                console.error(`处理文件失败 ${file}: ${error.message}`);
            }
        }
        
        console.log(`总共生成 ${docs.length} 个文档片段`);
        return docs;
    }
  
    /**
     * 将长文本按 Token 长度进行切分
     * @param {string} text - 原始文本
     * @param {number} maxTokenLen - 每个片段的最大 Token 长度
     * @param {number} coverContent - 片段间重叠的 Token 长度
     * @returns {string[]} 切分后的文档片段数组
     */
    getChunk(text, maxTokenLen = 600, coverContent = 150) {
        const chunkTexts = [];
        let currentLength = 0;
        let currentChunk = '';
        const tokenLength = maxTokenLen - coverContent;
        
        // 按行分割文本
        const lines = text.split('\n');
        
        for (const line of lines) {
            const cleanLine = line.trim();
            // 简单的 Token 长度估算(中文字符按2个token计算,英文按空格分割)
            const lineTokenLength = this.estimateTokenLength(cleanLine);
            
            if (lineTokenLength > maxTokenLen) {
                // 处理超长行:进一步切分
                const subChunks = this.splitLongLine(cleanLine, tokenLength);
                for (const subChunk of subChunks) {
                    if (currentChunk.trim()) {
                        chunkTexts.push(currentChunk.trim());
                    }
                    currentChunk = this.getOverlapText(currentChunk, coverContent) + subChunk;
                    currentLength = this.estimateTokenLength(currentChunk);
                }
            } else if (currentLength + lineTokenLength <= tokenLength) {
                // 当前片段还能容纳这一行
                currentChunk += cleanLine + '\n';
                currentLength += lineTokenLength + 1;
            } else {
                // 当前片段已满,开始新片段
                if (currentChunk.trim()) {
                    chunkTexts.push(currentChunk.trim());
                }
                // 新片段包含前一片段的部分内容(重叠处理)
                const overlapText = this.getOverlapText(currentChunk, coverContent);
                currentChunk = overlapText + cleanLine + '\n';
                currentLength = this.estimateTokenLength(currentChunk);
            }
        }
        
        // 添加最后一个片段
        if (currentChunk.trim()) {
            chunkTexts.push(currentChunk.trim());
        }
        
        return chunkTexts.filter(chunk => chunk.length > 0);
    }

    /**
     * 估算文本的 Token 长度
     * @param {string} text - 文本内容
     * @returns {number} 估算的 Token 长度
     */
    estimateTokenLength(text) {
        if (!text) return 0;
        
        // 简单估算:中文字符 = 2 tokens,英文单词 = 1 token
        const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length;
        const englishWords = text.replace(/[\u4e00-\u9fff]/g, '').split(/\s+/).filter(word => word.length > 0).length;
        
        return chineseChars * 2 + englishWords;
    }

    /**
     * 切分超长行
     * @param {string} line - 超长行文本
     * @param {number} maxLength - 最大长度
     * @returns {string[]} 切分后的片段
     */
    splitLongLine(line, maxLength) {
        const chunks = [];
        let current = '';
        const words = line.split(/(\s+)/); // 保留空格

        for (const word of words) {
            if (this.estimateTokenLength(current + word) > maxLength) {
                if (current.trim()) {
                    chunks.push(current.trim());
                }
                current = word;
            } else {
                current += word;
            }
        }
        if (current.trim()) {
            chunks.push(current.trim());
        }
        return chunks;
    }

    /**
     * 获取重叠文本
     * @param {string} text - 原文本
     * @param {number} overlapLength - 重叠长度
     * @returns {string} 重叠文本
     */
    getOverlapText(text, overlapLength) {
        if (!text || overlapLength <= 0) return '';
        const lines = text.split('\n');
        let overlap = '';
        let currentLength = 0;
        // 从末尾开始取重叠内容
        for (let i = lines.length - 1; i >= 0; i--) {
            const line = lines[i];
            const lineLength = this.estimateTokenLength(line);
            
            if (currentLength + lineLength <= overlapLength) {
                overlap = line + '\n' + overlap;
                currentLength += lineLength + 1;
            } else {
                break;
            }
        }
        return overlap;
    }
}

console.log("文档读取和切分类定义完成");

向量存储和检索

javascript 复制代码
/**
 * 向量存储和检索类
 * 负责文档向量的存储、持久化和相似度检索。
 * 支持本地 JSON 文件存储,提供高效的向量相似度搜索功能。
 */
class VectorStore {
    /**
     * 构造函数
     * @param {string[]} documents - 文档列表,默认为空数组
     */
    constructor(documents = []) {
        this.documents = documents; // 存储文档内容
        this.vectors = []; // 存储文档对应的向量表示
        this.metadata = {}; // 存储元数据信息
    }

    /**
     * 使用嵌入模型将所有文档转换为向量表示
     * @param {OllamaEmbedding} embeddingModel - 嵌入模型实例
     * @returns {Promise<number[][]>} 文档向量列表
     */
    async getVector(embeddingModel) {
        console.log(`开始向量化 ${this.documents.length} 个文档...`);
        this.vectors = [];
        // 处理每个文档
        for (let i = 0; i < this.documents.length; i++) {
            try {
                console.log(`向量化进度: ${i + 1}/${this.documents.length}`);
                const vector = await embeddingModel.getEmbedding(this.documents[i]);
                this.vectors.push(vector);
                
                // 添加适当的延迟,避免请求过于频繁
                if (i < this.documents.length - 1) {
                    await new Promise(resolve => setTimeout(resolve, 100));
                }
            } catch (error) {
                console.error(`文档 ${i + 1} 向量化失败: ${error.message}`);
                // 添加空向量作为占位符
                this.vectors.push([]);
            }
        }
        // 更新元数据
        this.metadata = {
            documentCount: this.documents.length,
            vectorCount: this.vectors.length,
            vectorDimension: this.vectors.length > 0 ? this.vectors[0].length : 0,
            createdAt: new Date().toISOString(),
            embeddingModel: embeddingModel.constructor.name
        };
        console.log(`向量化完成! 向量维度: ${this.metadata.vectorDimension}`);
        return this.vectors;
    }

    /**
     * 将向量数据和文档内容持久化到本地文件
     * @param {string} path - 存储路径,默认为 './storage'
     */
    async persist(path = STORAGE_PATH) {
        try {
            // 保存向量数据为 JSON 文件
            const vectorsPath = `${path}/vectors.json`;
            await Deno.writeTextFile(vectorsPath, JSON.stringify(this.vectors, null, 2));
            
            // 保存文档内容
            const documentsPath = `${path}/documents.txt`;
            const documentsContent = this.documents.join('\n---DOCUMENT_SEPARATOR---\n');
            await Deno.writeTextFile(documentsPath, documentsContent);
            
            // 保存元数据
            const metadataPath = `${path}/metadata.json`;
            await Deno.writeTextFile(metadataPath, JSON.stringify(this.metadata, null, 2));
            
        } catch (error) {
            console.error(`保存数据失败: ${error.message}`);
            throw error;
        }
    }

    /**
     * 从本地文件加载向量数据和文档内容
     * @param {string} path - 存储路径,默认为 './storage'
     */
    async loadVector(path = STORAGE_PATH) {
        try {
            // 加载向量数据
            const vectorsPath = `${path}/vectors.json`;
            const vectorsContent = await readTextFile(vectorsPath);
            this.vectors = JSON.parse(vectorsContent);
            
            // 加载文档内容
            const documentsPath = `${path}/documents.txt`;
            const documentsContent = await readTextFile(documentsPath);
            this.documents = documentsContent.split('\n---DOCUMENT_SEPARATOR---\n');
            
            // 加载元数据(可选)
            try {
                const metadataPath = `${path}/metadata.json`;
                const metadataContent = await readTextFile(metadataPath);
                this.metadata = JSON.parse(metadataContent);
            } catch (error) {
                console.warn("元数据文件不存在或损坏,跳过加载");
                this.metadata = {};
            }
            console.log(`数据加载完成:`);
        } catch (error) {
            console.error(`加载数据失败: ${error.message}`);
            throw error;
        }
    }

    /**
     * 计算两个向量之间的相似度(使用余弦相似度)
     * @param {number[]} vector1 - 第一个向量
     * @param {number[]} vector2 - 第二个向量
     * @returns {number} 相似度值,范围从 -1 到 1
     */
    getSimilarity(vector1, vector2) {
        return OllamaEmbedding.cosineSimilarity(vector1, vector2);
    }

    /**
     * 根据查询向量检索最相关的文档
     * @param {string} query - 查询文本
     * @param {OllamaEmbedding} embeddingModel - 嵌入模型实例
     * @param {number} k - 返回的文档数量,默认为 1
     * @returns {Promise<string[]>} 最相关的文档列表
     */
    async query(query, embeddingModel, k = 1) {
        if (this.vectors.length === 0) {
            throw new Error("向量数据库为空,请先加载或生成向量数据");
        }
                
        try {
            // 将查询文本转换为向量
            const queryVector = await embeddingModel.getEmbedding(query);
            
            // 计算查询向量与所有文档向量的相似度
            const similarities = this.vectors.map((vector, index) => ({
                index,
                similarity: this.getSimilarity(queryVector, vector),
                document: this.documents[index]
            }));
            
            // 按相似度降序排序,取前 k 个结果
            const topResults = similarities
                .sort((a, b) => b.similarity - a.similarity)
                .slice(0, k);
            
            // 输出检索结果信息
            console.log(`找到 ${topResults.length} 个相关文档:`);
            topResults.forEach((result, index) => {
                console.log(`  ${index + 1}. 相似度: ${result.similarity.toFixed(4)} - 文档片段: ${result.document.substring(0, 50)}...`);
            });
            
            // 返回相关文档内容
            return topResults.map(result => result.document);
            
        } catch (error) {
            console.error(`查询失败: ${error.message}`);
            throw error;
        }
    }

    /**
     * 批量查询,返回详细的相似度信息
     * @param {string} query - 查询文本
     * @param {OllamaEmbedding} embeddingModel - 嵌入模型实例
     * @param {number} k - 返回的文档数量
     * @returns {Promise<Object[]>} 包含文档内容和相似度的结果列表
     */
    async queryWithScores(query, embeddingModel, k = 1) {
        if (this.vectors.length === 0) {
            throw new Error("向量数据库为空,请先加载或生成向量数据");
        }
        
        try {
            // 将查询文本转换为向量
            const queryVector = await embeddingModel.getEmbedding(query);
            
            // 计算相似度并返回详细信息
            const similarities = this.vectors.map((vector, index) => ({
                index,
                similarity: this.getSimilarity(queryVector, vector),
                document: this.documents[index]
            }));
            
            // 按相似度排序并返回前 k 个
            return similarities
                .filter(item => item.similarity > 0)
                .sort((a, b) => b.similarity - a.similarity)
                .slice(0, k);
                
        } catch (error) {
            console.error(`详细查询失败: ${error.message}`);
            throw error;
        }
    }
  
    /**
     * 获取向量数据库的统计信息
     * @returns {Object} 统计信息
     */
    getStats() {
        return {
            documentCount: this.documents.length,
            vectorCount: this.vectors.length,
            vectorDimension: this.vectors.length > 0 ? this.vectors[0].length : 0,
            averageDocumentLength: this.documents.length > 0 
                ? this.documents.reduce((sum, doc) => sum + doc.length, 0) / this.documents.length 
                : 0,
            metadata: this.metadata
        };
    }
}

console.log("向量存储和检索类已定义完成");

4.3 框架实现

这里我们使用现在 构建 LLM 应用 最热门的开发框架 ------ LangChain 来实现一个简易的 RAG。

javascript 复制代码
// 核心 LangChain 组件
import { ChatOllama } from "npm:@langchain/ollama";
import { OllamaEmbeddings } from "npm:@langchain/ollama";
import { PromptTemplate } from "npm:@langchain/core/prompts";

// 文档处理
import { TextLoader } from "npm:langchain/document_loaders/fs/text";
import { RecursiveCharacterTextSplitter } from "npm:@langchain/textsplitters";

// 向量存储
import { MemoryVectorStore } from "npm:langchain/vectorstores/memory";

// RAG 链组件
import { createStuffDocumentsChain } from "npm:langchain/chains/combine_documents";
import { createRetrievalChain } from "npm:langchain/chains/retrieval";

// 配置 Ollama 服务
const OLLAMA_BASE_URL = "http://localhost:11434";

// 聊天模型
const chatModel = new ChatOllama({
  baseUrl: OLLAMA_BASE_URL,
  model: "deepseek-r1:8b",
  temperature: 0.7,
});

// 嵌入模型
const embeddings = new OllamaEmbeddings({
  baseUrl: OLLAMA_BASE_URL,
  model: "dengcao/Qwen3-Embedding-0.6B:Q8_0",
});

// 创建测试文档
const testDocPath = "./data/rag_guide.txt";


// 文档加载
const loader = new TextLoader(testDocPath);
const documents = await loader.load();

// 文本分割
const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 500,
  chunkOverlap: 50,
});
const splitDocuments = await textSplitter.splitDocuments(documents);

// 创建向量存储
const vectorStore = await MemoryVectorStore.fromDocuments(splitDocuments, embeddings);

// 创建检索器
const retriever = vectorStore.asRetriever({ k: 3 });

// 创建提示模板
const ragPrompt = PromptTemplate.fromTemplate(`
你是一个专业的AI助手,请根据以下上下文信息回答用户的问题。
如果上下文中没有相关信息,请说明无法从提供的上下文中找到答案。

上下文信息:
{context}

用户问题:{input}

回答:
`);

// 创建文档处理链
const documentChain = await createStuffDocumentsChain({
  llm: chatModel,
  prompt: ragPrompt,
});

// 创建 RAG 检索链
const ragChain = await createRetrievalChain({
  retriever: retriever,
  combineDocsChain: documentChain,
});

// RAG 问答函数
async function askRAG(question: string) {
  try {
    const response = await ragChain.invoke({ input: question });
    return {
      question: question,
      answer: response.answer,
      sourceDocuments: response.context,
    };
  } catch (error) {
    console.error("RAG 问答错误:", error);
    return null;
  }
}

// 示例问答
const question = "什么是 LangChain?";

const result = await askRAG(question);

if (result) {
  console.log(`回答: ${result.answer}`);
  console.log(`引用文档数: ${result.sourceDocuments.length}`);
}

运行命令:

css 复制代码
deno run --allow-net --allow-read --allow-env index.ts

五、总结思考

RAG 更底层的逻辑,也是我们对待 LLM 正确的态度

  • LLM 是逻辑推理引擎,而不是信息引擎。
  • 所以由外挂的向量数据库提供最有效的知识,然后由 LLM 根据知识进行推理,提供有价值的回复。

本文是简单梳理的 RAG 的基本原理,但是其中的奥秘远远不止于此。

RAG 实际是一个 门槛很低,但上限极高的技术。

后续笔者还会持续更新大模型相关技术的学习笔记欢迎三连!

相关推荐
袁煦丞11 分钟前
Redis内存闪电侠:cpolar内网穿透第614个成功挑战
前端·程序员·远程工作
BillKu16 分钟前
Vue3组件加载顺序
前端·javascript·vue.js
IT_陈寒24 分钟前
Python性能优化必知必会:7个让代码快3倍的底层技巧与实战案例
前端·人工智能·后端
暖木生晖36 分钟前
引入资源即针对于不同的屏幕尺寸,调用不同的css文件
前端·css·媒体查询
袁煦丞1 小时前
DS file文件管家远程自由:cpolar内网穿透实验室第492个成功挑战
前端·程序员·远程工作
用户013741284371 小时前
九个鲜为人知却极具威力的 CSS 功能:提升前端开发体验的隐藏技巧
前端
永远不打烊1 小时前
Window环境 WebRTC demo 运行
前端
风舞1 小时前
一文搞定JS所有类型判断最佳实践
前端·javascript
coding随想1 小时前
哈希值变化的魔法:深入解析HTML5 hashchange事件的奥秘与实战
前端
一树山茶1 小时前
uniapp在微信小程序中实现 SSE进行通信
前端·javascript