| """ |
| memory/vector_store.py — Jina Embedding + ChromaDB RAG Altyapısı |
| |
| AGENTIC PATTERN: Long-Term Memory (RAG) |
| ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
| Uzun vadeli hafıza = SSS dokümanının vektörleştirilmiş hali. |
| Uygulama her başladığında FAQ dokümanı chunk'lara bölünür, |
| Jina API ile embed edilir ve ChromaDB'ye kaydedilir. |
| |
| ChromaDB, veritabanını diske persist eder (data/chroma/). |
| Uygulama yeniden başlatılınca aynı vektörler tekrar kullanılır |
| (re-indeksleme yapılmaz). |
| |
| Jina AI free tier: https://jina.ai/embeddings/ |
| - Model: jina-embeddings-v3 |
| - 1M token/ay ücretsiz |
| - 8192 token context window |
| - Türkçe dahil çok dilli destek |
| """ |
| from __future__ import annotations |
| import os |
| import re |
| import httpx |
| import chromadb |
| from config import ( |
| CHROMA_PERSIST_DIR, |
| FAQ_COLLECTION_NAME, |
| JINA_API_KEY, |
| JINA_API_URL, |
| JINA_EMBEDDING_MODEL, |
| RAG_TOP_K, |
| ) |
|
|
| FAQ_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "faq.md") |
|
|
| |
| _client: chromadb.PersistentClient | None = None |
| _collection = None |
|
|
|
|
| def _get_client(): |
| global _client, _collection |
| if _client is None: |
| os.makedirs(CHROMA_PERSIST_DIR, exist_ok=True) |
| _client = chromadb.PersistentClient(path=CHROMA_PERSIST_DIR) |
| _collection = _client.get_or_create_collection(FAQ_COLLECTION_NAME) |
| return _client, _collection |
|
|
|
|
| def embed_texts(texts: list[str]) -> list[list[float]]: |
| """ |
| Jina AI API ile metinleri vektörleştirir. |
| |
| Jina'nın 'retrieval.passage' görevi embedding üretim içindir. |
| Sorgu zamanında 'retrieval.query' kullanılır (asimetrik retrieval). |
| """ |
| if not JINA_API_KEY: |
| raise ValueError("JINA_API_KEY tanımlı değil. .env dosyasını kontrol edin.") |
|
|
| response = httpx.post( |
| JINA_API_URL, |
| headers={"Authorization": f"Bearer {JINA_API_KEY}", "Content-Type": "application/json"}, |
| json={"model": JINA_EMBEDDING_MODEL, "task": "retrieval.passage", "input": texts}, |
| timeout=30.0, |
| ) |
| response.raise_for_status() |
| return [item["embedding"] for item in response.json()["data"]] |
|
|
|
|
| def embed_query(query: str) -> list[float]: |
| """ |
| Sorgu metnini vektörleştirir (passage'dan farklı görev). |
| Asimetrik retrieval: soru ve cevap farklı embedding uzayında. |
| """ |
| response = httpx.post( |
| JINA_API_URL, |
| headers={"Authorization": f"Bearer {JINA_API_KEY}", "Content-Type": "application/json"}, |
| json={"model": JINA_EMBEDDING_MODEL, "task": "retrieval.query", "input": [query]}, |
| timeout=30.0, |
| ) |
| response.raise_for_status() |
| return response.json()["data"][0]["embedding"] |
|
|
|
|
| def _chunk_faq(faq_text: str) -> list[dict]: |
| """ |
| FAQ dokümanını başlık + içerik çiftlerine böler. |
| ### başlığı bir chunk sınırıdır. |
| |
| Returns: |
| [{"id": str, "text": str, "title": str}, ...] |
| """ |
| chunks = [] |
| sections = re.split(r"\n### ", faq_text) |
|
|
| for i, section in enumerate(sections): |
| if not section.strip(): |
| continue |
| lines = section.strip().split("\n", 1) |
| title = lines[0].strip("# ").strip() |
| content = lines[1].strip() if len(lines) > 1 else "" |
| if content: |
| chunks.append({ |
| "id": f"faq_{i:03d}", |
| "text": f"Soru: {title}\nCevap: {content}", |
| "title": title, |
| }) |
| return chunks |
|
|
|
|
| def index_faq(force_reindex: bool = False): |
| """ |
| FAQ dokümanını okuyup ChromaDB'ye indeksler. |
| force_reindex=False → zaten indekslenmişse atla. |
| """ |
| _, collection = _get_client() |
|
|
| |
| if not force_reindex and collection.count() > 0: |
| print(f"[VectorStore] {collection.count()} chunk zaten indeksli, atlanıyor.") |
| return |
|
|
| with open(FAQ_PATH, encoding="utf-8") as f: |
| faq_text = f.read() |
|
|
| chunks = _chunk_faq(faq_text) |
| print(f"[VectorStore] {len(chunks)} chunk indeksleniyor...") |
|
|
| |
| batch_size = 20 |
| for i in range(0, len(chunks), batch_size): |
| batch = chunks[i: i + batch_size] |
| texts = [c["text"] for c in batch] |
| embeddings = embed_texts(texts) |
|
|
| collection.upsert( |
| ids=[c["id"] for c in batch], |
| documents=[c["text"] for c in batch], |
| embeddings=embeddings, |
| metadatas=[{"title": c["title"]} for c in batch], |
| ) |
|
|
| print(f"[VectorStore] İndeksleme tamamlandı. Toplam: {collection.count()} chunk") |
|
|
|
|
| def search_faq(query: str, top_k: int = RAG_TOP_K) -> list[dict]: |
| """ |
| Sorguya en alakalı FAQ chunk'larını döndürür. |
| |
| Returns: |
| [{"text": str, "title": str, "distance": float}, ...] |
| """ |
| _, collection = _get_client() |
|
|
| if collection.count() == 0: |
| return [] |
|
|
| query_embedding = embed_query(query) |
| results = collection.query( |
| query_embeddings=[query_embedding], |
| n_results=min(top_k, collection.count()), |
| include=["documents", "metadatas", "distances"], |
| ) |
|
|
| chunks = [] |
| for doc, meta, dist in zip( |
| results["documents"][0], |
| results["metadatas"][0], |
| results["distances"][0], |
| ): |
| chunks.append({"text": doc, "title": meta["title"], "distance": dist}) |
|
|
| return chunks |
|
|