rag production architecture

RAG 生产架构设计:从 POC 到百万级查询

从 POC 到生产的鸿沟 POC 阶段一个 LangChain + Chroma 就能跑通,但百万级查询的生产系统需要解决:检索精度、延迟、扩展性、成本、更新策略。 生产架构总览 ┌──────────────────────────────────────────────────────────────┐ │ 用户查询 │ └──────────────────────────┬───────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ 1. Query 处理层 │ │ - 查询改写 / HyDE / 多查询扩展 │ │ - 意图识别 / 路由 │ └──────────────────────────┬───────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ 2. 检索层 │ │ ┌────────────────┐ ┌────────────────┐ │ │ │ 向量检索 (ANN) │ │ 关键词检索 (BM25)│ │ │ │ top_k=20 │ │ top_k=20 │ │ │ └───────┬────────┘ └───────┬────────┘ │ │ └────────┬──────────┘ │ │ ▼ │ │ ┌────────────────┐ │ │ │ 融合 (RRF) │ │ │ │ top_k=50 │ │ │ └───────┬────────┘ │ └──────────────────┼─────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ 3. 重排层 (Reranker) │ │ Cross-Encoder 重排 → top_k=5 │ └──────────────────────────┬───────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ 4. 生成层 │ │ Context + Query → LLM → Response + Citations │ └──────────────────────────┬───────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────┐ │ 5. 缓存层 │ │ 语义缓存 → 响应缓存 │ └──────────────────────────────────────────────────────────────┘ 向量数据库选型 数据库 索引算法 百万级 QPS 扩展性 生态 许可证 Milvus HNSW/IVF/DiskANN 高 分布式原生 丰富 Apache 2.0 Pinecone 专有 高 全托管 SaaS only 商业 Weaviate HNSW 中高 分布式 良好 BSD-3 Qdrant HNSW 高 Rust 单机/分布式 新锐 Apache 2.0 pgvector IVFFlat/HNSW 中 PG 生态 最广 PostgreSQL Redis HNSW/FLAT 高 Redis 生态 良好 Redis SSPL 生产推荐: ...

2026-06-25 · 4 min · 773 words · 硅基 AGI 探索者
rag data pipeline

RAG 数据管道构建:从原始数据到高质量知识库

RAG 数据管道全景 RAG 系统的效果,70% 取决于数据质量,20% 取决于检索策略,10% 取决于模型能力。大多数人把精力花在那 10% 上,而忽略了 70% 的基础。 原始数据 → 接入 → 清洗 → 标准化 → 分块 → 元数据 → 嵌入 → 向量库 → 检索 → 生成 ──────────── ETL ──────────── ──── 索引 ──── 本文聚焦管道前半段:从原始数据到高质量向量索引。 数据源接入 多源数据统一接入 from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Iterator import hashlib @dataclass class Document: id: str content: str source: str source_type: str # pdf, html, markdown, database, api metadata: dict content_hash: str updated_at: str class DataSource(ABC): @abstractmethod def fetch(self) -> Iterator[Document]: pass class WebPageSource(DataSource): def __init__(self, urls: list, cache_dir: str): self.urls = urls self.cache = cache_dir def fetch(self) -> Iterator[Document]: for url in self.urls: html = self._fetch_with_cache(url) text = self._html_to_markdown(html) yield Document( id=self._url_hash(url), content=text, source=url, source_type="web", metadata={"title": self._extract_title(html)}, content_hash=hashlib.md5(text.encode()).hexdigest(), updated_at=datetime.now().isoformat() ) class PDFSource(DataSource): def fetch(self) -> Iterator[Document]: for pdf_path in self.pdf_files: pages = self._extract_pdf_pages(pdf_path) for i, page_text in enumerate(pages): yield Document( id=f"{pdf_path}_p{i+1}", content=page_text, source=pdf_path, source_type="pdf", metadata={"page": i+1, "total_pages": len(pages)}, content_hash=hashlib.md5(page_text.encode()).hexdigest(), updated_at=datetime.now().isoformat() ) class DatabaseSource(DataSource): def __init__(self, conn_str: str, query: str): self.conn_str = conn_str self.query = query def fetch(self) -> Iterator[Document]: import psycopg2 with psycopg2.connect(self.conn_str) as conn: with conn.cursor() as cur: cur.execute(self.query) for row in cur: yield Document( id=f"db_{row[0]}", content=row[1], source=f"database:{row[0]}", source_type="database", metadata={"table": row[2]} if len(row) > 2 else {}, content_hash=hashlib.md5(row[1].encode()).hexdigest(), updated_at=datetime.now().isoformat() ) 清洗与标准化 class DocumentCleaner: """文档清洗流水线""" def clean(self, doc: Document) -> Document: steps = [ self._remove_html_tags, self._normalize_whitespace, self._remove_boilerplate, self._fix_encoding, self._normalize_punctuation, ] for step in steps: doc.content = step(doc.content) return doc def _remove_html_tags(self, text: str) -> str: import re # 保留代码块中的内容 code_blocks = re.findall(r'```.*?```', text, re.DOTALL) text = re.sub(r'<[^>]+>', '', text) # 恢复代码块 for i, block in enumerate(code_blocks): text = text.replace(f'CODE_BLOCK_{i}', block, 1) return text def _normalize_whitespace(self, text: str) -> str: import re # 合并连续空行 text = re.sub(r'\n{3,}', '\n\n', text) # 去除行尾空格 text = '\n'.join(line.rstrip() for line in text.split('\n')) # 合并连续空格 text = re.sub(r' {2,}', ' ', text) return text.strip() def _remove_boilerplate(self, text: str) -> str: """移除样板文字""" boilerplate_patterns = [ r'Cookie.*?声明', r'版权所有.*?保留', r'本文档仅供参考', r'Last updated:.*?$', ] for pattern in boilerplate_patterns: text = re.sub(pattern, '', text, flags=re.IGNORECASE) return text def _fix_encoding(self, text: str) -> str: # 修复常见编码问题 replacements = { '\ufeff': '', # BOM '\u200b': '', # 零宽空格 '\u200c': '', # 零宽非连接符 '\u00a0': ' ', # 不间断空格 ''': "'", ''': "'", '"': '"', '"': '"', '—': '-', '–': '-', } for old, new in replacements.items(): text = text.replace(old, new) return text 分块策略:最关键的决策 三种分块策略对比 策略 原理 优点 缺点 适用场景 固定长度 按token数切割 简单可控 可能切断语义 FAQ、短文本 语义分块 按句子/段落边界切 保持语义完整 块大小不均 文章、文档 递归分块 先按段落,太长再按句子 平衡大小和语义 实现复杂 混合内容 递归分块实现 from typing import List import tiktoken class RecursiveChunker: def __init__( self, chunk_size: int = 512, chunk_overlap: int = 50, model: str = "gpt-4o" ): self.chunk_size = chunk_size self.chunk_overlap = chunk_overlap self.encoder = tiktoken.encoding_for_model(model) # 分隔符按优先级排序 self.separators = [ "\n## ", # Markdown 二级标题 "\n### ", # Markdown 三级标题 "\n\n", # 段落 "\n", # 行 "。", # 中文句号 ". ", # 英文句号 " ", # 空格 "", # 字符级(最后手段) ] def chunk(self, text: str) -> List[str]: return self._recursive_split(text, self.separators) def _recursive_split(self, text: str, separators: list) -> List[str]: if self._count_tokens(text) <= self.chunk_size: return [text] if text.strip() else [] for i, sep in enumerate(separators): if sep == "": return self._force_split(text) if sep not in text: continue parts = text.split(sep) chunks = [] current = "" for part in parts: candidate = current + sep + part if current else part if self._count_tokens(candidate) <= self.chunk_size: current = candidate else: if current: chunks.append(current) # 如果单个 part 就超长,用更细的分隔符递归 if self._count_tokens(part) > self.chunk_size: sub_chunks = self._recursive_split(part, separators[i+1:]) chunks.extend(sub_chunks) current = "" else: current = part if current: chunks.append(current) # 添加重叠 return self._add_overlap(chunks) return self._force_split(text) def _add_overlap(self, chunks: List[str]) -> List[str]: if len(chunks) <= 1 or self.chunk_overlap <= 0: return chunks result = [chunks[0]] for i in range(1, len(chunks)): # 从前一个 chunk 末尾取 overlap 个 token prev_tokens = self.encoder.encode(chunks[i-1]) overlap_text = self.encoder.decode( prev_tokens[-self.chunk_overlap:] ) result.append(overlap_text + chunks[i]) return result def _count_tokens(self, text: str) -> int: return len(self.encoder.encode(text)) def _force_split(self, text: str) -> List[str]: """按 token 强制切割""" tokens = self.encoder.encode(text) chunks = [] for i in range(0, len(tokens), self.chunk_size): chunk_tokens = tokens[i:i + self.chunk_size] chunks.append(self.encoder.decode(chunk_tokens)) return chunks 语义分块(基于嵌入) class SemanticChunker: """基于句子嵌入相似度的语义分块""" def __init__(self, model, threshold: float = 0.5): self.model = model # SentenceTransformer self.threshold = threshold def chunk(self, text: str) -> List[str]: # 1. 按句子分割 sentences = self._split_sentences(text) if len(sentences) <= 1: return [text] # 2. 计算相邻句子嵌入的余弦相似度 embeddings = self.model.encode(sentences) similarities = [ cosine_similarity([embeddings[i]], [embeddings[i+1]])[0][0] for i in range(len(embeddings) - 1) ] # 3. 在相似度低谷处分割 chunks = [] current = [sentences[0]] for i, sim in enumerate(similarities): if sim < self.threshold: chunks.append(" ".join(current)) current = [sentences[i + 1]] else: current.append(sentences[i + 1]) if current: chunks.append(" ".join(current)) return chunks 元数据提取 class MetadataExtractor: """从文档中提取结构化元数据""" def extract(self, doc: Document) -> dict: return { **doc.metadata, "language": self._detect_language(doc.content), "doc_type": self._classify_doc_type(doc.content), "key_phrases": self._extract_key_phrases(doc.content), "has_code": "```" in doc.content or "def " in doc.content, "has_table": "|" in doc.content and "---" in doc.content, "reading_time_min": max(1, len(doc.content) // 500), "summary": self._generate_summary(doc.content), } def _detect_language(self, text: str) -> str: # 简单启发式 chinese_ratio = sum(1 for c in text if '\u4e00' <= c <= '\u9fff') / max(len(text), 1) if chinese_ratio > 0.3: return "zh" return "en" def _classify_doc_type(self, text: str) -> str: if "```" in text: return "technical" if any(kw in text for kw in ["第", "章", "节"]): return "document" if "?" in text and len(text) < 500: return "faq" return "article" def _extract_key_phrases(self, text: str) -> list: # 使用 TextRank 或 YAKE 提取关键词 from keybert import KeyBERT kw_model = KeyBERT() keywords = kw_model.extract_keywords(text, top_n=5) return [kw[0] for kw in keywords] 嵌入生成与批量入库 import asyncio from typing import List, Tuple class EmbeddingPipeline: def __init__(self, model_name: str = "text-embedding-3-large"): self.model_name = model_name self.client = AsyncOpenAI() self.batch_size = 100 # OpenAI 批量限制 async def embed_chunks(self, chunks: List[str]) -> List[List[float]]: # 分批处理 all_embeddings = [] for i in range(0, len(chunks), self.batch_size): batch = chunks[i:i + self.batch_size] response = await self.client.embeddings.create( model=self.model_name, input=batch ) all_embeddings.extend([d.embedding for d in response.data]) return all_embeddings async def embed_with_retry(self, text: str, max_retries: int = 3): for attempt in range(max_retries): try: response = await self.client.embeddings.create( model=self.model_name, input=text ) return response.data[0].embedding except Exception as e: if attempt == max_retries - 1: raise await asyncio.sleep(2 ** attempt) class VectorIndexer: def __init__(self, pinecone_client): self.pinecone = pinecone_client self.index_name = "rag-knowledge" async def upsert(self, chunks: List[dict]): """ chunks: [{id, content, embedding, metadata}] """ index = self.pinecone.Index(self.index_name) # 批量 upsert batch_size = 100 for i in range(0, len(chunks), batch_size): batch = chunks[i:i + batch_size] vectors = [ { "id": c["id"], "values": c["embedding"], "metadata": { "content": c["content"][:1000], # Pinecone metadata 大小限制 **c["metadata"] } } for c in batch ] await index.upsert(vectors=vectors) 增量更新 class IncrementalUpdater: """只更新变化的部分,避免全量重建""" def __init__(self, vector_indexer, storage): self.indexer = vector_indexer self.storage = storage # 记录文档状态的数据库 async def update(self, new_docs: List[Document]): added, updated, deleted = 0, 0, 0 for doc in new_docs: existing = await self.storage.get(doc.id) if existing is None: # 新文档 await self._index_doc(doc) await self.storage.save(doc) added += 1 elif existing.content_hash != doc.content_hash: # 内容变更: 删旧建新 await self._delete_doc_chunks(doc.id) await self._index_doc(doc) await self.storage.save(doc) updated += 1 # content_hash 相同则跳过 # 检查删除: 源中不再存在的文档 all_ids = {d.id for d in new_docs} stored_ids = await self.storage.get_all_ids() deleted_ids = stored_ids - all_ids for doc_id in deleted_ids: await self._delete_doc_chunks(doc_id) await self.storage.delete(doc_id) deleted += 1 return {"added": added, "updated": updated, "deleted": deleted} 质量监控 class PipelineMonitor: """数据管道质量监控""" async def check_quality(self, docs: List[Document]) -> dict: checks = { "empty_docs": 0, "too_short": 0, # < 50 字符 "too_long": 0, # > 100k 字符 "encoding_issues": 0, "duplicate_content": 0, "low_density": 0, # 信息密度低 } contents = [d.content for d in docs] seen_hashes = set() for doc in docs: if not doc.content.strip(): checks["empty_docs"] += 1 elif len(doc.content) < 50: checks["too_short"] += 1 elif len(doc.content) > 100000: checks["too_long"] += 1 if doc.content_hash in seen_hashes: checks["duplicate_content"] += 1 seen_hashes.add(doc.content_hash) # 信息密度: 非空非标点字符比例 alpha_ratio = sum(c.isalnum() for c in doc.content) / max(len(doc.content), 1) if alpha_ratio < 0.3: checks["low_density"] += 1 # 检索质量测试 retrieval_score = await self._test_retrieval(docs) return { "total_docs": len(docs), "quality_issues": checks, "retrieval_test_score": retrieval_score, "timestamp": datetime.now().isoformat() } async def _test_retrieval(self, docs) -> float: """用标准查询测试检索质量""" test_queries = load_test_queries() # 预定义的测试查询 correct = 0 for q in test_queries: results = await self.search(q["query"], top_k=5) if q["expected_doc_id"] in [r["id"] for r in results]: correct += 1 return correct / len(test_queries) 分块参数调优建议 文档类型 chunk_size overlap 分隔符优先级 FAQ 256 0 问题边界 技术文档 512 50 标题→段落→句子 代码 1024 100 函数/类边界 法律文本 768 80 条款→段落 对话记录 384 30 话轮边界 总结 RAG 数据管道的核心决策在分块策略:太大切不到语义,太小丢失上下文,重叠太少检索不连续,太大浪费 token。递归分块是最通用的方案,语义分块效果最好但成本高。增量更新避免全量重建,质量监控防止垃圾数据入库。记住:检索质量的天花板在数据管道阶段就定了。 加入讨论 这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。 ...

