feat(retrieval): rerank semantic search results
This commit is contained in:
1 parent
2975619da8
commit
727c499d2c
3 files changed
+56
-4
No files matched your search
@@ -0,0 +1,36 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SPEC = importlib.util.spec_from_file_location('retrieval_server', ROOT / 'shared/retrieval/server.py')
|
||||||
|
MODULE = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(MODULE)
|
||||||
|
|
||||||
|
|
||||||
|
class RetrievalServerTests(unittest.TestCase):
|
||||||
|
def test_rerank_uses_cross_encoder_scores(self):
|
||||||
|
index = MODULE.Index.__new__(MODULE.Index)
|
||||||
|
|
||||||
|
class Reranker:
|
||||||
|
def predict(self, pairs):
|
||||||
|
self.pairs = pairs
|
||||||
|
return [0.1, 0.8, 0.5]
|
||||||
|
|
||||||
|
reranker = Reranker()
|
||||||
|
index.reranker = lambda: reranker
|
||||||
|
rows = [
|
||||||
|
{'path': 'a.md', 'score': 0.9, 'text': 'first'},
|
||||||
|
{'path': 'b.md', 'score': 0.8, 'text': 'second'},
|
||||||
|
{'path': 'c.md', 'score': 0.7, 'text': 'third'},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = index.rerank('query', rows, 2)
|
||||||
|
|
||||||
|
self.assertEqual([item['path'] for item in result], ['b.md', 'c.md'])
|
||||||
|
self.assertEqual(reranker.pairs, [('query', 'first'), ('query', 'second'), ('query', 'third')])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
mcp>=1.9,<2
|
mcp>=1.9,<2
|
||||||
sentence-transformers>=4.1,<5
|
sentence-transformers>=5.4,<6
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Local MCP semantic search backed by Qwen3-Embedding-0.6B."""
|
"""Local MCP semantic search backed by Qwen3 embedding and reranking."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import sqlite3
|
|||||||
|
|
||||||
|
|
||||||
MODEL = "Qwen/Qwen3-Embedding-0.6B"
|
MODEL = "Qwen/Qwen3-Embedding-0.6B"
|
||||||
|
RERANKER_MODEL = "Qwen/Qwen3-Reranker-0.6B"
|
||||||
TEXT_EXTENSIONS = {".c", ".cpp", ".cs", ".go", ".java", ".js", ".json", ".md", ".py", ".ps1", ".rs", ".sh", ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml"}
|
TEXT_EXTENSIONS = {".c", ".cpp", ".cs", ".go", ".java", ".js", ".json", ".md", ".py", ".ps1", ".rs", ".sh", ".toml", ".ts", ".tsx", ".txt", ".yaml", ".yml"}
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class Index:
|
|||||||
self.db.execute("create table if not exists chunks (path text, text text, vector blob)")
|
self.db.execute("create table if not exists chunks (path text, text text, vector blob)")
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.model = None
|
self.model = None
|
||||||
|
self.reranker_model = None
|
||||||
|
|
||||||
def files(self):
|
def files(self):
|
||||||
ignored = {".git", ".venv", "generated", "node_modules", ".my-ai-configuration"}
|
ignored = {".git", ".venv", "generated", "node_modules", ".my-ai-configuration"}
|
||||||
@@ -49,6 +51,19 @@ class Index:
|
|||||||
self.model = SentenceTransformer(MODEL)
|
self.model = SentenceTransformer(MODEL)
|
||||||
return self.model
|
return self.model
|
||||||
|
|
||||||
|
def reranker(self):
|
||||||
|
if self.reranker_model is None:
|
||||||
|
from sentence_transformers import CrossEncoder
|
||||||
|
self.reranker_model = CrossEncoder(RERANKER_MODEL)
|
||||||
|
return self.reranker_model
|
||||||
|
|
||||||
|
def rerank(self, query: str, rows: list[dict], limit: int):
|
||||||
|
candidates = rows[:min(len(rows), max(limit, 20))]
|
||||||
|
scores = self.reranker().predict([(query, item["text"]) for item in candidates])
|
||||||
|
for item, score in zip(candidates, scores):
|
||||||
|
item["score"] = round(float(score), 6)
|
||||||
|
return sorted(candidates, key=lambda item: item["score"], reverse=True)[:limit]
|
||||||
|
|
||||||
def rebuild(self):
|
def rebuild(self):
|
||||||
encoder = self.encoder()
|
encoder = self.encoder()
|
||||||
values = []
|
values = []
|
||||||
@@ -73,7 +88,8 @@ class Index:
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
score = float(np.dot(vector, np.frombuffer(blob, dtype=np.float32)))
|
score = float(np.dot(vector, np.frombuffer(blob, dtype=np.float32)))
|
||||||
rows.append({"path": path, "score": round(score, 6), "text": text})
|
rows.append({"path": path, "score": round(score, 6), "text": text})
|
||||||
return sorted(rows, key=lambda item: item["score"], reverse=True)[:max(1, min(top_k, 20))]
|
limit = max(1, min(top_k, 20))
|
||||||
|
return self.rerank(query, sorted(rows, key=lambda item: item["score"], reverse=True), limit)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -91,7 +107,7 @@ def main():
|
|||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def semantic_search(query: str, top_k: int = 5) -> list[dict]:
|
def semantic_search(query: str, top_k: int = 5) -> list[dict]:
|
||||||
"""Find repository passages by meaning with Qwen3-Embedding-0.6B."""
|
"""Find repository passages with Qwen3 embedding and reranking."""
|
||||||
return index.search(query, top_k)
|
return index.search(query, top_k)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|||||||
Reference in new issue
Block a user