Overhaul docs retrieval and web search quality
Replace the abandoned llms-txt-mcp/Chroma docs backend with an in-repo MCP service: SQLite WAL + FTS5 + sentence-transformer embeddings, transactional source replacement, persisted state across restarts, singleflight refresh with conditional requests, hybrid lexical/semantic ranking with exact-duplicate collapse, source/host filters, and explicit-by-default content retrieval. Add docs_rebuild and a docs-rebuild CLI command. Add deterministic llms-full.txt snapshot generation for machine-local menus with hash-validated provenance manifests; lifecycle commands promote a local menu to its snapshot only when the manifest validates. Switch public source profiles to content-bearing llms-full.txt feeds. Improve web search: bounded provider fallback with per-attempt diagnostics and cancellation, an optional Brave Search API provider, strict SearXNG engine selection, capped link/media extraction, and a real engine=browser renderer that routes every request through the existing SSRF vetting while blocking WebSockets, non-GET traffic, and private destinations. Extend release checks with offline unit suites and isolated candidate container tests for both images.
This commit is contained in:
82
docker/docs/context_docs/search.py
Normal file
82
docker/docs/context_docs/search.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .models import SearchResult, StoredDocument
|
||||
from .store import IndexStore
|
||||
|
||||
|
||||
class HybridSearch:
|
||||
def __init__(self, store: IndexStore, embedder, rrf_k: int = 60):
|
||||
self.store = store
|
||||
self.embedder = embedder
|
||||
self.rrf_k = rrf_k
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
sources: list[str] | None = None,
|
||||
hosts: list[str] | None = None,
|
||||
) -> list[SearchResult]:
|
||||
pool_size = max(limit * 8, 40)
|
||||
lexical = self.store.lexical_search(query, pool_size, sources, hosts)
|
||||
candidates = self.store.semantic_candidates(sources, hosts)
|
||||
semantic: list[StoredDocument] = []
|
||||
if candidates:
|
||||
query_vector = np.asarray(await self.embedder.encode_query(query), dtype=np.float32)
|
||||
query_norm = np.linalg.norm(query_vector)
|
||||
scored: list[tuple[float, StoredDocument]] = []
|
||||
for document in candidates:
|
||||
norm = np.linalg.norm(document.embedding) * query_norm
|
||||
score = float(np.dot(document.embedding, query_vector) / norm) if norm else 0.0
|
||||
scored.append((score, document))
|
||||
semantic = [document for _, document in sorted(scored, key=lambda item: (-item[0], item[1].id))[:pool_size]]
|
||||
|
||||
lexical_ranks = {document.id: rank for rank, document in enumerate(lexical, 1)}
|
||||
semantic_ranks = {document.id: rank for rank, document in enumerate(semantic, 1)}
|
||||
documents = {document.id: document for document in [*lexical, *semantic]}
|
||||
scores = defaultdict(float)
|
||||
for identifier, rank in lexical_ranks.items():
|
||||
scores[identifier] += 1.0 / (self.rrf_k + rank)
|
||||
for identifier, rank in semantic_ranks.items():
|
||||
scores[identifier] += 1.0 / (self.rrf_k + rank)
|
||||
|
||||
ordered = sorted(documents.values(), key=lambda item: (-scores[item.id], item.id))
|
||||
groups: dict[str, list[StoredDocument]] = {}
|
||||
group_order: list[str] = []
|
||||
for document in ordered:
|
||||
key = document.content_hash
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
group_order.append(key)
|
||||
groups[key].append(document)
|
||||
|
||||
results: list[SearchResult] = []
|
||||
for key in group_order[:limit]:
|
||||
group = groups[key]
|
||||
primary = group[0]
|
||||
alternates = [
|
||||
{"source": document.configured_source, "url": document.canonical_url}
|
||||
for document in group[1:]
|
||||
]
|
||||
results.append(
|
||||
SearchResult(
|
||||
id=primary.id,
|
||||
configured_source=primary.configured_source,
|
||||
canonical_url=primary.canonical_url,
|
||||
title=primary.title,
|
||||
description=primary.description,
|
||||
heading_path=primary.heading_path,
|
||||
content=primary.content,
|
||||
content_hash=primary.content_hash,
|
||||
score=min(1.0, scores[primary.id] / (2.0 / (self.rrf_k + 1))),
|
||||
lexical_rank=lexical_ranks.get(primary.id),
|
||||
semantic_rank=semantic_ranks.get(primary.id),
|
||||
duplicate_count=len(group),
|
||||
alternate_sources=alternates,
|
||||
)
|
||||
)
|
||||
return results
|
||||
Reference in New Issue
Block a user