# 📖 Search Architecture Specification (V3) ## Next-Gen Hybrid Search for Tipitaka Web Application > **Version:** 3.4.0 > **Updated:** 2026-05-26 > **Status:** Revised — เพิ่มโมเดล Qwen2.5-0.5B-Instruct เป็นโมเดลหลักสำหรับ CPU Basic และคง Qwen3.5-0.8B ไว้เปรียบเทียบ เอกสารนี้ระบุการออกแบบสถาปัตยกรรมการค้นหา V3 ที่ผสมผสาน Query Understanding ด้วย Local LLM (ค่าเริ่มต้น: Qwen2.5-0.5B-Instruct, ทางเลือก: Qwen3.5-0.8B) เข้ากับ Hybrid Search (FTS5 + Qdrant) และ Reranking เพื่อแก้ปัญหาการค้นหาด้วยภาษาพูดหรือความจำเลือนลาง --- ## 0. Model Name Reference & Comparison (ยืนยันจาก Official Source) | Model Role | Platform | ชื่อ model ที่ถูกต้อง | พารามิเตอร์ | จุดเด่น / การใช้งาน | |------------|----------|----------------------|------------|-------------------| | **Default** (Recommended) | HuggingFace | `Qwen/Qwen2.5-0.5B-Instruct` | 0.5B | โหลดเร็วมาก กินแรมต่ำสุด (~1GB ใน FP32) เหมาะกับ CPU Basic | | | Ollama | `qwen2.5:0.5b` | 0.5B | รันในเครื่องได้รวดเร็วมาก | | **Alternative** (Comparison) | HuggingFace | `Qwen/Qwen3.5-0.8B` | 0.8B | ตอบโครงสร้างลึกได้ดีกว่าเล็กน้อย แต่ใช้แรม ~1.6GB ใน FP32 และช้ากว่า | | | Ollama | `qwen3.5:0.8b` | 0.8B | รันผ่าน Ollama daemon | > **หมายเหตุ:** > - สำหรับรุ่น 2.5 ต้องระบุ `-Instruct` ท้ายชื่อบน HuggingFace เสมอ (เช่น `Qwen/Qwen2.5-0.5B-Instruct`) > - สำหรับรุ่น 3.5 ไม่มี `-Instruct` ต่อท้ายบน HuggingFace (ใช้ `Qwen/Qwen3.5-0.8B` ตัวนี้คือ Instruct version ส่วน `Qwen/Qwen3.5-0.8B-Base` คือรุ่นก่อนจูน) --- ## 1. System Dataflow ``` [ User Natural Query (ภาษาพูด) ] │ ▼ [ 1. Query Transformation ] ◄── SQLite Query Cache (exact match, normalize ก่อน) Qwen2.5-0.5B-Instruct (Default) / Qwen3.5-0.8B (Alternative) Local: via Ollama HTTP API (ollama pull qwen2.5:0.5b หรือ qwen3.5:0.8b) HF Space: via Transformers (Preloaded on startup, thread limits = 1) Output: {"fts_queries": [...], "vector_queries": [...]} │ ▼ (Fallback: original query ถ้า Qwen ล้มเหลว) │ ├─────────────────────────────────────────┐ ▼ asyncio.gather (parallel จริง) ▼ [ 2A. Lexical Search (FTS5) ] [ 2B. Semantic Search (Qdrant) ] SQLite pages_fts (Prefix matching) jina-embeddings-v5 (1024d) e.g. "ธัมมทินนา"* Qdrant batch search asyncio.gather(*fts_tasks) ```,StartLine:1,TargetContent: │ │ └──────────────────┬──────────────────────┘ ▼ [ 3. RRF Score Fusion ] Deduplicate: (volume_id, page_number) RRF(d) = Σ 1/(k=60 + rank) Top 15 candidates (optimized for CPU) │ ▼ [ 4. ONNX Reranking ] jina-reranker-v2-base-multilingual Re-score vs original user query (ไม่ใช่ transformed) │ ▼ [ 5. Post-Process & Highlight ] Highlight keywords จาก fts_queries (ไม่ใช่ original query) │ ┌──────────────────┴──────────────────┐ ▼ ▼ [ UI Search Page ] [ AI Assistant (RAG) ] (Paginated Reader Links) (DeepSeek / Gemma Generation) ``` --- ## 2. Layer Specifications ### Layer 1: Query Transformation — Dual Runtime & Model Comparison | คุณลักษณะ | Qwen2.5-0.5B-Instruct (Default - แนะนำ) | Qwen3.5-0.8B (Alternative - ตัวเปรียบเทียบ) | |-----------|-----------------------------------------|--------------------------------------------| | **HF Model ID** | `Qwen/Qwen2.5-0.5B-Instruct` | `Qwen/Qwen3.5-0.8B` | | **Ollama Model ID** | `qwen2.5:0.5b` | `qwen3.5:0.8b` | | **จำนวนพารามิเตอร์** | 0.5B | 0.8B | | **ขนาดแรม (FP32 CausalLM)** | **~1.0 GB** (เบาและเสถียรมากบน CPU ฟรีทีเออร์) | **~1.6 GB** (เสี่ยงหน่วยความจำล้นบน CPU Basic) | | **ความเร็วรันบน CPU (HF)** | **~0.3–0.6 วินาที** (เร็วมาก ประสบการณ์ผู้ใช้ลื่นไหล) | **~1.5–3.0 วินาที** (ค่อนข้างช้าบน CPU 1-2 คอร์) | | **Max output tokens** | **45 tokens** (ปรับปรุงเพื่อลดเวลาสร้างข้อความ) | **80 tokens** | | **การจัดการ CPU Thread** | `torch.set_num_threads(1)` (ป้องกัน CPU Pegging) | `torch.set_num_threads(1)` (ป้องกัน CPU Pegging) | | **ความแม่นยำด้านโครงสร้าง** | ดีมาก สำหรับการดึงข้อมูล JSON สั้นๆ | ดีมาก มีความหลากหลายของคำศัพท์มากกว่าเล็กน้อย | **Runtime detection & Model selection via environment variables:** ```bash # .env local (Default: qwen2.5:0.5b) QWEN_RUNTIME=ollama QWEN_OLLAMA_URL=http://localhost:11434 QWEN_OLLAMA_MODEL=qwen2.5:0.5b # .env HF Space (Default: Qwen2.5-0.5B-Instruct) QWEN_RUNTIME=transformers QWEN_MODEL_ID=Qwen/Qwen2.5-0.5B-Instruct ``` **Output JSON format:** ```json { "fts_queries": [ "วิสาขา ภิกษุณี สนทนา", "นางวิสาขา สงฆ์ ภาษิต" ], "vector_queries": [ "นางวิสาขาพูดคุยกับภิกษุณีสงฆ์เรื่องธรรมะ", "การสนทนาธรรมระหว่างอุบาสิกาและภิกษุณี" ] } ``` **ความต่างของสองช่อง (สำคัญ):** - `fts_queries` → keyword-style สั้น ใช้ AND/OR logic ใน FTS5 - `vector_queries` → natural sentence ยาวกว่า ให้ embedding เข้าใจ context **System Prompt:** ``` คุณคือผู้เชี่ยวชาญพระไตรปิฎกฉบับมหาจุฬา 45 เล่ม หน้าที่: แปลงคำค้นของผู้ใช้เป็น JSON เพื่อค้นหาในฐานข้อมูล กฎ: 1. ตอบด้วย JSON เท่านั้น ห้ามมีข้อความอื่น 2. fts_queries: คีย์เวิร์ดสั้น 2-5 คำ รวมคำบาลี/ไวพจน์ จำกัด 2 รายการ 3. vector_queries: ประโยคสมบูรณ์ความหมายชัดเจน จำกัด 2 รายการ 4. ห้ามแต่งเนื้อหาที่ไม่มีในพระไตรปิฎก ตัวอย่าง: Input: "นางวิสาขาคุยกับภิกษุณี" Output: {"fts_queries":["วิสาขา ภิกษุณี","นางวิสาขา สงฆ์ ภาษิต"],"vector_queries":["นางวิสาขาสนทนากับภิกษุณีสงฆ์","การพูดคุยธรรมะระหว่างอุบาสิกาและภิกษุณี"]} Input: "พระพุทธเจ้าเปรียบจิตกับน้ำขุ่น" Output: {"fts_queries":["จิต อุทก สมาธิ","จิต น้ำ ขุ่น ใส"],"vector_queries":["พระพุทธเจ้าอุปมาจิตเหมือนน้ำที่ขุ่นและใส","สมาธิทำให้จิตผ่องใสดุจน้ำนิ่ง"]} ``` --- ### Layer 2: Parallel Retrieval FTS5 และ Vector ทำงานพร้อมกันด้วย `asyncio.gather` จริงๆ ไม่ใช่ sequential loop **จำกัด queries:** สูงสุด 2 fts + 2 vector = 4 tasks parallel ต่อ 1 request --- ### Layer 3: RRF Score Fusion $$RRF\_Score(d) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$ - k = 60 (default มาตรฐาน) - Dedup key: `(volume_id, page_number)` - Top 15 candidates ส่งต่อ reranker (ปรับลดจาก 30 เพื่อเพิ่มความเร็วในการ Rerank บน CPU) --- ### Layer 4: ONNX Reranking - Model: `jina-reranker-v2-base-multilingual` (ONNX CPU) - **Re-score กับ original user query เสมอ** — ไม่ใช่ transformed query - **Sequence Length**: ใช้ `max_length=256` ใน Tokenizer (ปรับลดจาก 1024 เพื่อประหยัดเวลาคำนวณและลด Latency ลง 4 เท่า) - **CPU Threading**: ตั้งค่า SessionOptions `intra_op_num_threads = min(4, CPU_cores)` และ `inter_op_num_threads = 1` ป้องกัน Thread thrashing - Fallback: ถ้า reranker ไม่พร้อม ใช้ RRF score เดิม --- ### Layer 5: Highlight - ใช้ keywords จาก `fts_queries` (transformed) ไม่ใช่ original query - ส่งเป็น metadata ไปยัง frontend สำหรับ `` tag --- ## 3. RAM Budget ### Local (RTX 5070 laptop 8GB VRAM / 32GB RAM) | Component | RAM (Qwen2.5-0.5B) | RAM (Qwen3.5-0.8B) | |-----------|--------------------|--------------------| | SQLite in-memory | ~238 MB | ~238 MB | | Qdrant Embedded | ~1.2–1.5 GB | ~1.2–1.5 GB | | Jina-v5 (ST in-process) | ~1.2 GB | ~1.2 GB | | ONNX Reranker | ~500 MB | ~500 MB | | Qwen LLM (Ollama — แยก process) | ~0.5 GB | ~0.8 GB | | FastAPI + Python overhead | ~300 MB | ~300 MB | | **รวม** | **~3.9–4.2 GB** ✅ | **~4.3–4.5 GB** ✅ | ### HF Space (CPU Basic = 16GB) | Component | RAM (Qwen2.5-0.5B) | RAM (Qwen3.5-0.8B) | |-----------|--------------------|--------------------| | SQLite in-memory | ~238 MB | ~238 MB | | Qdrant Embedded | ~1.2–1.5 GB | ~1.2–1.5 GB | | Jina-v5 (ST in-process) | ~1.2 GB | ~1.2 GB | | ONNX Reranker | ~500 MB | ~500 MB | | Qwen LLM (in-process, FP32) | **~1.0 GB** | **~1.6 GB** | | FastAPI + Python + Ubuntu OS | ~800 MB | ~800 MB | | **รวม** | **~5.0–5.2 GB** ✅ | **~5.6–5.8 GB** ✅ | | **Headroom** | **~10.8 GB** | **~10.2 GB** | --- ## 4. Latency Profile | ขั้นตอนและสถานการณ์ | Local (Ollama - 0.5B) | Local (Ollama - 0.8B) | HF Space (Trans - 0.5B) | HF Space (Trans - 0.8B) | |--------------------|-----------------------|-----------------------|-------------------------|-------------------------| | **Cache hit** (ไม่ต้องรัน LLM) | ~50ms | ~50ms | ~50ms | ~50ms | | **Qwen transform** (LLM) | **~0.2–0.4 วินาที** | **~1.0–2.0 วินาที** | **~0.3–0.6 วินาที** | **~2.0–4.0 วินาที** | | **FTS5 + Qdrant parallel** | ~150ms | ~150ms | ~200ms | ~200ms | | **RRF fusion** | ~10ms | ~10ms | ~10ms | ~10ms | | **ONNX rerank** | ~200ms | ~200ms | ~300ms | ~300ms | | **รวมกรณี Cache Miss** | **~0.6–0.8 วินาที** | **~1.5–2.5 วินาที** | ****~0.8–1.2 วินาที**** | **~2.7–4.7 วินาที** | | **รวมกรณี Cache Hit** | **~400ms** | **~400ms** | **~600ms** | **~600ms** | --- ## 5. Implementation Blueprint ### 5.1 QueryTransformService — รองรับทั้งสอง Runtime & Preloading (Class-level load) ```python # app/services/query_transform_service.py import os import json import re import unicodedata from typing import Optional FALLBACK = lambda q: {"fts_queries": [q], "vector_queries": [q]} SYSTEM_PROMPT = """คุณคือผู้เชี่ยวชาญพระไตรปิฎกฉบับมหาจุฬา 45 เล่ม ตอบด้วย JSON เท่านั้น รูปแบบ: {"fts_queries":[...],"vector_queries":[...]} แต่ละช่องมีได้สูงสุด 2 รายการ ห้ามมีข้อความอื่นนอกจาก JSON""" class QueryTransformService: # Class-level static attributes to share loaded weights across all instances _tokenizer = None _model = None @classmethod def preload(cls, model_id: str = "Qwen/Qwen2.5-0.5B-Instruct"): """Preload the model and tokenizer into memory with PyTorch thread limits.""" if cls._model is not None and cls._tokenizer is not None: return import torch from transformers import AutoTokenizer, AutoModelForCausalLM import logging logger = logging.getLogger(__name__) try: torch.set_num_threads(1) torch.set_num_interop_threads(1) except Exception: pass logger.info(f"Loading Qwen model: {model_id}...") try: cls._tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True) cls._model = AutoModelForCausalLM.from_pretrained( model_id, torch_dtype=torch.float32, device_map="cpu", trust_remote_code=True ) cls._model.eval() logger.info("Qwen model preloaded successfully.") except Exception as e: logger.error(f"Failed to preload Qwen model: {e}") def __init__(self, db): self.db = db # ตั้งค่าพารามิเตอร์และ Runtime จากสภาพแวดล้อม self.runtime = os.getenv("QWEN_RUNTIME", "transformers") self.ollama_url = os.getenv("QWEN_OLLAMA_URL", "http://localhost:11434") self.ollama_model = os.getenv("QWEN_OLLAMA_MODEL", "qwen2.5:0.5b") self.model_id = os.getenv("QWEN_MODEL_ID", "Qwen/Qwen2.5-0.5B-Instruct") ``` def _normalize_key(self, query: str) -> str: # Normalize unicode to NFC q = unicodedata.normalize("NFC", query.strip().lower()) # Strip out punctuation and symbols except space q = re.sub(r"[^\w\s\u0e00-\u0e7f]", "", q) # Compress multiple spaces return re.sub(r"\s+", " ", q).strip() async def transform(self, query: str) -> dict: key = self._normalize_key(query) # 1. ตรวจ cache ก่อนเสมอ cached = self.db.get_query_cache(key) if cached: return cached # 2. เรียก Qwen ตาม runtime try: if self.runtime == "ollama": result = await self._call_ollama(query) else: result = await self._call_transformers(query) self.db.set_query_cache(key, result) return result except Exception: return FALLBACK(query) async def _call_ollama(self, query: str) -> dict: """Local: ยิง Ollama HTTP API""" import httpx url = f"{self.ollama_url}/api/chat" payload = { "model": self.ollama_model, # qwen2.5:0.5b "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": query} ], "stream": False, "options": {"num_predict": 45, "temperature": 0} } async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post(url, json=payload) raw = resp.json()["message"]["content"].strip() return self._parse_json(raw, query) async def _call_transformers(self, query: str) -> dict: """HF Space: Preloaded หรือ Lazy loading ในหน่วยความจำ""" import torch from transformers import AutoTokenizer, AutoModelForCausalLM # โหลดโมเดลระดับ Class-level เพื่อให้ใช้ตัวน้ำหนักร่วมกันได้ข้าม request if QueryTransformService._model is None or QueryTransformService._tokenizer is None: QueryTransformService.preload(self.model_id) messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": query} ] text = QueryTransformService._tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True ) inputs = QueryTransformService._tokenizer(text, return_tensors="pt") with torch.no_grad(): outputs = QueryTransformService._model.generate( **inputs, max_new_tokens=45, # จำกัด 45 tokens เพื่อให้ประมวลผลคำตอบได้เร็วขึ้นเท่าตัว do_sample=False, temperature=None, top_p=None, pad_token_id=QueryTransformService._tokenizer.eos_token_id ) raw = QueryTransformService._tokenizer.decode( outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True ).strip() return self._parse_json(raw, query) def _parse_json(self, raw: str, original: str) -> dict: try: # ค้นหาวงเล็บปีกกาตัวแรกและตัวสุดท้ายเพื่อสกัดเอาเฉพาะส่วน JSON start_idx = raw.find('{') end_idx = raw.rfind('}') if start_idx != -1 and end_idx != -1: json_str = raw[start_idx:end_idx+1] data = json.loads(json_str) if "fts_queries" in data and "vector_queries" in data: fts = [str(x).strip() for x in data["fts_queries"] if x][:2] vec = [str(x).strip() for x in data["vector_queries"] if x][:2] return {"fts_queries": fts, "vector_queries": vec} except Exception: pass return FALLBACK(original) ``` --- ### 5.2 SQLite Cache Schema ```sql CREATE TABLE IF NOT EXISTS query_cache ( cache_key TEXT PRIMARY KEY, result_json TEXT NOT NULL, hit_count INTEGER DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_query_cache_last_used ON query_cache(last_used); ``` ```python # app/database/sqlite_db.py (เพิ่ม methods) def get_query_cache(self, key: str) -> Optional[dict]: row = self.conn.execute( "SELECT result_json FROM query_cache WHERE cache_key = ?", (key,) ).fetchone() if row: self.conn.execute( """UPDATE query_cache SET hit_count = hit_count + 1, last_used = CURRENT_TIMESTAMP WHERE cache_key = ?""", (key,) ) return json.loads(row[0]) return None def set_query_cache(self, key: str, result: dict) -> None: self.conn.execute( """INSERT INTO query_cache (cache_key, result_json) VALUES (?, ?) ON CONFLICT(cache_key) DO UPDATE SET result_json = excluded.result_json, last_used = CURRENT_TIMESTAMP""", (key, json.dumps(result, ensure_ascii=False)) ) self.conn.commit() def evict_query_cache(self, max_entries: int = 10000) -> None: count = self.conn.execute( "SELECT COUNT(*) FROM query_cache" ).fetchone()[0] if count > max_entries: self.conn.execute(""" DELETE FROM query_cache WHERE cache_key IN ( SELECT cache_key FROM query_cache ORDER BY last_used ASC LIMIT ? ) """, (count - max_entries,)) self.conn.commit() ``` --- ### 5.3 SearchService — asyncio.gather จริง ```python # app/services/search_service.py import asyncio class SearchService: def __init__(self, db, rag_service=None, query_transform_service=None): self.db = db self.rag_service = rag_service self.qts = query_transform_service async def search_hybrid(self, query: str, limit: int = 10, offset: int = 0) -> dict: # 1. Transform (cache หรือ Qwen) transformed = await self.qts.transform(query) fts_queries = transformed["fts_queries"] vector_queries = transformed["vector_queries"] # 2. Parallel — asyncio.gather ทั้งหมดจริงๆ fts_tasks = [self._get_fts_results(fq) for fq in fts_queries] vector_tasks = [self._get_vector_results(vq) for vq in vector_queries] fts_results_list, vector_results_list = await asyncio.gather( asyncio.gather(*fts_tasks), asyncio.gather(*vector_tasks) ) # 3. RRF Fusion rrf_scores, candidate_data = {}, {} k = 60 for results in fts_results_list: for rank, item in enumerate(results): key = (item["volume_id"], item["page_number"]) rrf_scores[key] = rrf_scores.get(key, 0.0) + 1.0 / (k + rank + 1) candidate_data.setdefault(key, item) for results in vector_results_list: for rank, item in enumerate(results): key = (item["volume_id"], item["page_number"]) rrf_scores[key] = rrf_scores.get(key, 0.0) + 1.0 / (k + rank + 1) candidate_data.setdefault(key, item) top_keys = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:30] top_candidates = [candidate_data[k] for k in top_keys] # 4. Rerank กับ original query เสมอ if (self.rag_service and self.rag_service.reranker and self.rag_service.reranker != "error"): final = await self._rerank_candidates(query, top_candidates) else: final = top_candidates # 5. Format + highlight ด้วย fts_queries keywords return self._format_response(final, limit, offset, fts_queries) ``` --- ## 6. Requirements ``` # requirements.txt (HF Space) transformers>=4.51.0 # minimum สำหรับ Qwen3.5 DeltaNet torch>=2.2.0 accelerate>=0.27.0 httpx>=0.27.0 # สำหรับ Ollama HTTP client (local) ``` ```bash # Local: ติดตั้ง Ollama และดึงโมเดลหลัก / ทางเลือก ollama pull qwen2.5:0.5b ollama pull qwen3.5:0.8b ``` --- ## 7. ข้อจำกัดที่รับรู้แล้ว (Phase 1) | ข้อจำกัด | ผลกระทบ | แนวทาง Phase 2 | |---------|---------|----------------| | Cache เป็น exact match | query ต่างกันเล็กน้อยไม่ hit | Levenshtein fuzzy match | | Transformers บน CPU | latency ~0.3–0.6s (0.5B) / ~2–4s (0.8B) | GPU inference บน VPS | | ใช้ base Instruct ยังไม่ fine-tune | transformation ยังไม่ optimal | fine-tune ด้วย dataset ที่กำลังสร้าง | | Cold start HF Space เพิ่ม ~15–20s | restart ช้าขึ้น | Persistent Storage หรือ VPS | | Ollama local ต้องรัน daemon ก่อน | dev ต้อง start Ollama เอง | docker-compose ใน local setup | --- *Local: QWEN_RUNTIME=ollama (qwen2.5:0.5b หรือ qwen3.5:0.8b)* *HF Space: QWEN_RUNTIME=transformers (Qwen/Qwen2.5-0.5B-Instruct หรือ Qwen/Qwen3.5-0.8B)* *Phase 2 migration path อ้างอิง Tipitaka-Web-Application-Tech-Stack-Phase1-HF.md*