智能开发者文档搜索:RAG 在小团队文档里的落地

智能开发者文档搜索:RAG 在小团队文档里的落地

一、小团队文档搜索的痛点与技术机遇

小团队通常缺乏专门的文档管理平台。技术文档分散在多个地方:代码注释、Markdown 文件、Wiki 页面、Issue 讨论。开发者需要查找某个 API 用法或配置说明时,不得不在多个工具间切换搜索。

传统关键词搜索无法理解语义。搜索"如何配置数据库"时,找不到标题为"Database Configuration Guide"的文档。RAG(Retrieval-Augmented Generation)技术能够理解查询意图,从文档库中检索相关内容,并生成精准回答。

// 传统文档搜索的问题 // 开发者想查找"如何更新用户资料" // 但文档标题是"User Profile Update API" // 使用 grep 或简单搜索: // $ grep -r "更新用户" docs/ -- 无结果 // $ grep -r "update user" docs/ -- 可能找到,但漏掉中文内容 // RAG 方案的搜索流程: // 1. 将文档分块并转换为向量嵌入 // 2. 将用户查询转换为向量嵌入 // 3. 计算相似度,检索最相关的文档块 // 4. 将检索到的内容喂给 LLM 生成回答

小团队落地 RAG 的优势

  1. 成本低:可以使用开源嵌入模型(如 all-MiniLM-L6-v2),无需付费 API
  2. 部署简单:可以使用 Docker Compose 一键部署
  3. 数据安全:文档留在本地,不发送到第三方服务
  4. 定制化强:针对团队特定的技术栈优化

二、RAG 系统的技术架构与核心组件

RAG 系统包含以下核心组件:

  1. 文档加载器:从多种来源(Markdown、代码注释、Wiki)加载文档
  2. 文本分块器:将长文档拆分为适当大小的块(Chunk)
  3. 嵌入模型:将文本块转换为向量表示
  4. 向量数据库:存储向量和原始文本,支持相似度搜索
  5. 检索器:根据用户查询检索相关文档块
  6. 生成器:使用 LLM 基于检索内容生成回答
graph TD A[文档源] --> B[文档加载器] B --> C[文本分块] C --> D[嵌入模型] D --> E[向量数据库] F[用户查询] --> G[查询嵌入] G --> H[向量相似度搜索] E --> H H --> I[检索相关文档块] I --> J[提示词构建] J --> K[LLM 生成回答] K --> L[返回回答给用户] style A fill:#e1f5fe style E fill:#fff3e0 style H fill:#f3e5f5 style K fill:#e8f5e9

技术选型

  • 文档加载:LangChain Document Loaders 或自研解析器
  • 文本分块:递归字符分块、语义分块
  • 嵌入模型:Sentence Transformers(本地)、OpenAI Embeddings(云端)
  • 向量数据库:Chroma(轻量级)、Qdrant(高性能)、PostgreSQL + pgvector(现有基础设施复用)
  • LLM:Ollama(本地)、OpenAI API(云端)

场景描述:在一开始搭建 RAG 系统时,我们直接用 Chroma 的addDocuments方法索引了团队所有 Markdown 文档。看起来一切正常,直到有人问"如何部署到生产环境"——系统返回了包含secretKey: "sk-xxx"的配置文档片段。排查发现,我们的文档目录中有一份带着真实 API Key 的内部操作手册被一起索引了!没有任何敏感信息过滤机制。

# 索引前必须做的安全清洗 import re def sanitize_before_index(text: str) -> str: # 替换常见的敏感模式 text = re.sub(r'(sk-\w{20,})', '[API_KEY_REDACTED]', text) text = re.sub(r'(password\s*[:=]\s*["\']?)\S+', r'\1[REDACTED]', text) text = re.sub(r'(secret\s*[:=]\s*["\']?)\S+', r'\1[REDACTED]', text) return text

