RAG系统核心解析:文档向量化与检索的实践探索

什么是RAG?

RAG (Retrieval-Augmented Generation),中文全称为检索增强生成 ,是一种将信息检索 技术与大语言模型(如ChatGPT)深度结合的AI架构。

简单来说,RAG的核心逻辑是:在让大模型回答问题之前,先让它去指定知识库里"查阅资料",再基于检索到的信息生成精准答案。这种"先查后答"的模式,既弥补了模型知识滞后的问题,也降低了幻觉产生的风险。


第一步:文档向量化

构建RAG系统的首要环节,就是文档向量化------将企业内部的大量文档切割为适宜检索的文本片段,通过向量模型将其转化为数值向量,最后将"向量+原始文本"一并存入向量数据库。

文档切割方式主要有以下三类:

1. 固定长度切割

按固定字符数将文档切分为等长片段。这种方式切出的结果最规整,但可能将一个完整句子拦腰截断,导致语义丢失,检索时也容易召回不完整的信息。

javascript

vbnet 复制代码
/**
 * 按固定字符数切割文本
 * @param {string} text - 待切割的文本
 * @param {number} chunkSize - 每块的最大字符数
 * @param {number} overlap - 块之间的重叠字符数
 * @returns {string[]} 切割后的文本块数组
 */
function fixedLengthChunk(text, chunkSize = 500, overlap = 0) {
    if (chunkSize <= 0) throw new Error('chunkSize 必须大于0');
    if (overlap >= chunkSize) throw new Error('overlap 必须小于 chunkSize');

    const chunks = [];
    let start = 0;

    while (start < text.length) {
        let end = Math.min(start + chunkSize, text.length);
        
        // 非最后一块时,尽量在空格或标点处切断(保持单词/句子的完整性)
        if (end < text.length) {
            const separatorMatch = text.substring(end - 50, end + 50).match(/[。,、;:!?\s,.!?;:]\s*/);
            if (separatorMatch) {
                const separatorIndex = text.indexOf(separatorMatch[0], end - 50) + separatorMatch[0].length;
                if (separatorIndex > start && separatorIndex - start <= chunkSize * 1.2) {
                    end = separatorIndex;
                }
            }
        }

        chunks.push(text.substring(start, end));
        start = end - overlap;
        
        if (start >= end) break;
    }

    return chunks;
}

2. 段落/句子切割

依据句号、分号、换行符等标点进行切割。这种方式能较好地保持语句的完整性,但粒度可能过细,导致切分出的文档块数量庞大,单次召回的信息量偏小。

javascript

ini 复制代码
/**
 * 按句子切割文本(保留标点符号)
 * @param {string} text - 待切割的文本
 * @param {number} maxSentences - 每块最多包含的句子数
 * @param {number} overlapSentences - 块之间重叠的句子数
 * @returns {string[]} 切割后的文本块数组
 */
function sentenceChunk(text, maxSentences = 3, overlapSentences = 1) {
    const sentenceRegex = /[^。!?;.!?\n]+[。!?;.!?\n]+/g;
    const sentences = text.match(sentenceRegex) || [];
    
    if (sentences.length === 0) return [text];

    const chunks = [];
    const step = maxSentences - overlapSentences;
    
    for (let i = 0; i < sentences.length; i += step) {
        const end = Math.min(i + maxSentences, sentences.length);
        const chunk = sentences.slice(i, end).join('').trim();
        if (chunk.length > 0) {
            chunks.push(chunk);
        }
        if (end === sentences.length) break;
    }
    
    return chunks;
}

3. 语义化切割

基于句子的语义相似度进行切割,将含义相近的句子聚合在同一块中。这种方式产出的片段语义最完整,召回效率也最高,但成本相对较大,通常需要借助模型辅助。下面示例使用的是轻量级本地模型 all-MiniLM-L6-v2

javascript

ini 复制代码
import { pipeline } from '@huggingface/transformers';