2026-06-25 · 7 min · 1371 words · 硅基 AGI 探索者
rag vs long context

RAG vs 长上下文:该用哪个?

两种范式 随着模型上下文窗口从 4K 扩展到 1M+ token,出现了一个根本性问题: RAG 方案:检索相关片段,只放入少量上下文 长上下文方案:把所有文档直接塞进上下文窗口 两种方案各有优劣,选择哪种取决于具体场景。 长上下文模型现状 模型 上下文长度 约等于 定价倍数(vs 4K) GPT-4o 128K ~300页文档 2x Claude 3.5 Sonnet 200K ~500页文档 2x Gemini 1.5 Pro 1M ~2500页文档 1.5x Gemini 1.5 Flash 1M ~2500页文档 0.5x GPT-4 Turbo 128K ~300页文档 2x RAG 优势 1. 成本优势 场景: 10万页文档库,每次查询需要其中 5 页 RAG: 检索 5 页 (~2K token) → 输入 2K token → 成本: $0.006 长上下文: 全部塞入 (~250K token) → 输入 250K token → 成本: $0.75 RAG 成本仅为长上下文的 1/125。 ...

2026-06-24 · 3 min · 461 words · 硅基 AGI 探索者
ai customer service build

AI 客服系统构建指南:从知识库到多轮对话

