ai 精选推荐

RAG 检索增强生成应用:让 AI 基于你的数据回答问题

HTMLPAGE 团队
18 分钟阅读

深入讲解 RAG 技术原理和实现方法,涵盖文档处理、向量化存储、检索策略和生成优化

#RAG #向量数据库 #检索增强 #知识库

RAG 是什么

RAG(Retrieval-Augmented Generation)检索增强生成,是让 LLM 基于特定知识库回答问题的技术。

核心思路:先检索相关文档,再把文档作为上下文让 AI 生成回答

为什么需要 RAG

LLM 的知识有截止日期,且无法知道你的私有数据。RAG 解决这两个问题:

问题RAG 方案
知识过时实时检索最新文档
私有数据索引企业内部知识库
幻觉问题基于真实文档生成,可追溯
成本控制只检索相关片段,减少 Token

RAG 流程

┌─────────────────────────────────────────────────────────────────┐
│                         索引阶段                                 │
│  文档 → 分块 → 向量化 → 存储到向量数据库                           │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                         查询阶段                                 │
│  问题 → 向量化 → 检索相似文档 → 组装提示 → LLM 生成 → 返回答案      │
└─────────────────────────────────────────────────────────────────┘

索引阶段实现

1. 文档加载

import { DirectoryLoader } from 'langchain/document_loaders/fs/directory'
import { TextLoader } from 'langchain/document_loaders/fs/text'
import { PDFLoader } from 'langchain/document_loaders/fs/pdf'
import { NotionLoader } from 'langchain/document_loaders/fs/notion'

// 加载不同类型的文档
const loader = new DirectoryLoader('./documents', {
  '.txt': (path) => new TextLoader(path),
  '.md': (path) => new TextLoader(path),
  '.pdf': (path) => new PDFLoader(path)
})

const documents = await loader.load()
console.log(`加载了 ${documents.length} 个文档`)

2. 文档分块

分块是 RAG 的关键环节,直接影响检索质量。

import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'

// 递归字符分割(最常用)
const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,      // 每块最大字符数
  chunkOverlap: 200,    // 块之间重叠字符数
  separators: ['\n\n', '\n', '。', '!', '?', '.', '!', '?', ' '] // 分割符优先级
})

const chunks = await splitter.splitDocuments(documents)
console.log(`分割成 ${chunks.length} 个块`)

分块策略对比

策略优点缺点适用场景
固定大小简单可能切断语义通用文档
递归分割保持语义完整块大小不均大多数场景
按标题分割结构清晰需要结构化文档Markdown/HTML
语义分割语义完整计算成本高高质量需求

3. 向量化存储

import { OpenAIEmbeddings } from '@langchain/openai'
import { PineconeStore } from '@langchain/pinecone'
import { Pinecone } from '@pinecone-database/pinecone'

// 初始化 Pinecone
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY })
const index = pinecone.Index('my-index')

// 向量化并存储
const embeddings = new OpenAIEmbeddings({
  modelName: 'text-embedding-3-small'
})

const vectorStore = await PineconeStore.fromDocuments(chunks, embeddings, {
  pineconeIndex: index,
  namespace: 'knowledge-base'
})

console.log('索引完成')

向量数据库选型

数据库特点成本适用场景
Pinecone托管服务,易用按量付费快速上线
Supabase pgvector与 Postgres 集成便宜已用 Supabase
Chroma开源,本地部署免费开发测试
Weaviate功能丰富自托管免费复杂需求
Milvus高性能自托管大规模数据

查询阶段实现

1. 基础检索

// 从现有索引创建向量存储
const vectorStore = await PineconeStore.fromExistingIndex(embeddings, {
  pineconeIndex: index,
  namespace: 'knowledge-base'
})

// 相似性检索
const results = await vectorStore.similaritySearch('Vue 的响应式原理', 4)
// 返回最相关的 4 个文档块

2. 检索器配置

const retriever = vectorStore.asRetriever({
  k: 5,                      // 返回数量
  searchType: 'similarity',  // 或 'mmr'
  filter: {                  // 元数据过滤
    source: 'vue-docs'
  }
})

// MMR (最大边际相关性) - 平衡相关性和多样性
const mmrRetriever = vectorStore.asRetriever({
  searchType: 'mmr',
  searchKwargs: {
    fetchK: 20,     // 初始检索数量
    lambda: 0.5     // 相关性/多样性权重
  }
})

3. 构建问答链

import { ChatOpenAI } from '@langchain/openai'
import { createStuffDocumentsChain } from 'langchain/chains/combine_documents'
import { createRetrievalChain } from 'langchain/chains/retrieval'
import { ChatPromptTemplate } from '@langchain/core/prompts'

const llm = new ChatOpenAI({ modelName: 'gpt-4-turbo-preview' })

const prompt = ChatPromptTemplate.fromMessages([
  ['system', `你是一个知识库问答助手。根据以下上下文回答用户问题。

规则:
1. 只基于上下文内容回答,不要编造
2. 如果上下文中没有相关信息,明确说明
3. 引用来源文档

上下文:
{context}`],
  ['human', '{input}']
])

// 创建链
const documentChain = await createStuffDocumentsChain({ llm, prompt })
const retrievalChain = await createRetrievalChain({
  retriever,
  combineDocsChain: documentChain
})

// 查询
const result = await retrievalChain.invoke({
  input: 'Vue 3 的 Composition API 有什么优势?'
})

console.log('答案:', result.answer)
console.log('来源:', result.context.map(d => d.metadata.source))

