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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,658 @@
"""Enhanced tools for contextual retrieval with BM25 and semantic search
Educational implementation showing how contextual chunks improve both
BM25 (lexical) and embedding (semantic) retrieval.
"""
import json
import logging
import requests
import numpy as np
from typing import Dict, Any, List, Optional, Tuple
from dataclasses import dataclass
from datetime import datetime
import time
from rank_bm25 import BM25Okapi
import pickle
from pathlib import Path
from config import KnowledgeBaseConfig, KnowledgeBaseType
from tools import KnowledgeBaseTools, SearchResult
from contextual_chunking import ContextualChunk
# Shared tokenizer: 中文没有空格,原先的 .lower().split() 会把整段当成一个 token
# 导致 BM25 在中文语料上几乎失效。统一改用 compare_retrieval.tokenizejieba 分词)。
from compare_retrieval import tokenize as _bm25_tokenize
logger = logging.getLogger(__name__)
@dataclass
class ContextualSearchResult(SearchResult):
"""Enhanced search result with contextual information"""
is_contextual: bool = False
context_text: str = ""
bm25_score: float = 0.0
embedding_score: float = 0.0
hybrid_score: float = 0.0
retrieval_method: str = "hybrid" # bm25, embedding, or hybrid
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"is_contextual": self.is_contextual,
"context_text": self.context_text,
"bm25_score": self.bm25_score,
"embedding_score": self.embedding_score,
"hybrid_score": self.hybrid_score,
"retrieval_method": self.retrieval_method
})
return base
class ContextualKnowledgeBaseTools(KnowledgeBaseTools):
"""
Enhanced knowledge base tools with contextual retrieval support.
Key Educational Points:
1. Dual Indexing: Maintains both contextual and non-contextual indexes
2. BM25 Enhancement: Shows how context improves lexical matching
3. Hybrid Search: Combines BM25 and semantic search with rank fusion
4. Comparison Mode: Allows side-by-side evaluation of methods
"""
def __init__(self,
config: KnowledgeBaseConfig,
use_contextual: bool = True,
enable_comparison: bool = False):
"""
Initialize contextual knowledge base tools.
Args:
config: Knowledge base configuration
use_contextual: Whether to use contextual retrieval
enable_comparison: Whether to enable comparison mode
"""
super().__init__(config)
self.use_contextual = use_contextual
self.enable_comparison = enable_comparison
# BM25 indexes for lexical search
self.bm25_index = None
self.bm25_contextual_index = None
self.bm25_corpus = []
self.bm25_contextual_corpus = []
# Document and chunk storage
self.chunk_store = {} # chunk_id -> ContextualChunk
self.contextual_chunk_store = {} # chunk_id -> ContextualChunk (with context)
# Index paths
self.index_dir = Path("indexes")
self.index_dir.mkdir(exist_ok=True)
# Load existing indexes if available
self._load_indexes()
# Statistics
self.search_stats = {
"total_searches": 0,
"contextual_searches": 0,
"non_contextual_searches": 0,
"comparison_searches": 0,
"avg_retrieval_time": 0.0,
"total_retrieval_time": 0.0
}
logger.info(f"Initialized ContextualKnowledgeBaseTools (contextual={use_contextual}, comparison={enable_comparison})")
def index_contextual_chunks(self, chunks: List[ContextualChunk], rebuild_bm25: bool = True):
"""
Index contextual chunks for both BM25 and semantic search.
Educational Note:
This demonstrates the dual indexing strategy:
- BM25 index on contextualized text for better lexical matching
- Semantic embeddings on contextualized text for richer meaning
"""
logger.info(f"Indexing {len(chunks)} contextual chunks")
start_time = time.time()
# Store chunks
for chunk in chunks:
self.contextual_chunk_store[chunk.chunk_id] = chunk
# Also store non-contextual version for comparison
non_contextual_chunk = ContextualChunk(
chunk_id=chunk.chunk_id + "_nc",
doc_id=chunk.doc_id,
text=chunk.text,
context="",
contextualized_text=chunk.text,
chunk_index=chunk.chunk_index,
char_count=len(chunk.text),
metadata={"contextual": False}
)
self.chunk_store[non_contextual_chunk.chunk_id] = non_contextual_chunk
# Build BM25 indexes
if rebuild_bm25:
self._build_bm25_indexes()
# Index to retrieval pipeline (if local)
if self.config.type == KnowledgeBaseType.LOCAL:
self._index_chunks_to_pipeline(chunks)
elapsed = time.time() - start_time
logger.info(f"Indexed {len(chunks)} chunks in {elapsed:.2f}s")
# Save indexes
self._save_indexes()
def _build_bm25_indexes(self):
"""
Build BM25 indexes for both contextual and non-contextual chunks.
Educational Note:
BM25 uses TF-IDF with optimizations:
- Term frequency saturation prevents common words from dominating
- Document length normalization accounts for varying chunk sizes
- The contextual version has richer vocabulary from added context
"""
logger.info("Building BM25 indexes")
# Build contextual BM25 index
if self.contextual_chunk_store:
contextual_texts = []
for chunk in self.contextual_chunk_store.values():
# Tokenize for BM25 (jieba 中文分词,兼容英文)
tokens = _bm25_tokenize(chunk.contextualized_text)
contextual_texts.append(tokens)
self.bm25_contextual_corpus = contextual_texts
self.bm25_contextual_index = BM25Okapi(contextual_texts)
logger.info(f"Built contextual BM25 index with {len(contextual_texts)} documents")
# Build non-contextual BM25 index
if self.chunk_store:
non_contextual_texts = []
for chunk in self.chunk_store.values():
tokens = _bm25_tokenize(chunk.text)
non_contextual_texts.append(tokens)
self.bm25_corpus = non_contextual_texts
self.bm25_index = BM25Okapi(non_contextual_texts)
logger.info(f"Built non-contextual BM25 index with {len(non_contextual_texts)} documents")
def _index_chunks_to_pipeline(self, chunks: List[ContextualChunk]):
"""Index chunks to the retrieval pipeline"""
for chunk in chunks:
try:
# Index contextual version
if self.use_contextual:
response = requests.post(
f"{self.config.local_base_url}/index",
json={
"text": chunk.contextualized_text,
"doc_id": chunk.doc_id,
"metadata": {
"chunk_id": chunk.chunk_id,
"chunk_index": chunk.chunk_index,
"is_contextual": True,
"context": chunk.context[:200], # Store truncated context
"original_text": chunk.text[:500] # Store truncated original
}
}, timeout=30
)
response.raise_for_status()
# Also index non-contextual version if in comparison mode
if self.enable_comparison:
response = requests.post(
f"{self.config.local_base_url}/index",
json={
"text": chunk.text,
"doc_id": chunk.doc_id,
"metadata": {
"chunk_id": chunk.chunk_id + "_nc",
"chunk_index": chunk.chunk_index,
"is_contextual": False
}
}, timeout=30
)
response.raise_for_status()
except Exception as e:
logger.error(f"Error indexing chunk {chunk.chunk_id}: {e}")
def contextual_search(self,
query: str,
method: str = "hybrid",
top_k: int = 20) -> List[ContextualSearchResult]:
"""
Perform contextual search using specified method.
Args:
query: Search query
method: Search method - "bm25", "embedding", or "hybrid"
top_k: Number of results to return
Educational Note:
This demonstrates three retrieval strategies:
1. BM25: Pure lexical matching based on term frequency
2. Embedding: Semantic similarity using vector embeddings
3. Hybrid: Rank fusion combining both approaches
"""
logger.info(f"Performing {method} search for: {query[:100]}...")
start_time = time.time()
results = []
if method in ["bm25", "hybrid"]:
bm25_results = self._search_bm25(query, self.use_contextual, top_k * 2)
results.extend(bm25_results)
if method in ["embedding", "hybrid"]:
embedding_results = self._search_embeddings(query, self.use_contextual, top_k * 2)
results.extend(embedding_results)
if method == "hybrid":
# Rank fusion: combine and deduplicate results
results = self._rank_fusion(bm25_results, embedding_results, top_k)
else:
# Sort by score and limit
results = sorted(results, key=lambda x: x.score, reverse=True)[:top_k]
# Update statistics
elapsed = time.time() - start_time
self.search_stats["total_searches"] += 1
if self.use_contextual:
self.search_stats["contextual_searches"] += 1
else:
self.search_stats["non_contextual_searches"] += 1
self.search_stats["total_retrieval_time"] += elapsed
self.search_stats["avg_retrieval_time"] = (
self.search_stats["total_retrieval_time"] / self.search_stats["total_searches"]
)
logger.info(f"Search completed in {elapsed:.2f}s, returned {len(results)} results")
return results
def _search_bm25(self, query: str, use_contextual: bool, top_k: int) -> List[ContextualSearchResult]:
"""
Perform BM25 search.
Educational Note:
BM25 excels at finding exact term matches and handles
technical terms, IDs, and specific phrases well.
Contextual chunks help by adding synonyms and related terms.
"""
if use_contextual and self.bm25_contextual_index:
index = self.bm25_contextual_index
corpus = self.bm25_contextual_corpus
chunk_store = self.contextual_chunk_store
elif self.bm25_index:
index = self.bm25_index
corpus = self.bm25_corpus
chunk_store = self.chunk_store
else:
logger.warning("BM25 index not available")
return []
# Tokenize query (jieba 中文分词,兼容英文)
query_tokens = _bm25_tokenize(query)
# Get BM25 scores
scores = index.get_scores(query_tokens)
# Get top-k indices
top_indices = np.argsort(scores)[-top_k:][::-1]
# Create results
results = []
chunk_list = list(chunk_store.values())
for idx in top_indices:
if idx < len(chunk_list) and scores[idx] > 0:
chunk = chunk_list[idx]
result = ContextualSearchResult(
doc_id=chunk.doc_id,
chunk_id=chunk.chunk_id,
text=chunk.text,
score=float(scores[idx]),
is_contextual=use_contextual,
context_text=chunk.context if use_contextual else "",
bm25_score=float(scores[idx]),
retrieval_method="bm25",
metadata={"method": "bm25", "contextual": use_contextual}
)
results.append(result)
return results
def _search_embeddings(self, query: str, use_contextual: bool, top_k: int) -> List[ContextualSearchResult]:
"""
Perform semantic search using embeddings.
Educational Note:
Embedding search captures semantic meaning and relationships.
Contextual chunks provide richer semantic information,
helping find conceptually related content even without exact matches.
"""
try:
# Use the retrieval pipeline for embedding search
response = requests.post(
f"{self.config.local_base_url}/search",
json={
"query": query,
"mode": "embedding", # Use embedding mode
"top_k": top_k,
"filter": {"is_contextual": use_contextual} if self.enable_comparison else None
}, timeout=30
)
response.raise_for_status()
results = []
data = response.json()
for item in data.get("results", []):
# Map back to our chunk store
chunk_id = item.get("metadata", {}).get("chunk_id", "")
if use_contextual and chunk_id in self.contextual_chunk_store:
chunk = self.contextual_chunk_store[chunk_id]
elif chunk_id in self.chunk_store:
chunk = self.chunk_store[chunk_id]
else:
continue
result = ContextualSearchResult(
doc_id=chunk.doc_id,
chunk_id=chunk.chunk_id,
text=chunk.text,
score=item.get("score", 0.0),
is_contextual=use_contextual,
context_text=chunk.context if hasattr(chunk, 'context') else "",
embedding_score=item.get("score", 0.0),
retrieval_method="embedding",
metadata={"method": "embedding", "contextual": use_contextual}
)
results.append(result)
return results
except Exception as e:
logger.error(f"Error in embedding search: {e}")
return []
def _rank_fusion(self,
bm25_results: List[ContextualSearchResult],
embedding_results: List[ContextualSearchResult],
top_k: int) -> List[ContextualSearchResult]:
"""
Combine BM25 and embedding results using reciprocal rank fusion.
Educational Note:
Rank fusion combines different retrieval signals:
- BM25 provides strong exact matching
- Embeddings provide semantic understanding
- The combination often outperforms either method alone
We use Reciprocal Rank Fusion (RRF) which is simple but effective.
"""
fusion_scores = {}
chunk_map = {}
# RRF constant (typically 60)
k = 60
# Process BM25 results
for rank, result in enumerate(bm25_results):
rrf_score = 1.0 / (k + rank + 1)
fusion_scores[result.chunk_id] = fusion_scores.get(result.chunk_id, 0) + rrf_score
chunk_map[result.chunk_id] = result
result.bm25_score = result.score
# Process embedding results
for rank, result in enumerate(embedding_results):
rrf_score = 1.0 / (k + rank + 1)
if result.chunk_id in fusion_scores:
# Update existing result
fusion_scores[result.chunk_id] += rrf_score
chunk_map[result.chunk_id].embedding_score = result.score
else:
# New result from embeddings only
fusion_scores[result.chunk_id] = rrf_score
chunk_map[result.chunk_id] = result
result.embedding_score = result.score
# Create final results sorted by fusion score
final_results = []
for chunk_id, fusion_score in sorted(fusion_scores.items(),
key=lambda x: x[1],
reverse=True)[:top_k]:
result = chunk_map[chunk_id]
result.hybrid_score = fusion_score
result.score = fusion_score # Use fusion score as main score
result.retrieval_method = "hybrid"
final_results.append(result)
return final_results
def compare_retrieval_methods(self,
query: str,
top_k: int = 20) -> Dict[str, Any]:
"""
Compare contextual vs non-contextual retrieval.
Educational Note:
This method demonstrates the improvement that contextual
retrieval provides across different search methods.
It's useful for evaluation and understanding when context helps most.
"""
logger.info(f"Comparing retrieval methods for: {query[:100]}...")
comparison_results = {
"query": query,
"timestamp": datetime.now().isoformat(),
"top_k": top_k,
"methods": {}
}
# Test each combination
test_configs = [
("contextual_hybrid", True, "hybrid"),
("contextual_bm25", True, "bm25"),
("contextual_embedding", True, "embedding"),
("non_contextual_hybrid", False, "hybrid"),
("non_contextual_bm25", False, "bm25"),
("non_contextual_embedding", False, "embedding")
]
for name, use_contextual, method in test_configs:
# Temporarily set mode
original_contextual = self.use_contextual
self.use_contextual = use_contextual
# Perform search
start_time = time.time()
results = self.contextual_search(query, method, top_k)
elapsed = time.time() - start_time
# Store results
comparison_results["methods"][name] = {
"results": [r.to_dict() for r in results[:5]], # Top 5 for readability
"total_results": len(results),
"retrieval_time": elapsed,
"avg_score": np.mean([r.score for r in results]) if results else 0,
"max_score": max([r.score for r in results]) if results else 0
}
# Restore mode
self.use_contextual = original_contextual
# Add analysis
comparison_results["analysis"] = self._analyze_comparison(comparison_results)
# Update stats
self.search_stats["comparison_searches"] += 1
return comparison_results
def _analyze_comparison(self, results: Dict[str, Any]) -> Dict[str, Any]:
"""Analyze comparison results to highlight improvements"""
analysis = {
"contextual_improvement": {},
"method_comparison": {},
"recommendations": []
}
# Compare contextual vs non-contextual for each method
for method in ["hybrid", "bm25", "embedding"]:
contextual_key = f"contextual_{method}"
non_contextual_key = f"non_contextual_{method}"
if contextual_key in results["methods"] and non_contextual_key in results["methods"]:
contextual = results["methods"][contextual_key]
non_contextual = results["methods"][non_contextual_key]
# Calculate improvement
score_improvement = (
(contextual["avg_score"] - non_contextual["avg_score"])
/ non_contextual["avg_score"] * 100
if non_contextual["avg_score"] > 0 else 0
)
analysis["contextual_improvement"][method] = {
"score_improvement_pct": round(score_improvement, 2),
"contextual_avg_score": round(contextual["avg_score"], 4),
"non_contextual_avg_score": round(non_contextual["avg_score"], 4)
}
# Find best performing method
best_method = max(
results["methods"].items(),
key=lambda x: x[1]["avg_score"]
)
analysis["best_method"] = best_method[0]
# Generate recommendations
if "hybrid" in analysis["contextual_improvement"]:
if analysis["contextual_improvement"]["hybrid"]["score_improvement_pct"] > 10:
analysis["recommendations"].append(
"Contextual retrieval shows significant improvement (>10%). "
"Consider using it for production."
)
if analysis["contextual_improvement"]["bm25"]["score_improvement_pct"] > \
analysis["contextual_improvement"]["embedding"]["score_improvement_pct"]:
analysis["recommendations"].append(
"Contextual enhancement helps BM25 more than embeddings. "
"The query might contain specific terms that benefit from context."
)
return analysis
def _save_indexes(self):
"""Save BM25 indexes to disk"""
try:
# Save BM25 indexes
if self.bm25_contextual_index:
with open(self.index_dir / "bm25_contextual.pkl", "wb") as f:
pickle.dump({
"index": self.bm25_contextual_index,
"corpus": self.bm25_contextual_corpus
}, f)
if self.bm25_index:
with open(self.index_dir / "bm25_non_contextual.pkl", "wb") as f:
pickle.dump({
"index": self.bm25_index,
"corpus": self.bm25_corpus
}, f)
# Save chunk stores
with open(self.index_dir / "chunk_stores.json", "w") as f:
json.dump({
"contextual": {k: v.to_dict() for k, v in self.contextual_chunk_store.items()},
"non_contextual": {k: v.to_dict() for k, v in self.chunk_store.items()}
}, f, indent=2)
logger.info("Indexes saved successfully")
except Exception as e:
logger.error(f"Error saving indexes: {e}")
def _load_indexes(self):
"""Load BM25 indexes from disk"""
try:
# Load BM25 indexes
contextual_path = self.index_dir / "bm25_contextual.pkl"
if contextual_path.exists():
with open(contextual_path, "rb") as f:
data = pickle.load(f)
self.bm25_contextual_index = data["index"]
self.bm25_contextual_corpus = data["corpus"]
logger.info("Loaded contextual BM25 index")
non_contextual_path = self.index_dir / "bm25_non_contextual.pkl"
if non_contextual_path.exists():
with open(non_contextual_path, "rb") as f:
data = pickle.load(f)
self.bm25_index = data["index"]
self.bm25_corpus = data["corpus"]
logger.info("Loaded non-contextual BM25 index")
# Load chunk stores
stores_path = self.index_dir / "chunk_stores.json"
if stores_path.exists():
with open(stores_path, "r") as f:
data = json.load(f)
# Reconstruct contextual chunks
for chunk_id, chunk_dict in data.get("contextual", {}).items():
self.contextual_chunk_store[chunk_id] = ContextualChunk(
chunk_id=chunk_dict["chunk_id"],
doc_id=chunk_dict["doc_id"],
text=chunk_dict["text"],
context=chunk_dict["context"],
contextualized_text=chunk_dict["contextualized_text"],
chunk_index=chunk_dict["chunk_index"],
char_count=chunk_dict["char_count"],
context_tokens=chunk_dict.get("context_tokens", 0),
generation_time=chunk_dict.get("generation_time", 0),
metadata=chunk_dict.get("metadata", {})
)
# Reconstruct non-contextual chunks
for chunk_id, chunk_dict in data.get("non_contextual", {}).items():
self.chunk_store[chunk_id] = ContextualChunk(
chunk_id=chunk_dict["chunk_id"],
doc_id=chunk_dict["doc_id"],
text=chunk_dict["text"],
context="",
contextualized_text=chunk_dict["text"],
chunk_index=chunk_dict["chunk_index"],
char_count=chunk_dict["char_count"],
metadata=chunk_dict.get("metadata", {})
)
logger.info(f"Loaded {len(self.contextual_chunk_store)} contextual chunks")
logger.info(f"Loaded {len(self.chunk_store)} non-contextual chunks")
except Exception as e:
logger.info(f"No existing indexes found or error loading: {e}")
def get_statistics(self) -> Dict[str, Any]:
"""Get comprehensive statistics"""
stats = {
"search_stats": self.search_stats,
"index_stats": {
"contextual_chunks": len(self.contextual_chunk_store),
"non_contextual_chunks": len(self.chunk_store),
"bm25_contextual_size": len(self.bm25_contextual_corpus) if self.bm25_contextual_corpus else 0,
"bm25_non_contextual_size": len(self.bm25_corpus) if self.bm25_corpus else 0
}
}
return stats