/**
 * 使用 all-MiniLM-L6-v2 模型进行语义切割
 * @param {string} text - 待切割的原始文本
 * @param {Object} options - 配置参数
 * @param {number} options.similarityThreshold - 相似度阈值 (0-1),低于此值则切分,默认 0.5
 * @param {number} options.minChunkSize - 最小块大小(字符数),防止切出过小片段,默认 50
 * @returns {Promise<string[]>} 切割后的文本块数组
 */
async function semanticChunking(text, options = {}) {
    const { similarityThreshold = 0.5, minChunkSize = 50 } = options;

    const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');

    const sentences = text.match(/[^。!?;.!?\n]+[。!?;.!?\n]+/g) || [text];
    if (sentences.length <= 1) return sentences;

    const embeddings = await extractor(sentences, { pooling: 'mean', normalize: true });
    const embeddingsArray = embeddings.tolist();

    // 计算相邻句子的余弦相似度
    const similarities = [];
    for (let i = 0; i < embeddingsArray.length - 1; i++) {
        const sim = cosineSimilarity(embeddingsArray[i], embeddingsArray[i + 1]);
        similarities.push(sim);
    }

    const chunks = [];
    let currentChunk = sentences[0];

    for (let i = 0; i < similarities.length; i++) {
        if (currentChunk.length >= minChunkSize && similarities[i] < similarityThreshold) {
            chunks.push(currentChunk.trim());
            currentChunk = sentences[i + 1];
        } else {
            currentChunk += sentences[i + 1];
        }
    }
    if (currentChunk.trim()) {
        chunks.push(currentChunk.trim());
    }

    return chunks;
}

切割完成后,我们使用向量模型将每个文本块转化为向量,并将"向量 + 原始片段"一并存入向量数据库,供后续检索使用。

javascript

javascript 复制代码
import { pipeline } from '@huggingface/transformers';

/**
 * 将文本块批量转换为向量
 * @param {string[]} chunks - 已切割好的文本块数组
 * @returns {Promise<number[][]>} 向量数组
 */
async function vectorizeChunks(chunks) {
    const extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
    
    const result = await extractor(chunks, {
        pooling: 'mean',
        normalize: true
    });
    
    return result.tolist();
}

// 使用示例
const chunks = [
    '人工智能技术发展迅速。',
    '深度学习改变了计算机视觉领域。',
    '自然语言处理也有了重大突破。'
];

const embeddings = await vectorizeChunks(chunks);

第二步:文档检索

文档检索的核心任务,是在知识库(或开放网络)中找出与用户问题最相关的内容,将这些内容重新组织后提交给大模型,由大模型生成最终答案。

检索方式主要分为两类:

1. 向量检索(语义检索)

向量检索 是一种基于语义相似度的搜索方式。它先将文本、图片等内容转换为数学向量,然后在向量空间中寻找与问题"含义最接近"的结果------而不是像传统关键词搜索那样只做字面匹配。

下面以余弦相似度为例进行演示(实际应用中还有欧氏距离、点积等方式):

javascript

ini 复制代码
function cosineSimilarity(vecA, vecB) {
    if (vecA.length !== vecB.length) {
        throw new Error('两个向量的长度必须相同');
    }

    let dotProduct = 0;
    for (let i = 0; i < vecA.length; i++) {
        dotProduct += vecA[i] * vecB[i];
    }

    let normA = 0;
    for (let i = 0; i < vecA.length; i++) {
        normA += vecA[i] * vecA[i];
    }
    normA = Math.sqrt(normA);

    let normB = 0;
    for (let i = 0; i < vecB.length; i++) {
        normB += vecB[i] * vecB[i];
    }
    normB = Math.sqrt(normB);

    if (normA === 0 || normB === 0) {
        return 0;
    }

    return dotProduct / (normA * normB);
}

2. 关键词检索(字面匹配)

