Files
2026-07-06 19:26:00 +08:00

720 lines
24 KiB
Python

"""知识库管理模块 - 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 - 查看缓存")