需求分析:客服系统的核心指标 构建 AI 客服系统前,先明确要解决什么问题。客服系统的价值体现在三个维度: 维度 指标 目标值 衡量方式 效率 自动解决率 ≥60% 无需人工介入的会话占比 效率 平均响应时间 <2s 用户发送到首字的时间 质量 回答准确率 ≥90% 人工抽检评分 质量 用户满意度 ≥4.0/5 会话后评分 成本 单会话成本 <¥0.5 API 调用 + 基础设施 / 会话数 体验 人工转接等待 <30s 转人工后接通时间 客服场景分类 不同场景需要不同的处理策略: from enum import Enum from dataclasses import dataclass class QueryType(Enum): """客服查询类型""" FAQ = "faq" # 常见问题(退货政策、运费说明) TROUBLESHOOT = "trouble" # 故障排查(设备不工作、登录失败) TRANSACTION = "transaction" # 交易查询(订单状态、退款进度) COMPLAINT = "complaint" # 投诉建议 CHITCHAT = "chitchat" # 闲聊(超出业务范围) @dataclass class QueryStrategy: """不同查询类型的处理策略""" query_type: QueryType needs_rag: bool needs_tools: bool max_turns: int can_self_serve: bool escalation_threshold: float # 置信度低于此值则转人工 STRATEGIES = { QueryType.FAQ: QueryStrategy( query_type=QueryType.FAQ, needs_rag=True, needs_tools=False, max_turns=3, can_self_serve=True, escalation_threshold=0.6 ), QueryType.TROUBLESHOOT: QueryStrategy( query_type=QueryType.TROUBLESHOOT, needs_rag=True, needs_tools=True, max_turns=8, can_self_serve=True, escalation_threshold=0.5 ), QueryType.TRANSACTION: QueryStrategy( query_type=QueryType.TRANSACTION, needs_rag=False, needs_tools=True, max_turns=5, can_self_serve=True, escalation_threshold=0.7 ), QueryType.COMPLAINT: QueryStrategy( query_type=QueryType.COMPLAINT, needs_rag=False, needs_tools=False, max_turns=2, can_self_serve=False, escalation_threshold=0.8 ), QueryType.CHITCHAT: QueryStrategy( query_type=QueryType.CHITCHAT, needs_rag=False, needs_tools=False, max_turns=2, can_self_serve=True, escalation_threshold=0.9 ), } 知识库构建 知识库是 AI 客服的核心。垃圾进、垃圾出——知识库的质量直接决定回答质量。 ...

