| from __future__ import annotations |
|
|
| import json |
| import math |
| from collections import Counter |
| from pathlib import Path |
|
|
| from rag.schemas import DocumentChunk, RetrievalHit |
|
|
|
|
| class SparseKeywordIndex: |
| def __init__(self, index_path: str) -> None: |
| self.index_path = Path(index_path) |
| self.index_path.parent.mkdir(parents=True, exist_ok=True) |
| self._records: dict[str, dict] = self._load() |
|
|
| def upsert(self, chunks: list[DocumentChunk]) -> None: |
| for chunk in chunks: |
| term_counts = Counter(self._tokenize(chunk.text)) |
| self._records[chunk.chunk_id] = { |
| "document_id": chunk.document_id, |
| "text": chunk.text, |
| "metadata": chunk.metadata, |
| "term_counts": dict(term_counts), |
| "length": sum(term_counts.values()), |
| } |
| self._save() |
|
|
| def search(self, query: str, top_k: int = 5) -> list[RetrievalHit]: |
| query_terms = self._tokenize(query) |
| if not query_terms or not self._records: |
| return [] |
|
|
| doc_freq = Counter() |
| for record in self._records.values(): |
| for token in set(record["term_counts"]): |
| doc_freq[token] += 1 |
|
|
| total_docs = max(len(self._records), 1) |
| avg_doc_length = sum(record["length"] for record in self._records.values()) / total_docs |
| scores: list[tuple[float, str, dict]] = [] |
|
|
| for chunk_id, record in self._records.items(): |
| score = 0.0 |
| for term in query_terms: |
| tf = record["term_counts"].get(term, 0) |
| if not tf: |
| continue |
| idf = math.log(1 + (total_docs - doc_freq[term] + 0.5) / (doc_freq[term] + 0.5)) |
| score += idf * ((tf * 2.2) / (tf + 1.2 * (1 - 0.75 + 0.75 * record["length"] / max(avg_doc_length, 1)))) |
| if score > 0: |
| scores.append((score, chunk_id, record)) |
|
|
| scores.sort(key=lambda item: item[0], reverse=True) |
| hits = [] |
| for score, chunk_id, record in scores[:top_k]: |
| hits.append( |
| RetrievalHit( |
| chunk_id=chunk_id, |
| text=record["text"], |
| score=score, |
| metadata=record["metadata"], |
| ) |
| ) |
| return hits |
|
|
| def list_documents(self) -> list[dict]: |
| seen: dict[str, dict] = {} |
| for record in self._records.values(): |
| document_id = record.get("document_id", "") |
| metadata = record.get("metadata", {}) |
| if document_id and document_id not in seen: |
| seen[document_id] = { |
| "document_id": document_id, |
| "file_name": metadata.get("file_name", ""), |
| } |
| return sorted(seen.values(), key=lambda item: item["file_name"]) |
|
|
| def _load(self) -> dict[str, dict]: |
| if not self.index_path.exists(): |
| return {} |
| try: |
| return json.loads(self.index_path.read_text(encoding="utf-8")) |
| except Exception: |
| return {} |
|
|
| def _save(self) -> None: |
| self.index_path.write_text(json.dumps(self._records, ensure_ascii=True, indent=2), encoding="utf-8") |
|
|
| @staticmethod |
| def _tokenize(text: str) -> list[str]: |
| return [token.strip(".,:;!?()[]{}\"'").lower() for token in text.split() if token.strip()] |
|
|