mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-02 02:13:46 +00:00
* fix(bm25_index): 修正BM25索引计算中的文档长度归一化问题 修复了在计算BM25相似度时对文档长度进行不正确归一化的bug,确保所有查询都能得到准确的相关性评分。 * up * up * up * up * up * up * up * up * up * up * up * up * up * up * up * up * up * refactor(steps): Rename and adjust indexing step logic - Rename `scan_changes.py` and `reindex.py` to `clear_and_scan.py` - Update implementation details of `ScanChangesStep` and `ClearAndScanStep` - Modify the scheduling mechanism in `WatchChangesStep` - Adjust step registration and parameter configuration in config files - Update related tests to align with the new interface changes * up * feat(daily): replace daily CRUD operations with slug provisioning approach * refactor(tests): migrate CRUD step tests from HTTP server to direct LocalFileStore * up * up * up * up --------- Co-authored-by: huangsen <huangsen.huang@alibaba-inc.com>
130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
"""Hybrid search over file_store using RRF fusion of vector + keyword results."""
|
|
|
|
import asyncio
|
|
|
|
from ..base_step import BaseStep
|
|
from ...components import R
|
|
from ...schema import FileChunk
|
|
from ...utils import expand_links, render_expansion_lines
|
|
|
|
_RRF_K = 60
|
|
_MAX_CANDIDATES = 200
|
|
|
|
|
|
@R.register("search_step")
|
|
class SearchStep(BaseStep):
|
|
"""Hybrid search: run vector + keyword in parallel, fuse via RRF, filter, truncate."""
|
|
|
|
@staticmethod
|
|
def _rrf_merge(
|
|
vector: list[FileChunk],
|
|
keyword: list[FileChunk],
|
|
vector_weight: float,
|
|
) -> list[FileChunk]:
|
|
"""Fuse two ranked lists with Reciprocal Rank Fusion, keyed by chunk.id."""
|
|
text_weight = 1.0 - vector_weight
|
|
merged: dict[str, FileChunk] = {}
|
|
|
|
for rank, chunk in enumerate(vector, start=1):
|
|
contrib = vector_weight / (_RRF_K + rank)
|
|
c = chunk.model_copy(deep=False)
|
|
c.scores = {**chunk.scores, "vector": chunk.scores.get("vector", chunk.score), "score": contrib}
|
|
merged[c.id] = c
|
|
|
|
for rank, chunk in enumerate(keyword, start=1):
|
|
contrib = text_weight / (_RRF_K + rank)
|
|
existing = merged.get(chunk.id)
|
|
if existing is not None:
|
|
existing.scores = {
|
|
**existing.scores,
|
|
"keyword": chunk.scores.get("keyword", chunk.score),
|
|
"score": existing.scores["score"] + contrib,
|
|
}
|
|
else:
|
|
c = chunk.model_copy(deep=False)
|
|
c.scores = {**chunk.scores, "keyword": chunk.scores.get("keyword", chunk.score), "score": contrib}
|
|
merged[c.id] = c
|
|
|
|
results = list(merged.values())
|
|
results.sort(key=lambda r: r.score, reverse=True)
|
|
return results
|
|
|
|
@staticmethod
|
|
def _format_scores(scores: dict[str, float], hybrid: bool) -> str:
|
|
"""Format scores for the answer line: always show fused; show per-branch when hybrid."""
|
|
parts = [f"score={scores.get('score', 0.0):.4f}"]
|
|
if hybrid:
|
|
for k in ("vector", "keyword"):
|
|
v = scores.get(k)
|
|
parts.append(f"{k}={v:.4f}" if v is not None else f"{k}=-")
|
|
return " ".join(parts)
|
|
|
|
async def execute(self):
|
|
assert self.context is not None
|
|
query: str = (self.context.get("query", "") or "").strip()
|
|
limit: int = int(self.context.get("limit", 5))
|
|
min_score: float = float(self.context.get("min_score", 0.0))
|
|
vector_weight: float = float(self.kwargs.get("vector_weight", 0.7))
|
|
candidate_multiplier: float = float(self.kwargs.get("candidate_multiplier", 3.0))
|
|
expand_links_enabled: bool = bool(self.kwargs.get("expand_links", True))
|
|
max_links_per_direction: int = int(self.kwargs.get("max_links_per_direction", 10))
|
|
|
|
if not query:
|
|
self.context.response.success = False
|
|
self.context.response.answer = "Error: query cannot be empty"
|
|
return self.context.response
|
|
assert 0.0 <= vector_weight <= 1.0, f"vector_weight must be in [0, 1], got {vector_weight}"
|
|
assert limit > 0, f"limit must be positive, got {limit}"
|
|
|
|
candidates = min(_MAX_CANDIDATES, max(1, int(limit * candidate_multiplier)))
|
|
search_filter: dict = self.context.get("search_filter", {}) or {}
|
|
|
|
vector_results, keyword_results = await asyncio.gather(
|
|
self.file_store.vector_search(query, candidates, search_filter),
|
|
self.file_store.keyword_search(query, candidates, search_filter),
|
|
)
|
|
|
|
self.logger.info(
|
|
f"[{self.name}] query={query!r} candidates={candidates} "
|
|
f"vector_hits={len(vector_results)} keyword_hits={len(keyword_results)}",
|
|
)
|
|
|
|
hybrid = bool(vector_results) and bool(keyword_results)
|
|
if not vector_results and not keyword_results:
|
|
fused: list[FileChunk] = []
|
|
elif not keyword_results:
|
|
fused = vector_results
|
|
elif not vector_results:
|
|
fused = keyword_results
|
|
else:
|
|
fused = self._rrf_merge(vector_results, keyword_results, vector_weight)
|
|
|
|
if min_score > 0.0:
|
|
fused = [c for c in fused if c.score >= min_score]
|
|
fused = fused[:limit]
|
|
|
|
unique_paths = list(dict.fromkeys(c.path for c in fused))
|
|
link_expansion: dict[str, dict] = (
|
|
await expand_links(self.file_store, unique_paths, max_links_per_direction) if expand_links_enabled else {}
|
|
)
|
|
|
|
answer_lines: list[str] = []
|
|
for c in fused:
|
|
answer_lines.append(
|
|
f"========== {c.path}:{c.start_line}-{c.end_line} "
|
|
f"[{self._format_scores(c.scores, hybrid)}] ==========\n{c.text}",
|
|
)
|
|
answer_lines.extend(render_expansion_lines(link_expansion.get(c.path, {})))
|
|
|
|
self.context.response.answer = "\n".join(answer_lines)
|
|
self.context.response.metadata["results"] = [
|
|
c.model_dump(exclude_none=True, exclude={"embedding"}) for c in fused
|
|
]
|
|
self.context.response.metadata["link_expansion"] = link_expansion
|
|
self.context.response.metadata["counts"] = {
|
|
"vector": len(vector_results),
|
|
"keyword": len(keyword_results),
|
|
"returned": len(fused),
|
|
"hybrid": hybrid,
|
|
}
|
|
return self.context.response
|