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 "" # 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} 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)) if not query: 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) # 1. Embed vec = [] try: client = StreamHttpClient() resp = await client.request('POST', 'https://embedding.opencomputing.net:10443/api/embed', json={"texts": [query], "model": "CLIP-ViT-H-14"}) 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 raw_rows = [] try: recall_n = top_k * 3 client2 = StreamHttpClient() resp2 = await client2.request('POST', 'https://vectordb.opencomputing.net:10443/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() kw_rows = [] 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 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) hits = [] 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 = '' doc_name = '' async with get_sor_context(env, 'rag') as sor: recs = await sor.sqlExe( "SELECT content, doc_id FROM document_chunks WHERE id=${id}$", {"id": rid}) if recs: chunk_text = recs[0].content or '' doc_id = recs[0].doc_id or doc_id if chunk_text: is_kw = rid in kw_ids hits.append({"id": rid, "doc_id": doc_id, "score": 0.99 if is_kw else score, "text": chunk_text, "kw": is_kw}) seen.add(rid) for kr in kw_rows: if kr["id"] not in seen: hits.append(kr) seen.add(kr["id"]) hits.sort(key=lambda x: x.get("score", 0), reverse=True) hits = hits[:top_k] subwidgets = [ {"widgettype": "Text", "options": {"text": f"🔍 检索: {query}", "cfontsize": 18, "fontWeight": "bold", "marginBottom": "8px"}}, {"widgettype": "Text", "options": {"text": f"共 {len(hits)} 条结果 (召回 {len(raw_rows)} 条)", "cfontsize": 13, "color": "#888", "marginBottom": "16px"}} ] if not hits: subwidgets.append({"widgettype": "Text", "options": {"text": "😔 未找到相关内容", "cfontsize": 14, "color": "#aaa", "halign": "center", "marginTop": "40px"}}) else: for i, h in enumerate(hits): 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"}}) subwidgets.append({ "widgettype": "VBox", "options": {"padding": "12px 16px", "marginBottom": "8px", "border": "1px solid #e0e0e0", "borderLeft": f"3px solid {color}", "bgcolor": "#fafafa", "borderRadius": "4px"}, "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['id'][:16]}", "cfontsize": 11, "color": "#999", "marginLeft": "8px"}} ]}, {"widgettype": "Text", "options": {"text": h["text"][:300], "cfontsize": 13, "color": "#333", "lineHeight": "1.6"}} ] }) return json.dumps({"widgettype": "VBox", "options": {"padding": "20px", "spacing": "4px"}, "subwidgets": subwidgets}, ensure_ascii=False, default=str)