ns = params_kw.copy() kb_id = ns.get('kb_id', '') env = request._run_ns query = ns.get("query") or ns.get("value") or ns.get("keyword") or ns.get("text") or "" tag_ids_str = ns.get("tag_ids", "") # Defensive: legacy UI cached in browser double-encodes the keyword (%E5%88%86... arrives as-is). # Normal path: server already decoded once, no '%' present, this is a no-op. if "%" in query: try: from urllib.parse import unquote d = unquote(query) if d != query: info(f'[search_result] unquote fallback: {query!r} -> {d!r}') query = d except Exception: pass info(f'[search_result] params_kw={params_kw}') info(f'[search_result] parsed: kb_id={kb_id!r} query={query!r} tag_ids={tag_ids_str!r} method={request.method} url={request.url}') file_data = None file_name = None try: reader = await request.multipart() while True: part = await reader.next() if part is None: break if part.name == "file": file_data = await part.read(); file_name = part.filename; break except: pass if not file_data: try: file_data = await request.read(); except: pass if file_data and len(file_data) < 10: file_data = None import base64, io top_k = int(ns.get('top_k', 5)) # 0. Parse tag filter FIRST — find doc_ids that match ALL selected tags tag_doc_ids = None tag_info = "" if tag_ids_str: wanted_tags = [t.strip() for t in tag_ids_str.split(",") if t.strip()] if wanted_tags: try: async with get_sor_context(env, 'rag') as sor: placeholders = [] nsq = {} kb_filter = "" if kb_id and kb_id != "all": kb_filter = "kb_id=${kb_id}$ AND " nsq["kb_id"] = kb_id for i, tid in enumerate(wanted_tags): placeholders.append("${tid_" + str(i) + "}$") nsq["tid_" + str(i)] = tid sql = ("SELECT media_id FROM rag_media_tags " "WHERE " + kb_filter + "media_type='document' AND tag_id IN (" + ",".join(placeholders) + ") " "GROUP BY media_id HAVING COUNT(DISTINCT tag_id)=" + str(len(wanted_tags))) recs = await sor.sqlExe(sql, nsq) tag_doc_ids = set(r.media_id for r in recs) tag_info = " (标签: " + ",".join(wanted_tags[:3]) + ("..." if len(wanted_tags)>3 else "") + ")" info(f'[search_result] tag filter: {len(tag_doc_ids)} docs match {len(wanted_tags)} tags') except Exception as e: info(f'[search_result] tag filter error: {e}') # Early return only when nothing is provided if not query and not tag_ids_str and not file_data: return json.dumps({ "widgettype": "VBox", "options": {"padding": "20px", "spacing": "16px"}, "subwidgets": [ {"widgettype": "Text", "options": {"text": "🔍 知识检索", "cfontsize": 20, "fontWeight": "bold"}}, {"widgettype": "Text", "options": {"text": "请输入检索内容或选择标签", "color": "#666", "marginTop": "20px"}} ] }, ensure_ascii=False) hits = [] raw_rows = [] kw_rows = [] # 读知识库向量引擎:bge-m3(在线文本) / qwen3-vl-embedding(在线多模态) / clip-vith14(GPU本地) emb_engine = 'clip-vith14' try: async with get_sor_context(env, 'rag') as sor: krecs = await sor.sqlExe("SELECT embedding_engine FROM rag_knowledge_bases WHERE id=${kb_id}$", {"kb_id": kb_id}) if krecs: emb_engine = (getattr(krecs[0], 'embedding_engine', '') or 'clip-vith14').strip() except: pass is_vl = emb_engine == 'qwen3-vl-embedding' if emb_engine == 'bge-m3': emb_url = 'https://embedding.opencomputing.net:10443/txte/api/embed' emb_model = 'bge-m3' else: emb_url = 'https://embedding.opencomputing.net:10443/mme/api/embed' emb_model = 'CLIP-ViT-H-14' # VDB 服务地址:读 upapp.rag-vdb(生产已切内网),不硬编码 try: async with get_sor_context(env, 'rag') as sor: _u = await sor.sqlExe("SELECT baseurl FROM upapp WHERE id='rag-vdb'", {}) VDB_BASE = (_u[0].baseurl or '').rstrip('/') if _u else 'https://vectordb.opencomputing.net:10443' except: VDB_BASE = 'https://vectordb.opencomputing.net:10443' if query: # 1. Embed(在线:vl原生 / bge-m3兼容;GPU:CLIP /mme) vec = [] if is_vl: try: from rag.vl_online import vl_embed_contents vec = await vl_embed_contents([{"text": query}]) or [] except: vec = [] elif emb_engine == 'bge-m3': try: from rag.init import _online_embed _vs = await _online_embed(env, [query]) vec = _vs[0] if _vs else [] except: vec = [] else: try: client = StreamHttpClient() resp = await client.request('POST', emb_url, json={"texts": [query], "model": emb_model}) emb = json.loads(resp) vec = emb.get("text_embeddings", emb.get("embeddings", [[]]))[0] except: pass if not vec: return json.dumps({"widgettype": "Text", "options": {"text": "向量化失败(引擎未配置或不可用,请到引擎配置页检查)", "cfontsize": 14, "color": "#e74c3c"}}, ensure_ascii=False) # 2. VDB search try: recall_n = top_k * 3 client2 = StreamHttpClient() resp2 = await client2.request('POST', VDB_BASE + '/v1/query', json={"colname": kb_id, "vector": vec, "pagerows": recall_n, "output_fields": ["*"]}) vdb = json.loads(resp2) raw_rows = vdb.get("data", {}).get("rows", []) if not isinstance(raw_rows, list): raw_rows = [] except: pass # 3. Keyword recall (hybrid search: vector alone misses technical terms) kw_ids = set() try: tokens = [t for t in query.split() if t][:5] or [query] conds = [] nsq = {"kb_id": kb_id} for i, t in enumerate(tokens): conds.append("content LIKE ${kw_" + str(i) + "}$") nsq["kw_" + str(i)] = "%" + t + "%" ksql = "SELECT id, doc_id, content FROM rag_document_chunks WHERE kb_id=${kb_id}$ AND (" + " OR ".join(conds) + ") LIMIT 20" async with get_sor_context(env, 'rag') as sor: krecs = await sor.sqlExe(ksql, nsq) for r in krecs: kw_ids.add(r.id) kw_rows.append({"id": r.id, "doc_id": r.doc_id or "", "score": 0.99, "text": r.content or '', "kw": True}) except Exception as e: info('[search_result] keyword recall failed: %s' % e) seen = set() for row in raw_rows: rid = str(row.get("id", "")) score = row.get("score", 0) # Resolve doc_id from chunk id pattern "xxxx_c0" doc_id = rid.rsplit('_c', 1)[0] if '_c' in rid else rid # Look up chunk text from DB chunk_text = '' async with get_sor_context(env, 'rag') as sor: recs = await sor.sqlExe( "SELECT content, doc_id, metadata FROM rag_document_chunks WHERE id=${id}$", {"id": rid}) chunk_meta = {} if recs: chunk_text = recs[0].content or '' doc_id = recs[0].doc_id or doc_id try: chunk_meta = json.loads(recs[0].metadata) if recs[0].metadata else {} except: pass # Tag filter if tag_doc_ids is not None and doc_id not in tag_doc_ids: continue if chunk_text: is_kw = rid in kw_ids hit = {"id": rid, "doc_id": doc_id, "score": 0.99 if is_kw else score, "text": chunk_text, "kw": is_kw} if chunk_meta: hit.update({k: chunk_meta[k] for k in ('bbox', 'start_time', 'end_time') if k in chunk_meta}) hits.append(hit) seen.add(rid) for kr in kw_rows: if kr["id"] not in seen: if tag_doc_ids is not None and kr["doc_id"] not in tag_doc_ids: continue hits.append(kr) seen.add(kr["id"]) # 在线重排(mm_rerank;未配置/失败则保持召回分排序) rr_applied = False try: from rag.vl_online import vl_rerank cand = hits[:top_k * 3] if cand: rr = await vl_rerank(query, [h.get("text", "") for h in cand]) if rr and isinstance(rr.get("scores"), list) and len(rr["scores"]) == len(cand): for i, s in enumerate(rr["scores"]): cand[i]["score"] = s cand.sort(key=lambda x: x.get("score", 0), reverse=True) rr_applied = True except Exception as e: info('[search_result] rerank failed: %s' % e) if not rr_applied: hits.sort(key=lambda x: x.get("score", 0), reverse=True) hits = hits[:top_k] else: # Tag-only search: query documents table directly (handles text + non-text docs) if tag_doc_ids: try: async with get_sor_context(env, 'rag') as sor: placeholders2 = [] nsq2 = {} kb_cond = "" if kb_id and kb_id != "all": kb_cond = "kb_id=${kb_id}$ AND " nsq2["kb_id"] = kb_id for i, did in enumerate(tag_doc_ids): placeholders2.append("${did_" + str(i) + "}$") nsq2["did_" + str(i)] = did # Get documents (exclude face/voice generated derivatives) sql2 = ("SELECT id, file_name, kb_id, file_path FROM rag_documents WHERE " + kb_cond + "id IN (" + ",".join(placeholders2) + ") AND (metadata IS NULL OR metadata NOT LIKE '%%\"face\"%%') ORDER BY created_at DESC LIMIT " + str(top_k * 2)) docs = await sor.sqlExe(sql2, nsq2) doc_list = [(r.id, r.file_name or '', r.kb_id or '', r.file_path or '') for r in docs] if doc_list: doc_ids = [d[0] for d in doc_list] # Try to get chunks for these docs chunk_placeholders = [] nsq_c = {} for i, did in enumerate(doc_ids): chunk_placeholders.append("${cdid_" + str(i) + "}$") nsq_c["cdid_" + str(i)] = did chunks_sql = ("SELECT id, doc_id, content FROM rag_document_chunks WHERE doc_id IN (" + ",".join(chunk_placeholders) + ") ORDER BY created_at DESC LIMIT " + str(top_k)) chunk_recs = await sor.sqlExe(chunks_sql, nsq_c) chunks_by_doc = {} for cr in chunk_recs: if cr.doc_id not in chunks_by_doc: chunks_by_doc[cr.doc_id] = [] chunks_by_doc[cr.doc_id].append((cr.id, cr.content or '')) for did, fname, kbid, fpath in doc_list[:top_k]: chs = chunks_by_doc.get(did, []) if chs: for cid, ctext in chs[:2]: # up to 2 chunks per doc hits.append({"id": cid, "doc_id": did, "score": 1.0, "text": ctext, "file_name": fname, "file_path": fpath, "kw": False}) else: # Non-text document: render as media widget ext = fname.rsplit('.', 1)[-1].lower() if '.' in fname else '' hits.append({"id": did, "doc_id": did, "score": 1.0, "text": "", "file_name": fname, "file_path": fpath, "file_ext": ext, "kw": False, "is_media": True}) except Exception as e: info(f'[search_result] tag-only lookup error: {e}') header_text = f"🔍 检索: {query}" if query else "🔍 标签检索" # 补全 hits 的 file_name/file_path(向量/关键词召回路径只有 chunk 信息,缺文件名) try: need = sorted({h.get("doc_id", "") for h in hits if h.get("doc_id") and not h.get("file_name")}) if need: ph = [] nsq_d = {} for i, did in enumerate(need): ph.append("${ddid_" + str(i) + "}$") nsq_d["ddid_" + str(i)] = did async with get_sor_context(env, 'rag') as sor: drecs = await sor.sqlExe( "SELECT id, file_name, file_path FROM rag_documents WHERE id IN (" + ",".join(ph) + ")", nsq_d) dmap = {r.id: (r.file_name or '', r.file_path or '') for r in drecs} for h in hits: if not h.get("file_name") and h.get("doc_id") in dmap: fn, fp = dmap[h["doc_id"]] h["file_name"] = fn if not h.get("file_path"): h["file_path"] = fp except Exception as e: info('[search_result] enrich file_name failed: %s' % e) subwidgets = [ {"widgettype": "Text", "options": {"text": header_text + tag_info, "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}}, {"widgettype": "Text", "options": {"text": f"共 {len(hits)} 条结果" + (f" (召回 {len(raw_rows)} 条)" if raw_rows else ""), "cfontsize": 13, "color": "#888", "marginBottom": "16px"}} ] if not hits: subwidgets.append({"widgettype": "Text", "options": {"text": "😔 未找到相关内容", "cfontsize": 14, "color": "#aaa", "halign": "center", "marginTop": "40px"}}) else: def safe_url(p): if not p: return '' if p.startswith('/idfile'): return p return '/idfile' + p video_exts = {'mp4', 'avi', 'mov', 'mkv', 'webm'} audio_exts = {'mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac'} image_exts = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'} for i, h in enumerate(hits): is_media = h.get("is_media", False) score_pct = round(float(h["score"]) * 100, 1) color = "#3b82f6" if score_pct > 60 else ("#10b981" if score_pct > 30 else "#f59e0b") badges = [{"widgettype": "Text", "options": {"text": f"{score_pct}%", "cfontsize": 11, "bgcolor": color, "color": "#fff", "padding": "2px 8px", "borderRadius": "10px"}}] if h.get("kw"): badges.append({"widgettype": "Text", "options": {"text": "📌 关键词命中", "cfontsize": 11, "bgcolor": "#f59e0b", "color": "#fff", "padding": "2px 8px", "borderRadius": "10px", "marginLeft": "6px"}}) card_subwidgets = [ {"widgettype": "HBox", "options": {"alignItems": "center", "marginBottom": "6px"}, "subwidgets": [ {"widgettype": "Text", "options": {"text": f"#{i+1}", "cfontsize": 12, "fontWeight": "bold", "color": color, "marginRight": "8px"}}, *badges, {"widgettype": "Text", "options": {"text": f" {h.get('file_name') or (h.get('id') or '')[:16]}", "cfontsize": 11, "color": "#999", "marginLeft": "8px"}} ]} ] if is_media and h.get("file_ext"): media_url = entire_url(safe_url(h.get("file_path", ""))) ext = h["file_ext"] if ext in video_exts: if media_url: card_subwidgets.append({"widgettype": "Html", "options": {"html": "", "padding": "4px 0"}}) elif ext in audio_exts: if media_url: card_subwidgets.append({"widgettype": "Html", "options": {"html": "", "padding": "4px 0"}}) elif ext in image_exts: if media_url: card_subwidgets.append({"widgettype": "Image", "options": {"url": media_url, "width": "100%", "cheight": 14, "objectFit": "contain", "bgcolor": "#f0f0f0"}}) else: card_subwidgets.append({"widgettype": "Text", "options": {"text": f"📎 {h.get('file_name') or ''}", "cfontsize": 13, "color": "#888"}}) # Position info (bbox for images, timestamps for video/audio) meta_info = [] bbox = h.get("bbox") if bbox and isinstance(bbox, dict): meta_info.append(f"📍 ({bbox.get('x1',0):.0f},{bbox.get('y1',0):.0f})-({bbox.get('x2',0):.0f},{bbox.get('y2',0):.0f})") start_t = h.get("start_time") end_t = h.get("end_time") if start_t is not None or end_t is not None: st = f"{start_t:.1f}s" if start_t is not None else "0s" et = f"{end_t:.1f}s" if end_t is not None else "" if et: meta_info.append(f"⏱ {st} → {et}") else: meta_info.append(f"⏱ {st}") if meta_info: card_subwidgets.append({"widgettype": "Text", "options": {"text": " · ".join(meta_info), "cfontsize": 10, "color": "#888", "marginTop": "2px"}}) else: card_subwidgets.append({"widgettype": "Text", "options": {"text": h["text"][:300], "cfontsize": 13, "color": "#333", "lineHeight": "1.6"}}) subwidgets.append({ "widgettype": "VBox", "options": {"padding": "12px 16px", "marginBottom": "8px", "border": "1px solid #e0e0e0", "borderLeft": f"3px solid {color}", "bgcolor": "#fafafa", "borderRadius": "4px"}, "subwidgets": card_subwidgets }) return json.dumps({"widgettype": "VBox", "options": {"padding": "20px", "spacing": "4px"}, "subwidgets": subwidgets}, ensure_ascii=False, default=str)