146 lines
5.9 KiB
Python
146 lines
5.9 KiB
Python
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())
|