ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
# Sparse Vector Search Engine (BM25) / 稀疏向量搜索引擎(BM25)
|
||||
|
||||
> Companion material for *AI Agents in Depth*, Chapter 3 — **Experiment 3-5**: educational BM25 / inverted-index sparse search with offline CLI evaluation.
|
||||
> 配套《深入理解 AI Agent》第 3 章 **实验 3-5**:从零理解 BM25 与倒排索引,含可离线评测的 CLI。
|
||||
|
||||
← [Chapter 3 index / 返回第 3 章目录](../README.md)
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### Overview
|
||||
|
||||
An educational sparse vector search engine using an inverted index and BM25. It demonstrates core IR concepts with extensive logging and visualization.
|
||||
|
||||
### Features
|
||||
|
||||
- **Full BM25 implementation**
|
||||
- **Advanced tokenization**: numbers, codes, technical terms, mixed case
|
||||
- **Inverted index** for term lookup
|
||||
- **HTTP API** (FastAPI)
|
||||
- **Interactive Web UI** for index/search
|
||||
- **Educational logging** through index and search
|
||||
- **Index visualization** APIs
|
||||
- **In-memory storage** (educational simplicity)
|
||||
|
||||
#### Tokenization capabilities
|
||||
|
||||
- **Numbers**: `404`, `3.14`, `2.0.1`
|
||||
- **Codes**: `XK9-2B4-7Q1`, `API_KEY_123`
|
||||
- **Technical terms**: `C++`, `.NET`, `Node.js`
|
||||
- **Mixed case**: `JavaScript`, `PyTorch`, `iPhone`
|
||||
- **Email**: `user@example.com`
|
||||
- **Hex**: `#FF5733`, `0x1234`
|
||||
- **Acronyms**: `API`, `HTTP`, `NASA`
|
||||
- **Alphanumeric**: `Python3`, `ES6`, `HTML5`
|
||||
|
||||
### Architecture
|
||||
|
||||
1. **TextProcessor**: tokenizer for words, numbers, codes, technical terms, mixed case
|
||||
2. **InvertedIndex**: term/document frequencies
|
||||
3. **BM25**: ranking
|
||||
4. **SparseSearchEngine**: orchestration
|
||||
5. **HTTP Server**: FastAPI surface
|
||||
|
||||
BM25 uses TF, IDF, and document-length normalization. Key params: `k1` (default 1.5), `b` (default 0.75).
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# From the repository root: use the shared Chapter 3 environment
|
||||
uv sync --locked --python 3.12 --extra ch3
|
||||
|
||||
# Activate it before changing directories:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# pip fallback when uv is not installed:
|
||||
# python -m pip install -e ".[ch3]"
|
||||
|
||||
cd chapter3/sparse-embedding
|
||||
|
||||
# Single-project compatibility path, still supported during migration:
|
||||
# python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`cli.py` (below) uses only the Python standard library and runs offline with no third-party packages; `server.py` / `demo.py` need the shared `ch3` environment or the compatibility `requirements.txt` path.
|
||||
|
||||
### CLI tool `cli.py` (Experiment 3-5, recommended entry)
|
||||
|
||||
Fully offline CLI: BM25 on a built-in 10-doc corpus, per-term TF/IDF/BM25 contribution logs (as in the book), and labelled recall/precision/MRR. All flags have Chinese `--help`.
|
||||
|
||||
```bash
|
||||
python cli.py --help # all flags (Chinese)
|
||||
python cli.py # default demo query "model distillation"
|
||||
python cli.py -q "model distillation" --explain # per-term TF/IDF/BM25
|
||||
python cli.py --eval # recall@k / precision@k / MRR
|
||||
python cli.py -q "cat" # synonym failure (kitten/feline miss)
|
||||
python cli.py --corpus my.json -q "查询" -o out.json
|
||||
python cli.py --k1 2.0 -b 0.5 -q "..."
|
||||
python cli.py --method splade -q "..." # SPLADE (needs downloaded model)
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
| --- | --- |
|
||||
| `-q, --query` | Query string (default `model distillation`) |
|
||||
| `-c, --corpus` | Corpus (`.json` array or `.jsonl`); default built-in sample |
|
||||
| `-m, --method` | `bm25` (default, offline) or `splade` (learned sparse; needs model) |
|
||||
| `-k, --top-k` | Top-k (default 5) |
|
||||
| `-o, --output` | Write results/metrics JSON |
|
||||
| `--eval` | Evaluate recall@k / precision@k / MRR on labels |
|
||||
| `--labels` | Custom labels `{query: [doc_id,...]}` |
|
||||
| `--explain` | Per-term TF / IDF / BM25 on hits |
|
||||
| `--k1` / `-b` | BM25 k1 and b |
|
||||
| `-v, --verbose` | Engine DEBUG logs |
|
||||
|
||||
#### Retrieval quality (`--eval`)
|
||||
|
||||
Built-in labels cover exact keywords, error codes, proper names, and synonym-only queries. Real `python cli.py --eval` output (k=5):
|
||||
|
||||
```
|
||||
查询 'model distillation' recall@5=1.00 precision@5=1.00 RR=1.00
|
||||
查询 'HTTP 404 error' recall@5=1.00 precision@5=0.50 RR=1.00
|
||||
查询 'XK9-2B4-7Q1' recall@5=1.00 precision@5=1.00 RR=1.00
|
||||
查询 'BM25 ranking function' recall@5=1.00 precision@5=1.00 RR=1.00
|
||||
查询 'cat' recall@5=0.00 precision@5=0.00 RR=0.00 <- 漏召回(同义词短板)
|
||||
宏平均 recall@5=0.800 precision@5=0.700 MRR=0.800 漏召回率(1-recall@5)=0.200
|
||||
```
|
||||
|
||||
BM25 excels on exact keywords, codes, and names (recall=1.0) but misses synonyms—query `cat` does not hit docs that only say `kitten` / `feline`. That gap motivates hybrid search (Experiment 3-6 `retrieval-pipeline`).
|
||||
|
||||
#### Learned sparse (`--method splade`)
|
||||
|
||||
SPLADE weights terms with a masked LM and can expand semantically related terms. Needs pretrained `naver/splade-cocondenser-ensembledistil` (`torch`, `transformers`). Offline without weights, the command fails fast with a clear message (BM25 path needs no model). Online: `huggingface-cli download naver/splade-cocondenser-ensembledistil` then run.
|
||||
|
||||
### Server usage
|
||||
|
||||
```bash
|
||||
python server.py
|
||||
```
|
||||
|
||||
Server: `http://localhost:4241`. Web UI: open that URL. API docs: `http://localhost:4241/docs`.
|
||||
|
||||
#### API endpoints
|
||||
|
||||
```bash
|
||||
POST /index
|
||||
{
|
||||
"text": "Your document text here",
|
||||
"metadata": {"title": "Document Title", "category": "Category"}
|
||||
}
|
||||
|
||||
POST /search
|
||||
{
|
||||
"query": "your search query",
|
||||
"top_k": 10
|
||||
}
|
||||
|
||||
GET /stats
|
||||
GET /index/structure
|
||||
GET /document/{doc_id}
|
||||
DELETE /index
|
||||
```
|
||||
|
||||
#### Demo
|
||||
|
||||
```bash
|
||||
python demo.py
|
||||
```
|
||||
|
||||
Demo: clear index → sample CS docs → stats → index structure → sample queries → document get.
|
||||
|
||||
### Educational features
|
||||
|
||||
Logging covers tokenization, TF, IDF, per-term BM25, query processing, candidates. `/index/structure` returns inverted map, doc stats, BM25 params, global TF distribution. Search results include matched terms, doc length, TFs, per-term score contributions.
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
sparse-embedding/
|
||||
├── bm25_engine.py # Core engine
|
||||
├── cli.py # Offline CLI: BM25/SPLADE + metrics
|
||||
├── server.py # FastAPI server
|
||||
├── demo.py # Demo script
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Limitations
|
||||
|
||||
In-memory only; basic tokenization (no lemmatization); English stopwords; no phrase queries / synonyms / multi-thread.
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 概述
|
||||
|
||||
基于倒排索引与 BM25 的教学型稀疏向量搜索引擎,用详细日志与可视化帮助理解信息检索基本概念。
|
||||
|
||||
### 功能特性
|
||||
|
||||
- **完整 BM25 实现**
|
||||
- **高级分词**:数字、编码、技术术语、大小写混合
|
||||
- **倒排索引**
|
||||
- **HTTP API**(FastAPI)
|
||||
- **交互式 Web UI**
|
||||
- **教学日志**
|
||||
- **索引结构可视化 API**
|
||||
- **内存存储**(教学简化)
|
||||
|
||||
#### 分词能力
|
||||
|
||||
- **数字**:`404`、`3.14`、`2.0.1`
|
||||
- **编码**:`XK9-2B4-7Q1`、`API_KEY_123`
|
||||
- **技术术语**:`C++`、`.NET`、`Node.js`
|
||||
- **大小写混合**:`JavaScript`、`PyTorch`、`iPhone`
|
||||
- **邮箱**:`user@example.com`
|
||||
- **十六进制**:`#FF5733`、`0x1234`
|
||||
- **缩写**:`API`、`HTTP`、`NASA`
|
||||
- **字母数字**:`Python3`、`ES6`、`HTML5`
|
||||
|
||||
### 架构
|
||||
|
||||
1. **TextProcessor**:分词
|
||||
2. **InvertedIndex**:词频 / 文档频率
|
||||
3. **BM25**:相关性打分
|
||||
4. **SparseSearchEngine**:总控
|
||||
5. **HTTP Server**:FastAPI
|
||||
|
||||
BM25 使用 TF、IDF 与文档长度归一化。关键参数:`k1`(默认 1.5)、`b`(默认 0.75)。
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 在仓库根目录使用统一的第 3 章环境
|
||||
uv sync --locked --python 3.12 --extra ch3
|
||||
|
||||
# 切换目录前先激活环境:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell:.venv\Scripts\Activate.ps1
|
||||
# Windows cmd:.venv\Scripts\activate.bat
|
||||
|
||||
# 未安装 uv 时可用 pip 兜底:
|
||||
# python -m pip install -e ".[ch3]"
|
||||
|
||||
cd chapter3/sparse-embedding
|
||||
|
||||
# 迁移期间仍支持单项目兼容路径:
|
||||
# python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
`cli.py` 只依赖 Python 标准库,无需第三方包即可离线运行;`server.py` / `demo.py` 需要统一 `ch3` 环境或兼容 `requirements.txt` 路径提供的 FastAPI 等依赖。
|
||||
|
||||
### 命令行工具 cli.py(实验 3-5,推荐入口)
|
||||
|
||||
完全离线:在内置 10 篇小语料上跑 BM25、复现书中「逐词 IDF/TF/BM25 贡献」日志,并在标注集上算 recall/precision/MRR。参数均有中文 `--help`。
|
||||
|
||||
```bash
|
||||
python cli.py --help # 查看全部参数(中文)
|
||||
python cli.py # 默认演示:查询 "model distillation"
|
||||
python cli.py -q "model distillation" --explain # 逐词展示 TF/IDF/BM25 贡献
|
||||
python cli.py --eval # recall@k / precision@k / MRR
|
||||
python cli.py -q "cat" # 观察同义词短板(kitten/feline 漏召回)
|
||||
python cli.py --corpus my.json -q "查询" -o out.json
|
||||
python cli.py --k1 2.0 -b 0.5 -q "..."
|
||||
python cli.py --method splade -q "..." # SPLADE(需预先下载模型)
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `-q, --query` | 查询字符串(默认 `model distillation`) |
|
||||
| `-c, --corpus` | 语料(`.json` 数组或 `.jsonl`);缺省用内置示例 |
|
||||
| `-m, --method` | `bm25`(默认,离线)或 `splade`(学习型稀疏,需模型) |
|
||||
| `-k, --top-k` | 返回前 k 条(默认 5) |
|
||||
| `-o, --output` | 结果 / 指标写入 JSON |
|
||||
| `--eval` | 在标注集上评测 |
|
||||
| `--labels` | 自定义标注 `{query: [doc_id,...]}` |
|
||||
| `--explain` | 逐词 TF / IDF / BM25 |
|
||||
| `--k1` / `-b` | BM25 参数 |
|
||||
| `-v, --verbose` | DEBUG 日志 |
|
||||
|
||||
#### 检索质量评测(`--eval`)
|
||||
|
||||
内置标注覆盖精确关键词、错误码、专有名称与「只有同义表达」的查询。真实输出(k=5):
|
||||
|
||||
```
|
||||
查询 'model distillation' recall@5=1.00 precision@5=1.00 RR=1.00
|
||||
查询 'HTTP 404 error' recall@5=1.00 precision@5=0.50 RR=1.00
|
||||
查询 'XK9-2B4-7Q1' recall@5=1.00 precision@5=1.00 RR=1.00
|
||||
查询 'BM25 ranking function' recall@5=1.00 precision@5=1.00 RR=1.00
|
||||
查询 'cat' recall@5=0.00 precision@5=0.00 RR=0.00 <- 漏召回(同义词短板)
|
||||
宏平均 recall@5=0.800 precision@5=0.700 MRR=0.800 漏召回率(1-recall@5)=0.200
|
||||
```
|
||||
|
||||
BM25 在精确关键词、错误码、专有名称上极佳,但读不懂同义词——查询 `cat` 无法命中只写 `kitten` / `feline` 的文档。这正是引入混合检索(实验 3-6 `retrieval-pipeline`)的动机。
|
||||
|
||||
#### 学习型稀疏检索(`--method splade`)
|
||||
|
||||
用掩码语言模型为词项打权,并可为语义相关词项补权。需下载 `naver/splade-cocondenser-ensembledistil`(依赖 `torch`、`transformers`)。离线无权重时会快速给出清晰提示。联网可先 `huggingface-cli download naver/splade-cocondenser-ensembledistil`。
|
||||
|
||||
### 服务端用法
|
||||
|
||||
```bash
|
||||
python server.py
|
||||
```
|
||||
|
||||
服务地址:`http://localhost:4241`。Web UI 打开该地址。API 文档:`http://localhost:4241/docs`。
|
||||
|
||||
#### API 端点
|
||||
|
||||
```bash
|
||||
POST /index
|
||||
{
|
||||
"text": "Your document text here",
|
||||
"metadata": {"title": "Document Title", "category": "Category"}
|
||||
}
|
||||
|
||||
POST /search
|
||||
{
|
||||
"query": "your search query",
|
||||
"top_k": 10
|
||||
}
|
||||
|
||||
GET /stats
|
||||
GET /index/structure
|
||||
GET /document/{doc_id}
|
||||
DELETE /index
|
||||
```
|
||||
|
||||
#### 运行演示
|
||||
|
||||
```bash
|
||||
python demo.py
|
||||
```
|
||||
|
||||
演示:清空索引 → 示例文档 → 统计 → 索引结构 → 查询 → 按 ID 取文档。
|
||||
|
||||
### 教学特性
|
||||
|
||||
日志覆盖分词、TF、IDF、逐词 BM25、查询处理、候选文档。`/index/structure` 返回倒排映射、文档统计、BM25 参数、全局词频分布。检索结果含匹配词、文档长度、词频与分项得分。
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
sparse-embedding/
|
||||
├── bm25_engine.py # 核心检索引擎
|
||||
├── cli.py # 离线 CLI:BM25/SPLADE + 指标
|
||||
├── server.py # FastAPI 服务
|
||||
├── demo.py # 演示脚本
|
||||
├── requirements.txt
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 局限
|
||||
|
||||
仅内存存储;分词较基础(无词形还原);英文停用词;不支持短语查询 / 同义扩展 / 多线程。
|
||||
|
||||
---
|
||||
|
||||
## Notes / 说明
|
||||
|
||||
- Related next step / 相关后续:[`../retrieval-pipeline/`](../retrieval-pipeline/) hybrid dense+sparse pipeline (Exp. 3-6).
|
||||
- 相关后续:[`../retrieval-pipeline/`](../retrieval-pipeline/) 混合稠密+稀疏流水线(实验 3-6)。
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Acceptance campaign for Chapter 3 Experiments 3-5.
|
||||
|
||||
This intentionally exercises the repository's from-scratch inverted index and
|
||||
BM25 implementation. It checks one score against an independent, explicit
|
||||
calculation and then measures the exact-keyword/synonym contrast on a labelled
|
||||
corpus. No third-party retrieval implementation is used as an oracle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent
|
||||
CHAPTER_DIR = PROJECT_DIR.parent
|
||||
sys.path.insert(0, str(CHAPTER_DIR))
|
||||
|
||||
from experiment_utils import write_campaign_evidence # noqa: E402
|
||||
from bm25_engine import BM25, InvertedIndex, TextProcessor # noqa: E402
|
||||
from cli import DEFAULT_CORPUS, DEFAULT_LABELS, build_engine # noqa: E402
|
||||
|
||||
|
||||
K1 = 1.5
|
||||
B = 0.75
|
||||
TOP_K = 5
|
||||
|
||||
|
||||
def hand_calculation() -> dict[str, Any]:
|
||||
"""Compare engine output with a hand-calculable RSJ BM25 example."""
|
||||
|
||||
texts = [
|
||||
"rare rare common",
|
||||
"common common",
|
||||
"common filler filler filler",
|
||||
"filler",
|
||||
]
|
||||
index = InvertedIndex()
|
||||
for doc_id, text in enumerate(texts):
|
||||
index.add_document(doc_id, text, {"source": "hand-check"})
|
||||
bm25 = BM25(index, k1=K1, b=B)
|
||||
|
||||
term = "rare"
|
||||
doc_id = 0
|
||||
n_docs = len(texts)
|
||||
df = 1
|
||||
tf = 2
|
||||
dl = 3
|
||||
avgdl = sum(len(TextProcessor().tokenize(text)) for text in texts) / n_docs
|
||||
idf = math.log((n_docs - df + 0.5) / (df + 0.5))
|
||||
numerator = tf * (K1 + 1)
|
||||
denominator = tf + K1 * (1 - B + B * (dl / avgdl))
|
||||
expected = idf * numerator / denominator
|
||||
raw_engine_idf = bm25.calculate_raw_idf(term)
|
||||
actual = bm25.calculate_term_score(term, doc_id)
|
||||
tolerance = 1e-12
|
||||
|
||||
return {
|
||||
"corpus": texts,
|
||||
"term": term,
|
||||
"doc_id": doc_id,
|
||||
"parameters": {"N": n_docs, "df": df, "tf": tf, "dl": dl, "avgdl": avgdl, "k1": K1, "b": B},
|
||||
"formula": "ln((N-df+0.5)/(df+0.5)) * tf*(k1+1) / (tf+k1*(1-b+b*dl/avgdl))",
|
||||
"intermediate": {
|
||||
"independent_raw_idf": idf,
|
||||
"engine_raw_idf": raw_engine_idf,
|
||||
"scoring_idf": bm25.calculate_idf(term),
|
||||
"numerator": numerator,
|
||||
"denominator": denominator,
|
||||
},
|
||||
"expected_score": expected,
|
||||
"engine_score": actual,
|
||||
"absolute_error": abs(expected - actual),
|
||||
"tolerance": tolerance,
|
||||
"posting_list": sorted(index.get_posting_list(term)),
|
||||
"recorded_document_frequency": index.document_frequency[term],
|
||||
"passed": (
|
||||
abs(idf - raw_engine_idf) <= tolerance
|
||||
and abs(expected - actual) <= tolerance
|
||||
and index.document_frequency[term] == df
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def labelled_benchmark() -> dict[str, Any]:
|
||||
started = time.perf_counter()
|
||||
engine = build_engine(DEFAULT_CORPUS, k1=K1, b=B)
|
||||
build_ms = (time.perf_counter() - started) * 1000
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
for query, relevant_list in DEFAULT_LABELS.items():
|
||||
query_start = time.perf_counter()
|
||||
results = engine.search(query, top_k=TOP_K)
|
||||
latency_ms = (time.perf_counter() - query_start) * 1000
|
||||
retrieved = [result["doc_id"] for result in results]
|
||||
relevant = set(relevant_list)
|
||||
hits = [doc_id for doc_id in retrieved if doc_id in relevant]
|
||||
recall = len(set(hits)) / len(relevant)
|
||||
reciprocal_rank = next(
|
||||
(1.0 / rank for rank, doc_id in enumerate(retrieved, 1) if doc_id in relevant),
|
||||
0.0,
|
||||
)
|
||||
category = "synonym-only" if query == "cat" else "exact-keyword"
|
||||
rows.append(
|
||||
{
|
||||
"query": query,
|
||||
"category": category,
|
||||
"relevant": sorted(relevant),
|
||||
"retrieved": retrieved,
|
||||
"hits": hits,
|
||||
"recall_at_5": recall,
|
||||
"reciprocal_rank": reciprocal_rank,
|
||||
"latency_ms": round(latency_ms, 3),
|
||||
"results": [
|
||||
{
|
||||
"rank": rank,
|
||||
"doc_id": result["doc_id"],
|
||||
"score": result["score"],
|
||||
"matched_terms": result["debug"]["matched_terms"],
|
||||
"term_frequencies": result["debug"]["term_frequencies"],
|
||||
}
|
||||
for rank, result in enumerate(results, 1)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
exact = [row for row in rows if row["category"] == "exact-keyword"]
|
||||
synonym = [row for row in rows if row["category"] == "synonym-only"]
|
||||
return {
|
||||
"corpus": DEFAULT_CORPUS,
|
||||
"labels": DEFAULT_LABELS,
|
||||
"parameters": {"k1": K1, "b": B, "top_k": TOP_K},
|
||||
"index_statistics": engine.index.get_statistics(),
|
||||
"build_latency_ms": round(build_ms, 3),
|
||||
"queries": rows,
|
||||
"metrics": {
|
||||
"exact_keyword_recall_at_5": sum(row["recall_at_5"] for row in exact) / len(exact),
|
||||
"exact_keyword_mrr": sum(row["reciprocal_rank"] for row in exact) / len(exact),
|
||||
"synonym_only_recall_at_5": sum(row["recall_at_5"] for row in synonym) / len(synonym),
|
||||
"synonym_only_mrr": sum(row["reciprocal_rank"] for row in synonym) / len(synonym),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
hand = hand_calculation()
|
||||
benchmark = labelled_benchmark()
|
||||
metrics = benchmark["metrics"]
|
||||
acceptance = {
|
||||
"uses_from_scratch_engine": True,
|
||||
"hand_score_matches": hand["passed"],
|
||||
"inverted_index_df_matches": hand["recorded_document_frequency"] == hand["parameters"]["df"],
|
||||
"all_exact_keyword_queries_recalled": metrics["exact_keyword_recall_at_5"] == 1.0,
|
||||
"synonym_only_failure_observed": metrics["synonym_only_recall_at_5"] == 0.0,
|
||||
"transparent_tf_idf_scores_retained": all(
|
||||
"term_frequencies" in result
|
||||
for row in benchmark["queries"]
|
||||
for result in row["results"]
|
||||
),
|
||||
}
|
||||
passed = all(acceptance.values())
|
||||
evidence = {
|
||||
"status": "passed" if passed else "failed",
|
||||
"method": {
|
||||
"implementation": "chapter3/sparse-embedding/bm25_engine.py",
|
||||
"algorithm": "from-scratch inverted index + Robertson/Sparck Jones BM25",
|
||||
"third_party_retrieval_library": None,
|
||||
},
|
||||
"hand_calculation": hand,
|
||||
"benchmark": benchmark,
|
||||
"summary": metrics,
|
||||
"acceptance": acceptance,
|
||||
}
|
||||
manifest = write_campaign_evidence(
|
||||
PROJECT_DIR,
|
||||
"3-5",
|
||||
evidence,
|
||||
input_paths=[__file__, PROJECT_DIR / "bm25_engine.py", PROJECT_DIR / "cli.py"],
|
||||
)
|
||||
print(f"hand score error: {hand['absolute_error']:.3g}")
|
||||
print(f"exact recall@5: {metrics['exact_keyword_recall_at_5']:.3f}")
|
||||
print(f"synonym recall@5: {metrics['synonym_only_recall_at_5']:.3f}")
|
||||
print(f"evidence: {manifest['run_dir']}")
|
||||
return 0 if passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
BM25 Sparse Vector Search Engine
|
||||
An educational implementation of BM25 algorithm with inverted index
|
||||
"""
|
||||
|
||||
import math
|
||||
import re
|
||||
import logging
|
||||
from collections import defaultdict, Counter
|
||||
from typing import List, Dict, Set, Tuple, Optional
|
||||
|
||||
# Configure logging for educational purposes
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TextProcessor:
|
||||
"""Text preprocessing for indexing and searching"""
|
||||
|
||||
def __init__(self):
|
||||
# Common English stop words
|
||||
self.stop_words = {
|
||||
'the', 'is', 'at', 'which', 'on', 'a', 'an', 'as', 'are', 'was',
|
||||
'been', 'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will',
|
||||
'would', 'could', 'should', 'may', 'might', 'must', 'can', 'this',
|
||||
'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we',
|
||||
'they', 'what', 'who', 'when', 'where', 'why', 'how', 'all', 'each',
|
||||
'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such',
|
||||
'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just'
|
||||
}
|
||||
logger.info(f"TextProcessor initialized with {len(self.stop_words)} stop words")
|
||||
|
||||
def tokenize(self, text: str, remove_stop_words: bool = True) -> List[str]:
|
||||
"""Tokenize text into words, numbers, and codes.
|
||||
|
||||
Handles:
|
||||
- Words (preserving case for acronyms)
|
||||
- Numbers (404, 500, 3.14)
|
||||
- Codes (XK9-2B4-7Q1, API_KEY, user@example.com)
|
||||
- Technical terms (C++, .NET, Node.js)
|
||||
"""
|
||||
logger.debug(f"Tokenizing text of length {len(text)}")
|
||||
|
||||
# Comprehensive tokenization patterns
|
||||
patterns = [
|
||||
# Email addresses (keep whole)
|
||||
r'\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b',
|
||||
# URLs (simplified)
|
||||
r'https?://[^\s]+',
|
||||
# API keys, codes with hyphens/underscores (e.g., XK9-2B4-7Q1, API_KEY_123)
|
||||
r'\b[A-Z0-9]+(?:[_-][A-Z0-9]+)+\b',
|
||||
# Technical terms with special chars (C++, C#, .NET)
|
||||
r'\b[A-Z]\+\+|\b[A-Z]#|\.[A-Z]+[a-zA-Z]*',
|
||||
# Version numbers (3.14, 2.0.1)
|
||||
r'\b\d+(?:\.\d+)+\b',
|
||||
# Hex codes (#FF5733, 0x1234)
|
||||
r'#[0-9A-Fa-f]{3,8}\b|0x[0-9A-Fa-f]+\b',
|
||||
# Numbers (including decimals)
|
||||
r'\b\d+(?:\.\d+)?\b',
|
||||
# Words with apostrophes
|
||||
r"\b[a-zA-Z]+'[a-zA-Z]+\b",
|
||||
# Acronyms and uppercase words (USA, NASA, API)
|
||||
r'\b[A-Z]{2,}\b',
|
||||
# Mixed case words (JavaScript, PyTorch)
|
||||
r'\b[A-Z][a-z]+[A-Z][a-zA-Z]*\b',
|
||||
# Alphanumeric combinations (Python3, ES6, 3DS)
|
||||
r'\b[A-Za-z]+\d+\b|\b\d+[A-Za-z]+\b',
|
||||
# Regular words
|
||||
r"\b[a-zA-Z]+\b",
|
||||
]
|
||||
|
||||
# Combine all patterns
|
||||
combined_pattern = '|'.join(f'({p})' for p in patterns)
|
||||
|
||||
# Extract all tokens
|
||||
raw_tokens = re.findall(combined_pattern, text, re.IGNORECASE)
|
||||
|
||||
# Flatten the results (findall with groups returns tuples)
|
||||
tokens = []
|
||||
for match in raw_tokens:
|
||||
token = next(t for t in match if t) # Get the non-empty match
|
||||
|
||||
# Preserve case for:
|
||||
# - All uppercase words (API, USA)
|
||||
# - Mixed case (JavaScript, PyTorch)
|
||||
# - Codes with special chars
|
||||
# - Numbers
|
||||
# - Alphanumeric combinations (Python3, ES6)
|
||||
if (token.isupper() and len(token) > 1) or \
|
||||
any(c.isupper() for c in token[1:]) or \
|
||||
any(c in '-_@.#+' for c in token) or \
|
||||
any(c.isdigit() for c in token) or \
|
||||
token.startswith('.'):
|
||||
tokens.append(token)
|
||||
else:
|
||||
# Convert regular words to lowercase
|
||||
tokens.append(token.lower())
|
||||
|
||||
logger.debug(f"Found {len(tokens)} raw tokens")
|
||||
|
||||
if remove_stop_words:
|
||||
# Only remove stop words from lowercase word tokens
|
||||
filtered_tokens = []
|
||||
for token in tokens:
|
||||
# Keep if: not a lowercase word, or not in stop words
|
||||
if not token.islower() or token not in self.stop_words:
|
||||
filtered_tokens.append(token)
|
||||
tokens = filtered_tokens
|
||||
logger.debug(f"After removing stop words: {len(tokens)} tokens")
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
class InvertedIndex:
|
||||
"""Inverted index data structure for efficient term lookup"""
|
||||
|
||||
def __init__(self):
|
||||
# Main inverted index: term -> set of document IDs
|
||||
self.index: Dict[str, Set[int]] = defaultdict(set)
|
||||
|
||||
# Document frequency: term -> number of documents containing term
|
||||
self.document_frequency: Dict[str, int] = defaultdict(int)
|
||||
|
||||
# Term frequency in documents: doc_id -> term -> frequency
|
||||
self.term_frequency: Dict[int, Counter] = {}
|
||||
|
||||
# Document lengths (number of terms)
|
||||
self.doc_lengths: Dict[int, int] = {}
|
||||
|
||||
# Original documents for retrieval
|
||||
self.documents: Dict[int, str] = {}
|
||||
|
||||
# Document metadata
|
||||
self.doc_metadata: Dict[int, Dict] = {}
|
||||
|
||||
# Statistics
|
||||
self.total_documents = 0
|
||||
self.total_terms = 0
|
||||
self.unique_terms = 0
|
||||
|
||||
logger.info("InvertedIndex initialized")
|
||||
|
||||
def add_document(self, doc_id: int, text: str, metadata: Optional[Dict] = None):
|
||||
"""Add a document to the index"""
|
||||
logger.info(f"Adding document {doc_id} to index")
|
||||
logger.debug(f"Document text: {text[:100]}..." if len(text) > 100 else f"Document text: {text}")
|
||||
|
||||
is_update = doc_id in self.documents
|
||||
if is_update:
|
||||
old_terms = set(self.term_frequency.get(doc_id, Counter()).keys())
|
||||
for term in old_terms:
|
||||
if term in self.index and doc_id in self.index[term]:
|
||||
self.index[term].remove(doc_id)
|
||||
if not self.index[term]:
|
||||
del self.index[term]
|
||||
if term in self.document_frequency:
|
||||
self.document_frequency[term] -= 1
|
||||
if self.document_frequency[term] <= 0:
|
||||
del self.document_frequency[term]
|
||||
|
||||
# Store original document
|
||||
self.documents[doc_id] = text
|
||||
if metadata:
|
||||
self.doc_metadata[doc_id] = metadata
|
||||
logger.debug(f"Document metadata: {metadata}")
|
||||
|
||||
# Process text
|
||||
processor = TextProcessor()
|
||||
tokens = processor.tokenize(text)
|
||||
|
||||
# Count term frequencies
|
||||
term_freq = Counter(tokens)
|
||||
self.term_frequency[doc_id] = term_freq
|
||||
self.doc_lengths[doc_id] = len(tokens)
|
||||
|
||||
logger.debug(f"Document {doc_id}: {len(tokens)} tokens, {len(term_freq)} unique terms")
|
||||
|
||||
for term in term_freq:
|
||||
if term not in self.index or doc_id not in self.index[term]:
|
||||
self.document_frequency[term] = self.document_frequency.get(term, 0) + 1
|
||||
self.index[term].add(doc_id)
|
||||
|
||||
if not is_update:
|
||||
self.total_documents += 1
|
||||
self._update_statistics()
|
||||
|
||||
logger.info(f"Document {doc_id} indexed successfully")
|
||||
|
||||
def _update_statistics(self):
|
||||
"""Update index statistics"""
|
||||
self.unique_terms = len(self.index)
|
||||
self.total_terms = sum(self.doc_lengths.values())
|
||||
logger.debug(f"Index statistics: {self.total_documents} documents, "
|
||||
f"{self.unique_terms} unique terms, {self.total_terms} total terms")
|
||||
|
||||
def get_posting_list(self, term: str) -> Set[int]:
|
||||
"""Get document IDs containing the term"""
|
||||
return self.index.get(term, set())
|
||||
|
||||
def get_statistics(self) -> Dict:
|
||||
"""Get comprehensive index statistics"""
|
||||
stats = {
|
||||
'total_documents': self.total_documents,
|
||||
'unique_terms': self.unique_terms,
|
||||
'total_terms': self.total_terms,
|
||||
'average_document_length': self.total_terms / self.total_documents if self.total_documents > 0 else 0,
|
||||
'terms_by_frequency': self._get_term_frequency_distribution()
|
||||
}
|
||||
return stats
|
||||
|
||||
def _get_term_frequency_distribution(self, top_n: int = 10) -> List[Tuple[str, int]]:
|
||||
"""Get top N most frequent terms across all documents"""
|
||||
global_term_freq = Counter()
|
||||
for doc_term_freq in self.term_frequency.values():
|
||||
global_term_freq.update(doc_term_freq)
|
||||
return global_term_freq.most_common(top_n)
|
||||
|
||||
def get_index_structure(self) -> Dict:
|
||||
"""Get a visualization-friendly representation of the index"""
|
||||
structure = {
|
||||
'inverted_index': {},
|
||||
'document_info': {},
|
||||
'statistics': self.get_statistics()
|
||||
}
|
||||
|
||||
# Include top terms in the structure
|
||||
for term, doc_ids in list(self.index.items())[:20]: # Limit to 20 terms for readability
|
||||
structure['inverted_index'][term] = {
|
||||
'document_ids': list(doc_ids),
|
||||
'document_frequency': len(doc_ids)
|
||||
}
|
||||
|
||||
# Include document information
|
||||
for doc_id in self.documents:
|
||||
structure['document_info'][doc_id] = {
|
||||
'length': self.doc_lengths[doc_id],
|
||||
'unique_terms': len(self.term_frequency[doc_id]),
|
||||
'top_terms': self.term_frequency[doc_id].most_common(5)
|
||||
}
|
||||
|
||||
return structure
|
||||
|
||||
|
||||
class BM25:
|
||||
"""BM25 ranking algorithm implementation"""
|
||||
|
||||
def __init__(self, index: InvertedIndex, k1: float = 1.5, b: float = 0.75):
|
||||
"""
|
||||
Initialize BM25 with tuning parameters
|
||||
k1: controls term frequency saturation (typically 1.2 to 2.0)
|
||||
b: controls length normalization (0.0 to 1.0)
|
||||
"""
|
||||
self.index = index
|
||||
self.k1 = k1
|
||||
self.b = b
|
||||
|
||||
# Calculate average document length
|
||||
self.avgdl = 0
|
||||
if index.total_documents > 0:
|
||||
self.avgdl = sum(index.doc_lengths.values()) / index.total_documents
|
||||
|
||||
logger.info(f"BM25 initialized with k1={k1}, b={b}, avgdl={self.avgdl:.2f}")
|
||||
|
||||
def calculate_raw_idf(self, term: str) -> float:
|
||||
"""Calculate the Robertson/Sparck Jones IDF printed in Chapter 3."""
|
||||
N = self.index.total_documents
|
||||
df = len(self.index.get_posting_list(term))
|
||||
|
||||
if df == 0:
|
||||
return 0
|
||||
|
||||
N = max(N, df)
|
||||
val = (N - df + 0.5) / (df + 0.5)
|
||||
if val <= 0:
|
||||
return 0.0
|
||||
return math.log(val)
|
||||
|
||||
def calculate_idf(self, term: str) -> float:
|
||||
"""Return RSJ IDF with a small floor for corpus-ubiquitous terms.
|
||||
|
||||
Raw RSJ IDF is negative when a term occurs in more than half of a tiny
|
||||
corpus. Letting that value flow into ranking perversely rewards a
|
||||
document for matching fewer query terms. Production BM25 variants
|
||||
conventionally floor or smooth that edge case; the raw value remains
|
||||
available through :meth:`calculate_raw_idf` for transparent teaching
|
||||
and hand calculation.
|
||||
"""
|
||||
df = len(self.index.get_posting_list(term))
|
||||
if df == 0:
|
||||
return 0
|
||||
raw_idf = self.calculate_raw_idf(term)
|
||||
idf = max(raw_idf, 1e-6)
|
||||
|
||||
logger.debug(
|
||||
f"IDF for '{term}': N={self.index.total_documents}, df={df}, "
|
||||
f"raw_idf={raw_idf:.4f}, scoring_idf={idf:.4f}"
|
||||
)
|
||||
return idf
|
||||
|
||||
def calculate_term_score(self, term: str, doc_id: int) -> float:
|
||||
"""Calculate BM25 score for a single term in a document"""
|
||||
# Get term frequency in document
|
||||
tf = self.index.term_frequency.get(doc_id, Counter()).get(term, 0)
|
||||
if tf == 0:
|
||||
return 0
|
||||
|
||||
# Get document length
|
||||
dl = self.index.doc_lengths.get(doc_id, 0)
|
||||
|
||||
# Calculate IDF
|
||||
idf = self.calculate_idf(term)
|
||||
|
||||
# BM25 term score formula
|
||||
if self.avgdl == 0:
|
||||
return 0.0
|
||||
numerator = tf * (self.k1 + 1)
|
||||
denominator = tf + self.k1 * (1 - self.b + self.b * (dl / self.avgdl))
|
||||
score = idf * (numerator / denominator)
|
||||
|
||||
logger.debug(f"Term '{term}' in doc {doc_id}: tf={tf}, dl={dl}, score={score:.4f}")
|
||||
return score
|
||||
|
||||
def score_document(self, query_terms: List[str], doc_id: int) -> float:
|
||||
"""Calculate total BM25 score for a document given query terms"""
|
||||
total_score = 0
|
||||
term_scores = {}
|
||||
|
||||
for term in query_terms:
|
||||
term_score = self.calculate_term_score(term, doc_id)
|
||||
term_scores[term] = term_score
|
||||
total_score += term_score
|
||||
|
||||
logger.debug(f"Document {doc_id} total score: {total_score:.4f}")
|
||||
logger.debug(f"Term contributions: {term_scores}")
|
||||
|
||||
return total_score
|
||||
|
||||
def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float, Dict]]:
|
||||
"""
|
||||
Search for documents matching the query
|
||||
Returns list of (doc_id, score, debug_info) tuples
|
||||
"""
|
||||
logger.info(f"Searching for: '{query}'")
|
||||
|
||||
# Process query
|
||||
processor = TextProcessor()
|
||||
query_terms = processor.tokenize(query)
|
||||
logger.info(f"Query terms after processing: {query_terms}")
|
||||
|
||||
# Find candidate documents (documents containing at least one query term).
|
||||
# Resolve each term to the variant that actually matched the index:
|
||||
# when the lowercase fallback finds the docs, scoring must use the
|
||||
# lowercase term too, otherwise tf lookups return 0 and the fallback
|
||||
# candidates all score 0.0.
|
||||
candidate_docs = set()
|
||||
term_doc_mapping = {}
|
||||
resolved_terms = []
|
||||
|
||||
for term in query_terms:
|
||||
# Try exact match first
|
||||
docs = self.index.get_posting_list(term)
|
||||
|
||||
# If no exact match and term is not a number/code, try lowercase
|
||||
if not docs and term and not term[0].isdigit() and '-' not in term:
|
||||
lowered = term.lower()
|
||||
docs = self.index.get_posting_list(lowered)
|
||||
if docs:
|
||||
term = lowered
|
||||
|
||||
resolved_terms.append(term)
|
||||
candidate_docs.update(docs)
|
||||
term_doc_mapping[term] = docs
|
||||
logger.debug(f"Term '{term}' appears in {len(docs)} documents")
|
||||
|
||||
logger.info(f"Found {len(candidate_docs)} candidate documents")
|
||||
|
||||
# Score each candidate document
|
||||
doc_scores = []
|
||||
for doc_id in candidate_docs:
|
||||
score = self.score_document(resolved_terms, doc_id)
|
||||
|
||||
# Collect debug information
|
||||
debug_info = {
|
||||
'matched_terms': [term for term in resolved_terms
|
||||
if doc_id in self.index.get_posting_list(term)],
|
||||
'doc_length': self.index.doc_lengths[doc_id],
|
||||
'term_frequencies': {term: self.index.term_frequency[doc_id].get(term, 0)
|
||||
for term in resolved_terms}
|
||||
}
|
||||
|
||||
doc_scores.append((doc_id, score, debug_info))
|
||||
|
||||
# Sort by score (descending)
|
||||
doc_scores.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Return top k results
|
||||
results = doc_scores[:top_k]
|
||||
|
||||
logger.info(f"Returning top {len(results)} results")
|
||||
for rank, (doc_id, score, _) in enumerate(results, 1):
|
||||
logger.info(f"Rank {rank}: Document {doc_id} (score: {score:.4f})")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class SparseSearchEngine:
|
||||
"""Main search engine combining all components"""
|
||||
|
||||
def __init__(self):
|
||||
self.index = InvertedIndex()
|
||||
self.bm25 = None
|
||||
self.next_doc_id = 0
|
||||
# Map external doc_id to internal doc_id
|
||||
self.external_to_internal = {}
|
||||
# Map internal doc_id to external doc_id
|
||||
self.internal_to_external = {}
|
||||
logger.info("SparseSearchEngine initialized")
|
||||
|
||||
def index_document(self, text: str, metadata: Optional[Dict] = None, external_doc_id: Optional[str] = None) -> str:
|
||||
"""Index a new document and return its ID"""
|
||||
# Generate internal ID
|
||||
internal_doc_id = self.next_doc_id
|
||||
self.next_doc_id += 1
|
||||
|
||||
# Use external_doc_id if provided, otherwise use internal ID as string
|
||||
if external_doc_id:
|
||||
doc_id_str = external_doc_id
|
||||
else:
|
||||
doc_id_str = str(internal_doc_id)
|
||||
|
||||
# Store mappings
|
||||
self.external_to_internal[doc_id_str] = internal_doc_id
|
||||
self.internal_to_external[internal_doc_id] = doc_id_str
|
||||
|
||||
logger.info(f"Indexing document with external ID '{doc_id_str}' (internal ID {internal_doc_id})")
|
||||
self.index.add_document(internal_doc_id, text, metadata)
|
||||
|
||||
# Reinitialize BM25 with updated index
|
||||
self.bm25 = BM25(self.index)
|
||||
|
||||
return doc_id_str
|
||||
|
||||
def index_batch(self, documents: List[Dict]) -> List[str]:
|
||||
"""Index multiple documents at once"""
|
||||
logger.info(f"Batch indexing {len(documents)} documents")
|
||||
doc_ids = []
|
||||
|
||||
for doc in documents:
|
||||
text = doc.get('text', '')
|
||||
metadata = doc.get('metadata', None)
|
||||
external_doc_id = doc.get('doc_id', None)
|
||||
doc_id = self.index_document(text, metadata, external_doc_id)
|
||||
doc_ids.append(doc_id)
|
||||
|
||||
logger.info(f"Batch indexing complete. Indexed {len(doc_ids)} documents")
|
||||
return doc_ids
|
||||
|
||||
def search(self, query: str, top_k: int = 10) -> List[Dict]:
|
||||
"""Search for documents matching the query"""
|
||||
if self.bm25 is None:
|
||||
logger.warning("No documents indexed yet")
|
||||
return []
|
||||
|
||||
logger.info(f"Executing search query: '{query}'")
|
||||
results = self.bm25.search(query, top_k)
|
||||
|
||||
# Format results
|
||||
formatted_results = []
|
||||
for internal_doc_id, score, debug_info in results:
|
||||
# Get external doc_id
|
||||
external_doc_id = self.internal_to_external.get(internal_doc_id, str(internal_doc_id))
|
||||
|
||||
result = {
|
||||
'doc_id': external_doc_id, # Use external doc_id
|
||||
'score': score,
|
||||
'text': self.index.documents[internal_doc_id],
|
||||
'metadata': self.index.doc_metadata.get(internal_doc_id, {}),
|
||||
'debug': debug_info
|
||||
}
|
||||
formatted_results.append(result)
|
||||
|
||||
return formatted_results
|
||||
|
||||
def get_document(self, doc_id) -> Optional[Dict]:
|
||||
"""Retrieve a document by ID (can be internal or external)"""
|
||||
# Check if it's an external doc_id
|
||||
if isinstance(doc_id, str) and doc_id in self.external_to_internal:
|
||||
internal_id = self.external_to_internal[doc_id]
|
||||
elif isinstance(doc_id, int) and doc_id in self.index.documents:
|
||||
internal_id = doc_id
|
||||
doc_id = self.internal_to_external.get(internal_id, str(internal_id))
|
||||
else:
|
||||
return None
|
||||
|
||||
return {
|
||||
'doc_id': doc_id, # Return external doc_id
|
||||
'text': self.index.documents[internal_id],
|
||||
'metadata': self.index.doc_metadata.get(internal_id, {}),
|
||||
'statistics': {
|
||||
'length': self.index.doc_lengths[internal_id],
|
||||
'unique_terms': len(self.index.term_frequency[internal_id]),
|
||||
'top_terms': self.index.term_frequency[internal_id].most_common(10)
|
||||
}
|
||||
}
|
||||
|
||||
def get_index_info(self) -> Dict:
|
||||
"""Get comprehensive information about the index"""
|
||||
return {
|
||||
'statistics': self.index.get_statistics(),
|
||||
'structure': self.index.get_index_structure(),
|
||||
'bm25_params': {
|
||||
'k1': self.bm25.k1 if self.bm25 else None,
|
||||
'b': self.bm25.b if self.bm25 else None,
|
||||
'avgdl': self.bm25.avgdl if self.bm25 else None
|
||||
}
|
||||
}
|
||||
|
||||
def clear_index(self):
|
||||
"""Clear all indexed documents"""
|
||||
logger.info("Clearing index")
|
||||
self.index = InvertedIndex()
|
||||
self.bm25 = None
|
||||
self.next_doc_id = 0
|
||||
self.external_to_internal = {}
|
||||
self.internal_to_external = {}
|
||||
logger.info("Index cleared")
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
稀疏检索命令行工具(实验 3-5)
|
||||
|
||||
在一个小型示例语料上运行 BM25 稀疏检索,支持:
|
||||
- 自定义语料 / 查询 / top-k / 输出文件
|
||||
- --explain 复现书中"逐词 IDF / TF / BM25 贡献"的日志
|
||||
- --eval 在带标注的小型评测集上计算 recall@k / precision@k / MRR
|
||||
- --method splade 学习型稀疏检索(需要下载模型,离线环境会给出提示)
|
||||
|
||||
不带任何参数运行时,等价于书中实验 3-5 的默认演示(查询"model distillation")。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from bm25_engine import SparseSearchEngine
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内置示例语料与标注(英文,与引擎的分词器能力一致,可完全离线复现)
|
||||
# 语料刻意混合了:普通词、专有代码、技术缩写、以及只有"同义表达"的文档,
|
||||
# 用来同时展示 BM25 在精确关键词匹配上的强项与在同义词上的短板。
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_CORPUS: List[Dict] = [
|
||||
{"doc_id": "doc_1", "title": "Python Language",
|
||||
"text": "Python is a high-level programming language known for readability and a simple syntax."},
|
||||
{"doc_id": "doc_2", "title": "JavaScript Runtime",
|
||||
"text": "JavaScript runs in the browser and on servers via Node.js for full-stack web development."},
|
||||
{"doc_id": "doc_3", "title": "Model Distillation",
|
||||
"text": "Model distillation compresses a large teacher model into a smaller student model while preserving accuracy."},
|
||||
{"doc_id": "doc_4", "title": "Knowledge Distillation",
|
||||
"text": "Knowledge distillation transfers knowledge from a big neural network to a compact model for efficient inference."},
|
||||
{"doc_id": "doc_5", "title": "BM25 Ranking",
|
||||
"text": "BM25 is a probabilistic ranking function using term frequency and inverse document frequency."},
|
||||
{"doc_id": "doc_6", "title": "HTTP Errors",
|
||||
"text": "The HTTP 404 error code means the requested resource was not found on the web server."},
|
||||
{"doc_id": "doc_7", "title": "A Playful Kitten",
|
||||
"text": "A cute kitten chased a ball of yarn across the living room floor all afternoon."},
|
||||
{"doc_id": "doc_8", "title": "Silent Hunter",
|
||||
"text": "The feline predator stalked its prey silently through the tall grass at dusk."},
|
||||
{"doc_id": "doc_9", "title": "Hardware Fault",
|
||||
"text": "Error code XK9-2B4-7Q1 indicates a hardware fault in the storage controller board."},
|
||||
{"doc_id": "doc_10", "title": "Transformers",
|
||||
"text": "Transformer models use self-attention to process input sequences in parallel efficiently."},
|
||||
]
|
||||
|
||||
# query -> 相关文档 doc_id 集合(人工标注的 ground truth)
|
||||
DEFAULT_LABELS: Dict[str, List[str]] = {
|
||||
"model distillation": ["doc_3", "doc_4"],
|
||||
"HTTP 404 error": ["doc_6"],
|
||||
"XK9-2B4-7Q1": ["doc_9"],
|
||||
"BM25 ranking function": ["doc_5"],
|
||||
# 相关文档用 kitten / feline 表达"猫",故意不含字面 "cat",
|
||||
# 用于演示稀疏检索读不懂同义词的短板(BM25 会漏召回)。
|
||||
"cat": ["doc_7", "doc_8"],
|
||||
}
|
||||
|
||||
DEFAULT_QUERY = "model distillation"
|
||||
|
||||
|
||||
def _quiet_logging(verbose: bool) -> None:
|
||||
"""默认压低引擎日志;--verbose / --explain 时放开到 DEBUG 以展示计算过程。"""
|
||||
level = logging.DEBUG if verbose else logging.WARNING
|
||||
logging.getLogger().setLevel(level)
|
||||
logging.getLogger("bm25_engine").setLevel(level)
|
||||
|
||||
|
||||
def load_corpus(path: Optional[str]) -> List[Dict]:
|
||||
"""加载语料。支持 .json(文档数组)与 .jsonl(每行一个文档)。"""
|
||||
if not path:
|
||||
return DEFAULT_CORPUS
|
||||
docs: List[Dict] = []
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
if path.endswith(".jsonl"):
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
docs.append(json.loads(line))
|
||||
else:
|
||||
data = json.load(f)
|
||||
docs = data["documents"] if isinstance(data, dict) else data
|
||||
if not docs:
|
||||
raise ValueError(f"语料文件为空:{path}")
|
||||
return docs
|
||||
|
||||
|
||||
def load_labels(path: Optional[str]) -> Dict[str, List[str]]:
|
||||
"""加载评测标注:{query: [relevant_doc_id, ...]}。"""
|
||||
if not path:
|
||||
return DEFAULT_LABELS
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def build_engine(corpus: List[Dict], k1: float, b: float) -> SparseSearchEngine:
|
||||
"""把语料灌进引擎;用给定的 k1/b 重建 BM25。"""
|
||||
engine = SparseSearchEngine()
|
||||
engine.index_batch([
|
||||
{"text": d["text"],
|
||||
"doc_id": d.get("doc_id"),
|
||||
"metadata": {"title": d.get("title", "")}}
|
||||
for d in corpus
|
||||
])
|
||||
# index_batch 内部每篇都会重建 BM25,这里再显式用目标参数固定一次
|
||||
from bm25_engine import BM25
|
||||
engine.bm25 = BM25(engine.index, k1=k1, b=b)
|
||||
return engine
|
||||
|
||||
|
||||
def explain_result(engine: SparseSearchEngine, query: str, doc_id: str) -> List[Tuple[str, int, int, float, float]]:
|
||||
"""复现书中日志:对命中文档,逐个查询词给出 TF / 文档长度 / IDF / BM25 贡献。"""
|
||||
from bm25_engine import TextProcessor
|
||||
internal = engine.external_to_internal[doc_id]
|
||||
terms = TextProcessor().tokenize(query)
|
||||
rows = []
|
||||
for term in terms:
|
||||
tf = engine.index.term_frequency[internal].get(term, 0)
|
||||
if tf == 0:
|
||||
continue
|
||||
dl = engine.index.doc_lengths[internal]
|
||||
idf = engine.bm25.calculate_idf(term)
|
||||
contrib = engine.bm25.calculate_term_score(term, internal)
|
||||
rows.append((term, tf, dl, idf, contrib))
|
||||
return rows
|
||||
|
||||
|
||||
def run_search(engine: SparseSearchEngine, query: str, top_k: int,
|
||||
explain: bool) -> List[Dict]:
|
||||
"""执行单条查询并打印结果,返回结构化结果供 --output 落盘。"""
|
||||
results = engine.search(query, top_k=top_k)
|
||||
print(f"\n查询: '{query}' (BM25, top-{top_k})")
|
||||
print("-" * 60)
|
||||
if not results:
|
||||
print(" 没有命中任何文档(所有查询词都不在倒排索引中)。")
|
||||
return []
|
||||
out = []
|
||||
for rank, r in enumerate(results, 1):
|
||||
title = r["metadata"].get("title", "")
|
||||
print(f" #{rank} {r['doc_id']} score={r['score']:.4f} {title}")
|
||||
print(f" 命中词: {r['debug']['matched_terms']}")
|
||||
print(f" 预览: {r['text'][:80]}...")
|
||||
if explain:
|
||||
rows = explain_result(engine, query, r["doc_id"])
|
||||
for term, tf, dl, idf, contrib in rows:
|
||||
print(f" └ '{term}': TF={tf}, 文档长度={dl}词, "
|
||||
f"IDF={idf:.4f}, BM25贡献={contrib:.4f}")
|
||||
out.append({
|
||||
"rank": rank,
|
||||
"doc_id": r["doc_id"],
|
||||
"score": r["score"],
|
||||
"title": title,
|
||||
"matched_terms": r["debug"]["matched_terms"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _metrics_for_query(retrieved: List[str], relevant: Set[str], k: int) -> Dict:
|
||||
"""单条查询的 recall@k / precision@k / 命中排名(用于 MRR)。"""
|
||||
topk = retrieved[:k]
|
||||
hits = [d for d in topk if d in relevant]
|
||||
recall = len(set(hits)) / len(relevant) if relevant else 0.0
|
||||
precision = len(hits) / len(topk) if topk else 0.0
|
||||
rr = 0.0
|
||||
for i, d in enumerate(retrieved, 1):
|
||||
if d in relevant:
|
||||
rr = 1.0 / i
|
||||
break
|
||||
return {"recall": recall, "precision": precision, "rr": rr,
|
||||
"hits": hits, "retrieved": topk}
|
||||
|
||||
|
||||
def run_eval(engine: SparseSearchEngine, labels: Dict[str, List[str]],
|
||||
k: int) -> Dict:
|
||||
"""在标注集上做检索评测,打印每条查询指标 + 宏平均。"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"检索质量评测 (recall@{k} / precision@{k} / MRR)")
|
||||
print(f"{'='*60}")
|
||||
per_query = {}
|
||||
sum_recall = sum_prec = sum_rr = 0.0
|
||||
for query, rel_list in labels.items():
|
||||
relevant = set(rel_list)
|
||||
results = engine.search(query, top_k=max(k, 10))
|
||||
retrieved = [r["doc_id"] for r in results]
|
||||
m = _metrics_for_query(retrieved, relevant, k)
|
||||
per_query[query] = m
|
||||
sum_recall += m["recall"]
|
||||
sum_prec += m["precision"]
|
||||
sum_rr += m["rr"]
|
||||
flag = "" if m["recall"] > 0 else " <- 漏召回(同义词短板)" if query == "cat" else " <- 漏召回"
|
||||
print(f"\n查询 '{query}' 相关文档={sorted(relevant)}")
|
||||
print(f" 召回排序: {retrieved[:k]}")
|
||||
print(f" recall@{k}={m['recall']:.2f} precision@{k}={m['precision']:.2f} RR={m['rr']:.2f}{flag}")
|
||||
n = len(labels)
|
||||
macro = {
|
||||
"recall@k": sum_recall / n,
|
||||
"precision@k": sum_prec / n,
|
||||
"mrr": sum_rr / n,
|
||||
"miss_rate@k": 1.0 - sum_recall / n,
|
||||
}
|
||||
print(f"\n{'-'*60}")
|
||||
print(f"宏平均 recall@{k}={macro['recall@k']:.3f} "
|
||||
f"precision@{k}={macro['precision@k']:.3f} "
|
||||
f"MRR={macro['mrr']:.3f} 漏召回率(1-recall@{k})={macro['miss_rate@k']:.3f}")
|
||||
return {"k": k, "per_query": {q: {kk: vv for kk, vv in m.items() if kk != "retrieved"}
|
||||
for q, m in per_query.items()},
|
||||
"macro": macro}
|
||||
|
||||
|
||||
def run_splade(query: str, corpus: List[Dict], top_k: int) -> Optional[List[Dict]]:
|
||||
"""学习型稀疏检索(SPLADE)。需要 transformers + torch + 预训练模型。
|
||||
|
||||
离线环境无法下载模型时,会打印清晰提示并返回 None(不影响参数解析验证)。
|
||||
"""
|
||||
model_name = "naver/splade-cocondenser-ensembledistil"
|
||||
try:
|
||||
import torch # noqa: F401
|
||||
from transformers import AutoModelForMaskedLM, AutoTokenizer
|
||||
except Exception as e:
|
||||
print("\n[SPLADE] 需要依赖 transformers 与 torch,当前环境缺失:", e)
|
||||
print(" 安装:pip install torch transformers")
|
||||
print(" (BM25 路径无需任何模型,可完全离线运行)")
|
||||
return None
|
||||
try:
|
||||
# 只用本地缓存加载,避免离线环境卡在无休止的网络下载上。
|
||||
print(f"\n[SPLADE] 尝试从本地缓存加载模型 {model_name} ...")
|
||||
tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=True)
|
||||
model = AutoModelForMaskedLM.from_pretrained(model_name, local_files_only=True)
|
||||
model.eval()
|
||||
except Exception:
|
||||
print(f"\n[SPLADE] 本地缓存中没有模型 {model_name},且离线环境无法下载权重。")
|
||||
print(" 请先在联网环境执行一次以下命令把模型缓存到本地,再重跑本命令:")
|
||||
print(f" huggingface-cli download {model_name}")
|
||||
print(" (BM25 路径不依赖任何模型,可完全离线复现书中实验 3-5)")
|
||||
return None
|
||||
|
||||
import torch
|
||||
|
||||
def encode(text: str) -> Dict[str, float]:
|
||||
inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=256)
|
||||
with torch.no_grad():
|
||||
logits = model(**inputs).logits # [1, seq, vocab]
|
||||
# SPLADE: log(1+ReLU(logits)) 后在序列维做 max-pool,得到词表维稀疏权重
|
||||
weights = torch.max(
|
||||
torch.log1p(torch.relu(logits)) * inputs["attention_mask"].unsqueeze(-1),
|
||||
dim=1,
|
||||
).values.squeeze(0)
|
||||
nz = torch.nonzero(weights).squeeze(-1)
|
||||
return {int(i): float(weights[i]) for i in nz}
|
||||
|
||||
q_vec = encode(query)
|
||||
scored = []
|
||||
for d in corpus:
|
||||
d_vec = encode(d["text"])
|
||||
score = sum(w * d_vec.get(t, 0.0) for t, w in q_vec.items())
|
||||
scored.append((d.get("doc_id"), score, d.get("title", "")))
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
print(f"\n查询: '{query}' (SPLADE, top-{top_k})")
|
||||
print("-" * 60)
|
||||
out = []
|
||||
for rank, (doc_id, score, title) in enumerate(scored[:top_k], 1):
|
||||
print(f" #{rank} {doc_id} score={score:.4f} {title}")
|
||||
out.append({"rank": rank, "doc_id": doc_id, "score": score, "title": title})
|
||||
return out
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cli.py",
|
||||
description="稀疏检索命令行工具(实验 3-5):在小型语料上运行 BM25 / SPLADE 稀疏检索并评测检索质量。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""示例:
|
||||
python cli.py # 默认演示(查询 "model distillation")
|
||||
python cli.py -q "HTTP 404 error" --explain # 展示逐词 TF/IDF/BM25 贡献
|
||||
python cli.py --eval # 在标注集上算 recall/precision/MRR
|
||||
python cli.py -q "cat" # 观察 BM25 的同义词短板
|
||||
python cli.py --corpus my.json -q "..." -o out.json
|
||||
python cli.py --method splade -q "model distillation" # 学习型稀疏检索(需模型)
|
||||
""",
|
||||
)
|
||||
parser.add_argument("-q", "--query", default=DEFAULT_QUERY,
|
||||
help=f"查询字符串(默认: '{DEFAULT_QUERY}')")
|
||||
parser.add_argument("-c", "--corpus", default=None,
|
||||
help="语料文件路径(.json 文档数组 或 .jsonl 每行一篇);缺省用内置示例语料")
|
||||
parser.add_argument("-m", "--method", choices=["bm25", "splade"], default="bm25",
|
||||
help="检索方法:bm25(默认,离线) 或 splade(学习型稀疏,需下载模型)")
|
||||
parser.add_argument("-k", "--top-k", type=int, default=5,
|
||||
help="返回前 k 条结果(默认: 5)")
|
||||
parser.add_argument("-o", "--output", default=None,
|
||||
help="把结果/评测指标以 JSON 写入该文件")
|
||||
parser.add_argument("--eval", action="store_true",
|
||||
help="在标注集上评测 recall@k / precision@k / MRR,而非只跑单条查询")
|
||||
parser.add_argument("--labels", default=None,
|
||||
help="评测标注文件 {query: [相关doc_id,...]};缺省用内置标注")
|
||||
parser.add_argument("--explain", action="store_true",
|
||||
help="对每条命中文档展示逐词 TF/IDF/BM25 贡献(复现书中日志)")
|
||||
parser.add_argument("--k1", type=float, default=1.5,
|
||||
help="BM25 词频饱和参数 k1(默认: 1.5)")
|
||||
parser.add_argument("-b", "--b", type=float, default=0.75,
|
||||
help="BM25 文档长度归一化参数 b(默认: 0.75)")
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
help="打开引擎 DEBUG 日志(展示分词、倒排索引构建、打分全过程)")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
_quiet_logging(args.verbose)
|
||||
|
||||
corpus = load_corpus(args.corpus)
|
||||
print(f"已加载语料:{len(corpus)} 篇文档"
|
||||
+ ("(内置示例)" if not args.corpus else f"(来自 {args.corpus})"))
|
||||
|
||||
payload: Dict = {"method": args.method, "query": args.query, "top_k": args.top_k}
|
||||
|
||||
if args.method == "splade":
|
||||
results = run_splade(args.query, corpus, args.top_k)
|
||||
if results is None:
|
||||
return 0 # 已给出模型缺失提示,视为正常退出
|
||||
payload["results"] = results
|
||||
else:
|
||||
engine = build_engine(corpus, k1=args.k1, b=args.b)
|
||||
print(f"BM25 参数:k1={args.k1}, b={args.b}, avgdl={engine.bm25.avgdl:.2f}")
|
||||
if args.eval:
|
||||
labels = load_labels(args.labels)
|
||||
payload["eval"] = run_eval(engine, labels, args.top_k)
|
||||
else:
|
||||
payload["results"] = run_search(engine, args.query, args.top_k, args.explain)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n已写入结果:{args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Configuration for the sparse embedding service."""
|
||||
|
||||
import os
|
||||
|
||||
# Server configuration
|
||||
SPARSE_PORT = int(os.getenv("SPARSE_PORT", "4241")) # Port 4241 to avoid conflicts
|
||||
SPARSE_HOST = os.getenv("SPARSE_HOST", "0.0.0.0")
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Demo script for the Educational Sparse Vector Search Engine
|
||||
Shows how to use the engine with sample documents and queries
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Server URL
|
||||
BASE_URL = "http://localhost:4241"
|
||||
|
||||
|
||||
def wait_for_server(max_attempts=10):
|
||||
"""Wait for server to be ready"""
|
||||
logger.info("Waiting for server to be ready...")
|
||||
for i in range(max_attempts):
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/stats", timeout=30)
|
||||
if response.status_code == 200:
|
||||
logger.info("Server is ready!")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
return False
|
||||
|
||||
|
||||
def clear_index():
|
||||
"""Clear the index before demo"""
|
||||
logger.info("Clearing existing index...")
|
||||
response = requests.delete(f"{BASE_URL}/index", timeout=30)
|
||||
if response.status_code == 200:
|
||||
logger.info("Index cleared successfully")
|
||||
return response.json()
|
||||
|
||||
|
||||
def index_sample_documents():
|
||||
"""Index a collection of sample documents"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("INDEXING SAMPLE DOCUMENTS")
|
||||
logger.info("="*50)
|
||||
|
||||
sample_documents = [
|
||||
{
|
||||
"text": "Python is a high-level programming language known for its simplicity and readability. It supports multiple programming paradigms including procedural, object-oriented, and functional programming.",
|
||||
"metadata": {"title": "Python Programming", "category": "programming"}
|
||||
},
|
||||
{
|
||||
"text": "Machine learning is a subset of artificial intelligence that enables computers to learn from data without being explicitly programmed. It uses algorithms to identify patterns and make decisions.",
|
||||
"metadata": {"title": "Introduction to Machine Learning", "category": "AI"}
|
||||
},
|
||||
{
|
||||
"text": "Natural language processing (NLP) is a field of AI that focuses on the interaction between computers and human language. It involves tasks like text classification, sentiment analysis, and machine translation.",
|
||||
"metadata": {"title": "NLP Basics", "category": "AI"}
|
||||
},
|
||||
{
|
||||
"text": "Data structures are fundamental concepts in computer science that organize and store data efficiently. Common data structures include arrays, linked lists, trees, graphs, and hash tables.",
|
||||
"metadata": {"title": "Data Structures Overview", "category": "computer science"}
|
||||
},
|
||||
{
|
||||
"text": "JavaScript is a dynamic programming language commonly used for web development. It runs in browsers and on servers with Node.js, making it versatile for full-stack development.",
|
||||
"metadata": {"title": "JavaScript Essentials", "category": "programming"}
|
||||
},
|
||||
{
|
||||
"text": "Deep learning is a subset of machine learning that uses neural networks with multiple layers. It has achieved breakthrough results in computer vision, speech recognition, and natural language processing.",
|
||||
"metadata": {"title": "Deep Learning Introduction", "category": "AI"}
|
||||
},
|
||||
{
|
||||
"text": "Algorithms are step-by-step procedures for solving computational problems. Algorithm analysis involves studying their time and space complexity using Big O notation.",
|
||||
"metadata": {"title": "Algorithm Analysis", "category": "computer science"}
|
||||
},
|
||||
{
|
||||
"text": "Web development involves creating websites and web applications using technologies like HTML, CSS, JavaScript, and various frameworks. Modern web development often uses React, Vue, or Angular for frontend development.",
|
||||
"metadata": {"title": "Modern Web Development", "category": "web"}
|
||||
},
|
||||
{
|
||||
"text": "Databases are systems for storing and managing data. Relational databases use SQL and tables, while NoSQL databases offer flexible schemas for unstructured data. Popular choices include PostgreSQL, MongoDB, and Redis.",
|
||||
"metadata": {"title": "Database Systems", "category": "databases"}
|
||||
},
|
||||
{
|
||||
"text": "Cloud computing provides on-demand computing resources over the internet. Major providers like AWS, Google Cloud, and Azure offer services for storage, computation, and machine learning in the cloud.",
|
||||
"metadata": {"title": "Cloud Computing Basics", "category": "cloud"}
|
||||
}
|
||||
]
|
||||
|
||||
# Index documents
|
||||
doc_ids = []
|
||||
for i, doc in enumerate(sample_documents, 1):
|
||||
logger.info(f"\nIndexing document {i}/{len(sample_documents)}: {doc['metadata']['title']}")
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/index",
|
||||
json={"text": doc["text"], "metadata": doc["metadata"]}, timeout=30
|
||||
)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
doc_ids.append(result["doc_id"])
|
||||
logger.info(f"✓ Indexed with ID: {result['doc_id']}")
|
||||
else:
|
||||
logger.error(f"✗ Failed to index document")
|
||||
|
||||
logger.info(f"\nSuccessfully indexed {len(doc_ids)} documents")
|
||||
return doc_ids
|
||||
|
||||
|
||||
def show_statistics():
|
||||
"""Display index statistics"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("INDEX STATISTICS")
|
||||
logger.info("="*50)
|
||||
|
||||
response = requests.get(f"{BASE_URL}/stats", timeout=30)
|
||||
if response.status_code == 200:
|
||||
stats = response.json()
|
||||
logger.info(f"Total documents: {stats['total_documents']}")
|
||||
logger.info(f"Unique terms: {stats['unique_terms']}")
|
||||
logger.info(f"Total terms: {stats['total_terms']}")
|
||||
logger.info(f"Average document length: {stats['average_document_length']:.2f}")
|
||||
|
||||
if 'terms_by_frequency' in stats:
|
||||
logger.info("\nTop 10 most frequent terms:")
|
||||
for term, freq in stats['terms_by_frequency']:
|
||||
logger.info(f" - {term}: {freq} occurrences")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def perform_searches():
|
||||
"""Perform various search queries to demonstrate the engine"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("PERFORMING SEARCHES")
|
||||
logger.info("="*50)
|
||||
|
||||
search_queries = [
|
||||
("machine learning algorithms", 3),
|
||||
("programming language", 5),
|
||||
("database SQL", 3),
|
||||
("web development JavaScript", 3),
|
||||
("artificial intelligence", 5),
|
||||
("data structures algorithms", 3),
|
||||
("cloud computing AWS", 3),
|
||||
("neural networks deep learning", 3)
|
||||
]
|
||||
|
||||
for query, top_k in search_queries:
|
||||
logger.info(f"\n{'─'*40}")
|
||||
logger.info(f"Query: '{query}' (top {top_k} results)")
|
||||
logger.info('─'*40)
|
||||
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/search",
|
||||
json={"query": query, "top_k": top_k}, timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
results = response.json()
|
||||
|
||||
if not results:
|
||||
logger.info("No results found")
|
||||
else:
|
||||
for rank, result in enumerate(results, 1):
|
||||
logger.info(f"\n Rank {rank}:")
|
||||
logger.info(f" Score: {result['score']:.4f}")
|
||||
logger.info(f" Title: {result['metadata'].get('title', 'N/A')}")
|
||||
logger.info(f" Category: {result['metadata'].get('category', 'N/A')}")
|
||||
logger.info(f" Text preview: {result['text'][:100]}...")
|
||||
logger.info(f" Matched terms: {result['debug']['matched_terms']}")
|
||||
logger.info(f" Document length: {result['debug']['doc_length']} terms")
|
||||
else:
|
||||
logger.error(f"Search failed: {response.status_code}")
|
||||
|
||||
time.sleep(0.5) # Small delay between searches
|
||||
|
||||
|
||||
def show_index_structure():
|
||||
"""Display the internal structure of the index"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("INDEX STRUCTURE VISUALIZATION")
|
||||
logger.info("="*50)
|
||||
|
||||
response = requests.get(f"{BASE_URL}/index/structure", timeout=30)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Show BM25 parameters
|
||||
logger.info("\nBM25 Parameters:")
|
||||
params = data['bm25_params']
|
||||
logger.info(f" k1 (term frequency saturation): {params['k1']}")
|
||||
logger.info(f" b (length normalization): {params['b']}")
|
||||
logger.info(f" avgdl (average document length): {params['avgdl']:.2f}")
|
||||
|
||||
# The actual structure is nested under 'structure' key
|
||||
structure = data.get('structure', {})
|
||||
|
||||
# Show sample of inverted index
|
||||
logger.info("\nSample of Inverted Index (first 5 terms):")
|
||||
inv_index = structure.get('inverted_index', {})
|
||||
if inv_index:
|
||||
for i, (term, info) in enumerate(list(inv_index.items())[:5]):
|
||||
logger.info(f" '{term}':")
|
||||
logger.info(f" - Document frequency: {info['document_frequency']}")
|
||||
logger.info(f" - Appears in documents: {info['document_ids']}")
|
||||
else:
|
||||
logger.info(" No inverted index data available")
|
||||
|
||||
# Show document information
|
||||
logger.info("\nDocument Information:")
|
||||
doc_info = structure.get('document_info', {})
|
||||
if doc_info:
|
||||
for doc_id, info in list(doc_info.items())[:3]: # Show first 3 documents
|
||||
logger.info(f" Document {doc_id}:")
|
||||
logger.info(f" - Length: {info['length']} terms")
|
||||
logger.info(f" - Unique terms: {info['unique_terms']}")
|
||||
logger.info(f" - Top terms: {[f'{term}({freq})' for term, freq in info['top_terms'][:5]]}")
|
||||
else:
|
||||
logger.info(" No document information available")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def test_specific_document_retrieval():
|
||||
"""Test retrieving specific documents by ID"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("DOCUMENT RETRIEVAL TEST")
|
||||
logger.info("="*50)
|
||||
|
||||
# Retrieve document with ID 0
|
||||
doc_id = 0
|
||||
logger.info(f"\nRetrieving document with ID {doc_id}...")
|
||||
|
||||
response = requests.get(f"{BASE_URL}/document/{doc_id}", timeout=30)
|
||||
if response.status_code == 200:
|
||||
document = response.json()
|
||||
logger.info(f"Document {doc_id}:")
|
||||
logger.info(f" Title: {document['metadata'].get('title', 'N/A')}")
|
||||
logger.info(f" Category: {document['metadata'].get('category', 'N/A')}")
|
||||
logger.info(f" Text: {document['text'][:150]}...")
|
||||
else:
|
||||
logger.error(f"Failed to retrieve document: {response.status_code}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the complete demo"""
|
||||
logger.info("Starting Educational Sparse Vector Search Engine Demo")
|
||||
logger.info("Make sure the server is running (python server.py)")
|
||||
|
||||
# Wait for server
|
||||
if not wait_for_server():
|
||||
logger.error("Server is not responding. Please start the server first.")
|
||||
return
|
||||
|
||||
# Clear existing index
|
||||
clear_index()
|
||||
|
||||
# Index sample documents
|
||||
doc_ids = index_sample_documents()
|
||||
|
||||
# Show statistics
|
||||
show_statistics()
|
||||
|
||||
# Show index structure
|
||||
show_index_structure()
|
||||
|
||||
# Perform searches
|
||||
perform_searches()
|
||||
|
||||
# Test document retrieval
|
||||
test_specific_document_retrieval()
|
||||
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("DEMO COMPLETED")
|
||||
logger.info("="*50)
|
||||
logger.info("\nVisit http://localhost:8000 in your browser for the interactive UI")
|
||||
logger.info("API documentation available at http://localhost:8000/docs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick start script for the Educational Sparse Vector Search Engine
|
||||
Demonstrates basic usage in a simple, interactive way
|
||||
"""
|
||||
|
||||
import logging
|
||||
from bm25_engine import SparseSearchEngine
|
||||
|
||||
# Configure logging to show educational information
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
print("\n" + "="*60)
|
||||
print(" Educational Sparse Vector Search Engine - Quick Start")
|
||||
print("="*60)
|
||||
print("\nThis demo shows the core functionality of BM25 search.\n")
|
||||
|
||||
# Initialize the search engine
|
||||
print("Initializing search engine...")
|
||||
engine = SparseSearchEngine()
|
||||
|
||||
# Sample documents about different programming topics
|
||||
documents = [
|
||||
{
|
||||
"text": "Python is a versatile programming language widely used for web development, data science, machine learning, and automation. Its simple syntax makes it ideal for beginners.",
|
||||
"title": "Python Overview"
|
||||
},
|
||||
{
|
||||
"text": "JavaScript powers the interactive web. It runs in browsers and on servers with Node.js. Modern JavaScript includes features like async/await, arrow functions, and destructuring.",
|
||||
"title": "JavaScript Essentials"
|
||||
},
|
||||
{
|
||||
"text": "Machine learning algorithms enable computers to learn from data. Popular algorithms include linear regression, decision trees, neural networks, and support vector machines.",
|
||||
"title": "ML Algorithms"
|
||||
},
|
||||
{
|
||||
"text": "Web development involves HTML for structure, CSS for styling, and JavaScript for interactivity. Modern frameworks like React, Vue, and Angular simplify complex applications.",
|
||||
"title": "Web Development"
|
||||
},
|
||||
{
|
||||
"text": "Data structures organize information efficiently. Arrays provide fast access, linked lists enable dynamic sizing, trees support hierarchical data, and hash tables offer constant-time lookups.",
|
||||
"title": "Data Structures"
|
||||
},
|
||||
{
|
||||
"text": "Databases store and manage data persistently. SQL databases like PostgreSQL use structured tables, while NoSQL databases like MongoDB store flexible documents.",
|
||||
"title": "Database Systems"
|
||||
},
|
||||
{
|
||||
"text": "Cloud computing provides scalable infrastructure on demand. AWS, Google Cloud, and Azure offer services for compute, storage, networking, and machine learning.",
|
||||
"title": "Cloud Computing"
|
||||
},
|
||||
{
|
||||
"text": "Software testing ensures code quality. Unit tests verify individual functions, integration tests check component interactions, and end-to-end tests validate entire workflows.",
|
||||
"title": "Software Testing"
|
||||
},
|
||||
{
|
||||
"text": "Version control systems track code changes over time. Git is the most popular system, enabling collaboration through branches, commits, and pull requests.",
|
||||
"title": "Version Control"
|
||||
},
|
||||
{
|
||||
"text": "APIs (Application Programming Interfaces) enable communication between software systems. REST APIs use HTTP methods, while GraphQL provides flexible data querying.",
|
||||
"title": "APIs and Integration"
|
||||
}
|
||||
]
|
||||
|
||||
# Index documents
|
||||
print(f"\nIndexing {len(documents)} documents...")
|
||||
print("-" * 40)
|
||||
|
||||
for i, doc in enumerate(documents):
|
||||
doc_id = engine.index_document(doc["text"], {"title": doc["title"]})
|
||||
print(f" [{doc_id}] {doc['title']}")
|
||||
|
||||
print(f"\n✓ Indexed {len(documents)} documents successfully!")
|
||||
|
||||
# Show index statistics
|
||||
stats = engine.index.get_statistics()
|
||||
print(f"\nIndex Statistics:")
|
||||
print(f" • Total documents: {stats['total_documents']}")
|
||||
print(f" • Unique terms: {stats['unique_terms']}")
|
||||
print(f" • Average document length: {stats['average_document_length']:.1f} terms")
|
||||
|
||||
# Demonstrate searches
|
||||
print("\n" + "="*60)
|
||||
print(" Demonstration Searches")
|
||||
print("="*60)
|
||||
|
||||
queries = [
|
||||
"machine learning algorithms",
|
||||
"web development JavaScript",
|
||||
"database SQL NoSQL",
|
||||
"cloud computing AWS",
|
||||
"Python programming"
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\n🔍 Query: '{query}'")
|
||||
print("-" * 40)
|
||||
|
||||
results = engine.search(query, top_k=3)
|
||||
|
||||
if results:
|
||||
for rank, result in enumerate(results, 1):
|
||||
title = result['metadata'].get('title', 'Unknown')
|
||||
score = result['score']
|
||||
matched = result['debug']['matched_terms']
|
||||
|
||||
print(f"\n #{rank} {title} (Score: {score:.3f})")
|
||||
print(f" Matched terms: {', '.join(matched)}")
|
||||
print(f" Preview: {result['text'][:100]}...")
|
||||
else:
|
||||
print(" No results found")
|
||||
|
||||
# Interactive search
|
||||
print("\n" + "="*60)
|
||||
print(" Interactive Search")
|
||||
print("="*60)
|
||||
print("\nNow you can try your own searches!")
|
||||
print("Type 'quit' to exit, 'stats' for statistics, or enter a search query.\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
query = input("Enter search query: ").strip()
|
||||
|
||||
if query.lower() == 'quit':
|
||||
print("\nThank you for using the Educational Sparse Vector Search Engine!")
|
||||
break
|
||||
|
||||
if query.lower() == 'stats':
|
||||
stats = engine.index.get_statistics()
|
||||
print(f"\nCurrent Index Statistics:")
|
||||
print(f" • Documents: {stats['total_documents']}")
|
||||
print(f" • Unique terms: {stats['unique_terms']}")
|
||||
print(f" • Total terms: {stats['total_terms']}")
|
||||
print(f" • Top terms: {', '.join([t[0] for t in stats['terms_by_frequency'][:5]])}")
|
||||
print()
|
||||
continue
|
||||
|
||||
if not query:
|
||||
continue
|
||||
|
||||
# Perform search
|
||||
results = engine.search(query, top_k=5)
|
||||
|
||||
if results:
|
||||
print(f"\nFound {len(results)} results for '{query}':\n")
|
||||
for rank, result in enumerate(results, 1):
|
||||
title = result['metadata'].get('title', 'Unknown')
|
||||
score = result['score']
|
||||
matched = result['debug']['matched_terms']
|
||||
|
||||
print(f" #{rank} {title}")
|
||||
print(f" Score: {score:.4f}")
|
||||
print(f" Matched: {', '.join(matched) if matched else 'None'}")
|
||||
print(f" Text: {result['text'][:150]}...")
|
||||
print()
|
||||
else:
|
||||
print(f"\nNo results found for '{query}'")
|
||||
print("Try different keywords or check your spelling.\n")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nExiting...")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
continue
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("\nTo learn more:")
|
||||
print(" • Run 'python test_engine.py' to see comprehensive tests")
|
||||
print(" • Run 'python server.py' to start the HTTP API server")
|
||||
print(" • Run 'python demo.py' for a full demonstration")
|
||||
print(" • Check the README.md for detailed documentation")
|
||||
print("\n" + "="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
requests==2.31.0
|
||||
python-multipart==0.0.6
|
||||
@@ -0,0 +1,503 @@
|
||||
"""
|
||||
HTTP API Server for Sparse Vector Search Engine
|
||||
Educational server with extensive logging and visualization
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Optional
|
||||
import uvicorn
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from bm25_engine import SparseSearchEngine
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Educational Sparse Vector Search Engine",
|
||||
description="BM25-based search engine with inverted index for educational purposes",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Initialize search engine
|
||||
search_engine = SparseSearchEngine()
|
||||
|
||||
|
||||
# Pydantic models for request/response
|
||||
class IndexDocumentRequest(BaseModel):
|
||||
text: str = Field(..., description="Text content to index")
|
||||
metadata: Optional[Dict] = Field(None, description="Optional metadata")
|
||||
doc_id: Optional[str] = Field(None, description="Optional external document ID")
|
||||
|
||||
|
||||
class BatchIndexRequest(BaseModel):
|
||||
documents: List[Dict] = Field(..., description="List of documents to index")
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str = Field(..., description="Search query")
|
||||
top_k: int = Field(10, description="Number of results to return")
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
doc_id: str # Changed to str to support external IDs
|
||||
text: str
|
||||
metadata: Optional[Dict]
|
||||
score: Optional[float] = None
|
||||
debug: Optional[Dict] = None
|
||||
|
||||
|
||||
# Root endpoint with UI
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root():
|
||||
"""Serve a simple HTML interface for the search engine"""
|
||||
html_content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Educational Sparse Vector Search Engine</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.form-group {
|
||||
margin: 15px 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
pre {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.result-item {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
.score {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.debug-info {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.stat-item {
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 Educational Sparse Vector Search Engine</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Index Documents</h2>
|
||||
<div class="form-group">
|
||||
<label for="indexText">Document Text:</label>
|
||||
<textarea id="indexText" rows="4" placeholder="Enter document text to index..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="indexMetadata">Metadata (JSON, optional):</label>
|
||||
<input id="indexMetadata" placeholder='{"title": "Document Title", "author": "Author Name"}'>
|
||||
</div>
|
||||
<button onclick="indexDocument()">Index Document</button>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Search</h2>
|
||||
<div class="form-group">
|
||||
<label for="searchQuery">Query:</label>
|
||||
<input id="searchQuery" placeholder="Enter search query...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="topK">Number of Results:</label>
|
||||
<input id="topK" type="number" value="5" min="1" max="100">
|
||||
</div>
|
||||
<button onclick="search()">Search</button>
|
||||
<div id="searchResults" class="results"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Index Statistics</h2>
|
||||
<button onclick="loadStatistics()">Load Statistics</button>
|
||||
<div id="statistics"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Index Structure Visualization</h2>
|
||||
<button onclick="loadIndexStructure()">Load Index Structure</button>
|
||||
<div id="indexStructure"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function indexDocument() {
|
||||
const text = document.getElementById('indexText').value;
|
||||
const metadataStr = document.getElementById('indexMetadata').value;
|
||||
|
||||
let metadata = null;
|
||||
if (metadataStr) {
|
||||
try {
|
||||
metadata = JSON.parse(metadataStr);
|
||||
} catch (e) {
|
||||
alert('Invalid JSON in metadata field');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch('/index', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({text: text, metadata: metadata, doc_id: null})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
alert(`Document indexed successfully! ID: ${result.doc_id}`);
|
||||
document.getElementById('indexText').value = '';
|
||||
document.getElementById('indexMetadata').value = '';
|
||||
loadStatistics();
|
||||
} else {
|
||||
alert('Error indexing document');
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const query = document.getElementById('searchQuery').value;
|
||||
const topK = document.getElementById('topK').value;
|
||||
|
||||
const response = await fetch('/search', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({query: query, top_k: parseInt(topK)})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const results = await response.json();
|
||||
displaySearchResults(results);
|
||||
} else {
|
||||
alert('Error performing search');
|
||||
}
|
||||
}
|
||||
|
||||
function displaySearchResults(results) {
|
||||
const container = document.getElementById('searchResults');
|
||||
|
||||
if (results.length === 0) {
|
||||
container.innerHTML = '<p>No results found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<h3>Search Results</h3>';
|
||||
results.forEach((result, index) => {
|
||||
html += `
|
||||
<div class="result-item">
|
||||
<div><strong>Rank ${index + 1}</strong> - Doc ID: ${result.doc_id}</div>
|
||||
<div class="score">Score: ${result.score.toFixed(4)}</div>
|
||||
<div style="margin-top: 10px;">${result.text}</div>
|
||||
${result.metadata ? `<div style="margin-top: 10px;"><strong>Metadata:</strong> ${JSON.stringify(result.metadata)}</div>` : ''}
|
||||
<div class="debug-info">
|
||||
<strong>Debug Info:</strong>
|
||||
<pre>${JSON.stringify(result.debug, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadStatistics() {
|
||||
const response = await fetch('/stats');
|
||||
if (response.ok) {
|
||||
const stats = await response.json();
|
||||
displayStatistics(stats);
|
||||
}
|
||||
}
|
||||
|
||||
function displayStatistics(stats) {
|
||||
const container = document.getElementById('statistics');
|
||||
|
||||
let html = '<div class="stats">';
|
||||
html += `
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${stats.total_documents}</div>
|
||||
<div class="stat-label">Total Documents</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${stats.unique_terms}</div>
|
||||
<div class="stat-label">Unique Terms</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${stats.total_terms}</div>
|
||||
<div class="stat-label">Total Terms</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${stats.average_document_length.toFixed(2)}</div>
|
||||
<div class="stat-label">Avg Doc Length</div>
|
||||
</div>
|
||||
`;
|
||||
html += '</div>';
|
||||
|
||||
if (stats.terms_by_frequency && stats.terms_by_frequency.length > 0) {
|
||||
html += '<h4>Top Terms by Frequency</h4>';
|
||||
html += '<ul>';
|
||||
stats.terms_by_frequency.forEach(([term, freq]) => {
|
||||
html += `<li>${term}: ${freq}</li>`;
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadIndexStructure() {
|
||||
const response = await fetch('/index/structure');
|
||||
if (response.ok) {
|
||||
const structure = await response.json();
|
||||
displayIndexStructure(structure);
|
||||
}
|
||||
}
|
||||
|
||||
function displayIndexStructure(structure) {
|
||||
const container = document.getElementById('indexStructure');
|
||||
|
||||
let html = '<h4>Inverted Index Sample (Top Terms)</h4>';
|
||||
html += '<pre>' + JSON.stringify(structure.inverted_index, null, 2) + '</pre>';
|
||||
|
||||
html += '<h4>Document Information</h4>';
|
||||
html += '<pre>' + JSON.stringify(structure.document_info, null, 2) + '</pre>';
|
||||
|
||||
html += '<h4>BM25 Parameters</h4>';
|
||||
html += '<pre>' + JSON.stringify(structure.bm25_params, null, 2) + '</pre>';
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// Load statistics on page load
|
||||
window.onload = function() {
|
||||
loadStatistics();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html_content
|
||||
|
||||
|
||||
@app.post("/index", response_model=Dict)
|
||||
async def index_document(request: IndexDocumentRequest):
|
||||
"""Index a single document"""
|
||||
logger.info(f"Received index request for document of length {len(request.text)}")
|
||||
if request.doc_id:
|
||||
logger.info(f"External doc_id provided: {request.doc_id}")
|
||||
|
||||
try:
|
||||
# Extract doc_id from metadata if not provided directly
|
||||
external_doc_id = request.doc_id
|
||||
if not external_doc_id and request.metadata and 'doc_id' in request.metadata:
|
||||
external_doc_id = request.metadata['doc_id']
|
||||
|
||||
doc_id = search_engine.index_document(request.text, request.metadata, external_doc_id)
|
||||
logger.info(f"Document indexed successfully with ID {doc_id}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"doc_id": doc_id,
|
||||
"message": f"Document indexed successfully with ID {doc_id}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing document: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/index/batch", response_model=Dict)
|
||||
async def index_batch(request: BatchIndexRequest):
|
||||
"""Index multiple documents at once"""
|
||||
logger.info(f"Received batch index request for {len(request.documents)} documents")
|
||||
|
||||
try:
|
||||
doc_ids = search_engine.index_batch(request.documents)
|
||||
logger.info(f"Batch indexing successful: {len(doc_ids)} documents indexed")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"doc_ids": doc_ids,
|
||||
"message": f"Successfully indexed {len(doc_ids)} documents"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in batch indexing: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/search", response_model=List[DocumentResponse])
|
||||
async def search(request: SearchRequest):
|
||||
"""Search for documents"""
|
||||
logger.info(f"Received search request: '{request.query}' (top_k={request.top_k})")
|
||||
|
||||
try:
|
||||
results = search_engine.search(request.query, request.top_k)
|
||||
logger.info(f"Search completed, returning {len(results)} results")
|
||||
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error performing search: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/document/{doc_id}", response_model=DocumentResponse)
|
||||
async def get_document(doc_id: str):
|
||||
"""Retrieve a specific document by ID"""
|
||||
logger.info(f"Retrieving document {doc_id}")
|
||||
|
||||
document = search_engine.get_document(doc_id)
|
||||
if document is None:
|
||||
logger.warning(f"Document {doc_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Document {doc_id} not found")
|
||||
|
||||
logger.info(f"Document {doc_id} retrieved successfully")
|
||||
return document
|
||||
|
||||
|
||||
@app.get("/stats", response_model=Dict)
|
||||
async def get_statistics():
|
||||
"""Get index statistics"""
|
||||
logger.info("Retrieving index statistics")
|
||||
|
||||
stats = search_engine.index.get_statistics()
|
||||
logger.info(f"Statistics retrieved: {stats['total_documents']} documents, "
|
||||
f"{stats['unique_terms']} unique terms")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@app.get("/index/structure", response_model=Dict)
|
||||
async def get_index_structure():
|
||||
"""Get detailed index structure for visualization"""
|
||||
logger.info("Retrieving index structure")
|
||||
|
||||
info = search_engine.get_index_info()
|
||||
logger.info("Index structure retrieved successfully")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
@app.delete("/index", response_model=Dict)
|
||||
async def clear_index():
|
||||
"""Clear all indexed documents"""
|
||||
logger.warning("Clearing entire index")
|
||||
|
||||
search_engine.clear_index()
|
||||
logger.info("Index cleared successfully")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Index cleared successfully"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/logs", response_model=Dict)
|
||||
async def get_recent_logs(lines: int = Query(100, description="Number of log lines to retrieve")):
|
||||
"""Get recent application logs for educational purposes"""
|
||||
# This is a simplified version - in production you'd read from a log file
|
||||
return {
|
||||
"message": "Logs are being written to console. Check terminal for detailed logs.",
|
||||
"log_level": "DEBUG",
|
||||
"description": "Educational logging is enabled. All indexing and search operations are logged."
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
# Allow overriding port from command line
|
||||
port = 4241 # Default to 4241 to avoid conflicts with common services
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
port = int(sys.argv[1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
logger.info("Starting Educational Sparse Vector Search Engine Server")
|
||||
logger.info(f"Server will run on http://localhost:{port}")
|
||||
logger.info(f"Visit http://localhost:{port} for the web interface")
|
||||
logger.info(f"API documentation available at http://localhost:{port}/docs")
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
||||
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
from bm25_engine import InvertedIndex, BM25, TextProcessor
|
||||
|
||||
|
||||
def test_bm25_search_empty_term_in_query_terms_no_index_error():
|
||||
index = InvertedIndex()
|
||||
index.add_document(1, "hello world")
|
||||
bm25 = BM25(index)
|
||||
|
||||
orig_tokenize = TextProcessor.tokenize
|
||||
try:
|
||||
TextProcessor.tokenize = lambda self, text, remove_stop_words=True: ["hello", ""]
|
||||
results = bm25.search("hello")
|
||||
assert len(results) == 1
|
||||
assert results[0][0] == 1
|
||||
finally:
|
||||
TextProcessor.tokenize = orig_tokenize
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Test script for the Educational Sparse Vector Search Engine
|
||||
Tests core functionality and demonstrates educational aspects
|
||||
"""
|
||||
|
||||
import logging
|
||||
from bm25_engine import TextProcessor, InvertedIndex, BM25, SparseSearchEngine
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_text_processor():
|
||||
"""Test text processing functionality"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("TESTING TEXT PROCESSOR")
|
||||
logger.info("="*50)
|
||||
|
||||
processor = TextProcessor()
|
||||
|
||||
# Test basic tokenization
|
||||
text1 = "The quick brown fox jumps over the lazy dog."
|
||||
tokens1 = processor.tokenize(text1, remove_stop_words=False)
|
||||
logger.info(f"Input: {text1}")
|
||||
logger.info(f"Tokens (with stop words): {tokens1}")
|
||||
|
||||
tokens2 = processor.tokenize(text1, remove_stop_words=True)
|
||||
logger.info(f"Tokens (without stop words): {tokens2}")
|
||||
|
||||
# Test with technical text
|
||||
text2 = "Machine learning algorithms process data to identify patterns."
|
||||
tokens3 = processor.tokenize(text2)
|
||||
logger.info(f"\nInput: {text2}")
|
||||
logger.info(f"Tokens: {tokens3}")
|
||||
|
||||
# Test with mixed case and punctuation
|
||||
text3 = "Python, JavaScript, and C++ are POPULAR programming languages!"
|
||||
tokens4 = processor.tokenize(text3)
|
||||
logger.info(f"\nInput: {text3}")
|
||||
logger.info(f"Tokens: {tokens4}")
|
||||
|
||||
assert len(tokens2) < len(tokens1), "Stop word removal should reduce token count"
|
||||
logger.info("\n✓ Text processor tests passed")
|
||||
|
||||
|
||||
def test_inverted_index():
|
||||
"""Test inverted index functionality"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("TESTING INVERTED INDEX")
|
||||
logger.info("="*50)
|
||||
|
||||
index = InvertedIndex()
|
||||
|
||||
# Add test documents
|
||||
doc1 = "Python is a programming language"
|
||||
doc2 = "JavaScript is also a programming language"
|
||||
doc3 = "Python and JavaScript are both popular"
|
||||
|
||||
logger.info("\nAdding documents to index...")
|
||||
index.add_document(0, doc1, {"title": "Doc1"})
|
||||
index.add_document(1, doc2, {"title": "Doc2"})
|
||||
index.add_document(2, doc3, {"title": "Doc3"})
|
||||
|
||||
# Test posting lists
|
||||
logger.info("\nTesting posting lists:")
|
||||
python_docs = index.get_posting_list("python")
|
||||
logger.info(f"Documents containing 'python': {python_docs}")
|
||||
assert python_docs == {0, 2}, "Python should be in docs 0 and 2"
|
||||
|
||||
programming_docs = index.get_posting_list("programming")
|
||||
logger.info(f"Documents containing 'programming': {programming_docs}")
|
||||
assert programming_docs == {0, 1}, "Programming should be in docs 0 and 1"
|
||||
|
||||
# Test statistics
|
||||
stats = index.get_statistics()
|
||||
logger.info(f"\nIndex statistics:")
|
||||
logger.info(f" Total documents: {stats['total_documents']}")
|
||||
logger.info(f" Unique terms: {stats['unique_terms']}")
|
||||
logger.info(f" Average doc length: {stats['average_document_length']:.2f}")
|
||||
|
||||
assert stats['total_documents'] == 3, "Should have 3 documents"
|
||||
logger.info("\n✓ Inverted index tests passed")
|
||||
|
||||
|
||||
def test_bm25_scoring():
|
||||
"""Test BM25 scoring algorithm"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("TESTING BM25 SCORING")
|
||||
logger.info("="*50)
|
||||
|
||||
# Create index with test documents
|
||||
index = InvertedIndex()
|
||||
index.add_document(0, "The cat sat on the mat", {"title": "Simple"})
|
||||
index.add_document(1, "The dog sat on the log", {"title": "Similar"})
|
||||
index.add_document(2, "Cats and dogs are pets", {"title": "Pets"})
|
||||
index.add_document(3, "The mat was comfortable", {"title": "Mat"})
|
||||
|
||||
# Initialize BM25
|
||||
bm25 = BM25(index, k1=1.5, b=0.75)
|
||||
|
||||
# Test IDF calculation
|
||||
logger.info("\nTesting IDF calculations:")
|
||||
idf_cat = bm25.calculate_idf("cat")
|
||||
idf_the = bm25.calculate_idf("the") # Common word
|
||||
idf_mat = bm25.calculate_idf("mat")
|
||||
|
||||
logger.info(f"IDF('cat'): {idf_cat:.4f}")
|
||||
logger.info(f"IDF('the'): {idf_the:.4f}")
|
||||
logger.info(f"IDF('mat'): {idf_mat:.4f}")
|
||||
|
||||
# IDF of rare words should be higher than common words
|
||||
assert idf_cat > idf_the, "Rare words should have higher IDF"
|
||||
|
||||
# Test document scoring
|
||||
logger.info("\nTesting document scoring for query 'cat mat':")
|
||||
query_terms = ["cat", "mat"]
|
||||
|
||||
for doc_id in range(4):
|
||||
score = bm25.score_document(query_terms, doc_id)
|
||||
logger.info(f"Document {doc_id} score: {score:.4f}")
|
||||
|
||||
# Document 0 should have the highest score as it contains both terms
|
||||
score_0 = bm25.score_document(query_terms, 0)
|
||||
score_1 = bm25.score_document(query_terms, 1)
|
||||
assert score_0 > score_1, "Doc with both terms should score higher"
|
||||
|
||||
logger.info("\n✓ BM25 scoring tests passed")
|
||||
|
||||
|
||||
def test_search_engine():
|
||||
"""Test the complete search engine"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("TESTING SEARCH ENGINE")
|
||||
logger.info("="*50)
|
||||
|
||||
engine = SparseSearchEngine()
|
||||
|
||||
# Index test documents
|
||||
logger.info("\nIndexing test documents...")
|
||||
doc_ids = []
|
||||
|
||||
test_docs = [
|
||||
("Information retrieval is the science of searching for information in documents.",
|
||||
{"topic": "IR"}),
|
||||
("Search engines use inverted indices to quickly find relevant documents.",
|
||||
{"topic": "Search"}),
|
||||
("BM25 is a probabilistic ranking function used in information retrieval.",
|
||||
{"topic": "BM25"}),
|
||||
("The inverted index maps terms to the documents that contain them.",
|
||||
{"topic": "Index"}),
|
||||
("Relevance ranking determines the order of search results.",
|
||||
{"topic": "Ranking"})
|
||||
]
|
||||
|
||||
for text, metadata in test_docs:
|
||||
doc_id = engine.index_document(text, metadata)
|
||||
doc_ids.append(doc_id)
|
||||
logger.info(f"Indexed: {metadata['topic']} (ID: {doc_id})")
|
||||
|
||||
# Test search queries
|
||||
test_queries = [
|
||||
("information retrieval", 3),
|
||||
("inverted index", 2),
|
||||
("search ranking", 3),
|
||||
("BM25 algorithm", 2)
|
||||
]
|
||||
|
||||
for query, top_k in test_queries:
|
||||
logger.info(f"\nSearching for: '{query}' (top {top_k})")
|
||||
results = engine.search(query, top_k)
|
||||
|
||||
for rank, result in enumerate(results, 1):
|
||||
logger.info(f" Rank {rank}: {result['metadata']['topic']} "
|
||||
f"(score: {result['score']:.4f}, "
|
||||
f"matched: {result['debug']['matched_terms']})")
|
||||
|
||||
# Test document retrieval
|
||||
logger.info("\nTesting document retrieval:")
|
||||
doc = engine.get_document(0)
|
||||
assert doc is not None, "Should retrieve document"
|
||||
logger.info(f"Retrieved document 0: {doc['metadata']['topic']}")
|
||||
|
||||
# Test index clearing
|
||||
logger.info("\nTesting index clearing:")
|
||||
initial_stats = engine.index.get_statistics()
|
||||
engine.clear_index()
|
||||
final_stats = engine.index.get_statistics()
|
||||
assert final_stats['total_documents'] == 0, "Index should be empty after clearing"
|
||||
logger.info("✓ Index cleared successfully")
|
||||
|
||||
logger.info("\n✓ Search engine tests passed")
|
||||
|
||||
|
||||
def test_edge_cases():
|
||||
"""Test edge cases and special scenarios"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("TESTING EDGE CASES")
|
||||
logger.info("="*50)
|
||||
|
||||
engine = SparseSearchEngine()
|
||||
|
||||
# Test empty query
|
||||
logger.info("\nTesting empty query:")
|
||||
results = engine.search("", top_k=5)
|
||||
assert len(results) == 0, "Empty query should return no results"
|
||||
logger.info("✓ Empty query handled correctly")
|
||||
|
||||
# Test query with only stop words
|
||||
logger.info("\nTesting query with only stop words:")
|
||||
engine.index_document("This is a test document about nothing specific.")
|
||||
results = engine.search("the is a", top_k=5)
|
||||
logger.info(f"Results for stop words query: {len(results)} documents")
|
||||
|
||||
# Test single word document
|
||||
logger.info("\nTesting single word document:")
|
||||
doc_id = engine.index_document("Python")
|
||||
doc = engine.get_document(doc_id)
|
||||
assert doc is not None, "Should index single word document"
|
||||
logger.info("✓ Single word document indexed")
|
||||
|
||||
# Test duplicate documents
|
||||
logger.info("\nTesting duplicate documents:")
|
||||
text = "This is a duplicate document"
|
||||
id1 = engine.index_document(text)
|
||||
id2 = engine.index_document(text)
|
||||
assert id1 != id2, "Duplicate documents should have different IDs"
|
||||
logger.info(f"✓ Duplicate documents have different IDs: {id1}, {id2}")
|
||||
|
||||
# Test very long document
|
||||
logger.info("\nTesting very long document:")
|
||||
long_text = " ".join(["word" + str(i) for i in range(1000)])
|
||||
long_doc_id = engine.index_document(long_text)
|
||||
long_doc = engine.get_document(long_doc_id)
|
||||
logger.info(f"✓ Long document indexed (length: {long_doc['statistics']['length']} terms)")
|
||||
|
||||
# Test special characters
|
||||
logger.info("\nTesting special characters:")
|
||||
special_text = "Email: test@example.com, URL: https://example.com, Price: $99.99"
|
||||
special_id = engine.index_document(special_text)
|
||||
results = engine.search("email test example", top_k=1)
|
||||
logger.info(f"✓ Special characters handled, found {len(results)} results")
|
||||
|
||||
logger.info("\n✓ All edge case tests passed")
|
||||
|
||||
|
||||
def test_ranking_quality():
|
||||
"""Test the quality of search result ranking"""
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("TESTING RANKING QUALITY")
|
||||
logger.info("="*50)
|
||||
|
||||
engine = SparseSearchEngine()
|
||||
|
||||
# Create documents with varying relevance
|
||||
docs = [
|
||||
"Machine learning is a subset of artificial intelligence",
|
||||
"Deep learning uses neural networks for machine learning",
|
||||
"Machine learning algorithms learn from data",
|
||||
"Artificial intelligence includes machine learning and robotics",
|
||||
"Data science often uses machine learning techniques",
|
||||
"Neural networks are inspired by biological brains",
|
||||
"Supervised learning is a type of machine learning",
|
||||
"Unsupervised learning discovers patterns in data",
|
||||
"Reinforcement learning uses rewards and penalties",
|
||||
"Computer vision is an application of deep learning"
|
||||
]
|
||||
|
||||
logger.info("Indexing documents about machine learning...")
|
||||
for i, doc in enumerate(docs):
|
||||
engine.index_document(doc, {"id": i})
|
||||
|
||||
# Search for "machine learning"
|
||||
query = "machine learning"
|
||||
logger.info(f"\nSearching for: '{query}'")
|
||||
results = engine.search(query, top_k=5)
|
||||
|
||||
logger.info("\nTop 5 results:")
|
||||
for rank, result in enumerate(results, 1):
|
||||
logger.info(f" Rank {rank}: Score {result['score']:.4f}")
|
||||
logger.info(f" Text: {result['text']}")
|
||||
logger.info(f" Term frequencies: {result['debug']['term_frequencies']}")
|
||||
|
||||
# Verify that documents with both terms rank higher
|
||||
first_result_text = results[0]['text'].lower()
|
||||
assert 'machine' in first_result_text and 'learning' in first_result_text, \
|
||||
"Top result should contain both query terms"
|
||||
|
||||
logger.info("\n✓ Ranking quality test passed")
|
||||
|
||||
|
||||
def main():
|
||||
"""Run all tests"""
|
||||
logger.info("Starting Educational Sparse Vector Search Engine Tests")
|
||||
logger.info("This will test all components and demonstrate educational logging")
|
||||
|
||||
try:
|
||||
test_text_processor()
|
||||
test_inverted_index()
|
||||
test_bm25_scoring()
|
||||
test_search_engine()
|
||||
test_edge_cases()
|
||||
test_ranking_quality()
|
||||
|
||||
logger.info("\n" + "="*50)
|
||||
logger.info("ALL TESTS PASSED SUCCESSFULLY!")
|
||||
logger.info("="*50)
|
||||
logger.info("\nThe educational sparse vector search engine is working correctly.")
|
||||
logger.info("Run 'python server.py' to start the HTTP server.")
|
||||
logger.info("Run 'python demo.py' to see a full demonstration.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test failed: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
import pytest
|
||||
from bm25_engine import TextProcessor
|
||||
|
||||
|
||||
def test_tokenize_preserves_apostrophe_contractions():
|
||||
processor = TextProcessor()
|
||||
tokens = processor.tokenize("don't user's it's text")
|
||||
assert "don't" in tokens
|
||||
assert "user's" in tokens
|
||||
assert "it's" in tokens
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"schema_version": "chapter3-evidence-v1",
|
||||
"experiment": "3-5",
|
||||
"run_id": "20260729T183232Z-3_5-f9f70b37",
|
||||
"created_at": "2026-07-29T18:32:32.574550+00:00",
|
||||
"status": "passed",
|
||||
"run_dir": "/Users/boj/book/ai-agent-book/chapter3/sparse-embedding/validation/runs/20260729T183232Z-3_5-f9f70b37",
|
||||
"artifacts": {
|
||||
"evidence.json": "69bc798be2f18e814b53665d29692cb4182e70561f4dcf222f25bf36d21eae0e",
|
||||
"receipts.json": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570",
|
||||
"manifest.json": "632bd554d004aa4139907c02006d34f6c0c192a3b8e940689b5d154eadbc67d0"
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter3/sparse-embedding/benchmark.py",
|
||||
"sha256": "f3601a917de0b1a169691fbb5fb4323b7310220af9eb2221414ed74220a8da89",
|
||||
"bytes": 7038
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter3/sparse-embedding/bm25_engine.py",
|
||||
"sha256": "efcaa2eef462b05d2e1dc9d9f2c5dc0d548a529ca17315ec52e0b0e85fe636c0",
|
||||
"bytes": 20138
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter3/sparse-embedding/cli.py",
|
||||
"sha256": "6b76c01fd3889a029b5c37a4f589e3a438bc093c406e144e5e4209441c556ec2",
|
||||
"bytes": 16004
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"exact_keyword_recall_at_5": 1.0,
|
||||
"exact_keyword_mrr": 1.0,
|
||||
"synonym_only_recall_at_5": 0.0,
|
||||
"synonym_only_mrr": 0.0
|
||||
},
|
||||
"acceptance": {
|
||||
"uses_from_scratch_engine": true,
|
||||
"hand_score_matches": true,
|
||||
"inverted_index_df_matches": true,
|
||||
"all_exact_keyword_queries_recalled": true,
|
||||
"synonym_only_failure_observed": true,
|
||||
"transparent_tf_idf_scores_retained": true
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"schema_version": "chapter3-evidence-v1",
|
||||
"experiment": "3-5",
|
||||
"run_id": "20260729T183232Z-3_5-f9f70b37",
|
||||
"provenance": {
|
||||
"captured_at": "2026-07-29T18:32:32.564511+00:00",
|
||||
"git_revision": "4a7f37cf278bd15948c409f14533017c4c7fbc29",
|
||||
"python": "3.11.4 (main, Jul 5 2023, 08:40:20) [Clang 14.0.6 ]",
|
||||
"platform": "macOS-26.3-arm64-arm-64bit",
|
||||
"credential_presence": {
|
||||
"ARK_API_KEY": true,
|
||||
"MOONSHOT_API_KEY": true,
|
||||
"OPENAI_API_KEY": true,
|
||||
"GEMINI_API_KEY": true,
|
||||
"SILICONFLOW_API_KEY": true
|
||||
}
|
||||
},
|
||||
"status": "passed",
|
||||
"method": {
|
||||
"implementation": "chapter3/sparse-embedding/bm25_engine.py",
|
||||
"algorithm": "from-scratch inverted index + Robertson/Sparck Jones BM25",
|
||||
"third_party_retrieval_library": null
|
||||
},
|
||||
"hand_calculation": {
|
||||
"corpus": [
|
||||
"rare rare common",
|
||||
"common common",
|
||||
"common filler filler filler",
|
||||
"filler"
|
||||
],
|
||||
"term": "rare",
|
||||
"doc_id": 0,
|
||||
"parameters": {
|
||||
"N": 4,
|
||||
"df": 1,
|
||||
"tf": 2,
|
||||
"dl": 3,
|
||||
"avgdl": 2.5,
|
||||
"k1": 1.5,
|
||||
"b": 0.75
|
||||
},
|
||||
"formula": "ln((N-df+0.5)/(df+0.5)) * tf*(k1+1) / (tf+k1*(1-b+b*dl/avgdl))",
|
||||
"intermediate": {
|
||||
"independent_raw_idf": 0.8472978603872037,
|
||||
"engine_raw_idf": 0.8472978603872037,
|
||||
"scoring_idf": 0.8472978603872037,
|
||||
"numerator": 5.0,
|
||||
"denominator": 3.7249999999999996
|
||||
},
|
||||
"expected_score": 1.1373125642781259,
|
||||
"engine_score": 1.1373125642781259,
|
||||
"absolute_error": 0.0,
|
||||
"tolerance": 1e-12,
|
||||
"posting_list": [
|
||||
0
|
||||
],
|
||||
"recorded_document_frequency": 1,
|
||||
"passed": true
|
||||
},
|
||||
"benchmark": {
|
||||
"corpus": [
|
||||
{
|
||||
"doc_id": "doc_1",
|
||||
"title": "Python Language",
|
||||
"text": "Python is a high-level programming language known for readability and a simple syntax."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_2",
|
||||
"title": "JavaScript Runtime",
|
||||
"text": "JavaScript runs in the browser and on servers via Node.js for full-stack web development."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_3",
|
||||
"title": "Model Distillation",
|
||||
"text": "Model distillation compresses a large teacher model into a smaller student model while preserving accuracy."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_4",
|
||||
"title": "Knowledge Distillation",
|
||||
"text": "Knowledge distillation transfers knowledge from a big neural network to a compact model for efficient inference."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_5",
|
||||
"title": "BM25 Ranking",
|
||||
"text": "BM25 is a probabilistic ranking function using term frequency and inverse document frequency."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_6",
|
||||
"title": "HTTP Errors",
|
||||
"text": "The HTTP 404 error code means the requested resource was not found on the web server."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_7",
|
||||
"title": "A Playful Kitten",
|
||||
"text": "A cute kitten chased a ball of yarn across the living room floor all afternoon."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_8",
|
||||
"title": "Silent Hunter",
|
||||
"text": "The feline predator stalked its prey silently through the tall grass at dusk."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_9",
|
||||
"title": "Hardware Fault",
|
||||
"text": "Error code XK9-2B4-7Q1 indicates a hardware fault in the storage controller board."
|
||||
},
|
||||
{
|
||||
"doc_id": "doc_10",
|
||||
"title": "Transformers",
|
||||
"text": "Transformer models use self-attention to process input sequences in parallel efficiently."
|
||||
}
|
||||
],
|
||||
"labels": {
|
||||
"model distillation": [
|
||||
"doc_3",
|
||||
"doc_4"
|
||||
],
|
||||
"HTTP 404 error": [
|
||||
"doc_6"
|
||||
],
|
||||
"XK9-2B4-7Q1": [
|
||||
"doc_9"
|
||||
],
|
||||
"BM25 ranking function": [
|
||||
"doc_5"
|
||||
],
|
||||
"cat": [
|
||||
"doc_7",
|
||||
"doc_8"
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
"k1": 1.5,
|
||||
"b": 0.75,
|
||||
"top_k": 5
|
||||
},
|
||||
"index_statistics": {
|
||||
"total_documents": 10,
|
||||
"unique_terms": 98,
|
||||
"total_terms": 114,
|
||||
"average_document_length": 11.4,
|
||||
"terms_by_frequency": [
|
||||
[
|
||||
"model",
|
||||
4
|
||||
],
|
||||
[
|
||||
"for",
|
||||
3
|
||||
],
|
||||
[
|
||||
"and",
|
||||
3
|
||||
],
|
||||
[
|
||||
"in",
|
||||
3
|
||||
],
|
||||
[
|
||||
"web",
|
||||
2
|
||||
],
|
||||
[
|
||||
"distillation",
|
||||
2
|
||||
],
|
||||
[
|
||||
"knowledge",
|
||||
2
|
||||
],
|
||||
[
|
||||
"to",
|
||||
2
|
||||
],
|
||||
[
|
||||
"frequency",
|
||||
2
|
||||
],
|
||||
[
|
||||
"error",
|
||||
2
|
||||
]
|
||||
]
|
||||
},
|
||||
"build_latency_ms": 1.668,
|
||||
"queries": [
|
||||
{
|
||||
"query": "model distillation",
|
||||
"category": "exact-keyword",
|
||||
"relevant": [
|
||||
"doc_3",
|
||||
"doc_4"
|
||||
],
|
||||
"retrieved": [
|
||||
"doc_3",
|
||||
"doc_4"
|
||||
],
|
||||
"hits": [
|
||||
"doc_3",
|
||||
"doc_4"
|
||||
],
|
||||
"recall_at_5": 1.0,
|
||||
"reciprocal_rank": 1.0,
|
||||
"latency_ms": 0.276,
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"doc_id": "doc_3",
|
||||
"score": 3.121561765506991,
|
||||
"matched_terms": [
|
||||
"model",
|
||||
"distillation"
|
||||
],
|
||||
"term_frequencies": {
|
||||
"model": 3,
|
||||
"distillation": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"doc_id": "doc_4",
|
||||
"score": 2.219735866426749,
|
||||
"matched_terms": [
|
||||
"model",
|
||||
"distillation"
|
||||
],
|
||||
"term_frequencies": {
|
||||
"model": 1,
|
||||
"distillation": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "HTTP 404 error",
|
||||
"category": "exact-keyword",
|
||||
"relevant": [
|
||||
"doc_6"
|
||||
],
|
||||
"retrieved": [
|
||||
"doc_6",
|
||||
"doc_9"
|
||||
],
|
||||
"hits": [
|
||||
"doc_6"
|
||||
],
|
||||
"recall_at_5": 1.0,
|
||||
"reciprocal_rank": 1.0,
|
||||
"latency_ms": 0.269,
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"doc_id": "doc_6",
|
||||
"score": 4.994285959345282,
|
||||
"matched_terms": [
|
||||
"HTTP",
|
||||
"404",
|
||||
"error"
|
||||
],
|
||||
"term_frequencies": {
|
||||
"HTTP": 1,
|
||||
"404": 1,
|
||||
"error": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"doc_id": "doc_9",
|
||||
"score": 1.2953611811041896,
|
||||
"matched_terms": [
|
||||
"error"
|
||||
],
|
||||
"term_frequencies": {
|
||||
"HTTP": 0,
|
||||
"404": 0,
|
||||
"error": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "XK9-2B4-7Q1",
|
||||
"category": "exact-keyword",
|
||||
"relevant": [
|
||||
"doc_9"
|
||||
],
|
||||
"retrieved": [
|
||||
"doc_9"
|
||||
],
|
||||
"hits": [
|
||||
"doc_9"
|
||||
],
|
||||
"recall_at_5": 1.0,
|
||||
"reciprocal_rank": 1.0,
|
||||
"latency_ms": 0.129,
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"doc_id": "doc_9",
|
||||
"score": 1.9537998395246958,
|
||||
"matched_terms": [
|
||||
"XK9-2B4-7Q1"
|
||||
],
|
||||
"term_frequencies": {
|
||||
"XK9-2B4-7Q1": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "BM25 ranking function",
|
||||
"category": "exact-keyword",
|
||||
"relevant": [
|
||||
"doc_5"
|
||||
],
|
||||
"retrieved": [
|
||||
"doc_5"
|
||||
],
|
||||
"hits": [
|
||||
"doc_5"
|
||||
],
|
||||
"recall_at_5": 1.0,
|
||||
"reciprocal_rank": 1.0,
|
||||
"latency_ms": 0.261,
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"doc_id": "doc_5",
|
||||
"score": 5.626316650182078,
|
||||
"matched_terms": [
|
||||
"BM25",
|
||||
"ranking",
|
||||
"function"
|
||||
],
|
||||
"term_frequencies": {
|
||||
"BM25": 1,
|
||||
"ranking": 1,
|
||||
"function": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "cat",
|
||||
"category": "synonym-only",
|
||||
"relevant": [
|
||||
"doc_7",
|
||||
"doc_8"
|
||||
],
|
||||
"retrieved": [],
|
||||
"hits": [],
|
||||
"recall_at_5": 0.0,
|
||||
"reciprocal_rank": 0.0,
|
||||
"latency_ms": 0.095,
|
||||
"results": []
|
||||
}
|
||||
],
|
||||
"metrics": {
|
||||
"exact_keyword_recall_at_5": 1.0,
|
||||
"exact_keyword_mrr": 1.0,
|
||||
"synonym_only_recall_at_5": 0.0,
|
||||
"synonym_only_mrr": 0.0
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"exact_keyword_recall_at_5": 1.0,
|
||||
"exact_keyword_mrr": 1.0,
|
||||
"synonym_only_recall_at_5": 0.0,
|
||||
"synonym_only_mrr": 0.0
|
||||
},
|
||||
"acceptance": {
|
||||
"uses_from_scratch_engine": true,
|
||||
"hand_score_matches": true,
|
||||
"inverted_index_df_matches": true,
|
||||
"all_exact_keyword_queries_recalled": true,
|
||||
"synonym_only_failure_observed": true,
|
||||
"transparent_tf_idf_scores_retained": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema_version": "chapter3-evidence-v1",
|
||||
"experiment": "3-5",
|
||||
"run_id": "20260729T183232Z-3_5-f9f70b37",
|
||||
"created_at": "2026-07-29T18:32:32.574550+00:00",
|
||||
"status": "passed",
|
||||
"run_dir": "/Users/boj/book/ai-agent-book/chapter3/sparse-embedding/validation/runs/20260729T183232Z-3_5-f9f70b37",
|
||||
"artifacts": {
|
||||
"evidence.json": "69bc798be2f18e814b53665d29692cb4182e70561f4dcf222f25bf36d21eae0e",
|
||||
"receipts.json": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570"
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter3/sparse-embedding/benchmark.py",
|
||||
"sha256": "f3601a917de0b1a169691fbb5fb4323b7310220af9eb2221414ed74220a8da89",
|
||||
"bytes": 7038
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter3/sparse-embedding/bm25_engine.py",
|
||||
"sha256": "efcaa2eef462b05d2e1dc9d9f2c5dc0d548a529ca17315ec52e0b0e85fe636c0",
|
||||
"bytes": 20138
|
||||
},
|
||||
{
|
||||
"path": "/Users/boj/book/ai-agent-book/chapter3/sparse-embedding/cli.py",
|
||||
"sha256": "6b76c01fd3889a029b5c37a4f589e3a438bc093c406e144e5e4209441c556ec2",
|
||||
"bytes": 16004
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"exact_keyword_recall_at_5": 1.0,
|
||||
"exact_keyword_mrr": 1.0,
|
||||
"synonym_only_recall_at_5": 0.0,
|
||||
"synonym_only_mrr": 0.0
|
||||
},
|
||||
"acceptance": {
|
||||
"uses_from_scratch_engine": true,
|
||||
"hand_score_matches": true,
|
||||
"inverted_index_df_matches": true,
|
||||
"all_exact_keyword_queries_recalled": true,
|
||||
"synonym_only_failure_observed": true,
|
||||
"transparent_tf_idf_scores_retained": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user