这个教训让我们建立了文档索引的安全审查清单:索引前扫描敏感信息、支持文档级别的"不索引"标记(如 frontmatter 中的rag_index: false)、提供索引回滚能力。

// 使用 LangChain 构建基础 RAG 管道 import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'; import { HNSWLib } from 'langchain/vectorstores/hnswlib'; import { OpenAIEmbeddings } from 'langchain/embeddings/openai'; import { ChatOpenAI } from 'langchain/chat_models/openai'; import { RetrievalQAChain } from 'langchain/chains'; import { MarkdownTextSplitter } from 'langchain/text_splitter'; export class DocSearchService { private vectorStore: HNSWLib; private qaChain: RetrievalQAChain; private embeddings: OpenAIEmbeddings; private llm: ChatOpenAI; constructor(openaiApiKey: string) { this.embeddings = new OpenAIEmbeddings({ openaiApiKey, modelName: 'text-embedding-3-small' // 成本较低的嵌入模型 }); this.llm = new ChatOpenAI({ openaiApiKey, modelName: 'gpt-3.5-turbo', temperature: 0.1 // 低温度确保回答准确性 }); } /** * 初始化向量数据库(从文档目录) */ async initialize(documentsDir: string): Promise<void> { // 1. 加载 Markdown 文档 const docs = await this.loadMarkdownDocs(documentsDir); // 2. 分块 const splitter = new MarkdownTextSplitter({ chunkSize: 500, // 每个块 500 字符 chunkOverlap: 50 // 重叠 50 字符(保持上下文连贯) }); const splitDocs = await splitter.splitDocuments(docs); console.log(`Loaded ${docs.length} documents, split into ${splitDocs.length} chunks`); // 3. 创建向量存储 this.vectorStore = await HNSWLib.fromDocuments( splitDocs, this.embeddings ); // 4. 创建检索器 const retriever = this.vectorStore.asRetriever({ k: 3 // 检索最相关的 3 个文档块 }); // 5. 创建 QA 链 this.qaChain = RetrievalQAChain.fromLLM( this.llm, retriever, { returnSourceDocuments: true // 返回源文档(便于验证) } ); console.log('RAG system initialized successfully'); } /** * 搜索文档(问答模式) */ async search(query: string): Promise<{ answer: string; sourceDocuments: Array<{ pageContent: string; metadata: any }>; }> { if (!this.qaChain) { throw new Error('RAG system not initialized. Call initialize() first.'); } try { const result = await this.qaChain.call({ query }); return { answer: result.text, sourceDocuments: result.sourceDocuments }; } catch (error) { console.error('Search failed:', error); throw new Error(`Failed to search documents: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * 仅检索相关文档(不使用 LLM 生成) */ async retrieveOnly(query: string, k: number = 3): Promise<Array<{ content: string; metadata: any; score: number; }>> { if (!this.vectorStore) { throw new Error('Vector store not initialized'); } const results = await this.vectorStore.similaritySearchWithScore(query, k); return results.map(([doc, score]) => ({ content: doc.pageContent, metadata: doc.metadata, score })); } /** * 加载 Markdown 文档(简化实现) */ private async loadMarkdownDocs(dirPath: string): Promise<any[]> { const fs = require('fs').promises; const path = require('path'); const files = await this.getMarkdownFiles(dirPath); const docs: any[] = []; for (const file of files) { try { const content = await fs.readFile(file, 'utf-8'); docs.push({ pageContent: content, metadata: { source: file, fileName: path.basename(file), lastModified: (await fs.stat(file)).mtime } }); } catch (error) { console.error(`Failed to load document ${file}:`, error); } } return docs; } private async getMarkdownFiles(dirPath: string): Promise<string[]> { const fs = require('fs').promises; const path = require('path'); let results: string[] = []; const items = await fs.readdir(dirPath, { withFileTypes: true }); for (const item of items) { const itemPath = path.join(dirPath, item.name); if (item.isDirectory()) { const subFiles = await this.getMarkdownFiles(itemPath); results = results.concat(subFiles); } else if (item.isFile() && item.name.endsWith('.md')) { results.push(itemPath); } } return results; } }

