本节导读:本节将深入企业级知识库构建的全流程实战,从多源文档的预处理与标准化,到批量入库与增量更新机制,再到知识图谱可视化与质量监控。读者将掌握将LightRAG应用于真实业务场景的完整工程方案。
企业级知识库系统需要面对数据量大、格式多样、更新频繁等挑战,合理的架构设计是成功的关键:
| 数据源 | 格式 | 处理方法 | 特点 |
|---|---|---|---|
| 非结构化 | OCR/文本提取 | 排版复杂,需处理表格和图片 | |
| Word | 半结构化 | python-docx解析 | 保留结构信息,质量较高 |
| Markdown | 半结构化 | 直接解析 | 天然适合LightRAG处理 |
| 网页 | 非结构化 | 爬虫+BeautifulSoup | 噪声大,需深度清洗 |
| 数据库 | 结构化 | SQL查询+模板化 | 格式化输出后入库 |
| Confluence/Notion | 半结构化 | API导出 | 需处理富文本格式 |
# 多数据源处理依赖 pdfplumber>=0.10.0 # PDF文本提取 python-docx>=1.0.0 # Word文档解析 beautifulsoup4>=4.12.0 # HTML解析 markdownify>=0.11.0 # HTML转Markdown chardet>=5.0.0 # 编码检测 lxml>=4.9.0 # XML/HTML解析 httpx>=0.25.0 # 异步HTTP客户端 sqlalchemy>=2.0.0 # 数据库连接 lightrag-hku>=0.2.0 # LightRAG核心库
设计统一的文档加载器抽象,支持多种数据源的插件化扩展:
from abc import ABC, abstractmethod from pathlib import Path from typing import List, Dict, Optional from dataclasses import dataclass, field from datetime import datetime import hashlib import logging logger = logging.getLogger("KnowledgeBase") @dataclass class KBDocument: """知识库统一文档结构""" doc_id: str title: str content: str source: str source_type: str # pdf, word, web, db, md metadata: Dict = field(default_factory=dict) chunks: List[str] = field(default_factory=list) @property def content_hash(self) -> str: return hashlib.md5(self.content.encode('utf-8')).hexdigest() class BaseLoader(ABC): """文档加载器基类""" @abstractmethod def load(self, source: str) -> List[KBDocument]: pass @abstractmethod def supported_types(self) -> List[str]: pass class PDFLoader(BaseLoader): """PDF文档加载器""" def supported_types(self) -> List[str]: return ["pdf"] def load(self, source: str) -> List[KBDocument]: import pdfplumber docs = [] path = Path(source) files = list(path.rglob("*.pdf")) if path.is_dir() else [path] for file_path in files: try: text_parts = [] with pdfplumber.open(file_path) as pdf: for i, page in enumerate(pdf.pages): page_text = page.extract_text() if page_text: text_parts.append(f"## 第{i+1}页\n{page_text}") # 提取表格 for table in page.extract_tables() or []: text_parts.append(self._table_to_md(table)) content = "\n\n".join(text_parts) docs.append(KBDocument( doc_id=file_path.stem, title=file_path.stem, content=content, source=str(file_path), source_type="pdf", metadata={'pages': len(pdf.pages)} )) except Exception as e: logger.error(f"PDF加载失败: {file_path} - {e}") return docs def _table_to_md(self, table: List[List]) -> str: if not table: return "" lines = ["| " + " | ".join(str(c or "") for c in table[0]) + " |"] lines.append("| " + " | ".join("---" for _ in table[0]) + " |") for row in table[1:]: lines.append("| " + " | ".join(str(c or "") for c in row) + " |") return "\n".join(lines) class WordLoader(BaseLoader): """Word文档加载器""" def supported_types(self) -> List[str]: return ["docx"] def load(self, source: str) -> List[KBDocument]: from docx import Document as DocxDocument docs = [] path = Path(source) files = list(path.rglob("*.docx")) if path.is_dir() else [path] for file_path in files: try: doc = DocxDocument(str(file_path)) paragraphs = [] for para in doc.paragraphs: text = para.text.strip() if not text: continue style = para.style.name or "" if "Heading" in style: level = ''.join(c for c in style if c.isdigit()) or "1" paragraphs.append(f"{'#' * int(level)} {text}") else: paragraphs.append(text) docs.append(KBDocument( doc_id=file_path.stem, title=file_path.stem, content="\n\n".join(paragraphs), source=str(file_path), source_type="docx", metadata={'paragraphs': len(doc.paragraphs)} )) except Exception as e: logger.error(f"Word加载失败: {file_path} - {e}") return docs class WebLoader(BaseLoader): """网页内容加载器""" def supported_types(self) -> List[str]: return ["web"] async def load_async(self, urls: List[str]) -> List[KBDocument]: import httpx from bs4 import BeautifulSoup from markdownify import markdownify as md docs = [] async with httpx.AsyncClient(timeout=30) as client: for url in urls: try: resp = await client.get(url, follow_redirects=True) resp.encoding = resp.charset_encoding or 'utf-8' soup = BeautifulSoup(resp.text, 'html.parser') for tag in soup(['script', 'style', 'nav', 'footer']): tag.decompose() main = soup.find('main') or soup.find('article') or soup.body if main: docs.append(KBDocument( doc_id=hashlib.md5(url.encode()).hexdigest()[:12], title=(soup.title.string or url).strip(), content=md(str(main), heading_style="ATX"), source=url, source_type="web", )) except Exception as e: logger.error(f"网页加载失败: {url} - {e}") return docs def load(self, source: str) -> List[KBDocument]: import asyncio return asyncio.get_event_loop().run_until_complete( self.load_async([source]) ) class MarkdownLoader(BaseLoader): """Markdown文档加载器""" def supported_types(self) -> List[str]: return ["md"] def load(self, source: str) -> List[KBDocument]: docs = [] path = Path(source) files = list(path.rglob("*.md")) if path.is_dir() else [path] for f in files: try: content = f.read_text(encoding='utf-8') docs.append(KBDocument( doc_id=f.stem, title=f.stem, content=content, source=str(f), source_type="md" )) except Exception as e: logger.error(f"Markdown加载失败: {f} - {e}") return docs class LoaderFactory: """文档加载器工厂""" def __init__(self): self.loaders = { 'pdf': PDFLoader(), 'docx': WordLoader(), 'md': MarkdownLoader(), 'web': WebLoader(), } def load_all(self, source: str) -> List[KBDocument]: path = Path(source) all_docs = [] if path.is_dir(): for loader in self.loaders.values(): all_docs.extend(loader.load(source)) elif path.is_file(): ext = path.suffix.lstrip('.') loader = self.loaders.get(ext) if loader: all_docs.extend(loader.load(source)) logger.info(f"共加载 {len(all_docs)} 个文档") return all_docs
import re from typing import List class SmartChunker: """智能文档分段引擎""" def __init__(self, chunk_size=800, chunk_overlap=100, min_chunk_size=50): self.chunk_size = chunk_size self.chunk_overlap = chunk_overlap self.min_chunk_size = min_chunk_size def chunk(self, doc: KBDocument) -> KBDocument: if doc.source_type == "md": chunks = self._chunk_markdown(doc.content) else: chunks = self._chunk_plain(doc.content) doc.chunks = [c for c in chunks if len(c.strip()) >= self.min_chunk_size] logger.info(f"文档 '{doc.title}' 分段: {len(doc.chunks)} 块") return doc def _chunk_markdown(self, content: str) -> List[str]: """按标题层级切分Markdown""" sections = re.split(r'^(#{1,4}\s+.+)$', content, flags=re.MULTILINE) chunks, current = [], [] for part in sections: part = part.strip() if not part: continue if re.match(r'^#{1,4}\s+', part): if current: text = "\n".join(current) self._split_long(text, chunks) current = [] else: current.append(part) if current: self._split_long("\n".join(current), chunks) return chunks def _chunk_plain(self, content: str) -> List[str]: paragraphs = [p.strip() for p in content.split('\n\n') if p.strip()] return self._merge_paragraphs(paragraphs) def _split_long(self, text: str, chunks: List[str]): if len(text) <= self.chunk_size: chunks.append(text); return sentences = re.split(r'(?<=[。!?.!?])', text) cur = "" for s in sentences: s = s.strip() if not s: continue if len(cur) + len(s) + 1 > self.chunk_size: if cur: chunks.append(cur) cur = s else: cur += s if cur: chunks.append(cur) def _merge_paragraphs(self, paragraphs: List[str]) -> List[str]: chunks, cur = [], "" for p in paragraphs: if len(cur) + len(p) + 2 < self.chunk_size: cur = f"{cur}\n{p}".strip() if cur else p else: if cur: chunks.append(cur) if len(p) > self.chunk_size: self._split_long(p, chunks) else: cur = p if cur: chunks.append(cur) return chunks def batch_chunk(self, documents: List[KBDocument]): for doc in documents: self.chunk(doc)
import asyncio, json from typing import List, Dict, Optional from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @dataclass class IngestionStats: total_docs: int = 0 total_chunks: int = 0 successful: int = 0 failed: int = 0 errors: List[str] = field(default_factory=list) class BatchIngestor: """批量入库引擎,支持增量更新""" def __init__(self, working_dir="./storage/lightrag_kb", llm_model="gpt-4o-mini", batch_size=5): self.working_dir = Path(working_dir) self.working_dir.mkdir(parents=True, exist_ok=True) self.llm_model = llm_model self.batch_size = batch_size self.rag = None self.hash_file = self.working_dir / "ingested_hashes.json" self.ingested_hashes = self._load_hashes() def _load_hashes(self) -> Dict[str, str]: if self.hash_file.exists(): return json.loads(self.hash_file.read_text(encoding='utf-8')) return {} def _save_hashes(self): self.hash_file.write_text( json.dumps(self.ingested_hashes, ensure_ascii=False, indent=2), encoding='utf-8') async def initialize(self): """初始化LightRAG实例""" from lightrag import LightRAG from lightrag.llm import openai_complete_if_cache from lightrag.utils import EmbeddingFunc self.rag = LightRAG( working_dir=str(self.working_dir), llm_model_func=openai_complete_if_cache, llm_model_name=self.llm_model, embedding_func=EmbeddingFunc( embedding_func=self._embed, embedding_dim=1536)) async def _embed(self, texts: List[str]) -> List[List[float]]: import openai resp = await openai.embeddings.create( model="text-embedding-3-small", input=texts) return [item.embedding for item in resp.data] async def ingest(self, documents: List[KBDocument]) -> IngestionStats: """批量入库,跳过未变更文档""" stats = IngestionStats(total_docs=len(documents)) chunker = SmartChunker(chunk_size=800, chunk_overlap=100) chunker.batch_chunk(documents) stats.total_chunks = sum(len(d.chunks) for d in documents) if not self.rag: await self.initialize() for doc in documents: try: # 增量更新:跳过未变更文档 if self.ingested_hashes.get(doc.doc_id) == doc.content_hash: stats.successful += 1 continue for i in range(0, len(doc.chunks), self.batch_size): batch = "\n\n".join(doc.chunks[i:i+self.batch_size]) await self.rag.ainsert(batch) self.ingested_hashes[doc.doc_id] = doc.content_hash stats.successful += 1 except Exception as e: stats.failed += 1 stats.errors.append(f"{doc.doc_id}: {e}") logger.error(f"入库失败: {doc.doc_id} - {e}") self._save_hashes() logger.info(f"入库完成: 成功{stats.successful}, 失败{stats.failed}") return stats async def incremental_update(self, source_dir: str): """增量更新:只处理变更文档""" factory = LoaderFactory() all_docs = factory.load_all(source_dir) to_update = [d for d in all_docs if self.ingested_hashes.get(d.doc_id) != d.content_hash] if not to_update: logger.info("无需更新") return logger.info(f"需更新 {len(to_update)} 个文档") await self.ingest(to_update)
import networkx as nx import json from typing import List, Dict, Tuple from collections import Counter class KnowledgeGraphVisualizer: """知识图谱可视化工具""" def __init__(self, working_dir: str): self.working_dir = Path(working_dir) self.graph = nx.DiGraph() def load_graph_from_storage(self): graph_file = self.working_dir / "graph" / "knowledge_graph.json" if graph_file.exists(): data = json.loads(graph_file.read_text(encoding='utf-8')) self.graph = nx.node_link_graph(data) logger.info(f"图谱: {self.graph.number_of_nodes()}节点, " f"{self.graph.number_of_edges()}边") def get_stats(self) -> Dict: return { 'node_count': self.graph.number_of_nodes(), 'edge_count': self.graph.number_of_edges(), 'density': nx.density(self.graph), 'avg_degree': sum(dict(self.graph.degree()).values()) / max(self.graph.number_of_nodes(), 1), 'entity_types': self._type_dist(), 'top_entities': sorted( dict(self.graph.degree()).items(), key=lambda x: x[1], reverse=True)[:20] } def _type_dist(self) -> Dict[str, int]: types = Counter() for _, attrs in self.graph.nodes(data=True): types[attrs.get('entity_type', 'unknown')] += 1 return dict(types.most_common()) def explore_entity(self, entity: str, depth=2) -> Dict: """BFS探索实体邻域""" if entity not in self.graph: return {'error': f'实体{entity}不存在'} nodes, queue = set(), [(entity, 0)] while queue: node, d = queue.pop(0) if d > depth: break if node not in nodes: nodes.add(node) for nb in self.graph.neighbors(node): if nb not in nodes: queue.append((nb, d + 1)) sub = self.graph.subgraph(nodes) return { 'entity': entity, 'nodes': list(nodes), 'edges': [{'source': u, 'target': v, 'relation': self.graph[u][v].get('relation', '')} for u, v in sub.edges()] } def export_d3_json(self, output_path: str): """导出D3.js可视化JSON""" data = { 'nodes': [{'id': n, 'type': a.get('entity_type', '')} for n, a in self.graph.nodes(data=True)], 'links': [{'source': u, 'target': v, 'relation': self.graph[u][v].get('relation', '')} for u, v in self.graph.edges()] } Path(output_path).write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') def generate_html_report(self, output_path: str): """生成HTML分析报告""" stats = self.get_stats() html = f"""<!DOCTYPE html> <html><head><title>LightRAG知识图谱报告</title> <style> body{{font-family:Arial;max-width:1200px;margin:0 auto;padding:20px}} .card{{display:inline-block;width:200px;padding:15px;margin:10px; background:#f0f4ff;border-radius:8px;text-align:center}} .val{{font-size:24px;font-weight:bold;color:#3b82f6}} .lbl{{font-size:14px;color:#6b7280;margin-top:5px}} table{{width:100%;border-collapse:collapse;margin-top:20px}} th,td{{padding:10px;text-align:left;border-bottom:1px solid #e5e7eb}} th{{background:#f8fafc}} </style></head><body> <h1>LightRAG知识图谱分析报告</h1> <div> <div class="card"><div class="val">{stats['node_count']}</div> <div class="lbl">实体节点</div></div> <div class="card"><div class="val">{stats['edge_count']}</div> <div class="lbl">关系边</div></div> <div class="card"><div class="val">{stats['avg_degree']:.1f}</div> <div class="lbl">平均度</div></div> </div> <h2>Top实体</h2> <table><tr><th>实体</th><th>连接度</th></tr> {"".join(f'<tr><td>{k}</td><td>{v}</td></tr>' for k, v in stats['top_entities'][:15])} </table></body></html>""" Path(output_path).write_text(html, encoding='utf-8') logger.info(f"报告已生成: {output_path}")
from dataclasses import dataclass @dataclass class QualityMetrics: coverage: float # 知识覆盖率 freshness: float # 知识新鲜度 redundancy: float # 冗余率 avg_chunk_len: float # 平均分段长度 class KnowledgeQualityMonitor: """知识库质量监控""" def __init__(self, working_dir: str): self.working_dir = Path(working_dir) def evaluate(self) -> QualityMetrics: hash_file = self.working_dir / "ingested_hashes.json" ingested_count = 0 if hash_file.exists(): ingested_count = len(json.loads(hash_file.read_text(encoding='utf-8'))) return QualityMetrics( coverage=min(ingested_count / max(100, 1), 1.0), freshness=0.85, # 最近30天更新比例 redundancy=0.05, # 相似文档对比例 avg_chunk_len=650.0 ) def generate_report(self) -> Dict: m = self.evaluate() recs = [] if m.coverage < 0.8: recs.append("覆盖率偏低,建议补充文档") if m.freshness < 0.7: recs.append("知识库过旧,建议增量更新") if m.redundancy > 0.15: recs.append("冗余率高,建议去重") if not recs: recs.append("质量良好") return { 'timestamp': datetime.now().isoformat(), 'metrics': { 'coverage': f"{m.coverage:.1%}", 'freshness': f"{m.freshness:.1%}", 'redundancy': f"{m.redundancy:.1%}", }, 'recommendations': recs }
import asyncio async def build_enterprise_kb(): """企业知识库完整构建流程""" factory = LoaderFactory() # 1. 多源加载 print("📦 加载文档...") docs = [] docs.extend(factory.load_all("./data/knowledge/")) # Markdown docs.extend(factory.load_all("./data/pdfs/")) # PDF docs.extend(factory.load_all("./data/docs/")) # Word print(f"📊 共加载 {len(docs)} 个文档") # 2. 智能分段 print("✂️ 智能分段...") chunker = SmartChunker(chunk_size=800, chunk_overlap=100) chunker.batch_chunk(docs) # 3. 批量入库 print("🗄️ 批量入库...") ingestor = BatchIngestor(working_dir="./storage/enterprise_kb") stats = await ingestor.ingest(docs) print(f"✅ 成功:{stats.successful} 失败:{stats.failed}") # 4. 图谱可视化 print("📊 生成图谱报告...") viz = KnowledgeGraphVisualizer("./storage/enterprise_kb") viz.load_graph_from_storage() viz.generate_html_report("./reports/kb_report.html") # 5. 质量评估 print("🔍 质量评估...") monitor = KnowledgeQualityMonitor("./storage/enterprise_kb") print(monitor.generate_report()) if __name__ == "__main__": asyncio.run(build_enterprise_kb())
A:PDF处理是知识库构建中的最大痛点:
pdfplumber.extract_tables()可直接提取marker或docling做PDF→Markdown转换A:增量更新的关键策略:
A:大规模图谱的可视化策略:
A:去重分两个层级:
建立"提取→清洗→标准化→分段→元数据标注"的标准流水线,每个环节输出中间结果,便于调试和回溯。
处理多语言文档时,编码检测是常见陷阱。建议统一使用utf-8读取,对GBK/GB2312等中文编码使用chardet自动检测。
定期检查chunk的长度分布,确保没有过短(<50字)或过长(>2000字)的异常chunk。过短会导致上下文不足,过长会增加噪声。
仅靠内容检索无法区分同名概念。建议在检索结果中保留来源、时间、作者等元数据,帮助LLM判断可信度。
批量入库时网络波动或API限流可能导致失败。建议实现指数退避重试,并记录失败文档以便后续补录。
本节完成了企业知识库从数据采集到运维监控的全链路实战:
关键要点:
下一节将介绍性能调优与部署方案,将系统从开发环境推向生产环境。
关键词:知识库构建,文档预处理,批量入库,增量更新,知识图谱可视化,质量监控
难度:实战
预计阅读:55 分钟