Files
tirth8205--code-review-graph/code_review_graph/search.py
T
2026-07-13 12:42:18 +08:00

448 lines
15 KiB
Python

"""Hybrid search engine combining FTS5 (BM25) and vector embeddings.
Uses Reciprocal Rank Fusion (RRF) to merge results from full-text search
and semantic similarity, with query-aware kind boosting and context-file
boosting for relevance tuning.
"""
from __future__ import annotations
import logging
import re
import sqlite3
from typing import Any, Optional
from .graph import GraphStore, _sanitize_name
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# FTS5 index management
# ---------------------------------------------------------------------------
def rebuild_fts_index(store: GraphStore) -> int:
"""Rebuild the FTS5 index from the nodes table.
Checks whether the ``nodes_fts`` virtual table exists, clears it, then
repopulates it from every row in ``nodes``.
Returns:
Number of rows indexed.
"""
# NOTE: rebuild_fts_index uses store._conn directly because it manages
# the FTS5 virtual table DDL, which is tightly coupled to SQLite internals.
conn = store._conn
# Wrap the full DROP + CREATE + INSERT sequence in an explicit transaction
# so a crash mid-rebuild cannot leave the DB without an FTS table at all
# (DROP succeeded but CREATE/INSERT didn't). See #259.
if conn.in_transaction:
logger.warning("Rolling back uncommitted transaction before BEGIN IMMEDIATE")
conn.rollback()
conn.execute("BEGIN IMMEDIATE")
try:
# Drop and recreate the FTS table with content sync to match migration v5
conn.execute("DROP TABLE IF EXISTS nodes_fts")
conn.execute("""
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, qualified_name, file_path, signature,
content='nodes', content_rowid='rowid',
tokenize='porter unicode61'
)
""")
# Rebuild from the content table (nodes) using the FTS5 rebuild command
conn.execute("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')")
conn.commit()
except BaseException:
conn.rollback()
raise
count = conn.execute("SELECT count(*) FROM nodes_fts").fetchone()[0]
logger.info("FTS index rebuilt: %d rows indexed", count)
return count
# ---------------------------------------------------------------------------
# Query kind boosting heuristics
# ---------------------------------------------------------------------------
_DOTTED_IDENT_RE = re.compile(r'\b[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+\b')
_SNAKE_IDENT_RE = re.compile(r'\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b')
_PASCAL_IDENT_RE = re.compile(r'\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b')
def extract_query_identifiers(query: str) -> list[str]:
"""Pull out identifier-shaped tokens from anywhere in a query.
Catches dotted forms (``Context.Next``), snake_case (``get_dependant``),
and CamelCase (``APIRoute``) even when they're embedded in a natural-
language sentence. Used to boost search hits whose qualified_name
contains any of these tokens, so an LLM asking "Who advances the gin
middleware chain via Context.Next" lands on ``Context.Next`` instead of
the bare ``Context`` class.
"""
found: list[str] = []
seen: set[str] = set()
for pat in (_DOTTED_IDENT_RE, _SNAKE_IDENT_RE, _PASCAL_IDENT_RE):
for match in pat.findall(query):
lo = match.lower()
if lo not in seen and len(lo) >= 3:
seen.add(lo)
found.append(lo)
return found
def detect_query_kind_boost(query: str) -> dict[str, Any]:
"""Detect query patterns and return per-node boost multipliers.
Heuristics:
- PascalCase queries (e.g. ``MyClass``) boost Class/Type by 1.5x
- snake_case queries (e.g. ``get_users``) boost Function by 1.5x
- Queries containing ``.`` boost qualified name matches by 2.0x
- Identifier-shaped tokens *anywhere* in the query (dotted, snake_case,
CamelCase) boost results whose qualified_name contains them by 2.0x.
See ``extract_query_identifiers``.
Returns:
Dict whose keys are either node kind strings (mapped to float
multipliers) or one of the special keys ``_qualified``,
``_qualified_identifiers``.
"""
boosts: dict[str, Any] = {}
if not query or not query.strip():
return boosts
q = query.strip()
# PascalCase: starts with uppercase, has at least one lowercase after
if re.match(r'^[A-Z][a-z]', q) and not q.isupper():
boosts["Class"] = 1.5
boosts["Type"] = 1.5
# snake_case or SCREAMING_SNAKE_CASE: contains underscore with letters
if '_' in q and re.search(r'[a-zA-Z]', q):
boosts["Function"] = 1.5
# Dotted path: boost qualified name matches
if '.' in q:
boosts["_qualified"] = 2.0
# Identifiers extracted from anywhere in the query
idents = extract_query_identifiers(q)
if idents:
boosts["_qualified_identifiers"] = idents
return boosts
# ---------------------------------------------------------------------------
# Reciprocal Rank Fusion
# ---------------------------------------------------------------------------
def rrf_merge(*result_lists: list[tuple[int, float]], k: int = 60) -> list[tuple[int, float]]:
"""Merge multiple ranked result lists using Reciprocal Rank Fusion.
Each input list contains ``(id, score)`` tuples, ordered by score
descending. The RRF score for each item is the sum of
``1 / (k + rank + 1)`` across all lists it appears in, where rank is
the 0-based position.
Args:
*result_lists: Variable number of ranked result lists.
k: RRF constant (default 60). Higher values reduce the impact of
rank differences.
Returns:
Merged list of ``(id, rrf_score)`` tuples sorted by score descending.
"""
scores: dict[int, float] = {}
for result_list in result_lists:
for rank, (item_id, _score) in enumerate(result_list):
scores[item_id] = scores.get(item_id, 0.0) + 1.0 / (k + rank + 1)
merged = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return merged
# ---------------------------------------------------------------------------
# FTS5 search
# ---------------------------------------------------------------------------
def _fts_search(
conn: sqlite3.Connection,
query: str,
limit: int = 50,
) -> list[tuple[int, float]]:
"""Run an FTS5 BM25 search against the nodes_fts table.
Returns list of ``(node_id, bm25_score)`` tuples. The BM25 score is
negated so higher = better (FTS5 returns negative BM25).
"""
# Sanitize: wrap in double quotes to prevent FTS5 operator injection
safe_query = '"' + query.replace('"', '""') + '"'
try:
rows = conn.execute(
"SELECT rowid, rank FROM nodes_fts WHERE nodes_fts MATCH ? "
"ORDER BY rank LIMIT ?",
(safe_query, limit),
).fetchall()
# FTS5 rank is negative BM25 (lower = better), negate for consistency
return [(row[0], -row[1]) for row in rows]
except sqlite3.OperationalError as e:
logger.warning("FTS5 search failed: %s", e)
return []
# ---------------------------------------------------------------------------
# Embedding search (optional)
# ---------------------------------------------------------------------------
def _embedding_search(
store: GraphStore,
query: str,
limit: int = 50,
model: str | None = None,
provider: str | None = None,
) -> list[tuple[int, float]]:
"""Run a vector similarity search using the embedding store.
Returns list of ``(node_id, similarity_score)`` tuples.
Gracefully returns an empty list if embeddings are not available.
"""
try:
from .embeddings import EmbeddingStore
except ImportError:
return []
try:
emb_store = EmbeddingStore(store.db_path, provider=provider, model=model)
try:
if not emb_store.available or emb_store.count() == 0:
return []
results = emb_store.search(query, limit=limit)
# Map qualified names back to node IDs
id_scores: list[tuple[int, float]] = []
for qn, score in results:
node = store.get_node(qn)
if node:
id_scores.append((node.id, score))
return id_scores
finally:
emb_store.close()
except Exception as e:
logger.warning("Embedding search failed: %s", e)
return []
# ---------------------------------------------------------------------------
# Keyword LIKE fallback
# ---------------------------------------------------------------------------
def _keyword_search(
conn: sqlite3.Connection,
query: str,
limit: int = 50,
) -> list[tuple[int, float]]:
"""Fall back to simple LIKE keyword matching.
Each word in the query must match independently (AND logic).
Returns ``(node_id, score)`` tuples with a basic relevance score.
"""
words = query.lower().split()
if not words:
return []
conditions: list[str] = []
params: list[str | int] = []
for word in words:
conditions.append(
"(LOWER(name) LIKE ? OR LOWER(qualified_name) LIKE ?)"
)
params.extend([f"%{word}%", f"%{word}%"])
where = " AND ".join(conditions)
params.append(limit)
sql = f"SELECT id, name, qualified_name FROM nodes WHERE {where} LIMIT ?" # nosec B608
try:
rows = conn.execute(sql, params).fetchall()
except sqlite3.OperationalError:
return []
# Assign a simple relevance score: exact name match > prefix > contains
q_lower = query.lower()
results: list[tuple[int, float]] = []
for row in rows:
name_lower = row["name"].lower()
if name_lower == q_lower:
score = 3.0
elif name_lower.startswith(q_lower):
score = 2.0
else:
score = 1.0
results.append((row["id"], score))
results.sort(key=lambda x: x[1], reverse=True)
return results
# ---------------------------------------------------------------------------
# Main hybrid search
# ---------------------------------------------------------------------------
def hybrid_search(
store: GraphStore,
query: str,
kind: Optional[str] = None,
limit: int = 20,
context_files: Optional[list[str]] = None,
model: Optional[str] = None,
provider: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Hybrid search combining FTS5 BM25 and vector embeddings via RRF.
Attempts FTS5 + embedding search first, falling back to FTS5-only,
then keyword LIKE matching if FTS5 is unavailable.
Args:
store: The graph store to search.
query: Search query string.
kind: Optional node kind filter (e.g. ``"Function"``, ``"Class"``).
limit: Maximum results to return (default 20).
context_files: Optional list of file paths. Nodes in these files
receive a 1.5x score boost.
Returns:
List of dicts with node metadata and ``score`` field.
"""
if not query or not query.strip():
return []
# NOTE: hybrid_search uses store._conn for FTS5 and keyword queries
# because those operate on the FTS virtual table or need raw Row
# access for batch-fetch performance. This is documented coupling.
conn = store._conn
fetch_limit = limit * 3 # Fetch extra to allow for filtering and boosting
# ------ Phase 1: Gather ranked lists ------
fts_results: list[tuple[int, float]] = []
emb_results: list[tuple[int, float]] = []
# Try FTS5 search
try:
fts_results = _fts_search(conn, query, limit=fetch_limit)
except Exception as e:
logger.warning("FTS5 unavailable, will use fallback: %s", e)
# Try embedding search
emb_results = _embedding_search(
store, query, limit=fetch_limit, model=model, provider=provider,
)
# ------ Phase 2: Merge via RRF or fallback ------
if fts_results or emb_results:
lists_to_merge = []
if fts_results:
lists_to_merge.append(fts_results)
if emb_results:
lists_to_merge.append(emb_results)
merged = rrf_merge(*lists_to_merge)
else:
# Fallback: keyword LIKE matching
keyword_results = _keyword_search(conn, query, limit=fetch_limit)
if not keyword_results:
return []
merged = keyword_results
# ------ Phase 3+4: Batch-fetch nodes, apply boosting and kind filter ------
kind_boosts = detect_query_kind_boost(query)
context_set = set(context_files) if context_files else set()
# Batch-fetch all candidate nodes in one query
candidate_ids = [node_id for node_id, _ in merged]
node_rows: dict[int, Any] = {}
batch_size = 450
for i in range(0, len(candidate_ids), batch_size):
batch = candidate_ids[i:i + batch_size]
placeholders = ",".join("?" for _ in batch)
rows = conn.execute(
f"SELECT * FROM nodes WHERE id IN ({placeholders})", # nosec B608
batch,
).fetchall()
for row in rows:
node_rows[row["id"]] = row
# Apply boosting
boosted: list[tuple[int, float]] = []
for node_id, score in merged:
row = node_rows.get(node_id)
if not row:
continue
node_kind = row["kind"]
file_path = row["file_path"]
qualified_name = row["qualified_name"]
boost = 1.0
if node_kind in kind_boosts:
boost *= kind_boosts[node_kind]
if "_qualified" in kind_boosts and '.' in query:
if query.lower() in qualified_name.lower():
boost *= kind_boosts["_qualified"]
idents = kind_boosts.get("_qualified_identifiers")
if idents:
qn_lo = qualified_name.lower()
if any(ident in qn_lo for ident in idents):
boost *= 2.0
if context_set and file_path in context_set:
boost *= 1.5
boosted.append((node_id, score * boost))
boosted.sort(key=lambda x: x[1], reverse=True)
# Build results from the already-fetched rows
results: list[dict[str, Any]] = []
for node_id, final_score in boosted:
if len(results) >= limit:
break
row = node_rows.get(node_id)
if not row:
continue
node_kind = row["kind"]
if kind and node_kind != kind:
continue
results.append({
"name": _sanitize_name(row["name"]),
"qualified_name": _sanitize_name(row["qualified_name"]),
"kind": node_kind,
"file_path": row["file_path"],
"line_start": row["line_start"],
"line_end": row["line_end"],
"language": row["language"] or "",
"params": row["params"],
"return_type": row["return_type"],
"signature": row["signature"] if "signature" in row.keys() else None,
"score": round(final_score, 6),
})
return results