关键词检索 是最传统的搜索方式,核心原理是字面匹配:系统分析查询词,在数据库中寻找包含相同或相近词汇的文档,并根据词频、位置等因素排序。

BM25 是关键词检索中最经典的排序算法,它的核心作用是:判断哪篇文档与查询词"最相关",并将最相关的结果排在首位

javascript

ini 复制代码
/**
 * BM25 排序算法实现
 * 用于关键词检索时计算文档与查询词的相关性得分
 */
class BM25 {
    constructor(options = {}) {
        this.k1 = options.k1 || 1.2;
        this.b = options.b || 0.75;
        this.corpus = [];
        this.docLengths = [];
        this.avgDocLength = 0;
        this.docCount = 0;
        this.termFreqs = [];
        this.idfCache = new Map();
    }

    tokenize(text) {
        return text.toLowerCase()
            .replace(/[^\w\s\u4e00-\u9fa5]/g, ' ')
            .split(/\s+/)
            .filter(word => word.length > 0);
    }

    addDocument(doc) {
        const tokens = this.tokenize(doc);
        const termFreq = {};
        
        for (const token of tokens) {
            termFreq[token] = (termFreq[token] || 0) + 1;
        }

        this.corpus.push(doc);
        this.docLengths.push(tokens.length);
        this.termFreqs.push(termFreq);
        this.docCount++;
        this.avgDocLength = this.docLengths.reduce((a, b) => a + b, 0) / this.docCount;
    }

    calculateIDF(term) {
        if (this.idfCache.has(term)) {
            return this.idfCache.get(term);
        }

        let docCountWithTerm = 0;
        for (const freq of this.termFreqs) {
            if (freq[term] && freq[term] > 0) {
                docCountWithTerm++;
            }
        }

        const N = this.docCount;
        const n = docCountWithTerm;
        const idf = Math.log((N - n + 0.5) / (n + 0.5) + 1);
        
        this.idfCache.set(term, idf);
        return idf;
    }

    scoreTerm(termFreq, term, docLength) {
        const tf = termFreq[term] || 0;
        if (tf === 0) return 0;

        const idf = this.calculateIDF(term);
        const norm = 1 - this.b + this.b * (docLength / this.avgDocLength);
        
        return idf * (tf * (this.k1 + 1)) / (tf + this.k1 * norm);
    }

    searchTerm(term) {
        const results = [];
        const normalizedTerm = term.toLowerCase();

        for (let i = 0; i < this.docCount; i++) {
            const score = this.scoreTerm(
                this.termFreqs[i],
                normalizedTerm,
                this.docLengths[i]
            );
            if (score > 0) {
                results.push({
                    docIndex: i,
                    score: score,
                    content: this.corpus[i]
                });
            }
        }

        results.sort((a, b) => b.score - a.score);
        return results;
    }

    search(query, topK = 10) {
        const queryTerms = this.tokenize(query);
        if (queryTerms.length === 0) return [];

        const scores = new Map();

        for (const term of queryTerms) {
            const termResults = this.searchTerm(term);
            for (const result of termResults) {
                const existing = scores.get(result.docIndex) || 0;
                scores.set(result.docIndex, existing + result.score);
            }
        }

        const results = [];
        for (const [docIndex, score] of scores) {
            results.push({
                docIndex,
                score,
                content: this.corpus[docIndex]
            });
        }

        results.sort((a, b) => b.score - a.score);
        return results.slice(0, topK);
    }
}

// ========== 使用示例 ==========

const bm25 = new BM25({ k1: 1.2, b: 0.75 });

bm25.addDocument('苹果手机发布新款iPhone,配备A系列芯片');
bm25.addDocument('华为手机推出Mate系列,支持5G网络');
bm25.addDocument('苹果公司发布财报,iPhone销量增长');
bm25.addDocument('小米手机性价比高,深受年轻人喜爱');

const results = bm25.search('苹果 iPhone', 3);