三、实战:本地化 RAG 文档搜索系统

以下实现一个完整的本地化 RAG 系统,支持文档索引、语义搜索、问答功能,并且可以部署在小团队的服务器上。

1. 使用 Chroma 向量数据库的完整实现

// services/rag/documentIndexer.ts import { Chroma } from 'langchain/vectorstores/chroma'; import { SentenceTransformerEmbeddings } from 'langchain/embeddings/sentence_transformer'; import { TextLoader } from 'langchain/document_loaders/fs/text'; import { MarkdownLoader } from 'langchain/document_loaders/fs/markdown'; import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter'; import * as fs from 'fs/promises'; import * as path from 'path'; export interface DocumentMetadata { source: string; fileName: string; fileType: string; lastModified: Date; tags?: string[]; } export class DocumentIndexer { private embeddings: SentenceTransformerEmbeddings; private vectorStore: Chroma | null = null; private collectionName: string; constructor( embeddingsModel: string = 'Xenova/all-MiniLM-L6-v2', collectionName: string = 'team-docs' ) { // 使用本地 Sentence Transformers 模型(无需 API Key) this.embeddings = new SentenceTransformerEmbeddings({ modelName: embeddingsModel }); this.collectionName = collectionName; } /** * 初始化 Chroma 向量数据库 */ async initVectorStore(): Promise<void> { try { this.vectorStore = await Chroma.fromExistingCollection( this.embeddings, { collectionName: this.collectionName } ); console.log(`Connected to existing collection: ${this.collectionName}`); } catch (error) { // 集合不存在,创建新集合 console.log(`Creating new collection: ${this.collectionName}`); this.vectorStore = await Chroma.fromTexts( [], // 初始无文档 [], // 初始无元数据 this.embeddings, { collectionName: this.collectionName } ); } } /** * 索引文档目录 */ async indexDocuments(docsDir: string, options?: { filePatterns?: string[]; ignorePatterns?: string[]; }): Promise<{ indexed: number; failed: number }> { if (!this.vectorStore) { await this.initVectorStore(); } const filePatterns = options?.filePatterns || ['.md', '.txt', '.js', '.ts', '.tsx']; const ignorePatterns = options?.ignorePatterns || ['node_modules', 'dist', '.git']; const files = await this.scanDirectory(docsDir, filePatterns, ignorePatterns); let indexed = 0; let failed = 0; for (const file of files) { try { await this.indexFile(file); indexed++; console.log(`Indexed: ${file}`); } catch (error) { console.error(`Failed to index ${file}:`, error); failed++; } } return { indexed, failed }; } /** * 索引单个文件 */ async indexFile(filePath: string): Promise<void> { if (!this.vectorStore) { throw new Error('Vector store not initialized'); } // 根据文件类型选择加载器 let loader; if (filePath.endsWith('.md')) { loader = new MarkdownLoader(filePath); } else if (filePath.match(/\.(js|ts|tsx|jsx)$/)) { // 使用自定义代码文档加载器 loader = new CodeDocumentLoader(filePath); } else { loader = new TextLoader(filePath); } const docs = await loader.load(); // 分块 const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 800, chunkOverlap: 100, separators: ['\n## ', '\n### ', '\n```', '\n', ' ', ''] }); const splitDocs = await splitter.splitDocuments(docs); // 添加元数据 const docsWithMetadata = splitDocs.map(doc => ({ ...doc, metadata: { ...doc.metadata, indexedAt: new Date().toISOString(), source: filePath, fileName: path.basename(filePath), fileType: path.extname(filePath) } })); // 添加到向量数据库 await this.vectorStore.addDocuments(docsWithMetadata); console.log(`Indexed ${docsWithMetadata.length} chunks from ${filePath}`); } /** * 扫描目录,获取需要索引的文件 */ private async scanDirectory( dirPath: string, filePatterns: string[], ignorePatterns: string[] ): Promise<string[]> { let results: string[] = []; const items = await fs.readdir(dirPath, { withFileTypes: true }); for (const item of items) { const itemPath = path.join(dirPath, item.name); // 检查是否应该忽略 if (ignorePatterns.some(pattern => itemPath.includes(pattern))) { continue; } if (item.isDirectory()) { const subFiles = await this.scanDirectory(itemPath, filePatterns, ignorePatterns); results = results.concat(subFiles); } else if (item.isFile()) { const ext = path.extname(item.name); if (filePatterns.some(pattern => pattern === ext || item.name.endsWith(pattern))) { results.push(itemPath); } } } return results; } } /** * 自定义代码文档加载器(提取代码注释和文档字符串) */ class CodeDocumentLoader { private filePath: string; constructor(filePath: string) { this.filePath = filePath; } async load(): Promise<any[]> { const content = await fs.readFile(this.filePath, 'utf-8'); const ext = path.extname(this.filePath); let docs: any[] = []; if (['.js', '.ts', '.tsx', '.jsx'].includes(ext)) { // 提取 JSDoc 注释和函数签名 docs = this.extractCodeComments(content); } else { // 作为纯文本处理 docs = [{ pageContent: content, metadata: { source: this.filePath } }]; } return docs; } private extractCodeComments(code: string): any[] { const docs: any[] = []; // 匹配 JSDoc 注释(/* ... /) const jsdocRegex = /\/\*\*([\s\S]*?)\*\//g; let match; while ((match = jsdocRegex.exec(code)) !== null) { const comment = match[1] .split('\n') .map(line => line.replace(/^\s*\*/, '').trim()) .filter(line => line.length > 0) .join('\n'); if (comment.length > 0) { docs.push({ pageContent: comment, metadata: { source: this.filePath, type: 'jsdoc' } }); } } // 匹配单行注释(// ...)-- 仅当后面跟着函数声明时 const singleLineCommentRegex = /\/\/\s*(.+)/g; while ((match = singleLineCommentRegex.exec(code)) !== null) { const nextLine = code.split('\n')[match.index]; if (nextLine && nextLine.match(/(function|const|class|interface|type)\s+/)) { docs.push({ pageContent: match[1].trim(), metadata: { source: this.filePath, type: 'single-line-comment' } }); } } return docs; } }