2026-06-24 · 10 min · 1998 words · 硅基 AGI 探索者
llama index review

LlamaIndex 框架评测:RAG 领域的瑞士军刀

LlamaIndex 的核心命题 LlamaIndex 的定位是 “LLM 的数据框架”——解决 LLM 与私有数据之间的鸿沟。如果说 LangChain 是 LLM 应用的"胶水层",那 LlamaIndex 就是 “数据管道+检索引擎”。 核心理念:不是所有数据都要塞进 Prompt,关键在于如何高效检索最相关的片段。 核心架构 三层抽象 数据层 (Data Connectors + Documents + Nodes) ↓ 索引层 (Index: Vector / List / Tree / Keyword / Knowledge Graph) ↓ 查询层 (Query Engine / Retriever / Response Synthesizer) 数据连接器(LlamaHub) LlamaHub 提供了 200+ 数据连接器: from llama_index.core import download_loader # 加载 Notion 数据 NotionPageReader = download_loader("NotionPageReader") loader = NotionPageReader(integration_token="your-token") documents = loader.load_data(page_ids=["page-id-1", "page-id-2"]) # 加载 GitHub 仓库 GithubRepositoryReader = download_loader("GithubRepositoryReader") loader = GithubRepositoryReader(owner="owner", repo="repo") documents = loader.load_data(branch="main") Document 与 Node from llama_index.core import Document, VectorStoreIndex from llama_index.core.node_parser import SentenceSplitter # Document 是原始数据单元 doc = Document(text="...", metadata={"source": "hr-handbook.pdf", "page": 42}) # Node 是切分后的最小检索单元 splitter = SentenceSplitter(chunk_size=512, chunk_overlap=64) nodes = splitter.get_nodes_from_documents([doc]) # Node 携带元数据关系 print(nodes[0].relationships) # {Source: doc, Next: node[1], Previous: None} 索引类型详解 LlamaIndex 提供了最丰富的索引类型: ...

