dhammawatthumpra commited on
Commit
c6f014f
·
1 Parent(s): 806f16f

feat: implement Search Architecture V3 (Hybrid search with Qwen query transformation, batch embeddings, batch Qdrant searches, and optimized CPU reranking)

Browse files
.gitignore CHANGED
@@ -93,9 +93,10 @@ update_colab.py
93
  web_expert.py
94
  webui_tool_tipitaka*.py
95
 
96
- # Old architecture docs
97
  Dhamma-LM*.md
98
  TIPITAKA_WEB_ARCHITECTURE*.md
 
99
 
100
  # Notebooks
101
  *.ipynb
 
93
  web_expert.py
94
  webui_tool_tipitaka*.py
95
 
96
+ # architecture docs
97
  Dhamma-LM*.md
98
  TIPITAKA_WEB_ARCHITECTURE*.md
99
+ Tipitaka-Web-Application-Tech-Stack*.md
100
 
101
  # Notebooks
102
  *.ipynb
webapp/tipitaka-api/app/config.py CHANGED
@@ -36,6 +36,8 @@ class Settings(BaseSettings):
36
  QDRANT_PATH: str = ""
37
  SNAPSHOT_DIR: str = ""
38
  QDRANT_URL: str | None = None
 
 
39
 
40
  def __init__(self, **values):
41
  super().__init__(**values)
 
36
  QDRANT_PATH: str = ""
37
  SNAPSHOT_DIR: str = ""
38
  QDRANT_URL: str | None = None
39
+ ST_EMBED_MODEL: str = "jinaai/jina-embeddings-v5-text-small-retrieval"
40
+ QDRANT_COLLECTION: str = "tipitaka_chunks"
41
 
42
  def __init__(self, **values):
43
  super().__init__(**values)
webapp/tipitaka-api/app/database/sqlite_db.py CHANGED
@@ -104,6 +104,71 @@ class SQLiteDB:
104
  def is_in_memory(self) -> bool:
105
  return self._mem_conn is not None
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  # Singleton database instance
109
  _db: SQLiteDB | None = None
@@ -117,4 +182,5 @@ def get_db() -> SQLiteDB:
117
  _db = SQLiteDB(settings.DATABASE_PATH)
118
  _db.ensure_search_log_table()
119
  _db.ensure_reference_tables()
 
120
  return _db
 
104
  def is_in_memory(self) -> bool:
105
  return self._mem_conn is not None
106
 
107
+ def ensure_query_cache_table(self) -> None:
108
+ """Create query_cache table on disk if not exists."""
109
+ with sqlite3.connect(self.db_path) as conn:
110
+ conn.execute("""
111
+ CREATE TABLE IF NOT EXISTS query_cache (
112
+ cache_key TEXT PRIMARY KEY,
113
+ result_json TEXT NOT NULL,
114
+ hit_count INTEGER DEFAULT 1,
115
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
116
+ last_used DATETIME DEFAULT CURRENT_TIMESTAMP
117
+ )
118
+ """)
119
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_query_cache_last_used ON query_cache(last_used)")
120
+ conn.commit()
121
+
122
+ def get_query_cache(self, key: str) -> dict | None:
123
+ """Retrieve query cache from disk and increment hit count."""
124
+ import json
125
+ try:
126
+ with self.get_disk_connection() as conn:
127
+ row = conn.execute(
128
+ "SELECT result_json FROM query_cache WHERE cache_key = ?", (key,)
129
+ ).fetchone()
130
+ if row:
131
+ conn.execute(
132
+ """UPDATE query_cache
133
+ SET hit_count = hit_count + 1, last_used = datetime('now','localtime')
134
+ WHERE cache_key = ?""", (key,)
135
+ )
136
+ return json.loads(row["result_json"])
137
+ except Exception:
138
+ pass
139
+ return None
140
+
141
+ def set_query_cache(self, key: str, result: dict) -> None:
142
+ """Store query cache to disk and evict oldest if too large."""
143
+ import json
144
+ try:
145
+ self.evict_query_cache(max_entries=10000)
146
+ with self.get_disk_connection() as conn:
147
+ conn.execute(
148
+ """INSERT INTO query_cache (cache_key, result_json) VALUES (?, ?)
149
+ ON CONFLICT(cache_key) DO UPDATE SET
150
+ result_json = excluded.result_json,
151
+ last_used = datetime('now','localtime')""",
152
+ (key, json.dumps(result, ensure_ascii=False))
153
+ )
154
+ except Exception:
155
+ pass
156
+
157
+ def evict_query_cache(self, max_entries: int = 10000) -> None:
158
+ """Evict oldest cache entries on disk if count exceeds max_entries."""
159
+ try:
160
+ with self.get_disk_connection() as conn:
161
+ count = conn.execute("SELECT COUNT(*) FROM query_cache").fetchone()[0]
162
+ if count > max_entries:
163
+ conn.execute("""
164
+ DELETE FROM query_cache WHERE cache_key IN (
165
+ SELECT cache_key FROM query_cache
166
+ ORDER BY last_used ASC LIMIT ?
167
+ )
168
+ """, (count - max_entries,))
169
+ except Exception:
170
+ pass
171
+
172
 
173
  # Singleton database instance
174
  _db: SQLiteDB | None = None
 
182
  _db = SQLiteDB(settings.DATABASE_PATH)
183
  _db.ensure_search_log_table()
184
  _db.ensure_reference_tables()
185
+ _db.ensure_query_cache_table()
186
  return _db
webapp/tipitaka-api/app/routers/search.py CHANGED
@@ -2,12 +2,18 @@ from fastapi import APIRouter, Depends, Query
2
  from typing import List
3
  from app.schemas import SearchResponse
4
  from app.services.search_service import SearchService
 
5
  from app.database.sqlite_db import get_db, SQLiteDB
 
6
 
7
  router = APIRouter(prefix="/search", tags=["Search"])
8
 
9
- def get_search_service(db: SQLiteDB = Depends(get_db)) -> SearchService:
10
- return SearchService(db)
 
 
 
 
11
 
12
  @router.get("/suggestions", response_model=List[str])
