"""知识库管理模块 - RAG 检索增强生成 架构:文档 → 精细分块(代码块独立/标题层级切分) → 中文embedding(BGE) + numpy余弦相似度 → 关键词+向量混合检索 → LLM生成回答 """ import hashlib import json import os import re from pathlib import Path from urllib.parse import quote # ============ 配置 ============ KNOWLEDGE_DIR = Path(__file__).parent INDEX_DIR = KNOWLEDGE_DIR / "index" CACHE_FILE = KNOWLEDGE_DIR / "qa_cache.json" CACHE_VERSION = "v3-detail-grounded" DOCS_SOURCE = Path("/vol3/1000/Code/ngfchl.github.io/docs") OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434") OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3:8b") SITE_URL = "https://ptools.fun" # 切分参数 CHUNK_SIZE = 900 CHUNK_OVERLAP = 120 TOP_K = 10 # 中文 embedding 模型 EMBED_MODEL = "BAAI/bge-small-zh-v1.5" # 需要跳过的文件 SKIP_FILES = {"agreement.md", "index.md", "password.md", "termnal.md", "ssh.md"} SKIP_SECTION_KEYWORDS = ["油猴", "tampermonkey", "greasemonkey", "userscript"] # ============ Embedding ============ _model = None def get_model(): global _model if _model is None: from sentence_transformers import SentenceTransformer cache_dir = str(KNOWLEDGE_DIR / "models") os.environ["CUDA_VISIBLE_DEVICES"] = "" print(f"📦 加载中文 embedding 模型: {EMBED_MODEL}") _model = SentenceTransformer(EMBED_MODEL, cache_folder=cache_dir) print("✅ 模型加载完成") return _model def embed_texts(texts: list[str]): model = get_model() return model.encode(texts, normalize_embeddings=True, show_progress_bar=True, batch_size=32, convert_to_numpy=True) # ============ URL 生成 ============ def doc_path_to_url(rel_path: str) -> str: html_path = rel_path.replace(".md", ".html") parts = html_path.split("/") encoded = "/".join(quote(p, safe="") for p in parts) return f"{SITE_URL}/{encoded}" # ============ 文档清洗(保留结构) ============ def clean_markdown(text: str) -> str: """清洗但保留代码块标记和链接""" text = re.sub(r'<[^>]+>', '', text) # 图片替换为 alt 文本 text = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[图片:\1]', text) # 链接保留文本+URL text = re.sub(r'\[([^\]]+)\]\(([^\)]+)\)', r'\1 (\2)', text) # frontmatter text = re.sub(r'^---\n.*?---\n', '', text, flags=re.DOTALL) text = re.sub(r'\n{3,}', '\n\n', text) return text.strip() # ============ 代码块检测 ============ def find_code_blocks(text: str) -> list[tuple[int, int]]: """找到所有代码块的起止位置""" blocks = [] lines = text.split('\n') in_block = False start = -1 for i, line in enumerate(lines): stripped = line.strip() if re.match(r'^```\w*$', stripped): if not in_block: in_block = True start = i else: blocks.append((start, i)) in_block = False return blocks # ============ 精细分块 ============ def split_document(text: str, rel_path: str, doc_title: str, doc_url: str) -> list[dict]: """ 精细分块策略: 1. 代码块作为独立 chunk(不拆分) 2. 每个标题层级下的内容独立成 chunk 3. 大段落按 CHUNK_SIZE 拆分 4. 每个 chunk 带上标题路径上下文 """ chunks = [] lines = text.split('\n') # 先找代码块位置 code_blocks = find_code_blocks(text) code_block_lines = set() for start, end in code_blocks: for i in range(start, end + 1): code_block_lines.add(i) # 按标题切分 sections = [] # [(level, heading, line_start, line_end)] current_heading_stack = [] # [(level, heading)] current_start = 0 for i, line in enumerate(lines): if i in code_block_lines: continue m = re.match(r'^(#{1,6})\s+(.+)', line.strip()) if m: level = len(m.group(1)) heading = m.group(2).strip() # 保存之前的 section if i > current_start: sections.append({ "level": current_heading_stack[-1][0] if current_heading_stack else 0, "heading": current_heading_stack[-1][1] if current_heading_stack else "", "path": [h for _, h in current_heading_stack], "line_start": current_start, "line_end": i - 1, }) # 更新 heading stack while current_heading_stack and current_heading_stack[-1][0] >= level: current_heading_stack.pop() current_heading_stack.append((level, heading)) current_start = i # 最后一个 section sections.append({ "level": current_heading_stack[-1][0] if current_heading_stack else 0, "heading": current_heading_stack[-1][1] if current_heading_stack else "", "path": [h for _, h in current_heading_stack], "line_start": current_start, "line_end": len(lines) - 1, }) chunk_id = 0 for sec in sections: sec_lines = lines[sec["line_start"]:sec["line_end"] + 1] sec_text = '\n'.join(sec_lines).strip() if len(sec_text) < 15: continue # 检查是否包含油猴关键词 if any(kw in sec_text.lower() for kw in SKIP_SECTION_KEYWORDS): continue # 构建标题路径 title_path = " > ".join(sec["path"]) if sec["path"] else doc_title if title_path and title_path != doc_title: title_path = f"{doc_title} > {title_path}" else: title_path = doc_title # 代码块独立成 chunk code_text_parts = [] non_code_parts = [] in_code = False current_block = [] for line in sec_lines: stripped = line.strip() if re.match(r'^```\w*$', stripped): if not in_code: in_code = True current_block = [line] else: current_block.append(line) code_text = '\n'.join(current_block) if len(code_text.strip()) > 20: code_text_parts.append(code_text) current_block = [] in_code = False continue if in_code: current_block.append(line) continue non_code_parts.append(line) # 代码块 chunk for code_text in code_text_parts: chunks.append({ "id": f"{rel_path}#{chunk_id}", "text": f"[代码块] {title_path}\n{code_text}", "title": title_path, "doc_title": doc_title, "source": rel_path, "url": doc_url, "heading": sec["heading"], }) chunk_id += 1 # 非代码部分 chunk non_code_text = '\n'.join(non_code_parts).strip() if not non_code_text or len(non_code_text) < 15: continue if len(non_code_text) <= CHUNK_SIZE: chunks.append({ "id": f"{rel_path}#{chunk_id}", "text": f"{title_path}\n{non_code_text}", "title": title_path, "doc_title": doc_title, "source": rel_path, "url": doc_url, "heading": sec["heading"], }) chunk_id += 1 else: # 按段落拆分大文本 paragraphs = non_code_text.split('\n\n') current = "" for para in paragraphs: para = para.strip() if not para: continue if len(current) + len(para) + 2 <= CHUNK_SIZE: current += ("\n\n" if current else "") + para else: if current: chunks.append({ "id": f"{rel_path}#{chunk_id}", "text": f"{title_path}\n{current}", "title": title_path, "doc_title": doc_title, "source": rel_path, "url": doc_url, "heading": sec["heading"], }) chunk_id += 1 if len(para) > CHUNK_SIZE: # 按行拆 sub_lines = para.split('\n') current = "" for sl in sub_lines: if len(current) + len(sl) + 1 <= CHUNK_SIZE: current += ("\n" if current else "") + sl else: if current: chunks.append({ "id": f"{rel_path}#{chunk_id}", "text": f"{title_path}\n{current}", "title": title_path, "doc_title": doc_title, "source": rel_path, "url": doc_url, "heading": sec["heading"], }) chunk_id += 1 current = sl else: current = para if current.strip(): chunks.append({ "id": f"{rel_path}#{chunk_id}", "text": f"{title_path}\n{current}", "title": title_path, "doc_title": doc_title, "source": rel_path, "url": doc_url, "heading": sec["heading"], }) chunk_id += 1 return chunks # ============ 文档加载 ============ def load_all_docs() -> list[dict]: docs = [] target_dirs = ("收割机", "通用教程") for md_file in sorted(DOCS_SOURCE.rglob("*.md")): rel_path = str(md_file.relative_to(DOCS_SOURCE)) parts = md_file.relative_to(DOCS_SOURCE).parts if parts[0] not in target_dirs: continue if md_file.name in SKIP_FILES: continue try: content = md_file.read_text(encoding="utf-8") doc_title = "" title_match = re.search(r'^title:\s*(.+)$', content, re.MULTILINE) if title_match: doc_title = title_match.group(1).strip().strip('"').strip("'") doc_title = re.sub(r'^\d+\.\s*', '', doc_title) body = clean_markdown(content) if len(body) < 30: continue if not doc_title: doc_title = md_file.stem doc_url = doc_path_to_url(rel_path) chunks = split_document(body, rel_path, doc_title, doc_url) docs.extend(chunks) code_count = sum(1 for c in chunks if "[代码块]" in c["text"]) print(f" 📄 {doc_title}: {len(chunks)} chunks ({code_count} 代码块)") except Exception as e: print(f"⚠️ 跳过 {md_file}: {e}") return docs # ============ 索引管理 ============ def rebuild_index(): INDEX_DIR.mkdir(parents=True, exist_ok=True) docs = load_all_docs() if not docs: print("❌ 没有找到文档") return print(f"\n📚 加载了 {len(docs)} 个文档块") texts = [d["text"] for d in docs] print("🔄 正在生成中文向量...") embeddings = embed_texts(texts) import numpy as np np.save(INDEX_DIR / "embeddings.npy", embeddings) (INDEX_DIR / "metadata.json").write_text( json.dumps(docs, ensure_ascii=False, indent=2), encoding="utf-8" ) save_qa_cache({}) print(f"\n🎉 知识库索引完成,共 {len(docs)} 个文档块") def load_metadata() -> list[dict]: """只加载轻量 metadata,运行时 lite 检索使用。""" meta_path = INDEX_DIR / "metadata.json" if not meta_path.exists(): return [] return json.loads(meta_path.read_text(encoding="utf-8")) def load_index(): """加载向量索引。仅 vector 模式/离线构建环境需要 numpy。""" import numpy as np emb_path = INDEX_DIR / "embeddings.npy" meta_path = INDEX_DIR / "metadata.json" if not emb_path.exists() or not meta_path.exists(): return np.array([]), [] embeddings = np.load(emb_path) metadata = json.loads(meta_path.read_text(encoding="utf-8")) return embeddings, metadata # ============ 问答缓存 ============ def _cache_key(question: str) -> str: normalized = re.sub(r'\s+', '', question.strip().lower()) return hashlib.md5(f"{CACHE_VERSION}:{normalized}".encode()).hexdigest() def load_qa_cache() -> dict: if CACHE_FILE.exists(): try: content = CACHE_FILE.read_text(encoding="utf-8") if content.strip(): return json.loads(content) except Exception: return {} return {} def save_qa_cache(cache: dict): CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8") def get_cached_answer(question: str) -> str | None: cache = load_qa_cache() entry = cache.get(_cache_key(question)) if entry: return entry.get("answer") return None def set_cached_answer(question: str, answer: str): cache = load_qa_cache() cache[_cache_key(question)] = { "question": question, "answer": answer, } if len(cache) > 500: oldest = min(cache.keys(), key=lambda k: cache[k].get("question", "")) del cache[oldest] save_qa_cache(cache) # ============ 关键词→文档映射 ============ KEYWORD_DOC_MAP = { "安装": ["收割机/install.md"], "部署": ["收割机/install.md", "收割机/使用指南.md"], "docker": ["收割机/install.md", "收割机/使用指南.md"], "compose": ["收割机/install.md", "收割机/compose.md"], "群晖": ["收割机/install.md"], "飞牛": ["收割机/install.md"], "cookie": ["收割机/import.md", "通用教程/CookieCloud.md"], "导入": ["收割机/import.md"], "站点导入": ["收割机/import.md"], "签到": ["收割机/common.md"], "通知": ["收割机/common.md", "收割机/home.md"], "下载器": ["收割机/common.md", "收割机/home.md"], "app": ["收割机/app.md"], "辅种": ["收割机/common.md"], "主题": ["收割机/custom-theme.md"], "自定义": ["收割机/custom-add.md", "收割机/custom-theme.md"], "常见问题": ["收割机/common.md"], "微信": ["收割机/wechat.md"], "baidu": ["通用教程/baidu-ocr.md"], "ocr": ["通用教程/baidu-ocr.md"], "frp": ["通用教程/tunnel.md"], "内网穿透": ["通用教程/tunnel.md"], "tailscale": ["通用教程/tailscale-nat.md"], "telegram": ["通用教程/telegram-bot.md"], "阿里云": ["通用教程/aliyun.md"], "emby": ["收割机/common.md"], "做种": ["收割机/common.md"], "日志": ["收割机/common.md"], "代理": ["收割机/common.md"], "ua": ["收割机/common.md"], "登录": ["收割机/使用指南.md"], "添加站点": ["收割机/import.md", "收割机/custom-add.md"], "计划任务": ["收割机/home.md", "收割机/使用指南.md"], "辅种助手": ["收割机/import.md"], "种子迁移": ["收割机/使用指南.md"], "数据库": ["收割机/install.md"], "postgresql": ["收割机/install.md"], "环境变量": ["收割机/install.md"], "webhook": ["收割机/common.md"], "授权": ["收割机/common.md", "收割机/install.md"], "密码": ["收割机/使用指南.md"], "token": ["收割机/使用指南.md"], } def keyword_boost(query: str) -> list[str]: query_lower = query.lower() matched = [] for keyword, docs in KEYWORD_DOC_MAP.items(): if keyword in query_lower: matched.extend(docs) return list(dict.fromkeys(matched)) # ============ 混合检索 ============ def tokenize_query(query: str) -> list[str]: """轻量中文/英文分词:无需 jieba,运行镜像零重依赖。""" q = query.lower().strip() tokens: list[str] = [] # 英文、数字、版本号等 tokens.extend(re.findall(r"[a-z0-9][a-z0-9_.:-]{1,}", q)) # 连续中文词 + 2~4 字滑窗,兼顾“怎么安装/反代/环境变量”等短问法 for block in re.findall(r"[\u4e00-\u9fff]+", q): if len(block) <= 4: tokens.append(block) else: tokens.append(block) for n in (2, 3, 4): tokens.extend(block[i:i + n] for i in range(0, len(block) - n + 1)) # 去重并过滤太泛的单字 return list(dict.fromkeys(t for t in tokens if len(t) >= 2)) def lite_search(query: str, top_k: int = TOP_K) -> list[dict]: """轻量关键词/BM25-like 检索。 运行时不加载 sentence-transformers/torch,大幅降低 Docker 镜像体积。 对当前 200~500 个文档块规模,关键词+领域词表加权的性价比最高。 """ metadata = load_metadata() if not metadata: return [] query_lower = query.lower().strip() tokens = tokenize_query(query) boosted_sources = set(keyword_boost(query)) scored: list[tuple[float, int]] = [] for i, doc in enumerate(metadata): title = (doc.get("title") or "").lower() source = (doc.get("source") or "").lower() text = (doc.get("text") or "").lower() hay = f"{title}\n{source}\n{text}" score = 0.0 # 领域词表强加权 if doc.get("source") in boosted_sources: score += 20.0 # 完整问题/短语命中 if query_lower and query_lower in hay: score += 6.0 if query_lower and query_lower in title: score += 5.0 # token 频次 + 字段权重 for token in tokens: if token in title: score += 4.0 if token in source: score += 3.0 count = text.count(token) if count: # 类 BM25:频次递减,避免长文刷分 score += min(3.0, 1.0 + count ** 0.5) # 配置/安装类问题:代码块和关键配置变量优先,避免模型拿不到原文示例后自行编造 config_query = any(k in query_lower for k in ["compose", "docker", "postgres", "postgresql", "安装", "部署", "配置", "环境变量"]) if config_query and "[代码块]" in text: score += 10.0 config_hits = sum(1 for k in [ "image:", "services:", "environment:", "volumes:", "ports:", "postgres_db", "postgres_user", "postgres_password", "email", "token", "go_web_port", "database", "db_", ] if k in text) score += min(20.0, config_hits * 3.0) # 查询中英文/数字 token 对安装、docker、postgresql 等问题很关键 exact_tokens = re.findall(r"[a-z0-9][a-z0-9_.:-]{1,}", query_lower) for token in exact_tokens: if token in hay: score += 2.5 if score > 0: scored.append((score, i)) scored.sort(reverse=True, key=lambda x: x[0]) docs = [] for score, idx in scored: doc = metadata[idx].copy() doc["score"] = float(score) src_count = sum(1 for d in docs if d["source"] == doc["source"]) if src_count < 2: docs.append(doc) if len(docs) >= top_k: break return docs def vector_search(query: str, top_k: int = TOP_K) -> list[dict]: """向量检索:仅开发/宿主机模式使用,需要 sentence-transformers。""" embeddings, metadata = load_index() if len(embeddings) == 0: return [] query_vec = get_model().encode([query], normalize_embeddings=True) scores = (embeddings @ query_vec.T).flatten() boosted_sources = keyword_boost(query) if boosted_sources: for i, doc in enumerate(metadata): if doc["source"] in boosted_sources: scores[i] += 0.3 query_words = set(re.findall(r'[\u4e00-\u9fff]+', query)) for i, doc in enumerate(metadata): text_lower = doc["text"].lower() matches = sum(1 for w in query_words if w in text_lower) if matches >= 2: scores[i] += 0.15 * matches top_indices = np.argsort(scores)[::-1][:top_k] docs = [] for idx in top_indices: doc = metadata[idx].copy() doc["score"] = float(scores[idx]) src_count = sum(1 for d in docs if d["source"] == doc["source"]) if src_count < 2: docs.append(doc) return docs def expand_context(docs: list[dict], window: int = 1, max_chars: int = 12000, max_sources: int = 5) -> list[dict]: """为命中的 chunk 补充同源相邻上下文,减少回答过短/断章取义。""" if not docs: return [] metadata = load_metadata() by_id = {d.get("id"): d for d in metadata} expanded: list[dict] = [] seen: set[str] = set() total_chars = 0 def parse_id(doc_id: str) -> tuple[str, int] | None: if not doc_id or "#" not in doc_id: return None src, idx = doc_id.rsplit("#", 1) if not idx.isdigit(): return None return src, int(idx) allowed_sources = list(dict.fromkeys(d.get("source") for d in docs if d.get("source")))[:max_sources] allowed_sources_set = set(allowed_sources) for doc in docs: if doc.get("source") not in allowed_sources_set: continue parsed = parse_id(doc.get("id", "")) candidates = [doc] if parsed: src, idx = parsed neighbors = [] for offset in range(-window, window + 1): item = by_id.get(f"{src}#{idx + offset}") if item: neighbors.append(item) candidates = neighbors or [doc] for item in candidates: doc_id = item.get("id", "") if doc_id in seen: continue text_len = len(item.get("text", "")) if total_chars + text_len > max_chars and expanded: continue copy = item.copy() copy.setdefault("score", doc.get("score", 0)) expanded.append(copy) seen.add(doc_id) total_chars += text_len return expanded def search(query: str, top_k: int = TOP_K, expand: bool = True, context_window: int = 1) -> list[dict]: mode = os.environ.get("KNOWLEDGE_SEARCH_MODE", "lite").lower() if mode in {"vector", "embedding", "semantic"}: docs = vector_search(query, top_k=top_k) else: docs = lite_search(query, top_k=top_k) return expand_context(docs, window=context_window) if expand else docs def get_stats() -> dict: metadata = load_metadata() cache = load_qa_cache() return { "total_chunks": len(metadata), "cached_answers": len(cache), "site_url": SITE_URL, "search_mode": os.environ.get("KNOWLEDGE_SEARCH_MODE", "lite"), "embedding_model": EMBED_MODEL, "cache_version": CACHE_VERSION, } # ============ CLI ============ if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] == "rebuild": rebuild_index() elif len(sys.argv) > 1 and sys.argv[1] == "search": query = " ".join(sys.argv[2:]) results = search(query) for r in results: print(f"\n📄 {r['title']} ({r['source']}) [分数:{r.get('score', 0):.3f}]") print(f" URL: {r['url']}") print(f" {r['text'][:200]}...") elif len(sys.argv) > 1 and sys.argv[1] == "stats": print(json.dumps(get_stats(), ensure_ascii=False, indent=2)) elif len(sys.argv) > 1 and sys.argv[1] == "cache": cache = load_qa_cache() for k, v in cache.items(): print(f"\n🔑 {k[:8]}... Q: {v['question'][:50]}") else: print("用法:") print(" python -m knowledge rebuild - 重建索引") print(" python -m knowledge search <查询> - 搜索") print(" python -m knowledge stats - 统计信息") print(" python -m knowledge cache - 查看缓存")