2026-06-24 · 3 min · 619 words · 硅基 AGI 探索者
rag architecture patterns

RAG 架构设计模式:从朴素 RAG 到模块化 RAG 的演进

RAG 的三年代演进 RAG(Retrieval-Augmented Generation)从 2023 年的朴素方案到 2026 年的模块化架构,经历了三个阶段。每个阶段解决上一阶段的核心瓶颈。 第一阶段:朴素 RAG(Naive RAG) 用户问题 → 嵌入 → 向量搜索 → Top-K 文档 → LLM 生成答案 class NaiveRAG: def __init__(self): self.embedder = OpenAIEmbeddings() self.vectorstore = Chroma() self.llm = ChatOpenAI(model="gpt-4o") def query(self, question): # 1. 嵌入问题 q_emb = self.embedder.embed(question) # 2. 向量搜索 docs = self.vectorstore.similarity_search(q_emb, k=4) # 3. 拼接 Prompt context = "\n".join(d.page_content for d in docs) prompt = f"基于以下信息回答问题:\n{context}\n\n问题:{question}" # 4. LLM 生成 return self.llm.invoke(prompt) 朴素 RAG 的问题 问题 原因 影响 检索不相关 语义相似 ≠ 相关 答案跑偏 丢失上下文 Chunk 切割破坏段落 信息残缺 无法回答多跳问题 单次检索不够 复杂问题失败 幻觉仍存在 LLM 无视检索结果 答案不可信 第二阶段:进阶 RAG(Advanced RAG) 三个环节优化:检索前 + 检索中 + 检索后。 ...

2026-06-24 · 4 min · 663 words · 硅基 AGI 探索者
鲁ICP备2026018361号