13
  def suggestions(
@@ -19,10 +25,10 @@ def suggestions(
19
  return service.get_suggestions(q, limit)
20
 
21
  @router.get("", response_model=SearchResponse)
22
- def search(
23
  q: str = Query(..., min_length=1),
24
  limit: int = Query(50, ge=1, le=100),
25
  offset: int = Query(0, ge=0),
26
  service: SearchService = Depends(get_search_service)
27
  ):
28
- return service.search(q, limit, offset)
 
2
  from typing import List
3
  from app.schemas import SearchResponse
4
  from app.services.search_service import SearchService
5
+ from app.services.query_transform_service import QueryTransformService
6
  from app.database.sqlite_db import get_db, SQLiteDB
7
+ from app.routers.ai import get_llm_service
8
 
9
  router = APIRouter(prefix="/search", tags=["Search"])
10
 
11
+ def get_search_service(
12
+ db: SQLiteDB = Depends(get_db),
13
+ llm_service = Depends(get_llm_service)
14
+ ) -> SearchService:
15
+ qts = QueryTransformService(db)
16
+ return SearchService(db, rag_service=llm_service.rag_service, query_transform_service=qts)
17
 
18
  @router.get("/suggestions", response_model=List[str])
19
  def suggestions(
 
25
  return service.get_suggestions(q, limit)
26
 
27
  @router.get("", response_model=SearchResponse)
28
+ async def search(
29
  q: str = Query(..., min_length=1),
30
  limit: int = Query(50, ge=1, le=100),
31
  offset: int = Query(0, ge=0),
32
  service: SearchService = Depends(get_search_service)
33
  ):
34
+ return await service.search(q, limit, offset)
webapp/tipitaka-api/app/services/onnx_reranker.py CHANGED
@@ -2,6 +2,7 @@ import onnxruntime as ort
2
  from transformers import AutoTokenizer
3
  import numpy as np
4
  import os
 
5
 
6
  class ONNXReranker:
7
  def __init__(self, model_dir, device="cpu"):
@@ -9,8 +10,6 @@ class ONNXReranker:
9
  self.tokenizer = AutoTokenizer.from_pretrained(model_dir, fix_mistral_regex=True)
10
 
11
  # Determine the ONNX file path
12
- # Jina v2 repo puts it in onnx/model.onnx or model_quantized.onnx
13
- # We'll check both.
14
  model_path = os.path.join(model_dir, "onnx", "model_int8.onnx")
15
  if not os.path.exists(model_path):
16
  model_path = os.path.join(model_dir, "onnx", "model_quantized.onnx")
@@ -18,12 +17,23 @@ class ONNXReranker:
18
  model_path = os.path.join(model_dir, "onnx", "model.onnx")
19
 
20
  if not os.path.exists(model_path):
21
- raise FileNotFoundError(f"ONNX model not found in {model_dir}")
22
 
23
  print(f"Loading ONNX model from {model_path}...")
24
- self.session = ort.InferenceSession(model_path, providers=['CPUExecutionProvider'])
25
 
26
- def predict(self, pairs, batch_size=16, **kwargs):
 
 
 
 
 
 
 
 
 
 
 
 
27
  """
28
  Predict scores for (query, passage) pairs.
29
  pairs: list of [query, passage]
@@ -34,23 +44,19 @@ class ONNXReranker:
34
  queries = [p[0] for p in batch]
35
  passages = [p[1] for p in batch]
36
 
37
- # Jina v2 usually expects the pair as single input or separated by EOS
38
- # We'll use the tokenizer's __call__ which handles pairs correctly
39
  inputs = self.tokenizer(
40
  queries,
41
  passages,
42
  padding=True,
43
  truncation=True,
44
- max_length=1024,
45
  return_tensors="np"
46
  )
47
 
48
- # ONNX model usually expects input_ids, attention_mask
49
- # Some also expect token_type_ids
50
  onnx_inputs = {k: v for k, v in inputs.items() if k in [n.name for n in self.session.get_inputs()]}
51
 
52
  outputs = self.session.run(None, onnx_inputs)
53
- # The output is usually logits. Jina v2 has a single output dimension.
54
  logits = outputs[0]
55
 
56
  # Apply sigmoid to get scores in [0, 1]
@@ -58,3 +64,4 @@ class ONNXReranker:
58
  all_scores.extend(scores.flatten().tolist())
59
 
60
  return all_scores
 
 
2
  from transformers import AutoTokenizer
3
  import numpy as np
4
  import os
5
+ import multiprocessing
6
 
7
  class ONNXReranker:
8
  def __init__(self, model_dir, device="cpu"):
 
10
  self.tokenizer = AutoTokenizer.from_pretrained(model_dir, fix_mistral_regex=True)
11
 
12
  # Determine the ONNX file path
 
 
13
  model_path = os.path.join(model_dir, "onnx", "model_int8.onnx")
14
  if not os.path.exists(model_path):
15
  model_path = os.path.join(model_dir, "onnx", "model_quantized.onnx")
 
17
  model_path = os.path.join(model_dir, "onnx", "model.onnx")
18
 
19
  if not os.path.exists(model_path):
20
+ raise FileNotFoundError(f"ONNX model not found in {model_dir}")
21
 
22
  print(f"Loading ONNX model from {model_path}...")
 
23
 
24
+ # Optimize ONNX Runtime session for CPU execution
25
+ opts = ort.SessionOptions()
26
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
27
+
28
+ # Set thread count to physical CPU cores (cap at 4 to prevent thread thrashing)
29
+ cores = multiprocessing.cpu_count()
30
+ opts.intra_op_num_threads = min(4, cores)
31
+ opts.inter_op_num_threads = 1
32
+ opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
33
+
34
+ self.session = ort.InferenceSession(model_path, sess_options=opts, providers=['CPUExecutionProvider'])
35
+
36
+ def predict(self, pairs, batch_size=4, max_length=256, **kwargs):
37
  """
38
  Predict scores for (query, passage) pairs.
39
  pairs: list of [query, passage]
 
44
  queries = [p[0] for p in batch]
45
  passages = [p[1] for p in batch]
46
 
47
+ # Use max_length=256 for fast CPU reranking of snippet relevance
 
48
  inputs = self.tokenizer(
49
  queries,
50
  passages,
51
  padding=True,
52
  truncation=True,
53
+ max_length=max_length,
54
  return_tensors="np"
55
  )
56
 
 
 
57
  onnx_inputs = {k: v for k, v in inputs.items() if k in [n.name for n in self.session.get_inputs()]}
58
 
59
  outputs = self.session.run(None, onnx_inputs)
 
60
  logits = outputs[0]
61
 
62
  # Apply sigmoid to get scores in [0, 1]
 
64
  all_scores.extend(scores.flatten().tolist())
65
 
66
  return all_scores
67
+
webapp/tipitaka-api/app/services/query_transform_service.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import re
4
+ import unicodedata
5
+ from typing import Optional
6
+
7
+ FALLBACK = lambda q: {"fts_queries": [q], "vector_queries": [q]}
8
+
9
+ SYSTEM_PROMPT = """คุณคือผู้เชี่ยวชาญพระไตรปิฎกฉบับมหาจุฬา 45 เล่ม
10
+ ตอบด้วย JSON เท่านั้น รูปแบบ: {"fts_queries":[...],"vector_queries":[...]}
11
+ แต่ละช่องมีได้สูงสุด 2 รายการ ห้ามมีข้อความอื่นนอกจาก JSON"""
12
+
13
+ class QueryTransformService:
14
+ def __init__(self, db):
15
+ self.db = db
16
+
17
+ # ตั้งค่าพารามิเตอร์และ Runtime จากสภาพแวดล้อม
18
+ self.runtime = os.getenv("QWEN_RUNTIME", "transformers")
19
+ self.ollama_url = os.getenv("QWEN_OLLAMA_URL", "http://localhost:11434")
20
+ self.ollama_model = os.getenv("QWEN_OLLAMA_MODEL", "qwen3.5:0.8b")
21
+ self.model_id = os.getenv("QWEN_MODEL_ID", "Qwen/Qwen3.5-0.8B")
22
+
23
+ # ตัวแปรสำหรับ Lazy Loading เมื่อใช้ transformers ในเครือข่าย HF Space
24
+ self._tokenizer = None
25
+ self._model = None
26
+
27
+ def _normalize_key(self, query: str) -> str:
28
+ # 1. ปรับรูปอักขระภาษาไทย (NFC) และแปลงเป็นตัวพิมพ์เล็ก
29
+ q = unicodedata.normalize("NFC", query.strip().lower())
30
+ # 2. ลบเครื่องหมายวรรคตอนและสัญลักษณ์พิเศษยกเว้นเว้นวรรค
31
+ q = re.sub(r"[^\w\s\u0e00-\u0e7f]", "", q)
32
+ # 3. บีบช่องว่างที่ติดกันและลบช่องว่างหัวท้าย
33
+ return re.sub(r"\s+", " ", q).strip()
34
+
35
+ async def transform(self, query: str) -> dict:
36
+ key = self._normalize_key(query)
37
+
38
+ # 1. ตรวจ cache ก่อนเสมอเพื่อความเร็วสูงสูด
39
+ cached = self.db.get_query_cache(key)
40
+ if cached:
41
+ return cached
42
+
43
+ # 2. เรียกใช้โมเดลตาม runtime หาก cache miss
44
+ try:
45
+ if self.runtime == "ollama":
46
+ result = await self._call_ollama(query)
47
+ else:
48
+ result = await self._call_transformers(query)
49
+
50
+ # บันทึกผลลัพธ์ใหม่ลงใน cache
51
+ self.db.set_query_cache(key, result)
52
+ return result
53
+ except Exception:
54
+ return FALLBACK(query)
55
+
56
+ async def _call_ollama(self, query: str) -> dict:
57
+ """Local: ยิง Ollama HTTP API"""
58
+ import httpx
59
+ url = f"{self.ollama_url}/api/chat"
60
+ payload = {
61
+ "model": self.ollama_model, # qwen3.5:0.8b
62
+ "messages": [
63
+ {"role": "system", "content": SYSTEM_PROMPT},
64
+ {"role": "user", "content": query}
65
+ ],
66
+ "stream": False,
67
+ "options": {"num_predict": 80, "temperature": 0}
68
+ }
69
+ async with httpx.AsyncClient(timeout=10.0) as client:
70
+ resp = await client.post(url, json=payload)
71
+ raw = resp.json()["message"]["content"].strip()
72
+ return self._parse_json(raw, query)
73
+
74
+ async def _call_transformers(self, query: str) -> dict:
75
+ """HF Space: Lazy Loading ในแอปเพื่อไม่ให้ Startup บล็อคและเกิด Timeout"""
76
+ import torch
77
+ from transformers import AutoTokenizer, AutoModelForCausalLM
78
+
79
+ # โหลดโมเดลในหน่วยความจำเฉพาะเมื่อถูกใช้งานจริงครั้งแรก (Lazy Loading)
80
+ if self._model is None or self._tokenizer is None:
81
+ self._tokenizer = AutoTokenizer.from_pretrained(
82
+ self.model_id,
83
+ trust_remote_code=True
84
+ )
85
+ # โหลดด้วย float32 เพื่อความปลอดภัยและรันได้เสถียรบน CPU ของ HF Space
86
+ self._model = AutoModelForCausalLM.from_pretrained(
87
+ self.model_id,
88
+ torch_dtype=torch.float32,
89
+ device_map="cpu",
90
+ trust_remote_code=True
91
+ )
92
+ self._model.eval()
93
+
94
+ messages = [
95
+ {"role": "system", "content": SYSTEM_PROMPT},
96
+ {"role": "user", "content": query}
97
+ ]
98
+ text = self._tokenizer.apply_chat_template(
99
+ messages, tokenize=False, add_generation_prompt=True
100
+ )
101
+ inputs = self._tokenizer(text, return_tensors="pt")
102
+
103
+ with torch.no_grad():
104
+ outputs = self._model.generate(
105
+ **inputs,
106
+ max_new_tokens=80,
107
+ do_sample=False,
108
+ temperature=None,
109
+ top_p=None,
110
+ pad_token_id=self._tokenizer.eos_token_id
111
+ )
112
+ raw = self._tokenizer.decode(
113
+ outputs[0][inputs["input_ids"].shape[1]:],
114
+ skip_special_tokens=True
115
+ ).strip()
116
+ return self._parse_json(raw, query)
117
+
118
+ def _parse_json(self, raw: str, original: str) -> dict:
119
+ """สกัดส่วน JSON จากคู่เครื่องหมาย { และ } เพื่อลดข้อผิดพลาดกรณี LLM ตอบประโยคอภิปรายอื่นปนมา"""
120
+ try:
121
+ start_idx = raw.find('{')
122
+ end_idx = raw.rfind('}')
123
+ if start_idx != -1 and end_idx != -1:
124
+ json_str = raw[start_idx:end_idx+1]
125
+ data = json.loads(json_str)
126
+ if "fts_queries" in data and "vector_queries" in data:
127
+ # คลีนข้อมูลภายในให้ชัวร์ว่าเป็น List of string
128
+ fts = [str(x).strip() for x in data["fts_queries"] if x][:2]
129
+ vec = [str(x).strip() for x in data["vector_queries"] if x][:2]
130
+ return {"fts_queries": fts, "vector_queries": vec}
131
+ except Exception:
132
+ pass
133
+ return FALLBACK(original)
webapp/tipitaka-api/app/services/rag_service.py CHANGED
@@ -47,11 +47,11 @@ class RAGService:
47
  self.qdrant_path = settings.QDRANT_PATH
48
  self.snapshot_dir = Path(settings.SNAPSHOT_DIR)
49
  self.ollama_url = getattr(settings, "OLLAMA_URL", "http://localhost:11434")
50
- self.collections = ["tipitaka_chunks", "tipitaka_scripture"]
51
 
52
  self.model = None # Flag: None = not verified, 'ready' = OK
53
  self.reranker = None
54
- self.actual_chunks_col = "tipitaka_chunks" # Default name
55
  self._embed_cache = OrderedDict() # LRU cache: text → embedding
56
  self._embed_cache_max = 256 # max cached entries
57
  self.client = None
@@ -127,6 +127,7 @@ class RAGService:
127
  logger.error(f"Failed to list collections: {e}")
128
  all_cols = []
129
 
 
130
  for col_name in self.collections:
131
  actual_col = None
132
  if col_name in all_cols:
@@ -137,7 +138,7 @@ class RAGService:
137
  actual_col = matches[0]
138
 
139
  if actual_col:
140
- if col_name == "tipitaka_chunks":
141
  self.actual_chunks_col = actual_col
142
  else:
143
  snap_filename = f"{col_name}.snapshot"
@@ -153,7 +154,7 @@ class RAGService:
153
  abs_snap_path = "/" + abs_snap_path
154
  try:
155
  self.client.recover_snapshot(col_name, location=f"file://{abs_snap_path}")
156
- if col_name == "tipitaka_chunks":
157
  self.actual_chunks_col = col_name
158
  except Exception as e:
159
  logger.error(f"Failed to restore: {e}")
@@ -185,39 +186,69 @@ class RAGService:
185
  self.reranker = "error"
186
 
187
  def _get_embedding(self, text: str) -> list:
188
- """Get embedding vector — try Ollama first, fallback to sentence-transformers."""
189
- # Check LRU cache first
190
- if text in self._embed_cache:
191
- self._embed_cache.move_to_end(text) # Mark as recently used
192
- return self._embed_cache[text]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
- # ── Try 1: Ollama (local dev) ──
 
195
  try:
196
  response = httpx.post(
197
  f"{self.ollama_url}/api/embed",
198
- json={"model": EMBED_MODEL, "input": text},
199
- timeout=5
200
  )
201
  response.raise_for_status()
202
- embedding = response.json()["embeddings"][0]
203
- self._store_cache(text, embedding)
204
- return embedding
 
 
205
  except Exception as e:
206
- logger.info(f"Ollama embedding unavailable ({e}), falling back to sentence-transformers")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
- # ── Try 2: sentence-transformers (HF Space / no Ollama) ──
209
- try:
210
- if self._st_model is None:
211
- from sentence_transformers import SentenceTransformer
212
- logger.info(f"Loading sentence-transformers model: {ST_EMBED_MODEL}")
213
- self._st_model = SentenceTransformer(ST_EMBED_MODEL, trust_remote_code=True)
214
-
215
- embedding = self._st_model.encode(text, normalize_embeddings=True).tolist()
216
- self._store_cache(text, embedding)
217
- return embedding
218
- except Exception as e:
219
- logger.error(f"sentence-transformers embedding failed: {e}")
220
- return []
221
 
222
  def _store_cache(self, text: str, embedding: list):
223
  """Store embedding in LRU cache."""
 
47
  self.qdrant_path = settings.QDRANT_PATH
48
  self.snapshot_dir = Path(settings.SNAPSHOT_DIR)
49
  self.ollama_url = getattr(settings, "OLLAMA_URL", "http://localhost:11434")
50
+ self.collections = [settings.QDRANT_COLLECTION, "tipitaka_scripture"]
51
 
52
  self.model = None # Flag: None = not verified, 'ready' = OK
53
  self.reranker = None
54
+ self.actual_chunks_col = settings.QDRANT_COLLECTION # Default name
55
  self._embed_cache = OrderedDict() # LRU cache: text → embedding
56
  self._embed_cache_max = 256 # max cached entries
57
  self.client = None
 
127
  logger.error(f"Failed to list collections: {e}")
128
  all_cols = []
129
 
130
+ settings = get_settings()
131
  for col_name in self.collections:
132
  actual_col = None
133
  if col_name in all_cols:
 
138
  actual_col = matches[0]
139
 
140
  if actual_col:
141
+ if col_name == settings.QDRANT_COLLECTION:
142
  self.actual_chunks_col = actual_col
143
  else:
144
  snap_filename = f"{col_name}.snapshot"
 
154
  abs_snap_path = "/" + abs_snap_path
155
  try:
156
  self.client.recover_snapshot(col_name, location=f"file://{abs_snap_path}")
157
+ if col_name == settings.QDRANT_COLLECTION:
158
  self.actual_chunks_col = col_name
159
  except Exception as e:
160
  logger.error(f"Failed to restore: {e}")
 
186
  self.reranker = "error"
187
 
188
  def _get_embedding(self, text: str) -> list:
189
+ """Get embedding vector — wrapper around batched implementation."""
190
+ res = self._get_embeddings([text])
191
+ return res[0] if res else []
192
+
193
+ def _get_embeddings(self, texts: list[str]) -> list[list[float]]:
194
+ """Get embedding vectors for a list of texts using batching."""
195
+ if not texts:
196
+ return []
197
+
198
+ results = [None] * len(texts)
199
+ missing_indices = []
200
+ missing_texts = []
201
+
202
+ # 1. Check LRU cache first
203
+ for idx, text in enumerate(texts):
204
+ if text in self._embed_cache:
205
+ self._embed_cache.move_to_end(text) # Mark as recently used
206
+ results[idx] = self._embed_cache[text]
207
+ else:
208
+ missing_indices.append(idx)
209
+ missing_texts.append(text)
210
+
211
+ if not missing_texts:
212
+ return results
213
 
214
+ # 2. Try Ollama batch embedding
215
+ ollama_failed = False
216
  try:
217
  response = httpx.post(
218
  f"{self.ollama_url}/api/embed",
219
+ json={"model": EMBED_MODEL, "input": missing_texts},
220
+ timeout=10
221
  )
222
  response.raise_for_status()
223
+ embeddings = response.json()["embeddings"]
224
+ for idx, emb in zip(missing_indices, embeddings):
225
+ self._store_cache(texts[idx], emb)
226
+ results[idx] = emb
227
+ return results
228
  except Exception as e:
229
+ logger.info(f"Ollama batch embedding unavailable ({e}), falling back to sentence-transformers")
230
+ ollama_failed = True
231
+
232
+ # 3. Fallback to sentence-transformers batch embedding
233
+ if ollama_failed:
234
+ try:
235
+ if self._st_model is None:
236
+ settings = get_settings()
237
+ logger.info(f"Loading sentence-transformers model: {settings.ST_EMBED_MODEL}")
238
+ from sentence_transformers import SentenceTransformer
239
+ self._st_model = SentenceTransformer(settings.ST_EMBED_MODEL, trust_remote_code=True)
240
+
241
+ embeddings = self._st_model.encode(missing_texts, normalize_embeddings=True, show_progress_bar=False).tolist()
242
+ for idx, emb in zip(missing_indices, embeddings):
243
+ self._store_cache(texts[idx], emb)
244
+ results[idx] = emb
245
+ return results
246
+ except Exception as e:
247
+ logger.error(f"sentence-transformers batch embedding failed: {e}")
248
+ for idx in missing_indices:
249
+ results[idx] = []
250
+ return results
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
253
  def _store_cache(self, text: str, embedding: list):
254
  """Store embedding in LRU cache."""
webapp/tipitaka-api/app/services/search_service.py CHANGED
@@ -1,6 +1,7 @@
1
  import time
2
  import re
3
- from typing import List
 
4
  from app.database.sqlite_db import SQLiteDB
5
  from app.schemas import SearchResponse, SearchResultItem
6
  from app.services.pali_utils import (
@@ -13,11 +14,14 @@ from app.services.pali_utils import (
13
 
14
 
15
  class SearchService:
16
- def __init__(self, db: SQLiteDB):
17
  self.db = db
 
 
18
 
19
  # ── Autocomplete suggestions ────────────────────────────────────
20
 
 
21
  def get_suggestions(self, prefix: str, limit: int = 8) -> List[str]:
22
  """
23
  Return autocomplete suggestions for `prefix`.
@@ -130,37 +134,184 @@ class SearchService:
130
  result = result[: cut if cut > 0 else max_len].strip()
131
  return result or text[:max_len]
132
 
133
- def highlight(self, text: str, keyword: str) -> str:
134
- """Wrap keyword occurrences in <mark> tags (Thai digits variant too)."""
135
- for kw in set([keyword, to_thai_digits(keyword)]):
136
- if kw and kw in text:
137
- text = text.replace(kw, f"<mark>{kw}</mark>")
138
- return text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
- def search(self, query: str, limit: int = 50, offset: int = 0) -> SearchResponse:
141
- start_time = time.time()
142
 
143
- # ── 1. Auto-correct pipeline ─────────────────────────────
144
- q = query.strip()
145
 
146
- # Stage A: Pali-specific correction (explicit dict + auto-generated variants)
147
- corrected_q = PALI_CORRECTIONS_FULL.get(q, q)
148
 
149
- # Stage B: General Thai spelling correction via PyThaiNLP
150
- if corrected_q == q:
151
- pythai_q = _pythai_autocorrect(q)
152
- if pythai_q != q and _similar_enough(q, pythai_q):
153
- corrected_q = pythai_q
 
 
 
 
154
 
155
- thai_q = to_thai_digits(corrected_q)
156
- like_pattern = f"%{thai_q}%"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
- with self.db.get_connection() as conn:
159
- cursor = conn.cursor()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
- # ── 2. FTS5 search ──────────────────────────────────────
162
- fts_query = f'"{thai_q}"' if "*" not in thai_q else thai_q
 
 
 
 
 
 
163
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  cursor.execute("""
165
  SELECT p.id, p.volume_id, p.page_number, p.content_text, rank
166
  FROM pages_fts f
@@ -168,50 +319,128 @@ class SearchService:
168
  WHERE pages_fts MATCH ?
169
  ORDER BY rank
170
  LIMIT ?
171
- """, (fts_query, limit + offset))
172
- fts_rows = cursor.fetchall()
173
- except Exception:
174
- fts_rows = []
 
175
 
176
- fts_keys = {(r["volume_id"], r["page_number"]) for r in fts_rows}
 
 
 
177
 
178
- # ── 3. LIKE fallback ────────────────────────────────────
179
- cursor.execute("""
180
- SELECT p.id, p.volume_id, p.page_number, p.content_text
181
- FROM pages p
182
- WHERE p.content_text LIKE ?
183
- ORDER BY volume_id, page_number
184
- LIMIT ?
185
- """, (like_pattern, limit + offset))
186
- like_rows = cursor.fetchall()
187
-
188
- # ── 4. Merge & deduplicate ──────────────────────────────
189
- merged: list[SearchResultItem] = []
190
-
191
- for r in fts_rows:
192
- clean = self.clean_text(r["content_text"])
193
- snip = self.make_snippet(clean, thai_q)
194
- merged.append(SearchResultItem(
195
- volume_id=r["volume_id"],
196
- page_number=r["page_number"],
197
- snippet=self.highlight(snip, thai_q),
198
- rank=r["rank"],
199
- ))
200
-
201
- for r in like_rows:
202
- if (r["volume_id"], r["page_number"]) not in fts_keys:
203
- clean = self.clean_text(r["content_text"])
204
- snip = self.make_snippet(clean, thai_q)
205
- merged.append(SearchResultItem(
206
- volume_id=r["volume_id"],
207
- page_number=r["page_number"],
208
- snippet=self.highlight(snip, thai_q),
209
- rank=0.0,
210
- ))
211
-
212
- paginated = merged[offset: offset + limit]
213
-
214
- # ── 5. Breakdown by volume ──────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  cursor.execute("""
216
  SELECT volume_id, COUNT(*) as cnt,
217
  MIN(page_number) as first_page,
@@ -223,27 +452,30 @@ class SearchService:
223
  """, (like_pattern,))
224
  breakdown_rows = cursor.fetchall()
225
 
226
- breakdown_data = {
227
- row["volume_id"]: {
228
- "count": row["cnt"],
229
- "first_page": row["first_page"],
230
- "last_page": row["last_page"],
231
- }
232
- for row in breakdown_rows
233
  }
234
- total_count = sum(r["cnt"] for r in breakdown_rows)
 
 
235
 
236
- time_ms = (time.time() - start_time) * 1000
 
 
237
 
238
- response = SearchResponse(
239
- query=corrected_q,
240
- total_results=total_count,
241
- results=paginated,
242
- time_taken_ms=time_ms,
243
- breakdown_by_volume=breakdown_data,
244
- )
245
 
246
- # Log only if results found (don't pollute search_log with zero-result queries)
247
  if total_count > 0:
248
  self.log_search(corrected_q)
249
- return response
 
 
 
 
 
 
 
 
1
  import time
2
  import re
3
+ import asyncio
4
+ from typing import List, Optional
5
  from app.database.sqlite_db import SQLiteDB
6
  from app.schemas import SearchResponse, SearchResultItem
7
  from app.services.pali_utils import (
 
14
 
15
 
16
  class SearchService:
17
+ def __init__(self, db: SQLiteDB, rag_service=None, query_transform_service=None):
18
  self.db = db
19
+ self.rag_service = rag_service
20
+ self.qts = query_transform_service
21
 
22
  # ── Autocomplete suggestions ────────────────────────────────────
23
 
24
+
25
  def get_suggestions(self, prefix: str, limit: int = 8) -> List[str]:
26
  """
27
  Return autocomplete suggestions for `prefix`.
 
134
  result = result[: cut if cut > 0 else max_len].strip()
135
  return result or text[:max_len]
136
 
137
+ def highlight_multiple(self, text: str, fts_queries: List[str]) -> str:
138
+ """Wrap keywords from fts_queries in <mark> tags without nesting tags."""
139
+ if not text or not fts_queries:
140
+ return text
141
+
142
+ words_to_highlight = set()
143
+ for q in fts_queries:
144
+ parts = re.split(r"[\s\+\-\*\"\'\(\)]+", q)
145
+ for p in parts:
146
+ p_clean = p.strip()
147
+ # Filter out single character words to prevent over-highlighting
148
+ if len(p_clean) > 1:
149
+ words_to_highlight.add(p_clean)
150
+ words_to_highlight.add(to_thai_digits(p_clean))
151
+
152
+ sorted_words = sorted(list(words_to_highlight), key=len, reverse=True)
153
+
154
+ # ค้นหาช่วงตำแหน่งที่ต้องไฮไลท์แบบไม่ซ้อนทับกัน
155
+ ranges = []
156
+ for word in sorted_words:
157
+ if not word:
158
+ continue
159
+ start = 0
160
+ while True:
161
+ idx = text.find(word, start)
162
+ if idx == -1:
163
+ break
164
+ end = idx + len(word)
165
+
166
+ # ตรวจสอบการซ้อนทับกับช่วงที่มาร์คไปแล้ว
167
+ overlap = False
168
+ for rs, re_range in ranges:
169
+ if idx < re_range and end > rs:
170
+ overlap = True
171
+ break
172
 
173
+ if not overlap:
174
+ ranges.append((idx, end))
175
 
176
+ start = idx + 1
 
177
 
178
+ # เรียงลำดับตำแหน่งจากน้อยไปมาก
179
+ ranges.sort()
180
 
181
+ # สร้างข้อความใหม่พร้อมเครื่องหมาย <mark>
182
+ result = []
183
+ last_idx = 0
184
+ for rs, re_range in ranges:
185
+ result.append(text[last_idx:rs])
186
+ result.append(f"<mark>{text[rs:re_range]}</mark>")
187
+ last_idx = re_range
188
+ result.append(text[last_idx:])
189
+ return "".join(result)
190
 
191
+ def highlight(self, text: str, keyword: str) -> str:
192
+ """Wrap keyword in <mark> tags for backward compatibility."""
193
+ return self.highlight_multiple(text, [keyword]) if keyword else text
194
+
195
+ async def _get_vector_results_batch(self, query_texts: List[str], query_vectors: List[List[float]], limit: int = 30) -> List[List[dict]]:
196
+ """Fetch semantic vector results from Qdrant in batch."""
197
+ if not self.rag_service or not self.rag_service.client or not query_vectors:
198
+ return [[] for _ in query_texts]
199
+
200
+ import anyio
201
+ from qdrant_client.http import models as qmodels
202
+
203
+ def _blocking():
204
+ col = self.rag_service.actual_chunks_col
205
+
206
+ # Map request index to original query indices
207
+ valid_requests = []
208
+ req_mapping = []
209
+
210
+ for idx, vec in enumerate(query_vectors):
211
+ if vec:
212
+ valid_requests.append(
213
+ qmodels.SearchRequest(
214
+ vector=qmodels.NamedVector(name="dense", vector=vec),
215
+ limit=limit,
216
+ with_payload=True,
217
+ score_threshold=0.2
218
+ )
219
+ )
220
+ req_mapping.append(idx)
221
+
222
+ if not valid_requests:
223
+ return [[] for _ in query_texts]
224
 
225
+ try:
226
+ # Qdrant search_batch works in both local and server clients
227
+ batch_responses = self.rag_service.client.search_batch(
228
+ collection_name=col,
229
+ requests=valid_requests
230
+ )
231
+ except Exception as e:
232
+ import logging
233
+ logging.getLogger(__name__).warning(f"Qdrant batch query failed: {e}")
234
+ return [[] for _ in query_texts]
235
+
236
+ # Reconstruct list of list of dicts matching query_texts length
237
+ final_results = [[] for _ in query_texts]
238
+ for req_idx, hits in zip(req_mapping, batch_responses):
239
+ vec_results = []
240
+ for hit in hits:
241
+ vol = hit.payload.get("volume", hit.payload.get("volume_id"))
242
+ page = hit.payload.get("page", hit.payload.get("page_number"))
243
+ content = hit.payload.get("content", "")
244
+ vec_results.append({
245
+ "volume_id": vol,
246
+ "page_number": page,
247
+ "content_text": content,
248
+ "score": hit.score
249
+ })
250
+ final_results[req_idx] = vec_results
251
+ return final_results
252
+
253
+ return await anyio.to_thread.run_sync(_blocking)
254
+
255
+ async def _get_vector_results(self, query_text: str, limit: int = 30) -> List[dict]:
256
+ """Fetch semantic vector results from Qdrant via RAGService."""
257
+ if not self.rag_service or not self.rag_service.client:
258
+ return []
259
 
260
+ import anyio
261
+
262
+ def _blocking():
263
+ query_vector = self.rag_service._get_embedding(query_text)
264
+ if not query_vector:
265
+ return []
266
+
267
+ col = self.rag_service.actual_chunks_col
268
  try:
269
+ if hasattr(self.rag_service.client, "search"):
270
+ hits = self.rag_service.client.search(
271
+ collection_name=col,
272
+ query_vector=("dense", query_vector),
273
+ limit=limit,
274
+ with_payload=True,
275
+ score_threshold=0.2
276
+ )
277
+ else:
278
+ response = self.rag_service.client.query_points(
279
+ collection_name=col,
280
+ query=query_vector,
281
+ using="dense",
282
+ limit=limit,
283
+ with_payload=True,
284
+ score_threshold=0.2
285
+ )
286
+ hits = response.points
287
+ except Exception as e:
288
+ import logging
289
+ logging.getLogger(__name__).warning(f"Qdrant query failed: {e}")
290
+ return []
291
+
292
+ vec_results = []
293
+ for hit in hits:
294
+ vol = hit.payload.get("volume", hit.payload.get("volume_id"))
295
+ page = hit.payload.get("page", hit.payload.get("page_number"))
296
+ content = hit.payload.get("content", "")
297
+ vec_results.append({
298
+ "volume_id": vol,
299
+ "page_number": page,
300
+ "content_text": content,
301
+ "score": hit.score
302
+ })
303
+ return vec_results
304
+
305
+ return await anyio.to_thread.run_sync(_blocking)
306
+
307
+ def _get_fts_results(self, query_text: str, limit: int = 50) -> List[dict]:
308
+ """Run SQLite FTS5 search on query_text."""
309
+ thai_q = to_thai_digits(query_text)
310
+ fts_query = f'"{thai_q}"' if "*" not in thai_q else thai_q
311
+
312
+ try:
313
+ with self.db.get_connection() as conn:
314
+ cursor = conn.cursor()
315
  cursor.execute("""
316
  SELECT p.id, p.volume_id, p.page_number, p.content_text, rank
317
  FROM pages_fts f
 
319
  WHERE pages_fts MATCH ?
320
  ORDER BY rank
321
  LIMIT ?
322
+ """, (fts_query, limit))
323
+ rows = cursor.fetchall()
324
+ return [dict(r) for r in rows]
325
+ except Exception:
326
+ return []
327
 
328
+ async def _rerank_candidates(self, original_query: str, candidates: list) -> list:
329
+ """Rerank top candidates against the original query using ONNX Reranker."""
330
+ if not self.rag_service or not self.rag_service.reranker or self.rag_service.reranker == "error":
331
+ return candidates
332
 
333
+ import anyio
334
+ import torch
335
+
336
+ def _blocking_rerank():
337
+ passages = [self.clean_text(c["content_text"])[:1000] for c in candidates]
338
+ pairs = [[original_query, p] for p in passages]
339
+
340
+ try:
341
+ with torch.no_grad():
342
+ scores = self.rag_service.reranker.predict(pairs, show_progress_bar=False, batch_size=4)
343
+
344
+ for i, cand in enumerate(candidates):
345
+ cand["rerank_score"] = float(scores[i])
346
+
347
+ return sorted(candidates, key=lambda x: x.get("rerank_score", 0.0), reverse=True)
348
+ except Exception as e:
349
+ import logging
350
+ logging.getLogger(__name__).warning(f"Reranking failed in search service: {e}")
351
+ return candidates
352
+
353
+ return await anyio.to_thread.run_sync(_blocking_rerank)
354
+
355
+ async def search(self, query: str, limit: int = 50, offset: int = 0) -> SearchResponse:
356
+ start_time = time.time()
357
+ q = query.strip()
358
+
359
+ # ── 1. Auto-correct pipeline ─────────────────────────────
360
+ corrected_q = PALI_CORRECTIONS_FULL.get(q, q)
361
+ if corrected_q == q:
362
+ pythai_q = _pythai_autocorrect(q)
363
+ if pythai_q != q and _similar_enough(q, pythai_q):
364
+ corrected_q = pythai_q
365
+
366
+ thai_q = to_thai_digits(corrected_q)
367
+ like_pattern = f"%{thai_q}%"
368
+
369
+ # ── 2. Query Transformation (LLM) ─────────────────────────
370
+ fts_queries = [corrected_q]
371
+ vector_queries = [corrected_q]
372
+
373
+ if self.qts:
374
+ transformed = await self.qts.transform(corrected_q)
375
+ fts_queries = transformed.get("fts_queries", fts_queries)
376
+ vector_queries = transformed.get("vector_queries", vector_queries)
377
+
378
+ # ── 3. Parallel Retrieval (FTS5 + Qdrant Vector) ───────────
379
+ # Retrieve vector embeddings in batch and perform batch Qdrant searches
380
+ async def retrieve_vector_results():
381
+ if not self.rag_service:
382
+ return [[] for _ in vector_queries]
383
+ query_vectors = await asyncio.to_thread(self.rag_service._get_embeddings, vector_queries)
384
+ return await self._get_vector_results_batch(vector_queries, query_vectors)
385
+
386
+ # Run FTS searches and Vector searches concurrently
387
+ fts_tasks = [asyncio.to_thread(self._get_fts_results, fq) for fq in fts_queries]
388
+
389
+ # Gather FTS results and Vector results concurrently
390
+ fts_results_list, vector_results_list = await asyncio.gather(
391
+ asyncio.gather(*fts_tasks),
392
+ retrieve_vector_results()
393
+ )
394
+
395
+ # ── 4. RRF Fusion ─────────────────────────────────────────
396
+ k = 60
397
+ rrf_scores = {}
398
+ candidate_data = {}
399
+
400
+ for results in fts_results_list:
401
+ for rank, item in enumerate(results):
402
+ key = (item["volume_id"], item["page_number"])
403
+ rrf_scores[key] = rrf_scores.get(key, 0.0) + (1.0 / (k + rank + 1))
404
+ candidate_data.setdefault(key, item)
405
+
406
+ for results in vector_results_list:
407
+ for rank, item in enumerate(results):
408
+ key = (item["volume_id"], item["page_number"])
409
+ rrf_scores[key] = rrf_scores.get(key, 0.0) + (1.0 / (k + rank + 1))
410
+ candidate_data.setdefault(key, item)
411
+
412
+ # Get top 15 candidates by RRF score (down from 30 to speed up reranking)
413
+ top_keys = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:15]
414
+ top_candidates = [candidate_data[ky] for ky in top_keys]
415
+
416
+ # ── 5. Reranking (using Jina Reranker v2 ONNX) ────────────
417
+ if top_candidates:
418
+ final_results = await self._rerank_candidates(corrected_q, top_candidates)
419
+ else:
420
+ final_results = []
421
+
422
+ # ── 6. Pagination & Formatting ────────────────────────────
423
+ paginated = final_results[offset: offset + limit]
424
+
425
+
426
+ formatted_results = []
427
+ for r in paginated:
428
+ clean = self.clean_text(r["content_text"])
429
+ snip = self.make_snippet(clean, corrected_q)
430
+
431
+ # Highlight with FTS keywords
432
+ highlighted_snip = self.highlight_multiple(snip, fts_queries)
433
+
434
+ formatted_results.append(SearchResultItem(
435
+ volume_id=r["volume_id"],
436
+ page_number=r["page_number"],
437
+ snippet=highlighted_snip,
438
+ rank=r.get("rerank_score", r.get("score", 0.0))
439
+ ))
440
+
441
+ # ── 7. Volume Breakdown (SQLite LIKE counts) ───────────────
442
+ with self.db.get_connection() as conn:
443
+ cursor = conn.cursor()
444
  cursor.execute("""
445
  SELECT volume_id, COUNT(*) as cnt,
446
  MIN(page_number) as first_page,
 
452
  """, (like_pattern,))
453
  breakdown_rows = cursor.fetchall()
454
 
455
+ breakdown_data = {
456
+ row["volume_id"]: {
457
+ "count": row["cnt"],
458
+ "first_page": row["first_page"],
459
+ "last_page": row["last_page"],
 
 
460
  }
461
+ for row in breakdown_rows
462
+ }
463
+ total_count = sum(r["cnt"] for r in breakdown_rows)
464
 
465
+ # If zero results via FTS5/LIKE but we got vector results, guarantee total_count > 0
466
+ if total_count == 0 and final_results:
467
+ total_count = len(final_results)
468
 
469
+ time_ms = (time.time() - start_time) * 1000
 
 
 
 
 
 
470
 
471
+ # Log search only if results found
472
  if total_count > 0:
473
  self.log_search(corrected_q)
474
+
475
+ return SearchResponse(
476
+ query=corrected_q,
477
+ total_results=total_count,
478
+ results=formatted_results,
479
+ time_taken_ms=time_ms,
480
+ breakdown_by_volume=breakdown_data,
481
+ )
webapp/tipitaka-api/requirements.txt CHANGED
@@ -10,9 +10,10 @@ huggingface_hub>=0.20
10
  torch>=2.0
11
  pythainlp>=5.0
12
  onnxruntime>=1.17.0
13
- transformers>=4.40.0
14
  numpy>=1.24.0
15
  httpx>=0.27.0
16
  anyio>=4.0.0
17
  pytest>=8.0
18
  pytest-asyncio>=0.24
 
 
10
  torch>=2.0
11
  pythainlp>=5.0
12
  onnxruntime>=1.17.0
13
+ transformers>=4.51.0
14
  numpy>=1.24.0
15
  httpx>=0.27.0
16
  anyio>=4.0.0
17
  pytest>=8.0
18
  pytest-asyncio>=0.24
19
+ accelerate>=0.27.0
webapp/tipitaka-api/tests/test_search_service.py CHANGED
@@ -69,10 +69,11 @@ class TestAutocorrectPipeline:
69
  result = self.service.clean_text(text)
70
  assert result == "ข้อความปกติ"
71
 
72
- def test_search_accepts_query(self):
 
73
  """Verify search method accepts a query without raising."""
74
  # Will not raise if mock handles context manager properly
75
- self.service.search("อริยสัจ", limit=5)
76
 
77
 
78
  class TestLogSearch:
 
69
  result = self.service.clean_text(text)
70
  assert result == "ข้อความปกติ"
71
 
72
+ @pytest.mark.asyncio
73
+ async def test_search_accepts_query(self):
74
  """Verify search method accepts a query without raising."""
75
  # Will not raise if mock handles context manager properly
76
+ await self.service.search("อริยสัจ", limit=5)
77
 
78
 
79
  class TestLogSearch: