local_retriever.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. """
  2. Local Knowledge Base Retriever
  3. ==============================
  4. Dify 不可用时的本地 RAG 替代方案。
  5. 使用 character n-gram + TF 向量化的方式做检索,无需任何外部 ML 依赖(numpy+scipy only)。
  6. Usage:
  7. from local_retriever import LocalRetriever
  8. retriever = LocalRetriever()
  9. retriever.index_documents()
  10. results = retriever.query("主性格数字1的性格特点", top_k=5)
  11. """
  12. import os
  13. import re
  14. import json
  15. import hashlib
  16. from pathlib import Path
  17. from typing import List, Dict, Optional, Tuple
  18. import numpy as np
  19. # ─── 配置 ────────────────────────────────────────────────────────────────────
  20. KNOWLEDGE_DIR = Path(__file__).resolve().parent.parent / "research"
  21. NGRAM_N = 2 # character bigram
  22. TOP_K_DEFAULT = 6
  23. # ─── 文档分块 ────────────────────────────────────────────────────────────────
  24. def _parse_yaml_frontmatter(text: str) -> Tuple[Dict, str]:
  25. """解析 Markdown YAML frontmatter (--- 包裹的元数据)"""
  26. meta = {}
  27. body = text
  28. m = re.match(r'^---\s*\n(.*?)\n---\s*\n(.*)', text, re.DOTALL)
  29. if m:
  30. yaml_block = m.group(1)
  31. body = m.group(2)
  32. for line in yaml_block.strip().split('\n'):
  33. if ':' in line:
  34. k, v = line.split(':', 1)
  35. meta[k.strip()] = v.strip().strip('"').strip("'")
  36. return meta, body
  37. def _split_sections(text: str, source_file: str) -> List[Dict]:
  38. """将文档按 ## 标题拆分为 chunks,每个 chunk 包含上下文"""
  39. chunks = []
  40. lines = text.split('\n')
  41. current_heading = "前言"
  42. current_content = []
  43. heading_level = 0
  44. for line in lines:
  45. heading_match = re.match(r'^(#{1,4})\s+(.+)$', line)
  46. if heading_match:
  47. if current_content:
  48. chunks.append({
  49. "id": hashlib.md5(f"{source_file}:{current_heading}".encode()).hexdigest()[:12],
  50. "source": source_file,
  51. "heading": current_heading,
  52. "content": '\n'.join(current_content).strip(),
  53. "tokens": len(''.join(current_content)) // 2,
  54. })
  55. heading_level = len(heading_match.group(1))
  56. current_heading = heading_match.group(2).strip()
  57. current_content = []
  58. else:
  59. current_content.append(line)
  60. if current_content:
  61. chunks.append({
  62. "id": hashlib.md5(f"{source_file}:{current_heading}".encode()).hexdigest()[:12],
  63. "source": source_file,
  64. "heading": current_heading,
  65. "content": '\n'.join(current_content).strip(),
  66. "tokens": len(''.join(current_content)) // 2,
  67. })
  68. return chunks
  69. def load_all_documents(kb_dir: Optional[Path] = None) -> List[Dict]:
  70. """加载 knowledge/research/ 下所有 .md 文件,返回 chunks 列表"""
  71. if kb_dir is None:
  72. kb_dir = KNOWLEDGE_DIR
  73. all_chunks = []
  74. md_files = sorted(kb_dir.glob("*.md"))
  75. for fpath in md_files:
  76. try:
  77. text = fpath.read_text(encoding='utf-8')
  78. except Exception as e:
  79. print(f" [SKIP] {fpath.name}: {e}")
  80. continue
  81. meta, body = _parse_yaml_frontmatter(text)
  82. chunks = _split_sections(body, fpath.name)
  83. # 将 YAML 元信息附加到每个 chunk
  84. for ch in chunks:
  85. ch["file_path"] = str(fpath)
  86. ch["doc_title"] = meta.get("title", fpath.stem)
  87. ch["doc_tags"] = meta.get("tags", "")
  88. ch["doc_purpose"] = meta.get("purpose", "")
  89. all_chunks.extend(chunks)
  90. return all_chunks
  91. # ─── 向量化 ──────────────────────────────────────────────────────────────────
  92. def _char_ngrams(text: str, n: int = NGRAM_N) -> List[str]:
  93. """生成 character n-grams"""
  94. # 去除非中文/字母/数字的字符,保留语义
  95. cleaned = re.sub(r'[^\u4e00-\u9fff\w]', '', text)
  96. if len(cleaned) < n:
  97. return [cleaned]
  98. return [cleaned[i:i + n] for i in range(len(cleaned) - n + 1)]
  99. def _build_vocab(chunks: List[Dict], n: int = NGRAM_N) -> Dict[str, int]:
  100. """构建 n-gram 词汇表"""
  101. vocab = {}
  102. for ch in chunks:
  103. content = f"{ch.get('doc_title', '')} {ch.get('heading', '')} {ch.get('doc_tags', '')} {ch['content']}"
  104. grams = _char_ngrams(content, n)
  105. for g in grams:
  106. if g not in vocab:
  107. vocab[g] = len(vocab)
  108. return vocab
  109. def _vectorize(text: str, vocab: Dict[str, int], n: int = NGRAM_N) -> np.ndarray:
  110. """将文本转为 TF 向量"""
  111. vec = np.zeros(len(vocab), dtype=np.float32)
  112. grams = _char_ngrams(text, n)
  113. for g in grams:
  114. idx = vocab.get(g)
  115. if idx is not None:
  116. vec[idx] += 1.0
  117. # L2 归一化
  118. norm = np.linalg.norm(vec)
  119. if norm > 0:
  120. vec /= norm
  121. return vec
  122. # ─── 主检索器 ────────────────────────────────────────────────────────────────
  123. class LocalRetriever:
  124. """本地知识库检索器"""
  125. def __init__(self, kb_dir: Optional[Path] = None, ngram_n: int = NGRAM_N):
  126. self.kb_dir = kb_dir or KNOWLEDGE_DIR
  127. self.ngram_n = ngram_n
  128. self.chunks: List[Dict] = []
  129. self.vocab: Dict[str, int] = {}
  130. self.matrix: Optional[np.ndarray] = None
  131. self._indexed = False
  132. def index_documents(self) -> Dict:
  133. """索引所有文档,返回统计信息"""
  134. print(f" Loading documents from: {self.kb_dir}")
  135. self.chunks = load_all_documents(self.kb_dir)
  136. print(f" Total chunks: {len(self.chunks)}")
  137. # 构建词汇表
  138. print(f" Building {self.ngram_n}-gram vocabulary...")
  139. self.vocab = _build_vocab(self.chunks, self.ngram_n)
  140. print(f" Vocabulary size: {len(self.vocab)}")
  141. # 构建文档-词项矩阵
  142. print(" Vectorizing chunks...")
  143. rows = []
  144. for i, ch in enumerate(self.chunks):
  145. content = f"{ch['doc_title']} {ch['heading']} {ch['doc_tags']} {ch['content']}"
  146. vec = _vectorize(content, self.vocab, self.ngram_n)
  147. rows.append(vec)
  148. if (i + 1) % 50 == 0:
  149. print(f" ... {i + 1}/{len(self.chunks)}")
  150. self.matrix = np.array(rows, dtype=np.float32)
  151. print(f" Matrix shape: {self.matrix.shape}")
  152. self._indexed = True
  153. return {
  154. "documents": len(set(ch["source"] for ch in self.chunks)),
  155. "chunks": len(self.chunks),
  156. "vocab_size": len(self.vocab),
  157. "matrix_shape": list(self.matrix.shape),
  158. }
  159. def query(self, query_text: str, top_k: int = TOP_K_DEFAULT) -> List[Dict]:
  160. """检索与 query 最相关的 chunks"""
  161. if not self._indexed:
  162. raise RuntimeError("Call index_documents() first")
  163. # 向量化 query
  164. qvec = _vectorize(query_text, self.vocab, self.ngram_n)
  165. # 余弦相似度: cos = dot_product / (norm_q * norm_d)
  166. assert self.matrix is not None, "Matrix not initialized"
  167. norm_q = np.linalg.norm(qvec)
  168. norms_d = np.linalg.norm(self.matrix, axis=1)
  169. # 避免除零
  170. denom = norm_q * norms_d
  171. denom[denom == 0] = 1e-10
  172. similarities = np.dot(self.matrix, qvec) / denom
  173. # 获取 top-k 索引
  174. top_indices = np.argsort(similarities)[::-1][:top_k]
  175. results = []
  176. for idx in top_indices:
  177. sim = float(similarities[idx])
  178. if sim < 0.01: # 过滤无关结果
  179. break
  180. ch = self.chunks[idx]
  181. results.append({
  182. "id": ch["id"],
  183. "source": ch["source"],
  184. "heading": ch["heading"],
  185. "doc_title": ch["doc_title"],
  186. "content_preview": ch["content"][:200] + ("..." if len(ch["content"]) > 200 else ""),
  187. "score": round(sim, 4),
  188. "tokens": ch.get("tokens", 0),
  189. })
  190. return results
  191. def query_formatted(self, query_text: str, top_k: int = TOP_K_DEFAULT) -> str:
  192. """返回格式化的检索结果(用于测试报告)"""
  193. results = self.query(query_text, top_k)
  194. lines = [
  195. f" Query: \"{query_text}\"",
  196. f" Results: {len(results)}",
  197. ]
  198. for i, r in enumerate(results):
  199. lines.append(f" [{i + 1}] score={r['score']:.4f} | {r['source']} > {r['heading']}")
  200. lines.append(f" {r['content_preview'][:120]}")
  201. return '\n'.join(lines)
  202. def get_chunks_by_source(self, source: str) -> List[Dict]:
  203. """按源文件获取 chunks"""
  204. return [ch for ch in self.chunks if ch["source"] == source]
  205. def list_sources(self) -> List[str]:
  206. """列出所有源文件"""
  207. return sorted(set(ch["source"] for ch in self.chunks))
  208. # ─── CLI ─────────────────────────────────────────────────────────────────────
  209. def main():
  210. """CLI 入口:索引文档并交互式查询"""
  211. retriever = LocalRetriever()
  212. stats = retriever.index_documents()
  213. print(f"\n{'=' * 50}")
  214. print(f" 文档数: {stats['documents']}")
  215. print(f" Chunks: {stats['chunks']}")
  216. print(f" 词汇量: {stats['vocab_size']}")
  217. print(f" 矩阵: {stats['matrix_shape'][0]}x{stats['matrix_shape'][1]}")
  218. print(f"{'=' * 50}")
  219. test_queries = [
  220. "主性格数字1的性格特点",
  221. "财富能量数字组合",
  222. "感情婚姻运势分析",
  223. "健康疾病数字能量",
  224. "五区三组生命阶段",
  225. "三角命盘24个位置含义",
  226. "组合对判读规则优先级",
  227. "事业发展规划建议",
  228. ]
  229. print("\n --- 测试查询 ---")
  230. for q in test_queries:
  231. print()
  232. print(retriever.query_formatted(q, top_k=3))
  233. print(f"\n{'=' * 50}")
  234. print(" Source files indexed:")
  235. for src in retriever.list_sources():
  236. n = len(retriever.get_chunks_by_source(src))
  237. print(f" {src} ({n} chunks)")
  238. if __name__ == "__main__":
  239. main()