2. 问答服务(使用本地 LLM)

场景描述:使用本地 LLM(如 Ollama + llama2)做 RAG 问答时,最大的问题不是速度,而是输出格式。LLM 有时会忽略 Prompt 中的指令,生成 markdown 表格、多轮对话、甚至追加一些"这是我找到的相关信息…"的元描述文字。如果问答 API 的调用方是一个 Slack Bot,这些格式化的输出会让 Bot 的体验很糟糕。

# 踩坑:LLM 输出追加了不需要的元信息 "answer": "你需要修改 settings.py 中的 DATABASE_URL... \n\n*来源: docs/deployment.md*" # 解决办法:在 Prompt 中明确禁止追加信息 prompt_template = """ 仅回答用户的提问,不要追加任何引用来源、元描述或额外评论。 如果文档中没有相关信息,直接回答"未找到相关文档"。 """

另一个需要注意的细节是 Ollama 模型的首次响应延迟。每个新的查询都可能触发模型加载到内存,7B 模型首次推理可能需要 10-15 秒。通过在服务启动时 warm-up(发送一条空查询),可以显著减少首次用户请求的等待时间。

// services/rag/qaService.ts import { Chroma } from 'langchain/vectorstores/chroma'; import { Ollama } from 'langchain/llms/ollama'; import { PromptTemplate } from 'langchain/prompts'; import { RunnableSequence } from 'langchain/schema/runnable'; export class DocQAService { private vectorStore: Chroma; private llm: Ollama; private qaChain: RunnableSequence; constructor(vectorStore: Chroma, ollamaModel: string = 'llama2') { this.vectorStore = vectorStore; // 使用 Ollama 运行本地 LLM(无需 API Key) this.llm = new Ollama({ model: ollamaModel, temperature: 0.1 }); this.initQASystem(); } /** * 初始化问答系统 */ private async initQASystem(): Promise<void> { // 定义提示词模板 const promptTemplate = PromptTemplate.fromTemplate(` You are a technical documentation assistant for a development team. Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know. Don't try to make up an answer. Context: {context} Question: {question} Helpful Answer (in the same language as the question): `); // 创建检索器 const retriever = this.vectorStore.asRetriever({ k: 4, // 检索 4 个相关文档块 searchType: 'similarity' // 相似度搜索 }); // 构建 RAG 链 this.qaChain = RunnableSequence.from([ { context: async (input: { question: string }) => { const docs = await retriever.getRelevantDocuments(input.question); return docs.map(doc => doc.pageContent).join('\n\n'); }, question: (input: { question: string }) => input.question }, promptTemplate, this.llm ]); } /** * 问答接口 */ async ask(question: string): Promise<{ answer: string; sources: Array<{ content: string; source: string }>; }> { try { // 检索相关文档 const relevantDocs = await this.vectorStore.similaritySearch(question, 4); // 生成回答 const answer = await this.qaChain.invoke({ question }); return { answer: typeof answer === 'string' ? answer : answer.content, sources: relevantDocs.map(doc => ({ content: doc.pageContent.substring(0, 200) + '...', // 截断显示 source: doc.metadata.source || 'Unknown' })) }; } catch (error) { console.error('QA failed:', error); throw new Error(`Failed to answer question: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * 搜索相关文档(不使用 LLM) */ async searchDocuments(query: string, k: number = 5): Promise<Array<{ content: string; source: string; score: number; }>> { try { const results = await this.vectorStore.similaritySearchWithScore(query, k); return results.map(([doc, score]) => ({ content: doc.pageContent, source: doc.metadata.source || 'Unknown', score })); } catch (error) { console.error('Document search failed:', error); throw error; } } }

