Add bge-reranker-v2-m3; fix metadata and chunking bugs

This commit is contained in:
Eric Furst 2026-09-20 12:01:11 -04:00
commit 24a38ac43f
8 changed files with 149 additions and 23 deletions

7
.gitignore vendored
View file

@ -34,3 +34,10 @@ query.log
# Duplicate of CLAUDE.md # Duplicate of CLAUDE.md
claude.md claude.md
# Eval data generated FROM the journal -- contains verbatim personal text.
# The harness code in eval/*.py is safe to share; these are not.
eval/passages.yaml
eval/llm_compare/
eval/snapshots/
eval/__pycache__/

View file

@ -10,6 +10,7 @@
# Used Sonnet 4.5 to suggest changes; Opus 4.6 for incremental update # Used Sonnet 4.5 to suggest changes; Opus 4.6 for incremental update
from llama_index.core import ( from llama_index.core import (
Document,
SimpleDirectoryReader, SimpleDirectoryReader,
StorageContext, StorageContext,
VectorStoreIndex, VectorStoreIndex,
@ -17,11 +18,13 @@ from llama_index.core import (
Settings, Settings,
) )
from pathlib import Path from pathlib import Path
from llama_index.core.readers.file.base import default_file_metadata_func
from llama_index.embeddings.huggingface import HuggingFaceEmbedding from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core.node_parser import SentenceSplitter from llama_index.core.node_parser import SentenceSplitter
import argparse import argparse
import datetime import datetime
import os import os
import re
import time import time
# Shared constants # Shared constants
@ -31,6 +34,72 @@ EMBED_MODEL_NAME = "BAAI/bge-large-en-v1.5"
CHUNK_SIZE = 256 CHUNK_SIZE = 256
CHUNK_OVERLAP = 25 CHUNK_OVERLAP = 25
# Journal files are named YYYY-MM-DD.txt; the entry date is the filename.
# Filesystem timestamps are NOT the entry date (the 2000-07-29 entry carries
# creation_date 2001-05-25), so the date has to be parsed from the name.
ENTRY_DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})\.txt$")
# --- Metadata policy -------------------------------------------------------
#
# Every key below stays in the node's metadata dict and comes back on every
# retrieval result, whatever these lists say. The lists only control which
# keys get concatenated into the *text* that the embedder and the LLM see.
#
# Previously nothing was excluded from the embed text, so all 5210 chunks were
# embedded with a 36-token absolute path glued to the front -- 14% of a
# 256-token chunk, and the same constant string in every vector, which pulled
# the mean pairwise cosine up from 0.561 to 0.627 and made similarity cutoffs
# useless.
#
# Note BM25Retriever also indexes MetadataMode.EMBED, so the embed text is the
# only place it can see dates. That is why entry_date stays in: it keeps BM25
# able to match "2022-08-14" or "2022" at ~8 tokens instead of 36.
EXCLUDED_EMBED_KEYS = [
"file_path", "file_name", "file_type", "file_size",
"creation_date", "last_modified_date", "last_accessed_date",
"year", "month",
]
# The LLM gets file_name instead -- it is the same date, and the synthesis
# prompt asks the model to cite file names.
EXCLUDED_LLM_KEYS = [
"file_path", "file_type", "file_size",
"creation_date", "last_modified_date", "last_accessed_date",
"entry_date", "year", "month",
]
def journal_file_metadata(file_path: str) -> dict:
"""Default file metadata, plus the entry date parsed from the filename."""
meta = default_file_metadata_func(file_path)
m = ENTRY_DATE_RE.match(Path(file_path).name)
if m:
year, month, day = m.groups()
meta["entry_date"] = f"{year}-{month}-{day}"
meta["year"] = int(year)
meta["month"] = int(month)
return meta
def apply_metadata_policy(documents: list[Document]) -> list[Document]:
"""Set embed/LLM metadata exclusions. Nodes inherit these from the parent."""
for doc in documents:
doc.excluded_embed_metadata_keys = list(EXCLUDED_EMBED_KEYS)
doc.excluded_llm_metadata_keys = list(EXCLUDED_LLM_KEYS)
return documents
def load_documents(input_files=None) -> list[Document]:
"""Load journal documents with the shared metadata policy applied."""
if input_files is not None:
reader = SimpleDirectoryReader(
input_files=input_files, file_metadata=journal_file_metadata
)
else:
reader = SimpleDirectoryReader(
str(DATA_DIR), file_metadata=journal_file_metadata
)
return apply_metadata_policy(reader.load_data())
def get_text_splitter(): def get_text_splitter():
return SentenceSplitter( return SentenceSplitter(
@ -46,7 +115,7 @@ def rebuild():
raise FileNotFoundError(f"Data directory not found: {DATA_DIR.absolute()}") raise FileNotFoundError(f"Data directory not found: {DATA_DIR.absolute()}")
print(f"Loading documents from {DATA_DIR.absolute()}...") print(f"Loading documents from {DATA_DIR.absolute()}...")
documents = SimpleDirectoryReader(str(DATA_DIR)).load_data() documents = load_documents()
if not documents: if not documents:
raise ValueError("No documents found in data directory") raise ValueError("No documents found in data directory")
@ -69,13 +138,23 @@ def update():
if not DATA_DIR.exists(): if not DATA_DIR.exists():
raise FileNotFoundError(f"Data directory not found: {DATA_DIR.absolute()}") raise FileNotFoundError(f"Data directory not found: {DATA_DIR.absolute()}")
# Set transformations BEFORE constructing the index.
#
# index.insert() chunks with self._transformations, which BaseIndex captures
# once at construction time (transformations or Settings.transformations).
# Setting Settings.transformations after load_index_from_storage() is too
# late and is silently ignored -- which is what happened here until
# 2026-09-20: every incrementally added file was chunked at the 1024-token
# default instead of CHUNK_SIZE. Pass it explicitly as well, so this does
# not depend on global state at all.
Settings.transformations = [get_text_splitter()]
# Load existing index # Load existing index
print(f"Loading existing index from {PERSIST_DIR}...") print(f"Loading existing index from {PERSIST_DIR}...")
storage_context = StorageContext.from_defaults(persist_dir=PERSIST_DIR) storage_context = StorageContext.from_defaults(persist_dir=PERSIST_DIR)
index = load_index_from_storage(storage_context) index = load_index_from_storage(
storage_context, transformations=[get_text_splitter()]
# Set transformations so index.insert() chunks correctly )
Settings.transformations = [get_text_splitter()]
# Build lookup of indexed files: file_name -> (ref_doc_id, metadata) # Build lookup of indexed files: file_name -> (ref_doc_id, metadata)
all_ref_docs = index.docstore.get_all_ref_doc_info() all_ref_docs = index.docstore.get_all_ref_doc_info()
@ -87,8 +166,17 @@ def update():
print(f"Index contains {len(indexed)} documents") print(f"Index contains {len(indexed)} documents")
# Scan current files on disk # Scan current files on disk.
disk_files = {f.name: f for f in sorted(DATA_DIR.glob("*.txt"))} #
# Skip hidden files. pathlib's glob("*.txt") matches dotfiles (unlike shell
# globbing), but rebuild() goes through SimpleDirectoryReader, which skips
# them. Without this filter the two modes disagree: a stray .2026-02-21.txt
# was classified "new" after every rebuild and re-inserted via input_files=,
# which bypasses the hidden-file filter.
disk_files = {
f.name: f for f in sorted(DATA_DIR.glob("*.txt"))
if not f.name.startswith(".")
}
print(f"Data directory contains {len(disk_files)} files") print(f"Data directory contains {len(disk_files)} files")
# Classify files # Classify files
@ -146,9 +234,7 @@ def update():
if files_to_add: if files_to_add:
print(f"\nIndexing {len(files_to_add)} file(s)...") print(f"\nIndexing {len(files_to_add)} file(s)...")
# Use "./" prefix to match paths from full build (pathlib strips it) # Use "./" prefix to match paths from full build (pathlib strips it)
docs = SimpleDirectoryReader( docs = load_documents(input_files=[f"./{f}" for f in files_to_add])
input_files=[f"./{f}" for f in files_to_add]
).load_data()
for doc in docs: for doc in docs:
index.insert(doc) index.insert(doc)
@ -169,7 +255,13 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Configure embedding model # Configure embedding model
embed_model = HuggingFaceEmbedding(model_name=EMBED_MODEL_NAME) # cache_folder + local_files_only match the query scripts, so the build
# and the queries are guaranteed to use identical weights.
embed_model = HuggingFaceEmbedding(
model_name=EMBED_MODEL_NAME,
cache_folder="./models",
local_files_only=True,
)
Settings.embed_model = embed_model Settings.embed_model = embed_model
start = time.time() start = time.time()

View file

@ -23,7 +23,7 @@ from pathlib import Path
os.chdir(Path(__file__).resolve().parent.parent) os.chdir(Path(__file__).resolve().parent.parent)
os.environ["TOKENIZERS_PARALLELISM"] = "false" os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["SENTENCE_TRANSFORMERS_HOME"] = "./models" os.environ["HF_HUB_CACHE"] = "./models" # HF hub cache for offline model loads
os.environ["HF_HUB_OFFLINE"] = "1" os.environ["HF_HUB_OFFLINE"] = "1"
import chromadb import chromadb

View file

@ -19,7 +19,7 @@ from pathlib import Path
os.chdir(Path(__file__).resolve().parent.parent) os.chdir(Path(__file__).resolve().parent.parent)
os.environ["TOKENIZERS_PARALLELISM"] = "false" os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["SENTENCE_TRANSFORMERS_HOME"] = "./models" os.environ["HF_HUB_CACHE"] = "./models" # HF hub cache for offline model loads
os.environ["HF_HUB_OFFLINE"] = "1" os.environ["HF_HUB_OFFLINE"] = "1"
import chromadb import chromadb
@ -45,7 +45,9 @@ EMBED_MODEL = HuggingFaceEmbedding(
) )
# Cross-encoder model for re-ranking (cached in ./models/) # Cross-encoder model for re-ranking (cached in ./models/)
RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-12-v2" # Must match query_hybrid.py -- see the note there on why this replaced
# cross-encoder/ms-marco-MiniLM-L-12-v2.
RERANK_MODEL = "BAAI/bge-reranker-v2-m3"
RERANK_TOP_N = 15 RERANK_TOP_N = 15
RETRIEVE_TOP_K = 30 RETRIEVE_TOP_K = 30

View file

@ -26,6 +26,8 @@ dependencies = [
"huggingface-hub==0.34.4", "huggingface-hub==0.34.4",
# --- Keyword search / document loaders --- # --- Keyword search / document loaders ---
"nltk>=3.9.1", "nltk>=3.9.1",
# --- eval harness (eval/, see EVAL_PLAN.md) ---
"pyyaml>=6.0",
"beautifulsoup4>=4.13.4", "beautifulsoup4>=4.13.4",
"striprtf>=0.0.26", "striprtf>=0.0.26",
"pypdf>=6.7.1", "pypdf>=6.7.1",

View file

@ -20,7 +20,7 @@
# at import time. # at import time.
import os import os
os.environ["TOKENIZERS_PARALLELISM"] = "false" os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["SENTENCE_TRANSFORMERS_HOME"] = "./models" os.environ["HF_HUB_CACHE"] = "./models" # HF hub cache for offline model loads
os.environ["HF_HUB_OFFLINE"] = "1" os.environ["HF_HUB_OFFLINE"] = "1"
from llama_index.core import ( from llama_index.core import (
@ -33,6 +33,7 @@ from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.ollama import Ollama from llama_index.llms.ollama import Ollama
from llama_index.core.prompts import PromptTemplate from llama_index.core.prompts import PromptTemplate
from llama_index.core.postprocessor import SentenceTransformerRerank from llama_index.core.postprocessor import SentenceTransformerRerank
from llama_index.core.schema import MetadataMode
from llama_index.retrievers.bm25 import BM25Retriever from llama_index.retrievers.bm25 import BM25Retriever
import sys import sys
@ -43,13 +44,27 @@ import sys
# Embedding model (must match build_store.py) # Embedding model (must match build_store.py)
EMBED_MODEL = HuggingFaceEmbedding(cache_folder="./models", model_name="BAAI/bge-large-en-v1.5", local_files_only=True) EMBED_MODEL = HuggingFaceEmbedding(cache_folder="./models", model_name="BAAI/bge-large-en-v1.5", local_files_only=True)
# LLM model for generation. Use temp 0.3. # LLM model for generation.
#LLM_MODEL = "command-r7b" #LLM_MODEL = "command-r7b"
# testing gemma4:e4b. Recommendations are to use temp 1.0
LLM_MODEL = "gemma4:e4b" LLM_MODEL = "gemma4:e4b"
# Temperature. Gemma's own guidance is 1.0, but that is for open-ended chat.
# This is grounded extraction -- the prompt says to answer only from context --
# so a low temperature is the safer default. 1.0 previously; raise it back if
# answers come out flat.
LLM_TEMPERATURE = 0.3
# Ollama num_ctx. The prompt itself is only ~2500 tokens with 15 chunks, but
# thinking=True spends reasoning tokens out of this same budget, so leave room.
LLM_CONTEXT_WINDOW = 16384
# Cross-encoder model for re-ranking (cached in ./models/) # Cross-encoder model for re-ranking (cached in ./models/)
RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-12-v2" # Was cross-encoder/ms-marco-MiniLM-L-12-v2 (2021, trained on MS MARCO web
# queries). It scored journal-style queries far out of its calibrated range --
# top hits at -0.77 and -1.90 logits -- because "what would the author say
# about..." is nothing like a search-engine query. bge-reranker-v2-m3 returns
# calibrated 0-1 scores and handles the phrasing. Costs ~5s more per query.
RERANK_MODEL = "BAAI/bge-reranker-v2-m3"
RERANK_TOP_N = 15 RERANK_TOP_N = 15
# Retrieval parameters # Retrieval parameters
@ -91,10 +106,10 @@ def main():
# Note: Ollama temperature defaults to 0.8 # Note: Ollama temperature defaults to 0.8
Settings.llm = Ollama( Settings.llm = Ollama(
model=LLM_MODEL, model=LLM_MODEL,
temperature=1.0, temperature=LLM_TEMPERATURE,
thinking=True, # enable thinking=True, # enable
request_timeout=360.0, request_timeout=360.0,
context_window=8000, context_window=LLM_CONTEXT_WINDOW,
) )
# Use OpenAI API: # Use OpenAI API:
@ -157,7 +172,11 @@ def main():
n_both = len(vector_nodes) + len(bm25_nodes) - len(merged) n_both = len(vector_nodes) + len(bm25_nodes) - len(merged)
# Estimate context length (prompt + node text) # Estimate context length (prompt + node text)
context_text = "\n\n".join(n.get_content() for n in reranked) # MetadataMode.LLM to match what the synthesizer actually sends
# (response_synthesizers/base.py uses MetadataMode.LLM). The default mode
# strips the "file_name: ..." prefix and understates the prompt.
context_text = "\n\n".join(
n.node.get_content(metadata_mode=MetadataMode.LLM) for n in reranked)
prompt_text = PROMPT.format(context_str=context_text, query_str=q) prompt_text = PROMPT.format(context_str=context_text, query_str=q)
n_context_tokens = len(prompt_text.split()) # rough word count; ~1.3 tokens/word n_context_tokens = len(prompt_text.split()) # rough word count; ~1.3 tokens/word

View file

@ -15,7 +15,7 @@
# at import time. # at import time.
import os import os
os.environ["TOKENIZERS_PARALLELISM"] = "false" os.environ["TOKENIZERS_PARALLELISM"] = "false"
os.environ["SENTENCE_TRANSFORMERS_HOME"] = "./models" os.environ["HF_HUB_CACHE"] = "./models" # HF hub cache for offline model loads
os.environ["HF_HUB_OFFLINE"] = "1" os.environ["HF_HUB_OFFLINE"] = "1"
from llama_index.core import ( from llama_index.core import (
@ -37,7 +37,9 @@ import textwrap
EMBED_MODEL = HuggingFaceEmbedding(cache_folder="./models", model_name="BAAI/bge-large-en-v1.5", local_files_only=True) EMBED_MODEL = HuggingFaceEmbedding(cache_folder="./models", model_name="BAAI/bge-large-en-v1.5", local_files_only=True)
# Cross-encoder model for re-ranking (cached in ./models/) # Cross-encoder model for re-ranking (cached in ./models/)
RERANK_MODEL = "cross-encoder/ms-marco-MiniLM-L-12-v2" # Must match query_hybrid.py -- see the note there on why this replaced
# cross-encoder/ms-marco-MiniLM-L-12-v2.
RERANK_MODEL = "BAAI/bge-reranker-v2-m3"
RERANK_TOP_N = 15 RERANK_TOP_N = 15
# Retrieval parameters # Retrieval parameters

2
uv.lock generated
View file

@ -4277,6 +4277,7 @@ dependencies = [
{ name = "llama-index-vector-stores-chroma" }, { name = "llama-index-vector-stores-chroma" },
{ name = "nltk" }, { name = "nltk" },
{ name = "pypdf" }, { name = "pypdf" },
{ name = "pyyaml" },
{ name = "sentence-transformers" }, { name = "sentence-transformers" },
{ name = "striprtf" }, { name = "striprtf" },
{ name = "transformers" }, { name = "transformers" },
@ -4308,6 +4309,7 @@ requires-dist = [
{ name = "llama-index-vector-stores-chroma", specifier = "==0.5.5" }, { name = "llama-index-vector-stores-chroma", specifier = "==0.5.5" },
{ name = "nltk", specifier = ">=3.9.1" }, { name = "nltk", specifier = ">=3.9.1" },
{ name = "pypdf", specifier = ">=6.7.1" }, { name = "pypdf", specifier = ">=6.7.1" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "sentence-transformers", specifier = ">=5.1.0" }, { name = "sentence-transformers", specifier = ">=5.1.0" },
{ name = "striprtf", specifier = ">=0.0.26" }, { name = "striprtf", specifier = ">=0.0.26" },
{ name = "transformers", specifier = "==4.55.0" }, { name = "transformers", specifier = "==4.55.0" },