console.log('检索结果:');
results.forEach((item, index) => {
    console.log(`${index + 1}. 得分: ${item.score.toFixed(4)}`);
    console.log(`   内容: ${item.content}\n`);
});

检索后处理流程

检索完成后,为了保证召回内容的置信度,还需要对结果进行重排序(Rerank) 。通过专用重排序模型对候选文档进行精细打分,将最相关的内容排在前面,并计算每个结果的置信度,最终输出给用户。

重排序代码

javascript

ini 复制代码
import { pipeline } from '@huggingface/transformers';

/**
 * 重排序器 - 对召回结果进行精细排序
 */
class Reranker {
    constructor() {
        this.reranker = null;
        this.modelName = 'Xenova/ms-marco-MiniLM-L-6-v2';
        this.isLoaded = false;
    }

    /**
     * 加载重排序模型
     */
    async loadModel() {
        if (this.isLoaded) return;
        console.log('正在加载重排序模型...');
        this.reranker = await pipeline('text-classification', this.modelName);
        this.isLoaded = true;
        console.log('重排序模型加载成功!');
    }

    /**
     * 对召回的文档进行重排序
     * @param {string} query - 用户查询
     * @param {Array} candidates - 召回的候选文档 [{content: string, score: number, ...}]
     * @param {number} topK - 返回前K个结果
     * @returns {Promise<Array>} 重排序后的结果
     */
    async rerank(query, candidates, topK = 5) {
        if (!this.isLoaded) {
            await this.loadModel();
        }

        if (candidates.length === 0) return [];

        // 1. 构造查询-文档对
        const pairs = candidates.map(doc => ({
            text: `${query} [SEP] ${doc.content}`,
            doc: doc
        }));

        // 2. 批量计算相关性分数
        const batchSize = 16;
        const scoredResults = [];

        for (let i = 0; i < pairs.length; i += batchSize) {
            const batch = pairs.slice(i, Math.min(i + batchSize, pairs.length));
            const texts = batch.map(p => p.text);
            
            const results = await this.reranker(texts);
            
            for (let j = 0; j < results.length; j++) {
                scoredResults.push({
                    ...batch[j].doc,
                    rerankScore: results[j].score,
                    originalScore: batch[j].doc.score || 0
                });
            }
        }

        // 3. 按重排序分数降序排列
        scoredResults.sort((a, b) => b.rerankScore - a.rerankScore);

        // 4. 返回Top-K
        return scoredResults.slice(0, topK);
    }

    /**
     * 计算置信度(基于重排序分数)
     */
    calculateConfidence(scoredResults) {
        if (scoredResults.length === 0) return [];

        const scores = scoredResults.map(r => r.rerankScore);
        const maxScore = Math.max(...scores);
        const minScore = Math.min(...scores);
        const range = maxScore - minScore;

        return scoredResults.map((result, index) => {
            let confidence = 0;
            if (range > 0) {
                confidence = (result.rerankScore - minScore) / range;
            } else {
                confidence = 1.0;
            }
            
            return {
                ...result,
                confidence: confidence,
                rank: index + 1
            };
        });
    }
}

// ========== 使用示例 ==========