3. REST API 服务(供团队使用)

// api/ragServer.ts import express from 'express'; import { DocumentIndexer } from '../services/rag/documentIndexer'; import { DocQAService } from '../services/rag/qaService'; import { Chroma } from 'langchain/vectorstores/chroma'; export function createRAGServer() { const app = express(); app.use(express.json()); let qaService: DocQAService | null = null; // 初始化端点 app.post('/api/rag/init', async (req, res) => { try { const { docsDir, collectionName } = req.body; // 索引文档 const indexer = new DocumentIndexer('Xenova/all-MiniLM-L6-v2', collectionName); const result = await indexer.indexDocuments(docsDir); // 初始化 QA 服务 const vectorStore = await Chroma.fromExistingCollection( indexer['embeddings'], { collectionName } ); qaService = new DocQAService(vectorStore, 'llama2'); res.json({ success: true, message: 'RAG system initialized', result }); } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }); } }); // 问答端点 app.post('/api/rag/ask', async (req, res) => { if (!qaService) { return res.status(400).json({ success: false, error: 'RAG system not initialized. Call /api/rag/init first.' }); } try { const { question } = req.body; if (!question || typeof question !== 'string') { return res.status(400).json({ success: false, error: 'Invalid question parameter' }); } const result = await qaService.ask(question); res.json({ success: true, question, answer: result.answer, sources: result.sources }); } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }); } }); // 文档搜索端点(不使用 LLM) app.post('/api/rag/search', async (req, res) => { if (!qaService) { return res.status(400).json({ success: false, error: 'RAG system not initialized' }); } try { const { query, k = 5 } = req.body; const results = await qaService.searchDocuments(query, k); res.json({ success: true, query, results }); } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }); } }); // 索引状态端点 app.get('/api/rag/status', async (req, res) => { try { // 这里可以添加获取索引统计信息的逻辑 res.json({ success: true, initialized: qaService !== null }); } catch (error) { res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }); } }); const PORT = process.env.PORT || 3003; app.listen(PORT, () => { console.log(`RAG server running on port ${PORT}`); }); return app; } // 启动服务器 if (require.main === module) { createRAGServer(); }