高级优化

1. 混合检索

结合关键词和语义检索:

import { BM25Retriever } from 'langchain/retrievers/bm25'

// 关键词检索
const bm25Retriever = BM25Retriever.fromDocuments(documents, { k: 5 })

// 语义检索
const vectorRetriever = vectorStore.asRetriever({ k: 5 })

// 融合结果
async function hybridSearch(query: string) {
  const [bm25Results, vectorResults] = await Promise.all([
    bm25Retriever.invoke(query),
    vectorRetriever.invoke(query)
  ])
  
  // 去重并排序
  const seen = new Set()
  const merged = [...vectorResults, ...bm25Results].filter(doc => {
    const key = doc.pageContent.slice(0, 100)
    if (seen.has(key)) return false
    seen.add(key)
    return true
  })
  
  return merged.slice(0, 5)
}

2. 重排序

先召回多个,再用更精确的模型排序:

import { CohereRerank } from '@langchain/cohere'

const reranker = new CohereRerank({
  apiKey: process.env.COHERE_API_KEY,
  topN: 5
})

async function searchWithRerank(query: string) {
  // 先粗召回 20 个
  const candidates = await vectorStore.similaritySearch(query, 20)
  
  // 重排序选 5 个
  const reranked = await reranker.compressDocuments(candidates, query)
  
  return reranked
}

3. 查询改写

优化用户查询:

async function rewriteQuery(query: string) {
  const response = await llm.invoke(`
将以下用户问题改写成更适合搜索的查询,保持语义不变:

原问题:${query}

改写后的查询(直接输出,不要其他内容):
  `)
  
  return response.content as string
}

// 使用
const originalQuery = '怎么让页面加载更快'
const rewrittenQuery = await rewriteQuery(originalQuery)
// '网页性能优化 页面加载速度提升方法'

4. 多步检索

复杂问题分解:

async function multiStepRetrieval(query: string) {
  // 1. 分解问题
  const subQueries = await llm.invoke(`
将以下复杂问题分解成 2-3 个简单的子问题:

问题:${query}

子问题(每行一个):
  `)
  
  const queries = (subQueries.content as string).split('\n').filter(Boolean)
  
  // 2. 分别检索
  const allDocs = []
  for (const q of queries) {
    const docs = await retriever.invoke(q)
    allDocs.push(...docs)
  }
  
  // 3. 去重合并
  return deduplicateDocs(allDocs)
}

前端集成

Nuxt 3 完整示例

// server/api/rag/query.post.ts
import { createRAGChain } from '../utils/rag'

export default defineEventHandler(async (event) => {
  const { question, namespace = 'default' } = await readBody(event)

  const chain = await createRAGChain(namespace)

  // 流式响应
  setHeader(event, 'Content-Type', 'text/event-stream')

  const stream = await chain.stream({ input: question })

  return new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder()
      
      for await (const chunk of stream) {
        if (chunk.answer) {
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify({ content: chunk.answer })}\n\n`)
          )
        }
      }
      
      controller.enqueue(encoder.encode('data: [DONE]\n\n'))
      controller.close()
    }
  })
})

前端组件

<script setup lang="ts">
const question = ref('')
const answer = ref('')
const sources = ref<Source[]>([])
const isLoading = ref(false)

async function askQuestion() {
  if (!question.value.trim()) return
  
  isLoading.value = true
  answer.value = ''
  sources.value = []

  try {
    const response = await fetch('/api/rag/query', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question: question.value })
    })

    const reader = response.body!.getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      const text = decoder.decode(value)
      const lines = text.split('\n')

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const data = line.slice(6)
          if (data === '[DONE]') continue
          
          try {
            const parsed = JSON.parse(data)
            answer.value += parsed.content || ''
          } catch {}
        }
      }
    }
  } finally {
    isLoading.value = false
  }
}
</script>

评估与优化

检索质量指标

// 计算召回率
function calculateRecall(retrieved: string[], relevant: string[]) {
  const found = retrieved.filter(r => relevant.includes(r))
  return found.length / relevant.length
}

// 计算精确率
function calculatePrecision(retrieved: string[], relevant: string[]) {
  const found = retrieved.filter(r => relevant.includes(r))
  return found.length / retrieved.length
}

// 评估示例
const testCases = [
  {
    query: 'Vue 响应式原理',
    expectedDocs: ['vue-reactivity.md', 'proxy-reflect.md']
  }
]

for (const testCase of testCases) {
  const results = await retriever.invoke(testCase.query)
  const retrievedSources = results.map(r => r.metadata.source)
  
  const recall = calculateRecall(retrievedSources, testCase.expectedDocs)
  const precision = calculatePrecision(retrievedSources, testCase.expectedDocs)
  
  console.log(`Query: ${testCase.query}`)
  console.log(`Recall: ${recall}, Precision: ${precision}`)
}

常见问题

Q: 分块大小怎么选?

A: 一般 500-1500 字符。太小会丢失上下文,太大会引入噪音。建议实验对比。

Q: 检索返回几个文档合适?

A: 通常 3-5 个。太少可能遗漏信息,太多会超出 context 长度并增加成本。

Q: 如何处理长文档?

A: 分层索引。先按章节粗分,再细分。检索时先找章节,再找具体段落。

总结

RAG 实施要点:

阶段关键点
分块保持语义完整,适当重叠
向量化选择合适的 Embedding 模型
检索混合策略 + 重排序
生成明确提示 + 引用来源
评估持续测试 + 迭代优化

相关文章推荐: