# 📖 Search Architecture Specification (Turbovec Edition) ## Next-Gen Hybrid Search for Tipitaka Web Application > **Version:** 3.5.0 (Turbovec Edition) > **Updated:** 2026-05-26 > **Status:** Revised — ใช้ Turbovec เป็น Vector Search Engine หลัก และเพิ่ม SQLite Content Enrichment ใน RAG pipeline เอกสารนี้ระบุการออกแบบสถาปัตยกรรมการค้นหา V3 ที่ถูกปรับปรุงเพื่อใช้งาน **Turbovec** (In-process Vector Search) แทน Qdrant โดยรวมเข้ากับ Query Understanding ด้วย Local LLM (Qwen2.5-0.5B-Instruct / Qwen3.5-0.8B) และ FTS5 + ONNX Reranker เพื่อความรวดเร็วและน้ำหนักที่เบาที่สุดบน Hugging Face Space --- ## 0. Model Name Reference & Comparison | 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 | --- ## 1. System Dataflow ``` [ User Natural Query (ภาษาพูด) ] │ ▼ [ 1. Query Transformation ] ◄── SQLite Query Cache (exact match) Qwen2.5-0.5B-Instruct (Default) / Qwen3.5-0.8B (Alternative) Local: via Ollama HTTP API HF Space: via Transformers (Preloaded on startup) Output: {"fts_queries": [...], "vector_queries": [...]} │ ▼ (Fallback: original query) │ ├─────────────────────────────────────────┐ ▼ asyncio.gather (parallel) ▼ [ 2A. Lexical Search (FTS5) ] [ 2B. Semantic Search (Turbovec) ] SQLite pages_fts (Prefix matching) jina-embeddings-v5 (1024d) e.g. "ธัมมทินนา"* Turbovec IdMapIndex batch search asyncio.gather(*fts_tasks) tv_index.search(stacked_vectors) │ │ └──────────────────┬──────────────────────┘ ▼ [ 3. RRF Score Fusion ] Deduplicate: (volume_id, page_number) RRF(d) = Σ 1/(k=60 + rank) Top 15 candidates │ ▼ [ 3.5 SQLite Content Enrichment ] ◄── ดึงเนื้อหาจาก pages_db (เติมเนื้อหาฉบับเต็มลงใน payload ของ vector candidates ที่ไม่มีข้อความ) │ ▼ [ 4. ONNX Reranking ] jina-reranker-v2-base-multilingual (max_length=256) Re-score vs original user query │ ▼ [ 5. Post-Process & Highlight ] Highlight keywords จาก fts_queries │ ┌──────────────────┴──────────────────┐ ▼ ▼ [ UI Search Page ] [ AI Assistant (RAG) ] (Paginated Reader Links) (DeepSeek / Gemma Generation) ``` --- ## 2. Layer Specifications ### Layer 1: Query Transformation — Dual Runtime & Model Comparison *(เหมือนรุ่น V3.4.0 — ใช้ Qwen ในการแปลงข้อความภาษาพูดให้กลายเป็น FTS และ Vector query)* ### Layer 2: Parallel Retrieval (FTS5 & Turbovec) - FTS5 และ Vector Search ทำงานแบบขนานผ่าน `asyncio.gather` - **FTS5**: ค้นหาคีย์เวิร์ดในตารางเสมือน `pages_fts` - **Turbovec (Vector)**: ทำการแปลงคิวรีเป็นเวกเตอร์ 1024 มิติ จากนั้นส่งแบบกลุ่ม (Batch) โดย Stack เวกเตอร์เป็น NumPy Array ไปยัง `tv_index.search(vecs, k=limit)` การประมวลผลเกิดขึ้นในหน่วยความจำของ Process Python ทันที ไม่มี overhead จาก Network หรือ Client-Server serialization ### Layer 3: RRF Score Fusion & SQLite Enrichment - หลังจากรวมคะแนนด้วย Reciprocal Rank Fusion (RRF) และกรองผู้เข้ารอบสูงสุด (เช่น Top 15) - **SQLite Content Enrichment (ขั้นตอนสำคัญ)**: เนื่องจากดัชนี Turbovec (`.tvim`) เก็บเพียง ID ตัวเลขและค่าคะแนนเพื่อประหยัดแรมและลดขนาดไฟล์ เราจึงจำเป็นต้องทำการ Lazy Load ข้อความตัวเต็ม (`content_text`) ของผลลัพธ์เวกเตอร์ โดยเปิด Transaction ไปสืบค้นกับ SQLite In-memory อย่างรวดเร็วก่อนนำข้อมูลไปประมวลผลต่อ ### Layer 4: ONNX Reranking - นำคู่คิวรีต้นฉบับของผู้ใช้และข้อความจากขั้นตอน Enrichment มา Rerank คะแนนใหม่ด้วย `jina-reranker-v2-base-multilingual` รันบน ONNX CPU ด้วยความยาว Tokenizer สูงสุด `max_length=256` --- ## 3. RAM Budget Comparison ด้วยการสลับจาก Qdrant มาใช้ Turbovec ทำให้ประหยัด RAM ได้มหาศาลเนื่องจากไม่ต้องมี Qdrant process รันค้าง ### Local (RTX Laptop / PC Dev) | Component | RAM (Qdrant Backend) | RAM (Turbovec Backend) | |-----------|--------------------|-----------------------| | SQLite in-memory | ~238 MB | ~238 MB | | Vector Engine | **~1.2–1.5 GB** (Qdrant Embedded) | **~14 MB** (Turbovec Index) | | Jina-v5 (ST in-process) | ~1.2 GB | ~1.2 GB | | ONNX Reranker | ~500 MB | ~500 MB | | Qwen LLM (Ollama) | ~0.5 GB | ~0.5 GB | | FastAPI + Python overhead | ~300 MB | ~300 MB | | **รวม** | **~3.9–4.2 GB** | **~2.7–3.0 GB** ✅ | ### HF Space (CPU Basic = 16GB) | Component | RAM (Qdrant Backend) | RAM (Turbovec Backend) | |-----------|--------------------|-----------------------| | SQLite in-memory | ~238 MB | ~238 MB | | Vector Engine | **~1.2–1.5 GB** (Qdrant Embedded) | **~14 MB** (Turbovec Index) | | 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.0 GB | | FastAPI + Python + Ubuntu OS | ~800 MB | ~800 MB | | **รวม** | **~5.0–5.2 GB** | **~3.5–3.7 GB** ✅ | | **Headroom ที่เหลือ** | **~10.8 GB** | **~12.3 GB** ✅ | --- ## 4. Latency & Performance Profile จากการรัน Benchmark เปรียบเทียบความเร็วระหว่างการสืบค้นเวกเตอร์ด้วย Random Queries (k=30, 100 queries) พบว่า Turbovec มีประสิทธิภาพเหนือกว่าอย่างก้าวกระโดด: | Metric | Turbovec (4-bit SQ) | Qdrant Local Server | |--------|---------------------|---------------------| | **Average Latency (Mean)** | **1.425 ms** | **19.418 ms** (ช้ากว่า ~13.5 เท่า) | | **Median (p50)** | **1.353 ms** | **18.724 ms** | | **90th Percentile** | **1.621 ms** | **23.115 ms** | | **Throughput (QPS)** | **701.7 queries/sec** | **51.5 queries/sec** (ต่ำกว่า ~13.6 เท่า) | | **Disk Storage** | **13.2 MB** (`.tvim` file) | **~600+ MB** (snapshots/data) | | **RAM Idle Overhead** | **~14 MB** | **~150–300 MB** (Docker Server) | --- ## 5. Implementation Blueprint ### 5.1 SearchService Implementation (Batch Search & SQLite Enrichment) ```python # app/services/search_service.py import asyncio from typing import List import numpy as np import anyio from app.database.sqlite_db import get_db 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 query (มี Cache ป้องกันการยิง LLM ซ้ำ) transformed = await self.qts.transform(query) fts_queries = transformed["fts_queries"] vector_queries = transformed["vector_queries"] # 2. ค้นหา FTS5 และ Vector คู่ขนานกันจริงๆ fts_tasks = [self._get_fts_results(fq) for fq in fts_queries] vector_tasks = [self._get_vector_batch_results(vector_queries, limit=30)] # รันงานทั้งหมดแบบขนาน fts_results_list, [vector_results_list] = await asyncio.gather( asyncio.gather(*fts_tasks), vector_tasks ) # 3. รวมคะแนนด้วย RRF (Reciprocal Rank 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) # คัดกรองผู้เข้ารอบ 15 อันดับแรกเพื่อส่งให้ Reranker top_keys = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:15] top_candidates = [candidate_data[ky] for ky in top_keys] # 3.5 SQLite Content Enrichment (เติมข้อความเต็มที่ไม่มีในเวกเตอร์ดัชนี) missing_content_cands = [c for c in top_candidates if not c.get("content_text")] if missing_content_cands: try: with self.db.get_connection() as conn: cursor = conn.cursor() for c in missing_content_cands: cursor.execute( "SELECT content_text FROM pages WHERE volume_id = ? AND page_number = ?", (c["volume_id"], c["page_number"]) ) row = cursor.fetchone() if row: # ป้องกันปัญหารับค่า float/int จาก SQLite c["content_text"] = row["content_text"] except Exception as e: import logging logging.getLogger(__name__).warning(f"Enriching candidates content failed: {e}") # 4. Reranking ด้วยโมเดล ONNX Reranker กับประโยคคำถามเดิม if top_candidates and self.rag_service and self.rag_service.reranker and self.rag_service.reranker != "error": final_results = await self._rerank_candidates(query, top_candidates) else: final_results = top_candidates # 5. จัดรูปแบบและทำ Highlight คีย์เวิร์ด return self._format_response(final_results, limit, offset, fts_queries) async def _get_vector_batch_results(self, query_texts: List[str], limit: int = 30) -> List[List[dict]]: """ทำ Batch Search บน Turbovec โดยประมวลผลรวดเดียว""" if not self.rag_service or not self.rag_service.tv_index or not query_texts: return [[] for _ in query_texts] tv = self.rag_service.tv_index def _blocking(): # 1. แปลงคำค้นทั้งหมดเป็นเวกเตอร์ query_vectors = [] valid_indices = [] for idx, text in enumerate(query_texts): vec = self.rag_service._get_embedding(text) if vec: query_vectors.append(vec) valid_indices.append(idx) if not query_vectors: return [[] for _ in query_texts] # 2. ซ้อน NumPy Array เพื่อทำค้นหาแบบกลุ่มใน Turbovec vecs = np.array(query_vectors, dtype=np.float32) all_scores, all_ids = tv.search(vecs, k=limit) final_results = [[] for _ in query_texts] for req_pos, req_idx in enumerate(valid_indices): vec_results = [] for score, uid in zip(all_scores[req_pos], all_ids[req_pos]): if float(score) < 0.2: continue # ถอดรหัส ID: UID = volume * 100,000 + page # และแปลงเป็น int ป้องกันปัญหา numpy.uint64 ใน SQLite vol = int(uid) // 100_000 page = int(uid) % 100_000 vec_results.append({ "volume_id": int(vol), "page_number": int(page), "content_text": "", # โหลดภายหลังในขั้นตอน Enrichment "score": float(score) }) final_results[req_idx] = vec_results return final_results return await anyio.to_thread.run_sync(_blocking) ```