四、RAG 系统的优化与工程化

1. 文档分块策略优化:不同的分块策略对检索效果影响显著。

// 多种分块策略对比 export class ChunkingStrategy { /** * 策略 1:固定大小分块(简单但可能切断语义) */ static fixedSizeChunking(text: string, chunkSize: number = 500, overlap: number = 50): string[] { const chunks: string[] = []; for (let i = 0; i < text.length; i += chunkSize - overlap) { chunks.push(text.substring(i, i + chunkSize)); } return chunks; } /** * 策略 2:语义分块(按段落或章节分块) */ static semanticChunking(markdown: string): string[] { const chunks: string[] = []; // 按 Markdown 标题分块 const sections = markdown.split(/\n(?=#{1,3}\s)/); let currentChunk = ''; for (const section of sections) { if (currentChunk.length + section.length > 800) { // 当前块已满,保存并开始新块 chunks.push(currentChunk); currentChunk = section; } else { currentChunk += section; } } if (currentChunk) { chunks.push(currentChunk); } return chunks; } /** * 策略 3:递归分块(LangChain 默认策略,推荐) */ static async recursiveChunking(text: string): Promise<any[]> { const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 800, chunkOverlap: 100, separators: [ '\n## ', // Markdown 二级标题 '\n### ', // Markdown 三级标题 '\n```', // 代码块 '\n\n', // 段落 '\n', // 换行 '。', // 中文句号 '. ', // 英文句号 ' ', // 空格 '' // 字符级 ] }); return splitter.splitText(text); } }

2. 混合检索(向量搜索 + 关键词搜索):提升检索准确率。

场景描述:纯向量搜索在面对"API 接口版本变更"这类查询时,有时会返回不相关但语义相近的文档——比如把"版本变更"的查询匹配到了"代码审查流程变更"的文档,因为它们都包含"变更"这个词的语义向量。这就是"向量搜索会匹配语义但不够精确"的典型问题。

混合检索的 RRF(Reciprocal Rank Fusion)方案可以在一定程度上缓解这个问题:关键词搜索对"API"和"版本"做精确匹配,向量搜索提供语义补充。但 RRF 的权重调优是个经验活——如果keywordWeight=0.7,又回到了"关键词匹配即搜索结果"的原始问题。通常在文档量不大(<1000 篇)时使用keywordWeight=0.3, vectorWeight=0.7,当文档量增长到数千篇后调整为keywordWeight=0.5, vectorWeight=0.5——因为语义搜索在大规模文档中更容易产生不相关匹配。

// 混合检索实现 export class HybridRetriever { private vectorStore: Chroma; private keywordWeight: number = 0.3; private vectorWeight: number = 0.7; constructor(vectorStore: Chroma, options?: { keywordWeight?: number; vectorWeight?: number }) { this.vectorStore = vectorStore; if (options) { this.keywordWeight = options.keywordWeight ?? 0.3; this.vectorWeight = options.vectorWeight ?? 0.7; } } /** * 混合检索(向量相似度 + BM25 关键词匹配) */ async retrieve(query: string, k: number = 5): Promise<Array<{ document: any; score: number; vectorScore?: number; keywordScore?: number; }>> { // 1. 向量检索 const vectorResults = await this.vectorStore.similaritySearchWithScore(query, k * 2); // 2. 关键词检索(简化版 BM25) const keywordResults = await this.keywordSearch(query, k * 2); // 3. 合并结果(RRF: Reciprocal Rank Fusion) const merged = this.reciprocalRankFusion(vectorResults, keywordResults, k); return merged; } /** * 简化的关键词搜索(实际应使用 Elasticsearch 或 BM25 库) */ private async keywordSearch(query: string, k: number): Promise<Array<{ document: any; score: number }>> { // 这里简化为:从向量数据库获取所有文档,然后进行关键词匹配 // 实际生产中应使用专门的关键词搜索引擎 const allDocs = await this.vectorStore.similaritySearch(query, 100); // 获取较多文档 const queryTerms = query.toLowerCase().split(/\s+/); const scored = allDocs.map(doc => { const content = doc.pageContent.toLowerCase(); let score = 0; for (const term of queryTerms) { const matches = (content.match(new RegExp(term, 'g')) || []).length; score += matches; } return { document: doc, score: score / queryTerms.length }; }); return scored .sort((a, b) => b.score - a.score) .slice(0, k); } /** * 倒数排名融合(RRF) */ private reciprocalRankFusion( vectorResults: Array<[any, number]>, keywordResults: Array<{ document: any; score: number }>, k: number ): Array<{ document: any; score: number; vectorScore?: number; keywordScore?: number }> { const scores: Map<string, { document: any; score: number; vectorScore?: number; keywordScore?: number }> = new Map(); // 处理向量检索结果 vectorResults.forEach(([doc, score], rank) => { const key = doc.pageContent; // 使用内容作为键(简化处理) const rrfScore = 1 / (rank + 60); // k=60 是标准 RRF 常数 scores.set(key, { document: doc, score: rrfScore * this.vectorWeight, vectorScore: score }); }); // 处理关键词检索结果 keywordResults.forEach((result, rank) => { const key = result.document.pageContent; const rrfScore = 1 / (rank + 60); const existing = scores.get(key); if (existing) { existing.score += rrfScore * this.keywordWeight; existing.keywordScore = result.score; } else { scores.set(key, { document: result.document, score: rrfScore * this.keywordWeight, keywordScore: result.score }); } }); return Array.from(scores.values()) .sort((a, b) => b.score - a.score) .slice(0, k); } }

3. 自动化评估与持续优化:建立评估流程,量化 RAG 系统性能。

// RAG 系统评估 export class RAGEvaluator { /** * 评估检索准确率(需要标注数据集) */ async evaluateRetrieval( testQueries: Array<{ query: string; relevantDocs: string[] }> ): Promise<{ precision: number; recall: number; mrr: number }> { let totalPrecision = 0; let totalRecall = 0; let totalMRR = 0; // Mean Reciprocal Rank for (const test of testQueries) { const retrieved = await this.retrieveDocuments(test.query, 5); const retrievedIds = retrieved.map(doc => doc.metadata.source); // 计算 Precision@K const relevantRetrieved = retrievedIds.filter(id => test.relevantDocs.includes(id)); const precision = relevantRetrieved.length / retrievedIds.length; // 计算 Recall@K const recall = relevantRetrieved.length / test.relevantDocs.length; // 计算 MRR let mrr = 0; for (let i = 0; i < retrievedIds.length; i++) { if (test.relevantDocs.includes(retrievedIds[i])) { mrr = 1 / (i + 1); break; } } totalPrecision += precision; totalRecall += recall; totalMRR += mrr; } return { precision: totalPrecision / testQueries.length, recall: totalRecall / testQueries.length, mrr: totalMRR / testQueries.length }; } private async retrieveDocuments(query: string, k: number): Promise<any[]> { // 调用实际的检索函数 return []; } }

五、总结

RAG 技术在小团队文档搜索场景中具有显著优势。通过本地化部署,可以在保护数据安全的同时,提供语义搜索和智能问答能力。

关键实践包括:选择合适的嵌入模型和向量数据库、优化文档分块策略、实现混合检索、建立评估体系。对于资源有限的团队,可以从开源方案起步,逐步优化效果。

RAG 系统的落地是一个持续迭代的过程。建议从小规模试点开始,收集团队反馈,逐步扩展文档覆盖范围和优化检索质量。

);