(async function() {
    // 1. 创建重排序器
    const reranker = new Reranker();

    // 2. 模拟召回的候选文档
    const candidates = [
        {
            content: '人工智能是计算机科学的一个分支,致力于创建能够执行通常需要人类智能的任务的系统。',
            score: 0.85,
            source: 'vector_db'
        },
        {
            content: '深度学习是机器学习的一个子集,使用多层神经网络来学习数据的表示。',
            score: 0.78,
            source: 'vector_db'
        },
        {
            content: '人工智能技术包括机器学习、自然语言处理、计算机视觉等多个领域。',
            score: 0.72,
            source: 'bm25'
        },
        {
            content: '今天天气很好,适合出去散步。',
            score: 0.45,
            source: 'bm25'
        },
        {
            content: 'AI正在改变各行各业,从医疗到金融再到制造业。',
            score: 0.68,
            source: 'vector_db'
        }
    ];

    // 3. 执行重排序
    const query = '什么是人工智能?';
    const reranked = await reranker.rerank(query, candidates, 3);
    
    // 4. 计算置信度
    const resultsWithConfidence = reranker.calculateConfidence(reranked);
    
    // 5. 输出结果
    console.log('重排序结果:');
    resultsWithConfidence.forEach((item) => {
        console.log(`\n排名 ${item.rank}:`);
        console.log(`  内容: ${item.content.substring(0, 50)}...`);
        console.log(`  重排序分数: ${item.rerankScore.toFixed(4)}`);
        console.log(`  原始分数: ${item.originalScore.toFixed(4)}`);
        console.log(`  置信度: ${(item.confidence * 100).toFixed(1)}%`);
        console.log(`  来源: ${item.source}`);
    });
})();

输出与反馈

重排序完成后,最终结果可通过流式方式 输出到前端展示给用户。如果用户对回答不满意,可按以下步骤逐步排查问题

问题排查清单

步骤 检查项 说明
1 检查文档是否过时 确认知识库中的源文档是否包含最新信息,是否已过期或失效。
2 检查文档切分结果是否合适 验证切块大小、重叠度是否合理,是否存在关键信息被切断或上下文丢失的问题。
3 检查召回结果是否合适 评估向量检索或关键词检索返回的候选文档是否与用户问题高度相关,是否存在低质量或无关内容。
4 检查系统提示词是否合适 确认大模型的系统提示词(System Prompt)是否清晰、准确,是否引导模型正确理解和组织检索到的信息。

检查顺序

通过以上逐步排查,可以定位问题根源并针对性优化,从而持续提升系统的回答质量。


写在最后

以上是我对RAG系统中文档向量化与文档检索环节的初步理解与实践总结。当然,这只是一个入门视角,文章所示代码也不是唯一解法,实际工程中还有大量值得深入探索的方向------比如:

  • 向量规模扩大后,如何通过ANN(近似最近邻) 索引来加速检索;
  • 如何设计混合检索(向量+关键词)来兼顾语义和精确匹配;
  • 如何优化召回质量,避免无关内容干扰大模型生成;
  • 如何设计评估体系来衡量RAG系统的整体效果。

受限于个人水平,文章中若有理解不当之处,恳请各位读者批评指正,不胜感激。


关于我

7年前端开发经验

借此专栏,我希望系统记录自己在 AI Agent 开发领域的思考与实践,以此为媒,寻找下一份工作机会。

📍 坐标:成都

如有合适的岗位或合作机会,欢迎各位大佬推荐或联系,不胜感激!

相关推荐
code_371492 小时前
Agent 设计及实现 demo
agent
极客密码3 小时前
DeepSeek V4-Flash 正式版发布:Agent 基准、价格与 Codex 接入保姆级教程
agent·ai编程·deepseek
jsl_jsl_jsl5 小时前
claudecode学习 第 3 章 · Agent 循环与工具协议
agent
TrisighT5 小时前
一张路由表少写一行,装好的 tri-loop 就成了死 skill:我把 tri-intent 的 27 个落点扒了一遍
aigc·agent·ai编程
5pat54OfCg5 小时前
OpenCode 源码拆解(一):Agent 怎么活下来?——进程生命周期的 3 个设计模式
agent
树状图设计者6 小时前
MCP2026-07-28:无状态核心拆解
agent·mcp
新知图书6 小时前
2.3 开发工作流
人工智能·agent·ai agent·智能体
安逸sgr6 小时前
如何减少 Prompt 引起的幻觉和答非所问?
人工智能·ai·大模型·prompt·agent·智能体
城管不管6 小时前
重生——第五次面试2026.8.1一面
java·数据库·后端·ai·面试·职场和发展·agent