from __future__ import annotations import argparse import json from pathlib import Path from typing import Any from common import read_json, search_lexical_index from rag_profiles import resolve_rag_profile from search_1c_rag_vector import DEFAULT_CORPUS, DEFAULT_INDEX as DEFAULT_VECTOR_INDEX from search_1c_rag_vector import search_vector_index ROOT = Path(__file__).resolve().parents[1] DEFAULT_LEXICAL_INDEX = ROOT / "plugins" / "1c" / "datasets" / "prepared" / "rag_index.json" def document_key(document: dict[str, Any]) -> str: return str(document.get("id") or f"{document.get('source_path')}#{document.get('chunk_index')}") def reciprocal_rank(position: int, *, k: int = 60) -> float: return 1.0 / float(k + position) def hybrid_search( query: str, *, lexical_index_path: Path, vector_index_path: Path, corpus_path: Path, limit: int, candidate_limit: int, source_types: list[str] | None, lexical_weight: float = 1.0, vector_weight: float = 1.0, embedding_base_url: str = "", embedding_api_key_env: str = "OPENAI_API_KEY", ) -> dict[str, Any]: lexical_index = read_json(lexical_index_path) lexical_results = search_lexical_index( lexical_index, query=query, limit=candidate_limit, candidate_limit=candidate_limit, min_score=0.0, source_types=source_types, ) vector_response = search_vector_index( vector_index_path, query, limit=candidate_limit, candidate_limit=candidate_limit, min_score=-1.0, source_types=source_types, corpus_path=corpus_path, embedding_base_url=embedding_base_url, embedding_api_key_env=embedding_api_key_env, ) fused: dict[str, dict[str, Any]] = {} for position, item in enumerate(lexical_results, start=1): key = document_key(item["document"]) fused.setdefault(key, {"document": item["document"], "score": 0.0, "channels": {}}) fused[key]["score"] += lexical_weight * reciprocal_rank(position) fused[key]["channels"]["lexical"] = {"rank": position, "score": item["score"]} for position, item in enumerate(vector_response.get("results") or [], start=1): key = document_key(item["document"]) fused.setdefault(key, {"document": item["document"], "score": 0.0, "channels": {}}) fused[key]["score"] += vector_weight * reciprocal_rank(position) fused[key]["channels"]["vector"] = {"rank": position, "score": item["score"]} results = sorted(fused.values(), key=lambda item: item["score"], reverse=True)[:limit] return { "schema": "onec_rag_hybrid_search.v1", "status": "ok", "query": query, "indexes": {"lexical": str(lexical_index_path), "vector": str(vector_index_path)}, "vector_freshness": vector_response.get("freshness"), "vector_meta": vector_response.get("meta"), "results": results, "counts": { "results": len(results), "lexical_candidates": len(lexical_results), "vector_candidates": len(vector_response.get("results") or []), "fused_candidates": len(fused), }, } def print_result(result: dict, index: int) -> None: document = result["document"] channels = ", ".join(f"{name}#{data['rank']}" for name, data in sorted((result.get("channels") or {}).items())) content = (document.get("content") or "").strip().replace("\n", " ") if len(content) > 500: content = content[:497].rstrip() + "..." print(f"{index}. score={result['score']:.4f} channels={channels}") print(f" source={document.get('source_path')} chunk={document.get('chunk_index')}") print(f" title={document.get('title')}") print(f" content={content}") def main() -> int: parser = argparse.ArgumentParser(description="Hybrid search over 1C RAG lexical and vector indexes.") parser.add_argument("query") parser.add_argument("--lexical-index", type=Path, default=DEFAULT_LEXICAL_INDEX) parser.add_argument("--vector-index", type=Path, default=DEFAULT_VECTOR_INDEX) parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS) parser.add_argument("--profile", default="auto") parser.add_argument("--limit", type=int) parser.add_argument("--candidate-limit", type=int) parser.add_argument("--source-type", action="append", dest="source_types") parser.add_argument("--lexical-weight", type=float, default=1.0) parser.add_argument("--vector-weight", type=float, default=1.0) parser.add_argument("--embedding-base-url", default="") parser.add_argument("--embedding-api-key-env", default="OPENAI_API_KEY") parser.add_argument("--json", action="store_true") args = parser.parse_args() profile = resolve_rag_profile(args.profile, args.query) source_types = args.source_types if args.source_types is not None else profile["source_types"] result = hybrid_search( args.query, lexical_index_path=args.lexical_index, vector_index_path=args.vector_index, corpus_path=args.corpus, limit=args.limit or int(profile["limit"]), candidate_limit=args.candidate_limit or int(profile["candidate_limit"]), source_types=source_types, lexical_weight=args.lexical_weight, vector_weight=args.vector_weight, embedding_base_url=args.embedding_base_url, embedding_api_key_env=args.embedding_api_key_env, ) if args.json: print(json.dumps(result, ensure_ascii=False, indent=2)) elif result["results"]: print(f"vector_freshness={(result.get('vector_freshness') or {}).get('status')}") for position, item in enumerate(result["results"], start=1): print_result(item, position) else: print("No matches.") return 0 if (result.get("vector_freshness") or {}).get("status") != "stale" else 1 if __name__ == "__main__": raise SystemExit(main())