Download app/services/rag.py from nvikou/ReplyFlow: direct link, hf CLI and curl.
- Browser
- Download file 7.54 kB
-
https://huggingface.co/spaces/nvikou/ReplyFlow/resolve/main/app/services/rag.py
- Command line
-
hf download hf://spaces/nvikou/ReplyFlow/app/services/rag.py
-
curl -L -o rag.py https://huggingface.co/spaces/nvikou/ReplyFlow/resolve/main/app/services/rag.py
7.54 kB
| """RAG knowledge base: ingest, search, no hallucination.""" | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| import uuid | |
| from pathlib import Path | |
| from typing import Any | |
| from bs4 import BeautifulSoup | |
| from pypdf import PdfReader | |
| from app.config import get_settings | |
| from app.services.embeddings import ( | |
| chunk_text, | |
| cosine_similarity, | |
| embed_texts, | |
| token_overlap_score, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| class KnowledgeStore: | |
| """File-backed vector store (Chroma when available).""" | |
| def __init__(self) -> None: | |
| self.settings = get_settings() | |
| self._collection = None | |
| self._fallback_path = ( | |
| Path(self.settings.chroma_path) / "fallback.json" | |
| ) | |
| self._fallback: list[dict[str, Any]] = [] | |
| self._init_store() | |
| def _init_store(self) -> None: | |
| path = Path(self.settings.chroma_path) | |
| path.mkdir(parents=True, exist_ok=True) | |
| if self.settings.use_json_store: | |
| self._collection = None | |
| self._load_fallback() | |
| logger.info("JSON knowledge store ready") | |
| return | |
| try: | |
| import chromadb | |
| client = chromadb.PersistentClient(path=str(path)) | |
| self._collection = client.get_or_create_collection( | |
| name="replyflow_kb", | |
| metadata={"hnsw:space": "cosine"}, | |
| ) | |
| logger.info("Chroma knowledge store ready") | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning( | |
| "Chroma unavailable (%s); using JSON fallback", | |
| exc, | |
| ) | |
| self._collection = None | |
| self._load_fallback() | |
| def _load_fallback(self) -> None: | |
| if self._fallback_path.exists(): | |
| self._fallback = json.loads( | |
| self._fallback_path.read_text(encoding="utf-8") | |
| ) | |
| else: | |
| self._fallback = [] | |
| def _save_fallback(self) -> None: | |
| self._fallback_path.parent.mkdir( | |
| parents=True, exist_ok=True | |
| ) | |
| self._fallback_path.write_text( | |
| json.dumps(self._fallback, ensure_ascii=False), | |
| encoding="utf-8", | |
| ) | |
| def chunk_count(self) -> int: | |
| if self._collection is not None: | |
| return int(self._collection.count()) | |
| return len(self._fallback) | |
| async def add_chunks( | |
| self, | |
| chunks: list[str], | |
| metadata: dict[str, Any], | |
| ) -> int: | |
| """Embed and store text chunks.""" | |
| if not chunks: | |
| return 0 | |
| embeddings = await embed_texts( | |
| chunks, | |
| force_local=self.settings.seed_local_embeddings, | |
| ) | |
| ids = [str(uuid.uuid4()) for _ in chunks] | |
| metadatas = [ | |
| {**metadata, "chunk_index": i} | |
| for i in range(len(chunks)) | |
| ] | |
| if self._collection is not None: | |
| self._collection.add( | |
| ids=ids, | |
| documents=chunks, | |
| embeddings=embeddings, | |
| metadatas=metadatas, | |
| ) | |
| else: | |
| for i, chunk in enumerate(chunks): | |
| self._fallback.append( | |
| { | |
| "id": ids[i], | |
| "document": chunk, | |
| "embedding": embeddings[i], | |
| "metadata": metadatas[i], | |
| } | |
| ) | |
| self._save_fallback() | |
| return len(chunks) | |
| async def search( | |
| self, | |
| query: str, | |
| top_k: int = 4, | |
| min_score: float = 0.12, | |
| ) -> list[dict[str, Any]]: | |
| """Return relevant chunks above similarity threshold.""" | |
| if self.chunk_count == 0: | |
| return [] | |
| query_emb = ( | |
| await embed_texts( | |
| [query], | |
| force_local=self.settings.seed_local_embeddings, | |
| ) | |
| )[0] | |
| use_openai = ( | |
| get_settings().has_openai | |
| and not self.settings.seed_local_embeddings | |
| ) | |
| if self._collection is not None: | |
| result = self._collection.query( | |
| query_embeddings=[query_emb], | |
| n_results=min(top_k, max(1, self.chunk_count)), | |
| ) | |
| docs = result.get("documents", [[]])[0] | |
| metas = result.get("metadatas", [[]])[0] | |
| dists = result.get("distances", [[]])[0] | |
| hits: list[dict[str, Any]] = [] | |
| for doc, meta, dist in zip(docs, metas, dists): | |
| vector_score = 1.0 - float(dist) | |
| keyword = token_overlap_score(query, doc) | |
| score = ( | |
| vector_score | |
| if use_openai | |
| else 0.45 * vector_score + 0.55 * keyword | |
| ) | |
| if score >= min_score: | |
| hits.append( | |
| { | |
| "text": doc, | |
| "metadata": meta or {}, | |
| "score": score, | |
| } | |
| ) | |
| hits.sort(key=lambda x: x["score"], reverse=True) | |
| return hits[:top_k] | |
| scored = [] | |
| for item in self._fallback: | |
| vector_score = cosine_similarity( | |
| query_emb, item["embedding"] | |
| ) | |
| keyword = token_overlap_score(query, item["document"]) | |
| score = 0.35 * vector_score + 0.65 * keyword | |
| if score >= min_score: | |
| scored.append( | |
| { | |
| "text": item["document"], | |
| "metadata": item["metadata"], | |
| "score": score, | |
| } | |
| ) | |
| scored.sort(key=lambda x: x["score"], reverse=True) | |
| return scored[:top_k] | |
| _store: KnowledgeStore | None = None | |
| def get_knowledge_store() -> KnowledgeStore: | |
| """Singleton knowledge store.""" | |
| global _store | |
| if _store is None: | |
| _store = KnowledgeStore() | |
| return _store | |
| def extract_text_from_file(path: Path) -> str: | |
| """Extract text from pdf, html, md, or txt.""" | |
| suffix = path.suffix.lower() | |
| raw = path.read_bytes() | |
| if suffix == ".pdf": | |
| reader = PdfReader(str(path)) | |
| parts = [] | |
| for page in reader.pages: | |
| parts.append(page.extract_text() or "") | |
| return "\n".join(parts) | |
| text = raw.decode("utf-8", errors="ignore") | |
| if suffix in {".html", ".htm"}: | |
| soup = BeautifulSoup(text, "html.parser") | |
| return soup.get_text(separator="\n") | |
| return text | |
| async def ingest_path(path: Path, source_type: str) -> int: | |
| """Ingest a single file into the knowledge store.""" | |
| text = extract_text_from_file(path) | |
| chunks = chunk_text(text) | |
| store = get_knowledge_store() | |
| return await store.add_chunks( | |
| chunks, | |
| { | |
| "filename": path.name, | |
| "source_type": source_type, | |
| }, | |
| ) | |
| async def ingest_directory(directory: Path) -> int: | |
| """Ingest all supported files from a directory.""" | |
| total = 0 | |
| patterns = ("*.md", "*.txt", "*.pdf", "*.html", "*.htm") | |
| files: list[Path] = [] | |
| for pattern in patterns: | |
| files.extend(directory.glob(pattern)) | |
| for path in sorted(set(files)): | |
| source = path.suffix.lower().lstrip(".") or "text" | |
| count = await ingest_path(path, source) | |
| total += count | |
| logger.info("Ingested %s (%s chunks)", path.name, count) | |
| return total | |