| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- """
- Local Knowledge Base Retriever
- ==============================
- Dify 不可用时的本地 RAG 替代方案。
- 使用 character n-gram + TF 向量化的方式做检索,无需任何外部 ML 依赖(numpy+scipy only)。
- Usage:
- from local_retriever import LocalRetriever
- retriever = LocalRetriever()
- retriever.index_documents()
- results = retriever.query("主性格数字1的性格特点", top_k=5)
- """
- import os
- import re
- import json
- import hashlib
- from pathlib import Path
- from typing import List, Dict, Optional, Tuple
- import numpy as np
- # ─── 配置 ────────────────────────────────────────────────────────────────────
- KNOWLEDGE_DIR = Path(__file__).resolve().parent.parent / "research"
- NGRAM_N = 2 # character bigram
- TOP_K_DEFAULT = 6
- # ─── 文档分块 ────────────────────────────────────────────────────────────────
- def _parse_yaml_frontmatter(text: str) -> Tuple[Dict, str]:
- """解析 Markdown YAML frontmatter (--- 包裹的元数据)"""
- meta = {}
- body = text
- m = re.match(r'^---\s*\n(.*?)\n---\s*\n(.*)', text, re.DOTALL)
- if m:
- yaml_block = m.group(1)
- body = m.group(2)
- for line in yaml_block.strip().split('\n'):
- if ':' in line:
- k, v = line.split(':', 1)
- meta[k.strip()] = v.strip().strip('"').strip("'")
- return meta, body
- def _split_sections(text: str, source_file: str) -> List[Dict]:
- """将文档按 ## 标题拆分为 chunks,每个 chunk 包含上下文"""
- chunks = []
- lines = text.split('\n')
- current_heading = "前言"
- current_content = []
- heading_level = 0
- for line in lines:
- heading_match = re.match(r'^(#{1,4})\s+(.+)$', line)
- if heading_match:
- if current_content:
- chunks.append({
- "id": hashlib.md5(f"{source_file}:{current_heading}".encode()).hexdigest()[:12],
- "source": source_file,
- "heading": current_heading,
- "content": '\n'.join(current_content).strip(),
- "tokens": len(''.join(current_content)) // 2,
- })
- heading_level = len(heading_match.group(1))
- current_heading = heading_match.group(2).strip()
- current_content = []
- else:
- current_content.append(line)
- if current_content:
- chunks.append({
- "id": hashlib.md5(f"{source_file}:{current_heading}".encode()).hexdigest()[:12],
- "source": source_file,
- "heading": current_heading,
- "content": '\n'.join(current_content).strip(),
- "tokens": len(''.join(current_content)) // 2,
- })
- return chunks
- def load_all_documents(kb_dir: Optional[Path] = None) -> List[Dict]:
- """加载 knowledge/research/ 下所有 .md 文件,返回 chunks 列表"""
- if kb_dir is None:
- kb_dir = KNOWLEDGE_DIR
- all_chunks = []
- md_files = sorted(kb_dir.glob("*.md"))
- for fpath in md_files:
- try:
- text = fpath.read_text(encoding='utf-8')
- except Exception as e:
- print(f" [SKIP] {fpath.name}: {e}")
- continue
- meta, body = _parse_yaml_frontmatter(text)
- chunks = _split_sections(body, fpath.name)
- # 将 YAML 元信息附加到每个 chunk
- for ch in chunks:
- ch["file_path"] = str(fpath)
- ch["doc_title"] = meta.get("title", fpath.stem)
- ch["doc_tags"] = meta.get("tags", "")
- ch["doc_purpose"] = meta.get("purpose", "")
- all_chunks.extend(chunks)
- return all_chunks
- # ─── 向量化 ──────────────────────────────────────────────────────────────────
- def _char_ngrams(text: str, n: int = NGRAM_N) -> List[str]:
- """生成 character n-grams"""
- # 去除非中文/字母/数字的字符,保留语义
- cleaned = re.sub(r'[^\u4e00-\u9fff\w]', '', text)
- if len(cleaned) < n:
- return [cleaned]
- return [cleaned[i:i + n] for i in range(len(cleaned) - n + 1)]
- def _build_vocab(chunks: List[Dict], n: int = NGRAM_N) -> Dict[str, int]:
- """构建 n-gram 词汇表"""
- vocab = {}
- for ch in chunks:
- content = f"{ch.get('doc_title', '')} {ch.get('heading', '')} {ch.get('doc_tags', '')} {ch['content']}"
- grams = _char_ngrams(content, n)
- for g in grams:
- if g not in vocab:
- vocab[g] = len(vocab)
- return vocab
- def _vectorize(text: str, vocab: Dict[str, int], n: int = NGRAM_N) -> np.ndarray:
- """将文本转为 TF 向量"""
- vec = np.zeros(len(vocab), dtype=np.float32)
- grams = _char_ngrams(text, n)
- for g in grams:
- idx = vocab.get(g)
- if idx is not None:
- vec[idx] += 1.0
- # L2 归一化
- norm = np.linalg.norm(vec)
- if norm > 0:
- vec /= norm
- return vec
- # ─── 主检索器 ────────────────────────────────────────────────────────────────
- class LocalRetriever:
- """本地知识库检索器"""
- def __init__(self, kb_dir: Optional[Path] = None, ngram_n: int = NGRAM_N):
- self.kb_dir = kb_dir or KNOWLEDGE_DIR
- self.ngram_n = ngram_n
- self.chunks: List[Dict] = []
- self.vocab: Dict[str, int] = {}
- self.matrix: Optional[np.ndarray] = None
- self._indexed = False
- def index_documents(self) -> Dict:
- """索引所有文档,返回统计信息"""
- print(f" Loading documents from: {self.kb_dir}")
- self.chunks = load_all_documents(self.kb_dir)
- print(f" Total chunks: {len(self.chunks)}")
- # 构建词汇表
- print(f" Building {self.ngram_n}-gram vocabulary...")
- self.vocab = _build_vocab(self.chunks, self.ngram_n)
- print(f" Vocabulary size: {len(self.vocab)}")
- # 构建文档-词项矩阵
- print(" Vectorizing chunks...")
- rows = []
- for i, ch in enumerate(self.chunks):
- content = f"{ch['doc_title']} {ch['heading']} {ch['doc_tags']} {ch['content']}"
- vec = _vectorize(content, self.vocab, self.ngram_n)
- rows.append(vec)
- if (i + 1) % 50 == 0:
- print(f" ... {i + 1}/{len(self.chunks)}")
- self.matrix = np.array(rows, dtype=np.float32)
- print(f" Matrix shape: {self.matrix.shape}")
- self._indexed = True
- return {
- "documents": len(set(ch["source"] for ch in self.chunks)),
- "chunks": len(self.chunks),
- "vocab_size": len(self.vocab),
- "matrix_shape": list(self.matrix.shape),
- }
- def query(self, query_text: str, top_k: int = TOP_K_DEFAULT) -> List[Dict]:
- """检索与 query 最相关的 chunks"""
- if not self._indexed:
- raise RuntimeError("Call index_documents() first")
- # 向量化 query
- qvec = _vectorize(query_text, self.vocab, self.ngram_n)
- # 余弦相似度: cos = dot_product / (norm_q * norm_d)
- assert self.matrix is not None, "Matrix not initialized"
- norm_q = np.linalg.norm(qvec)
- norms_d = np.linalg.norm(self.matrix, axis=1)
- # 避免除零
- denom = norm_q * norms_d
- denom[denom == 0] = 1e-10
- similarities = np.dot(self.matrix, qvec) / denom
- # 获取 top-k 索引
- top_indices = np.argsort(similarities)[::-1][:top_k]
- results = []
- for idx in top_indices:
- sim = float(similarities[idx])
- if sim < 0.01: # 过滤无关结果
- break
- ch = self.chunks[idx]
- results.append({
- "id": ch["id"],
- "source": ch["source"],
- "heading": ch["heading"],
- "doc_title": ch["doc_title"],
- "content_preview": ch["content"][:200] + ("..." if len(ch["content"]) > 200 else ""),
- "score": round(sim, 4),
- "tokens": ch.get("tokens", 0),
- })
- return results
- def query_formatted(self, query_text: str, top_k: int = TOP_K_DEFAULT) -> str:
- """返回格式化的检索结果(用于测试报告)"""
- results = self.query(query_text, top_k)
- lines = [
- f" Query: \"{query_text}\"",
- f" Results: {len(results)}",
- ]
- for i, r in enumerate(results):
- lines.append(f" [{i + 1}] score={r['score']:.4f} | {r['source']} > {r['heading']}")
- lines.append(f" {r['content_preview'][:120]}")
- return '\n'.join(lines)
- def get_chunks_by_source(self, source: str) -> List[Dict]:
- """按源文件获取 chunks"""
- return [ch for ch in self.chunks if ch["source"] == source]
- def list_sources(self) -> List[str]:
- """列出所有源文件"""
- return sorted(set(ch["source"] for ch in self.chunks))
- # ─── CLI ─────────────────────────────────────────────────────────────────────
- def main():
- """CLI 入口:索引文档并交互式查询"""
- retriever = LocalRetriever()
- stats = retriever.index_documents()
- print(f"\n{'=' * 50}")
- print(f" 文档数: {stats['documents']}")
- print(f" Chunks: {stats['chunks']}")
- print(f" 词汇量: {stats['vocab_size']}")
- print(f" 矩阵: {stats['matrix_shape'][0]}x{stats['matrix_shape'][1]}")
- print(f"{'=' * 50}")
- test_queries = [
- "主性格数字1的性格特点",
- "财富能量数字组合",
- "感情婚姻运势分析",
- "健康疾病数字能量",
- "五区三组生命阶段",
- "三角命盘24个位置含义",
- "组合对判读规则优先级",
- "事业发展规划建议",
- ]
- print("\n --- 测试查询 ---")
- for q in test_queries:
- print()
- print(retriever.query_formatted(q, top_k=3))
- print(f"\n{'=' * 50}")
- print(" Source files indexed:")
- for src in retriever.list_sources():
- n = len(retriever.get_chunks_by_source(src))
- print(f" {src} ({n} chunks)")
- if __name__ == "__main__":
- main()
|