mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
examples: programs that run, in four languages
Eight of them, each doing something a pipeline actually has to do, and each starting from something anyone can fetch — a public domain book from Project Gutenberg, a NASA broadcast, or a sample carried in the file. Shell, because the tools are filters first: a book to passages and what its notes cite; a recording to chapter marks, citable passages and the one ffmpeg call that makes it play on a phone; passages into pgvector and a search that asks twice and reconciles. Rust, for the case where the caller already holds the text and a subprocess would be serialising for nothing. Python, for the shape a pipeline stage takes once the reading is a library call. TypeScript, for the app side — chapter marks and deep links, and fusing two rankings in process. Three of them end with what re-ranking means, because it is two different things under one name: fusing ranked lists, which is what `rrf` does and needs no model, and a cross-encoder scoring query and passage together, which is accurate, slow, and runs over what retrieval already found.
This commit is contained in:
@@ -48,6 +48,10 @@ Each tool installs by name, so you can take one without taking the rest.
|
||||
Each links to its own page: what it does, when you would use it, worked
|
||||
examples, options and exit codes.
|
||||
|
||||
**→ [Examples](examples/README.md) — programs that run.** A book to
|
||||
passages and references, a recording to chapter marks and a copy that plays
|
||||
on a phone, and a hybrid search fused, in shell, Rust, Python and TypeScript.
|
||||
|
||||
**→ [Recipes](docs/recipes.md) — using these tools together.** A scanned book
|
||||
to a searchable passage, a transcript to a citation with a timestamp, vectors
|
||||
into pgvector, and hybrid search fused on top.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
work/
|
||||
typescript/node_modules/
|
||||
typescript/package-lock.json
|
||||
@@ -0,0 +1,125 @@
|
||||
# Examples
|
||||
|
||||
Working programs that put these tools together, in the four ways you would
|
||||
actually reach for them: as filters in a shell script, and as libraries from
|
||||
Rust, Python and JavaScript.
|
||||
|
||||
Every one of them runs. Where an example needs a book it fetches a public
|
||||
domain one from Project Gutenberg; where it needs a recording it fetches a
|
||||
NASA broadcast, which is a work of the US government.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [`shell/01-book-to-passages.sh`](shell/01-book-to-passages.sh) | A book file to passages worth embedding, and what its notes cite |
|
||||
| [`shell/02-video-to-chapters.sh`](shell/02-video-to-chapters.sh) | A recording to chapter marks, citable passages, and a copy that plays on a phone |
|
||||
| [`shell/03-search-and-rerank.sh`](shell/03-search-and-rerank.sh) | Passages into pgvector, then a search that asks twice and reconciles |
|
||||
| [`rust/`](rust/src/main.rs) | The same book walked through in one process, with no pipe anywhere |
|
||||
| [`python/book_to_citations.py`](python/book_to_citations.py) | A book to rows a database can take |
|
||||
| [`python/search_and_rerank.py`](python/search_and_rerank.py) | The pgvector search, in the language a pipeline is usually written in |
|
||||
| [`typescript/src/episode-to-chapters.ts`](typescript/src/episode-to-chapters.ts) | Captions into chapter marks and deep links for a player |
|
||||
| [`typescript/src/search-and-rerank.ts`](typescript/src/search-and-rerank.ts) | Fusing two rankings app-side, and what re-ranking means |
|
||||
|
||||
## Shell
|
||||
|
||||
The tools are filters first: bytes in, records out, and `jq` between them.
|
||||
Nothing is installed but the commands themselves.
|
||||
|
||||
```sh
|
||||
cargo install --git https://git.pumpin.online/funkpower/catena \
|
||||
booksniff bookmeta tocfind pdftriage mdscrub chunkmd citefind \
|
||||
cuenorm cueseg cuepack avplan emb rrf
|
||||
|
||||
examples/shell/01-book-to-passages.sh ./work
|
||||
```
|
||||
|
||||
`01` fetches Gibbon's first volume, works out what the file is, reads the
|
||||
metadata and the contents the publisher declared, converts and scrubs and
|
||||
chunks it, and reads the footnotes. Needs `pandoc` and `jq`.
|
||||
|
||||
`02` takes a recording's captions and produces chapter marks, passages that
|
||||
carry the second they were said at, and — since a recording that is indexed
|
||||
but unplayable is half a job — the one `ffmpeg` call that makes it play on a
|
||||
phone. Needs `jq`, `ffmpeg`, and yt-dlp with impersonation support, which
|
||||
YouTube now requires before it will hand over captions:
|
||||
|
||||
```sh
|
||||
pipx install "yt-dlp[default,curl-cffi]"
|
||||
```
|
||||
|
||||
`03` loads the passages `01` wrote into Postgres with a vector column and a
|
||||
text index, searches both ways, and fuses the two rankings. Needs `psql`, a
|
||||
Postgres with pgvector, and an embedding server:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=postgresql://user@host/db \
|
||||
EMB_MODEL=nomic-embed-text \
|
||||
examples/shell/03-search-and-rerank.sh "why did the empire decline" ./work
|
||||
```
|
||||
|
||||
## Rust
|
||||
|
||||
Every tool is a library with a thin command on top, so anything a command
|
||||
does can be done in process instead. This walks a file from "what even is it"
|
||||
to passages and references without serialising anything.
|
||||
|
||||
```sh
|
||||
cd examples/rust
|
||||
cargo run # the built-in sample
|
||||
cargo run -- book.md # a real one
|
||||
```
|
||||
|
||||
## Python
|
||||
|
||||
The shape a pipeline stage ends up as once the reading is a library call:
|
||||
convert, then loop, and what is left is the part that is actually about your
|
||||
schema.
|
||||
|
||||
```sh
|
||||
pip install catena
|
||||
python examples/python/book_to_citations.py
|
||||
```
|
||||
|
||||
```sh
|
||||
pip install catena psycopg
|
||||
DATABASE_URL=postgresql://user@host/db \
|
||||
python examples/python/search_and_rerank.py "why did the empire decline"
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
The bindings are WebAssembly, so they run in Node and in a browser with the
|
||||
same API.
|
||||
|
||||
```sh
|
||||
cd examples/typescript
|
||||
npm install
|
||||
npm run chapters # captions to chapter marks
|
||||
npm run search # fusing two rankings
|
||||
```
|
||||
|
||||
## On re-ranking
|
||||
|
||||
Three of these examples end with the same explanation, because it is the
|
||||
thing most often got wrong.
|
||||
|
||||
Retrieval uses a **bi-encoder**: the passage and the query are embedded
|
||||
separately. That independence is what lets passage vectors be computed once
|
||||
and indexed, and it is also the limit — nothing in a passage's vector knows
|
||||
what was asked.
|
||||
|
||||
**Fusion** takes several ranked lists and makes one. Reciprocal rank fusion
|
||||
scores a hit by where it placed rather than by what it scored: `1/(k + rank)`
|
||||
from each list that found it, added up. It is used because the scores are not
|
||||
comparable — a cosine distance and a `ts_rank_cd` measure different things,
|
||||
and no scaling makes them the same — so the ranks are kept and the scores
|
||||
thrown away. A passage that both searches liked beats one that either loved.
|
||||
That is [`rrf`](../crates/rrf/README.md), and it needs no model.
|
||||
|
||||
A **cross-encoder** reads the query and the passage together and scores the
|
||||
pair. Far more accurate, and far too slow to run over a corpus, so it runs
|
||||
over the twenty or fifty candidates retrieval already found. In a pipeline
|
||||
the two stack: retrieve both ways, fuse the lists, then hand the top of the
|
||||
fused list to a cross-encoder for the final order.
|
||||
|
||||
Both get called re-ranking. Nothing here loads a model, so the last step is
|
||||
yours — the fused list is what you would hand it.
|
||||
@@ -0,0 +1,128 @@
|
||||
"""A book to rows a database can take, without a subprocess in sight.
|
||||
|
||||
This is the shape a pipeline stage ends up as once the reading is a library
|
||||
call: convert, then loop, and what is left is the part that is actually about
|
||||
your schema.
|
||||
|
||||
python examples/python/book_to_citations.py path/to/book.epub
|
||||
|
||||
With no argument it fetches Gibbon's first volume from Project Gutenberg,
|
||||
which is public domain and has a few hundred pages of footnotes.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import catena
|
||||
|
||||
GUTENBERG = "https://www.gutenberg.org/cache/epub/731/pg731.epub"
|
||||
|
||||
|
||||
def fetch(into: pathlib.Path) -> pathlib.Path:
|
||||
"""The sample book, downloaded once."""
|
||||
book = into / "gibbon.epub"
|
||||
if not book.exists():
|
||||
into.mkdir(parents=True, exist_ok=True)
|
||||
print(f"fetching {GUTENBERG}")
|
||||
with urllib.request.urlopen(GUTENBERG, timeout=120) as reply: # noqa: S310
|
||||
book.write_bytes(reply.read())
|
||||
return book
|
||||
|
||||
|
||||
def to_markdown(book: pathlib.Path) -> str:
|
||||
"""Pandoc is the one part of this that is somebody else's program.
|
||||
|
||||
`commonmark` rather than `commonmark_x`: the extended flavour writes
|
||||
anchors as bracketed spans, and they end up inside the passages.
|
||||
"""
|
||||
done = subprocess.run(
|
||||
["pandoc", "-f", "epub", "-t", "commonmark", "--wrap=none", str(book)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return done.stdout
|
||||
|
||||
|
||||
def rows(markdown: str) -> list[dict]:
|
||||
"""Passages in, citation rows out.
|
||||
|
||||
Two library calls do the work. `chunk_markdown` cuts the book into
|
||||
passages and flags the ones carrying citation material — which is the
|
||||
whole reason to ask it rather than looking for a heading, since a page of
|
||||
notes at the foot of a chapter has no heading over it. `find_citations`
|
||||
then reads each of those.
|
||||
"""
|
||||
out = []
|
||||
for chunk in catena.chunk_markdown(catena.scrub(markdown)):
|
||||
if "footnote_citations" not in chunk["flags"] and chunk["role"] != "bibliography":
|
||||
continue
|
||||
for found in catena.find_citations(chunk["text"]):
|
||||
# An `ibid.` or a short cite points at an entry above rather than
|
||||
# naming a work of its own, so there is nothing to store.
|
||||
if found["continuation"]:
|
||||
continue
|
||||
if not (found["title"] or found["authors"] or found["ids"]):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"page": chunk["page"],
|
||||
"chapter": chunk["chapter"],
|
||||
"citation_text": found["text"],
|
||||
"referenced_work": found["title"],
|
||||
"referenced_author": "; ".join(found["authors"]) or None,
|
||||
"referenced_year": found["year"],
|
||||
"referenced_container": found["container"],
|
||||
"kind": found["kind"],
|
||||
"unsettled": found["unsettled"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
where = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else fetch(pathlib.Path("work"))
|
||||
|
||||
print(f"== {where.name}")
|
||||
identified = catena.identify(where.name, where.read_bytes())
|
||||
print(f" a {identified['kind']}, {identified['bytes']} bytes")
|
||||
|
||||
markdown = to_markdown(where)
|
||||
passages = catena.chunk_markdown(catena.scrub(markdown))
|
||||
carrying = [c for c in passages if "footnote_citations" in c["flags"]]
|
||||
print(f" {len(passages)} passages, {len(carrying)} carrying citation material")
|
||||
|
||||
found = rows(markdown)
|
||||
named = [r for r in found if r["referenced_work"]]
|
||||
print(f"\n== {len(found)} rows, {len(named)} naming a work")
|
||||
|
||||
# What a stage like this would do next is its own business: one executemany
|
||||
# against your own table. The point is that everything above it is two
|
||||
# function calls rather than a parser you have to keep.
|
||||
print("\n INSERT INTO citations (citation_text, referenced_work, referenced_author, …)")
|
||||
whole = [r for r in named if r["referenced_author"]] or named
|
||||
for row in whole[:5]:
|
||||
work = (row["referenced_work"] or "")[:40]
|
||||
who = (row["referenced_author"] or "—")[:22]
|
||||
print(f" (…, {work!r}, {who!r}, {row['referenced_year']})")
|
||||
|
||||
# Nothing is invented. Where a field could not be read the record says so
|
||||
# rather than guessing, and a caller that wants a model to fill the gaps
|
||||
# has `citation_prompt` and `merge_citation_reply` for exactly that.
|
||||
# Gibbon cites in the classical short form — `Tacit. Annal. xiii. 5.` —
|
||||
# so most of his notes name a passage rather than a book. Nothing is
|
||||
# invented to cover that: the record says which fields it could not read,
|
||||
# and `citation_prompt` and `merge_citation_reply` are there for a caller
|
||||
# that wants a model to finish them.
|
||||
unsettled = [r for r in found if r["unsettled"]]
|
||||
print(f"\n {len(unsettled)} rows name something they could not finish reading:")
|
||||
for row in unsettled[:3]:
|
||||
print(f" {row['citation_text'][:56]} → {', '.join(row['unsettled'])}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Passages into pgvector, then a search that asks twice and reconciles.
|
||||
|
||||
Loads the passages of a book into Postgres with both a vector column and a
|
||||
text index, searches both ways, and fuses the two rankings into one.
|
||||
|
||||
DATABASE_URL=postgresql://user@host/db \
|
||||
EMB_MODEL=nomic-embed-text \
|
||||
python examples/python/search_and_rerank.py "why did the empire decline"
|
||||
|
||||
Needs psycopg, a Postgres with pgvector, an embedding server with Ollama's
|
||||
API, and a book — the sample is fetched if none is given.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
import catena
|
||||
import psycopg
|
||||
|
||||
EMB_HOST = os.environ.get("EMB_HOST", "http://localhost:11434")
|
||||
EMB_MODEL = os.environ.get("EMB_MODEL", "nomic-embed-text")
|
||||
GUTENBERG = "https://www.gutenberg.org/cache/epub/731/pg731.epub"
|
||||
|
||||
|
||||
def embed(texts: list[str]) -> list[list[float]]:
|
||||
"""Ask the embedding server, in batches it will accept.
|
||||
|
||||
The one thing here that touches the network. No model is loaded in this
|
||||
process: it belongs behind a server, where it is shared and where its
|
||||
GPU is somebody else's problem.
|
||||
"""
|
||||
out: list[list[float]] = []
|
||||
for at in range(0, len(texts), 32):
|
||||
batch = texts[at : at + 32]
|
||||
body = json.dumps({"model": EMB_MODEL, "input": batch}).encode()
|
||||
request = urllib.request.Request(
|
||||
f"{EMB_HOST}/api/embed", data=body, headers={"content-type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=300) as reply: # noqa: S310
|
||||
out.extend(json.load(reply)["embeddings"])
|
||||
print(f" embedded {len(out)}/{len(texts)}", end="\r", file=sys.stderr)
|
||||
return out
|
||||
|
||||
|
||||
def passages(book: pathlib.Path) -> list[dict]:
|
||||
"""The book, cut into passages worth embedding."""
|
||||
markdown = subprocess.run(
|
||||
["pandoc", "-f", "epub", "-t", "commonmark", "--wrap=none", str(book)],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
chunks = catena.chunk_markdown(catena.scrub(markdown))
|
||||
# A table of contents embeds as well as anything and retrieves for every
|
||||
# query, which is worse than not having it.
|
||||
return [c for c in chunks if c["role"] == "body" and c["tokens"] > 40]
|
||||
|
||||
|
||||
def load(conn, rows: list[dict], vectors: list[list[float]]) -> None:
|
||||
width = len(vectors[0])
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
cur.execute("DROP TABLE IF EXISTS catena_passages")
|
||||
# The tsvector is generated from the body rather than written
|
||||
# alongside it, so the two cannot drift apart.
|
||||
cur.execute(f"""
|
||||
CREATE TABLE catena_passages (
|
||||
idx integer PRIMARY KEY,
|
||||
chapter text,
|
||||
body text NOT NULL,
|
||||
fts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED,
|
||||
vec vector({width})
|
||||
)""")
|
||||
with cur.copy("COPY catena_passages (idx, chapter, body, vec) FROM STDIN") as copy:
|
||||
for chunk, vector in zip(rows, vectors):
|
||||
copy.write_row((chunk["idx"], chunk["chapter"], chunk["text"], str(vector)))
|
||||
cur.execute("CREATE INDEX ON catena_passages USING gin (fts)")
|
||||
cur.execute("CREATE INDEX ON catena_passages USING hnsw (vec vector_cosine_ops)")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def by_meaning(conn, vector: list[float], limit: int = 10) -> list[dict]:
|
||||
"""Nearest in the embedding space.
|
||||
|
||||
`<=>` is cosine distance, so smaller is nearer. This finds a passage
|
||||
about the fall of Rome for a query about decline, sharing no words.
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT idx, regexp_replace(left(body, 68), '\\s+', ' ', 'g')
|
||||
FROM catena_passages ORDER BY vec <=> %s::vector LIMIT %s""",
|
||||
(str(vector), limit),
|
||||
)
|
||||
return [
|
||||
{"v": 1, "id": str(idx), "channel": "vec", "rank": at, "score": 0.0, "text": body}
|
||||
for at, (idx, body) in enumerate(cur.fetchall(), start=1)
|
||||
]
|
||||
|
||||
|
||||
def by_word(conn, query: str, limit: int = 10) -> list[dict]:
|
||||
"""Nearest by the words themselves.
|
||||
|
||||
Finds the passage that actually says the word where the vector search
|
||||
drifted to a paraphrase, and finds nothing at all where the words are
|
||||
not in the book — which is a useful thing to be told.
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT idx, regexp_replace(left(body, 68), '\\s+', ' ', 'g')
|
||||
FROM catena_passages
|
||||
WHERE fts @@ websearch_to_tsquery('english', %(q)s)
|
||||
ORDER BY ts_rank_cd(fts, websearch_to_tsquery('english', %(q)s)) DESC
|
||||
LIMIT %(n)s""",
|
||||
{"q": query, "n": limit},
|
||||
)
|
||||
return [
|
||||
{"v": 1, "id": str(idx), "channel": "fts", "rank": at, "score": 0.0, "text": body}
|
||||
for at, (idx, body) in enumerate(cur.fetchall(), start=1)
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
query = sys.argv[1] if len(sys.argv) > 1 else "why did the roman empire decline"
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if not url:
|
||||
print("set DATABASE_URL", file=sys.stderr)
|
||||
return 64
|
||||
|
||||
work = pathlib.Path("work")
|
||||
work.mkdir(exist_ok=True)
|
||||
book = work / "gibbon.epub"
|
||||
if not book.exists():
|
||||
print(f"fetching {GUTENBERG}")
|
||||
with urllib.request.urlopen(GUTENBERG, timeout=120) as reply: # noqa: S310
|
||||
book.write_bytes(reply.read())
|
||||
|
||||
rows = passages(book)
|
||||
print(f"== {len(rows)} passages")
|
||||
vectors = embed([" ".join(chunk["text"].split()) for chunk in rows])
|
||||
print(f" {EMB_MODEL} returns {len(vectors[0])} numbers per passage")
|
||||
|
||||
with psycopg.connect(url) as conn:
|
||||
load(conn, rows, vectors)
|
||||
print(" loaded into catena_passages")
|
||||
|
||||
print(f"\n== query: {query}")
|
||||
hits = by_meaning(conn, embed([query])[0]) + by_word(conn, query)
|
||||
|
||||
for channel, label in (("vec", "by meaning"), ("fts", "by word")):
|
||||
print(f"\n {label}:")
|
||||
for hit in [h for h in hits if h["channel"] == channel][:4]:
|
||||
print(f" {hit['rank']}. [{hit['id']}] {hit['text']}")
|
||||
|
||||
# Fusion is a library call rather than a subprocess: by the time a hit
|
||||
# reaches here it is already yours, and serialising it to hand to a
|
||||
# program and back is work for nothing.
|
||||
print("\n== fused")
|
||||
text_of = {hit["id"]: hit["text"] for hit in hits}
|
||||
for hit in catena.fuse_hits(hits, limit=5):
|
||||
print(f" {hit['rank']}. [{hit['id']}] score {hit['score']:.3f} {text_of[hit['id']]}")
|
||||
|
||||
print(RERANKING)
|
||||
return 0
|
||||
|
||||
|
||||
RERANKING = """
|
||||
Reciprocal rank fusion scores a passage by where it placed, not by what
|
||||
it scored: 1/(k + rank) from each list that found it, added up. The two
|
||||
searches return numbers that mean different things — a cosine distance
|
||||
and a ts_rank_cd — and no scaling makes them comparable, so their ranks
|
||||
are used and their scores thrown away. A passage both searches liked
|
||||
beats one that either loved, which is the behaviour worth having.
|
||||
|
||||
This is fusion, and it is often called re-ranking, but a second thing
|
||||
wears the same name. Retrieval uses a bi-encoder: passage and query are
|
||||
embedded separately, which is what lets the passage vectors be computed
|
||||
once and indexed, and is also what limits it — nothing in a passage's
|
||||
vector knows what was asked.
|
||||
|
||||
A cross-encoder reads the query and the passage together and scores the
|
||||
pair. It is far more accurate and far too slow to run over a corpus, so
|
||||
it runs over the twenty or fifty candidates retrieval already found. The
|
||||
two stack: retrieve both ways, fuse the lists, then hand the top of the
|
||||
fused list to a cross-encoder for the final order. Nothing here loads a
|
||||
model, so that step is yours — the fused list is what you would hand it.
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
@@ -8,6 +8,13 @@
|
||||
//!
|
||||
//! cargo run -- book.md
|
||||
//! cargo run # reads the sample below
|
||||
//!
|
||||
//! For a real book, convert one first — Gibbon's first volume is public
|
||||
//! domain and has a few hundred pages of footnotes:
|
||||
//!
|
||||
//! curl -sL -o gibbon.epub https://www.gutenberg.org/cache/epub/731/pg731.epub
|
||||
//! pandoc -f epub -t commonmark --wrap=none gibbon.epub -o gibbon.md
|
||||
//! cargo run -- gibbon.md
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -74,8 +81,8 @@ fn main() {
|
||||
},
|
||||
};
|
||||
let chunks = chunkmd::chunk(&clean, &budgets);
|
||||
println!("\n{} passage(s):", chunks.len());
|
||||
for chunk in &chunks {
|
||||
println!("\n{} passage(s), the first few:", chunks.len());
|
||||
for chunk in chunks.iter().take(6) {
|
||||
let chapter = chunk.chapter.as_deref().unwrap_or("—");
|
||||
let opening: String = chunk.text.chars().take(64).collect();
|
||||
println!(
|
||||
@@ -87,18 +94,50 @@ fn main() {
|
||||
);
|
||||
}
|
||||
|
||||
// The references at the back. A passage whose role is `bibliography` is
|
||||
// where they live; a note at the foot of a page is the other place, and
|
||||
// `chunkmd` flags those so this does not have to guess.
|
||||
let references: Vec<citefind::Reference> = chunks
|
||||
// The references. A passage whose role is `bibliography` is one place
|
||||
// they live; a page of notes at the foot of a chapter is the other, and
|
||||
// it has no heading over it to find it by — which is why `chunkmd`
|
||||
// flags those, so this does not have to guess.
|
||||
let carrying: Vec<&corpus_core::Chunk> = chunks
|
||||
.iter()
|
||||
.filter(|chunk| {
|
||||
chunk.role == corpus_core::Role::Bibliography
|
||||
|| chunk
|
||||
.flags
|
||||
.iter()
|
||||
.any(|flag| flag == corpus_core::chunk::flags::FOOTNOTE_CITATIONS)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let references: Vec<citefind::Reference> = carrying
|
||||
.iter()
|
||||
.filter(|chunk| chunk.role == corpus_core::Role::Bibliography)
|
||||
.flat_map(|chunk| citefind::read(&chunk.text))
|
||||
.filter(|found| !found.continuation)
|
||||
.collect();
|
||||
|
||||
println!("\n{} reference(s):", references.len());
|
||||
for found in &references {
|
||||
println!(
|
||||
"\n{} passage(s) carry citation material, giving {} reference(s):",
|
||||
carrying.len(),
|
||||
references.len()
|
||||
);
|
||||
// The ones that named something, which is not all of them: a book whose
|
||||
// notes cite in the classical short form — `Tacit. Annal. xiii. 5.` —
|
||||
// gives mostly passages rather than works, and the record says as much
|
||||
// rather than inventing a title to fill the field.
|
||||
let with_work = references
|
||||
.iter()
|
||||
.filter(|found| found.title.is_some())
|
||||
.count();
|
||||
let named: Vec<&citefind::Reference> = references
|
||||
.iter()
|
||||
.filter(|found| !found.authors.is_empty())
|
||||
.collect();
|
||||
println!(
|
||||
"{with_work} name a work and {} name an author; those:",
|
||||
named.len()
|
||||
);
|
||||
|
||||
for found in named.iter().take(6) {
|
||||
let who = match found.authors.is_empty() {
|
||||
true => "—".to_string(),
|
||||
false => found.authors.join("; "),
|
||||
@@ -116,4 +155,16 @@ fn main() {
|
||||
println!(" could not read: {}", found.unsettled.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
// Worth saying plainly, because the numbers above look poor on a book
|
||||
// like Gibbon and the reason is not the parser having failed quietly.
|
||||
// His notes cite in the classical short form — `Tacit. Annal. xiii. 5.`
|
||||
// — which names a passage rather than a work, so there is no title and
|
||||
// no year to read and the record says which fields it could not fill
|
||||
// rather than inventing them. `citation_prompt` and `merge_citation_reply`
|
||||
// are there for a caller that wants a model to finish the job.
|
||||
if references.len() > 20 && named.len() * 10 < references.len() {
|
||||
println!("\n most of these are classical short cites — a passage");
|
||||
println!(" rather than a work — there is nothing more to read in them.");
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/bin/sh
|
||||
# A book file to passages worth embedding, and what its notes cite.
|
||||
#
|
||||
# The starting point is Gibbon's first volume from Project Gutenberg: public
|
||||
# domain, a real table of contents, and a few hundred pages of footnotes.
|
||||
#
|
||||
# examples/shell/01-book-to-passages.sh [work-dir]
|
||||
#
|
||||
# Needs pandoc and jq, and the catena commands on PATH.
|
||||
set -eu
|
||||
|
||||
work="${1:-./work}"
|
||||
mkdir -p "$work"
|
||||
book="$work/gibbon.epub"
|
||||
|
||||
if [ ! -s "$book" ]; then
|
||||
echo "fetching Gibbon vol. 1 from Project Gutenberg"
|
||||
curl -sSL --max-time 120 -o "$book" \
|
||||
"https://www.gutenberg.org/cache/epub/731/pg731.epub"
|
||||
fi
|
||||
|
||||
# What the file actually is. Extensions lie, and a `.epub` that is really a
|
||||
# zip of page scans fails four stages later, in a way that looks like a bug
|
||||
# in the converter.
|
||||
echo
|
||||
echo "== what it is"
|
||||
booksniff "$book" | jq -r '" \(.kind), \(.bytes) bytes"'
|
||||
|
||||
# A PDF gets one more question first: exit 0 from pdftriage means the text
|
||||
# layer is not worth reading and the file has to be OCRed before any of this.
|
||||
if [ "$(booksniff "$book" | jq -r .kind)" = "pdf" ] && pdftriage --quiet "$book"; then
|
||||
echo " the text layer is garbled; OCR it before going on"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# What the file already says about itself — the publisher's own metadata,
|
||||
# which beats anything guessed from a filename, and costs no lookup.
|
||||
echo
|
||||
echo "== what it says about itself"
|
||||
bookmeta "$book" | jq -r '" \(.title)\n \(.authors | join("; "))\n \(.language // "?") · \(.date // "no date")"'
|
||||
|
||||
# The chapters the book declares, rather than any inferred from the text.
|
||||
echo
|
||||
echo "== the contents it declares"
|
||||
tocfind "$book" --outline | sed -n '6,10p' | sed 's/^/ /'
|
||||
echo " … $(tocfind "$book" | wc -l | tr -d ' ') entries"
|
||||
|
||||
# Convert, scrub, chunk. `commonmark` rather than `commonmark_x`, because the
|
||||
# extended flavour writes anchors as bracketed spans and they end up in the
|
||||
# passages. The scrub is what takes out the rest of the converter's leavings.
|
||||
echo
|
||||
echo "== passages"
|
||||
pandoc -f epub -t commonmark --wrap=none "$book" -o "$work/book.md"
|
||||
mdscrub < "$work/book.md" > "$work/clean.md"
|
||||
chunkmd < "$work/clean.md" > "$work/chunks.ndjson"
|
||||
|
||||
jq -s -r '
|
||||
" \(length) passages over \([.[].chapter] | unique | length) chapters",
|
||||
" roles: \([.[].role] | group_by(.) | map("\(length) \(.[0])") | join(", "))",
|
||||
" \([.[] | select(.flags | index("footnote_citations"))] | length) carry citation material"
|
||||
' "$work/chunks.ndjson"
|
||||
|
||||
echo
|
||||
echo " a passage, with where it came from:"
|
||||
jq -s -r '[.[] | select(.role == "body" and .chapter != null)][40]
|
||||
| " [\(.idx)] \(.tokens) tokens · \(.chapter)\n \(.text[0:150])…"' \
|
||||
"$work/chunks.ndjson"
|
||||
|
||||
# The notes. A passage flagged `footnote_citations` is a page of them, and
|
||||
# each is read on its own — the flag says which passages are worth the ask,
|
||||
# which is the whole reason chunkmd sets it.
|
||||
echo
|
||||
echo "== what the notes cite"
|
||||
: > "$work/references.ndjson"
|
||||
jq -c 'select(.flags | index("footnote_citations")) | .text' "$work/chunks.ndjson" \
|
||||
| while IFS= read -r line; do
|
||||
printf '%s' "$line" | jq -r . | citefind >> "$work/references.ndjson" || true
|
||||
done
|
||||
|
||||
jq -s -r '
|
||||
" \(length) entries read",
|
||||
" \([.[] | select(.title)] | length) name a work, \([.[] | select(.authors | length > 0)] | length) name an author",
|
||||
" \([.[] | select(.unsettled | length > 0)] | length) came back with something unread"
|
||||
' "$work/references.ndjson"
|
||||
|
||||
echo
|
||||
echo " Gibbon cites in the classical short form — \"Tacit. Annal. xiii. 5.\" —"
|
||||
echo " so most entries name the passage rather than a book, and say so:"
|
||||
jq -r 'select(.unsettled | length > 0) | " \(.text[0:58]) → unread: \(.unsettled | join(", "))"' \
|
||||
"$work/references.ndjson" | head -4
|
||||
|
||||
echo
|
||||
echo "written to $work/"
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/bin/sh
|
||||
# A recording to chapter marks, citable passages, and a copy that plays.
|
||||
#
|
||||
# Takes the captions a video already carries, normalises them to one cue
|
||||
# stream, and turns that into chapter marks for a player and passages worth
|
||||
# embedding — each keeping the second it was said at. Then asks whether the
|
||||
# file itself would play on a phone, and prints the one ffmpeg call that
|
||||
# fixes it.
|
||||
#
|
||||
# examples/shell/02-video-to-chapters.sh [url] [work-dir]
|
||||
#
|
||||
# The default is a NASA upload: a work of the US government, so public
|
||||
# domain, and captioned by a person rather than by a model. Any URL yt-dlp
|
||||
# can reach works, and any local subtitle file works too — skip to the
|
||||
# cuenorm line and feed it your own.
|
||||
#
|
||||
# Needs jq, ffmpeg, and yt-dlp with impersonation support, which YouTube now
|
||||
# requires before it will hand over captions:
|
||||
#
|
||||
# pipx install "yt-dlp[default,curl-cffi]"
|
||||
#
|
||||
# and the catena commands on PATH.
|
||||
set -eu
|
||||
|
||||
url="${1:-https://www.youtube.com/watch?v=h0wLZJeYGxw}"
|
||||
work="${2:-./work}"
|
||||
mkdir -p "$work"
|
||||
|
||||
# Captions written by a person where there are any, and a model's otherwise.
|
||||
# Automatic captions roll — each line is re-sent as it scrolls up the screen —
|
||||
# so the same sentence arrives three times and every passage says it thrice.
|
||||
if [ -z "$(find "$work" -name '*.vtt' -print -quit 2>/dev/null)" ]; then
|
||||
echo "fetching captions"
|
||||
yt-dlp --skip-download --write-subs --sub-langs 'en.*' --sub-format vtt \
|
||||
--output "$work/%(id)s.%(ext)s" "$url" >/dev/null 2>&1 ||
|
||||
yt-dlp --skip-download --write-auto-subs --sub-langs 'en.*' --sub-format vtt \
|
||||
--output "$work/%(id)s.%(ext)s" "$url" >/dev/null
|
||||
fi
|
||||
|
||||
# Where several tracks came back, the smallest is the written one: a rolling
|
||||
# track repeats every line and is four or five times the size.
|
||||
subs="$(find "$work" -name '*.vtt' -exec ls -S {} + | tail -1)"
|
||||
[ -n "$subs" ] || { echo "no captions on that video; try another URL" >&2; exit 1; }
|
||||
echo " captions: $(basename "$subs")"
|
||||
|
||||
echo
|
||||
echo "== cues"
|
||||
cuenorm "$subs" > "$work/cues.ndjson"
|
||||
jq -s -r '" \(length) cues over \((map(.end) | max) / 60 | floor)m\((map(.end) | max) % 60 | floor)s"' "$work/cues.ndjson"
|
||||
jq -r '" [\(.start | floor)s] \(.text[0:62])"' "$work/cues.ndjson" | head -3
|
||||
|
||||
# Where the subject changes, found from the words alone — no model, no
|
||||
# network. The titles come out of each chapter's own first sentence, and are
|
||||
# meant to be replaced by something better later.
|
||||
echo
|
||||
echo "== chapters"
|
||||
cueseg < "$work/cues.ndjson" > "$work/chapters.ndjson"
|
||||
jq -r '" \(.start | floor)s–\(.end | floor)s \(.title)"' "$work/chapters.ndjson"
|
||||
|
||||
# Cues packed to a size worth embedding, each carrying the seconds it spans,
|
||||
# so a search hit can be played back at the moment it was said.
|
||||
echo
|
||||
echo "== passages"
|
||||
cuepack < "$work/cues.ndjson" > "$work/passages.ndjson"
|
||||
jq -s -r '" \(length) passages, \(map(.tokens) | add / length | floor) tokens on average"' "$work/passages.ndjson"
|
||||
jq -s -r '.[0] | " [\(.start_time | floor)s–\(.end_time | floor)s] \(.text[0:150])…"' "$work/passages.ndjson"
|
||||
|
||||
echo
|
||||
echo "== chapter marks, as a player would take them"
|
||||
jq -r '"\(.start | floor) \(.title)"' "$work/chapters.ndjson" |
|
||||
awk '{ t = $1; $1 = ""; printf " %02d:%02d:%02d%s\n", t/3600, (t%3600)/60, t%60, $0 }'
|
||||
|
||||
# And the file itself. A recording indexed but unplayable is half a job: a
|
||||
# phone will not open VP9 in a WebM container, whatever the transcript says.
|
||||
# Twenty seconds of it is enough to judge the streams, and spares the
|
||||
# download.
|
||||
echo
|
||||
echo "== would it play on a phone"
|
||||
video="$(find "$work" -type f \( -name '*.webm' -o -name '*.mkv' -o -name '*.mp4' \) -print -quit)"
|
||||
if [ -z "$video" ]; then
|
||||
echo " fetching twenty seconds of it"
|
||||
yt-dlp -f 'worstvideo[ext=webm]+worstaudio[ext=webm]/worst' \
|
||||
--download-sections '*0-20' --force-keyframes-at-cuts \
|
||||
--output "$work/%(id)s.%(ext)s" "$url" >/dev/null 2>&1 || true
|
||||
video="$(find "$work" -type f \( -name '*.webm' -o -name '*.mkv' -o -name '*.mp4' \) -print -quit)"
|
||||
fi
|
||||
[ -n "$video" ] || { echo " nothing to judge"; exit 0; }
|
||||
|
||||
avplan --json "$video" | jq -r '
|
||||
" \(.streams.container): \(.streams.video) video, \(.streams.audio // "no") audio",
|
||||
(if .playable then " plays as it stands" else (.reasons[] | " · \(.)") end)'
|
||||
|
||||
# Exit 0 means there is a plan to print; 1 means the file already plays. The
|
||||
# encoder is whatever this machine actually has — videotoolbox on a Mac,
|
||||
# NVENC or VAAPI on a Linux box, libx264 where there is no card. Nothing is
|
||||
# run: converting is expensive and irreversible enough to read first.
|
||||
echo
|
||||
if plan="$(avplan --output "$work/for-phone.mp4" "$video")"; then
|
||||
echo " the one call that fixes it:"
|
||||
echo
|
||||
printf ' %s\n' "$plan"
|
||||
echo
|
||||
echo " run it with: eval \"\$(avplan --output out.mp4 FILE)\""
|
||||
else
|
||||
echo " nothing to convert"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "written to $work/"
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/bin/sh
|
||||
# Passages into pgvector, then a search that asks twice and reconciles.
|
||||
#
|
||||
# Takes the passages example 01 wrote, embeds them, loads them into Postgres
|
||||
# with a vector column and a text index, then searches both ways and fuses
|
||||
# the two lists into one.
|
||||
#
|
||||
# examples/shell/01-book-to-passages.sh ./work
|
||||
# examples/shell/03-search-and-rerank.sh "why did the empire decline" ./work
|
||||
#
|
||||
# Needs psql and jq, a Postgres with pgvector, an embedding server, and the
|
||||
# catena commands on PATH:
|
||||
#
|
||||
# DATABASE_URL=postgresql://user@host/db
|
||||
# EMB_HOST=http://localhost:11434 # ollama, or anything with its API
|
||||
# EMB_MODEL=nomic-embed-text # 768 wide; bge-m3 is 1024
|
||||
set -eu
|
||||
|
||||
query="${1:-why did the roman empire decline}"
|
||||
work="${2:-./work}"
|
||||
: "${DATABASE_URL:?set DATABASE_URL}"
|
||||
host="${EMB_HOST:-http://localhost:11434}"
|
||||
model="${EMB_MODEL:-nomic-embed-text}"
|
||||
|
||||
[ -s "$work/chunks.ndjson" ] || { echo "run 01-book-to-passages.sh first" >&2; exit 1; }
|
||||
psql="psql -X -q -v ON_ERROR_STOP=1 $DATABASE_URL"
|
||||
tab=$(printf '\t')
|
||||
|
||||
# How wide the model's vectors are. Asked rather than assumed, because the
|
||||
# column is typed and a mismatch is a wall of errors at insert time.
|
||||
dim=$(printf 'how wide is this\n' | emb --host "$host" --model "$model" | jq 'length')
|
||||
echo "== $model returns $dim numbers per passage"
|
||||
|
||||
# One table. The vector is what the model said; the tsvector is Postgres's
|
||||
# own, generated from the text so it cannot drift out of step with it.
|
||||
$psql <<SQL
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
DROP TABLE IF EXISTS catena_passages;
|
||||
CREATE TABLE catena_passages (
|
||||
idx integer PRIMARY KEY,
|
||||
chapter text,
|
||||
body text NOT NULL,
|
||||
tokens integer NOT NULL,
|
||||
fts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED,
|
||||
vec vector($dim)
|
||||
);
|
||||
CREATE INDEX ON catena_passages USING gin (fts);
|
||||
SQL
|
||||
|
||||
# Body passages only: a table of contents embeds as well as anything and
|
||||
# retrieves for every query, which is worse than not having it.
|
||||
jq -c 'select(.role == "body" and .tokens > 40)' "$work/chunks.ndjson" > "$work/body.ndjson"
|
||||
echo "== embedding $(wc -l < "$work/body.ndjson" | tr -d ' ') passages"
|
||||
|
||||
# The newlines have to go before the text is a line: emb is line-oriented,
|
||||
# so a passage containing one would arrive as two.
|
||||
jq -r '.text | gsub("\\s+"; " ")' "$work/body.ndjson" > "$work/lines.txt"
|
||||
emb --host "$host" --model "$model" --dim "$dim" < "$work/lines.txt" > "$work/vectors.ndjson"
|
||||
|
||||
# Line for line, which is emb's promise, so paste is a safe join.
|
||||
jq -c '[.idx, .chapter, (.text | gsub("\\s+"; " ")), .tokens]' "$work/body.ndjson" > "$work/meta.ndjson"
|
||||
paste -d '\t' "$work/meta.ndjson" "$work/vectors.ndjson" |
|
||||
python3 -c '
|
||||
import csv, json, sys
|
||||
out = csv.writer(sys.stdout, delimiter="\t", quoting=csv.QUOTE_NONE, escapechar="\\")
|
||||
for line in sys.stdin:
|
||||
meta, vector = line.rstrip("\n").split("\t")
|
||||
idx, chapter, body, tokens = json.loads(meta)
|
||||
out.writerow([idx, chapter or "", body, tokens, vector])
|
||||
' > "$work/rows.tsv"
|
||||
|
||||
$psql -c "\copy catena_passages (idx, chapter, body, tokens, vec) FROM '$work/rows.tsv'"
|
||||
$psql -c "CREATE INDEX ON catena_passages USING hnsw (vec vector_cosine_ops);"
|
||||
$psql -tAc "SELECT ' ' || count(*) || ' passages loaded' FROM catena_passages"
|
||||
|
||||
# psql's rows into hit records. `rrf` wants an id, which channel found it,
|
||||
# and what rank that channel gave it; the text is carried along so the
|
||||
# output is readable. Written in Python because the passages contain
|
||||
# quotation marks and a shell printf would not escape them.
|
||||
hits() {
|
||||
python3 -c '
|
||||
import json, sys
|
||||
channel = sys.argv[1]
|
||||
for line in sys.stdin:
|
||||
line = line.rstrip("\n")
|
||||
if not line.strip():
|
||||
continue
|
||||
ident, rank, text = line.split("\t", 2)
|
||||
print(json.dumps({"v": 1, "id": ident, "channel": channel,
|
||||
"rank": int(rank), "score": 0.0, "text": text}))
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Two searches. They disagree, and that is the point.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "== query: $query"
|
||||
printf '%s\n' "$query" | emb --host "$host" --model "$model" --dim "$dim" | tr -d '\n' > "$work/query.vec"
|
||||
qvec=$(cat "$work/query.vec")
|
||||
|
||||
# Nearest by meaning. `<=>` is cosine distance, so smaller is closer, and a
|
||||
# passage about "the fall of Rome" ranks even though it shares no words with
|
||||
# the query.
|
||||
$psql -tAF"$tab" -c "
|
||||
SELECT idx, row_number() OVER (ORDER BY vec <=> '$qvec'::vector),
|
||||
regexp_replace(left(body, 68), '\s+', ' ', 'g')
|
||||
FROM catena_passages
|
||||
ORDER BY vec <=> '$qvec'::vector
|
||||
LIMIT 10" | hits vec > "$work/hits.ndjson"
|
||||
|
||||
# Nearest by word. Finds the passage that says "decline" where the vector
|
||||
# search drifted to a paraphrase, and finds nothing at all for a query whose
|
||||
# words are not in the book.
|
||||
$psql -tAF"$tab" -c "
|
||||
SELECT idx,
|
||||
row_number() OVER (ORDER BY ts_rank_cd(fts, websearch_to_tsquery('english', \$\$$query\$\$)) DESC),
|
||||
regexp_replace(left(body, 68), '\s+', ' ', 'g')
|
||||
FROM catena_passages
|
||||
WHERE fts @@ websearch_to_tsquery('english', \$\$$query\$\$)
|
||||
ORDER BY 2
|
||||
LIMIT 10" | hits fts >> "$work/hits.ndjson"
|
||||
|
||||
echo
|
||||
echo " by meaning:"
|
||||
jq -r 'select(.channel == "vec") | " \(.rank). [\(.id)] \(.text)"' "$work/hits.ndjson" | head -4
|
||||
echo
|
||||
echo " by word:"
|
||||
jq -r 'select(.channel == "fts") | " \(.rank). [\(.id)] \(.text)"' "$work/hits.ndjson" | head -4
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reconciling them.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "== fused"
|
||||
rrf --limit 5 < "$work/hits.ndjson" > "$work/fused.ndjson"
|
||||
jq -s -r --slurpfile hits "$work/hits.ndjson" '
|
||||
($hits | INDEX(.id)) as $by_id
|
||||
| .[] | " \(.rank). [\(.id)] score \(.score * 1000 | floor / 1000) \($by_id[.id].text)"
|
||||
' "$work/fused.ndjson"
|
||||
|
||||
cat <<'NOTE'
|
||||
|
||||
Reciprocal rank fusion scores a passage by where it placed, not by what
|
||||
it scored: 1/(k + rank) from each list that found it, added up. The two
|
||||
searches return numbers that mean different things — a cosine distance
|
||||
and a ts_rank_cd — and no amount of scaling makes them comparable, so
|
||||
their ranks are used and their scores thrown away. A passage both
|
||||
searches liked beats one that either loved, which is the behaviour worth
|
||||
having: agreement between two different ways of looking is evidence, and
|
||||
a high score from one is only ever evidence about that one.
|
||||
|
||||
The run above shows it happening. The word search's first hit is the
|
||||
front matter, which says "Roman Empire" more often than any chapter of
|
||||
the book does; the vector search never offers it. Fusion pushes it down
|
||||
and lifts the one passage both searches found.
|
||||
|
||||
The constant k, 60 by default, is how much a first place is worth over a
|
||||
second. Raise it and the lists flatten toward equal weight; lower it and
|
||||
the top of each list dominates. `--weight vec=2` trusts one channel more
|
||||
than the other, which is the knob to reach for when one of them is
|
||||
consistently better on your corpus.
|
||||
|
||||
This is fusion, and it is often called re-ranking, but there is a second
|
||||
thing wearing the same name. Retrieval uses a bi-encoder: the passage
|
||||
and the query are embedded separately, so the passage vectors can be
|
||||
computed once and indexed, and a search is one vector comparison against
|
||||
an index. That independence is what makes it fast and is also what
|
||||
limits it — nothing in the passage's vector knows what was asked.
|
||||
|
||||
A cross-encoder reads the query and the passage together and scores the
|
||||
pair. It is far more accurate and far too slow to run over a corpus, so
|
||||
it runs over the twenty or fifty candidates retrieval already found. In
|
||||
a pipeline the two stack: retrieve both ways, fuse the lists as above,
|
||||
then hand the top of the fused list to a cross-encoder for the final
|
||||
order. Nothing here does that step — it needs a model, and these tools
|
||||
do not load models — but the fused list is what you would hand it.
|
||||
NOTE
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "catena-example",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"chapters": "node --experimental-strip-types src/episode-to-chapters.ts",
|
||||
"search": "node --experimental-strip-types src/search-and-rerank.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"catena": "file:../../crates/bindings/npm/dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// A recording's captions into chapter marks and citable passages.
|
||||
//
|
||||
// What a player needs from an episode: somewhere to jump to, and passages
|
||||
// that carry the second they were said at so a search result can be played
|
||||
// rather than just quoted.
|
||||
//
|
||||
// npm install
|
||||
// npm run chapters -- path/to/captions.vtt
|
||||
//
|
||||
// With no argument it reads the sample below, which is a few cues of a NASA
|
||||
// broadcast — a work of the US government, so public domain.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parseSubtitles, segmentCues, packCues, countTokens } from "catena";
|
||||
|
||||
const SAMPLE = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:04.200
|
||||
Hey there, and welcome to NASA's Curious Universe.
|
||||
|
||||
00:00:04.200 --> 00:00:08.900
|
||||
Our universe is a wild and wonderful place, and in this
|
||||
show, NASA is your tour guide.
|
||||
|
||||
00:00:08.900 --> 00:00:15.100
|
||||
Today we are going to talk about how we find planets
|
||||
around other stars, which is harder than it sounds.
|
||||
|
||||
00:00:15.100 --> 00:00:21.800
|
||||
A planet is small and dark and its star is enormous and
|
||||
bright, so you almost never see the planet itself.
|
||||
|
||||
00:00:21.800 --> 00:00:28.400
|
||||
Instead you watch the star, and you wait for it to dim
|
||||
by about one part in ten thousand.
|
||||
|
||||
00:00:28.400 --> 00:00:34.000
|
||||
That dimming is a transit, and it is how most of the
|
||||
planets we know about were found.
|
||||
`;
|
||||
|
||||
const path = process.argv[2];
|
||||
const content = path ? readFileSync(path, "utf8") : SAMPLE;
|
||||
|
||||
// One cue stream, whatever the format was. `keepRepeats` is off, which is
|
||||
// what throws away the re-sent lines of a rolling automatic caption track —
|
||||
// leave it on and every passage says each sentence three times.
|
||||
const cues = parseSubtitles(content);
|
||||
const runsTo = Math.max(...cues.map((cue) => cue.end));
|
||||
console.log(`== ${cues.length} cues over ${Math.round(runsTo)}s`);
|
||||
for (const cue of cues.slice(0, 3)) {
|
||||
console.log(` [${cue.start.toFixed(1)}s] ${cue.text.slice(0, 58)}`);
|
||||
}
|
||||
|
||||
// Where the subject changes, from the words alone — no model, no network.
|
||||
// The titles come out of each chapter's own first sentence and are meant to
|
||||
// be replaced by something better later.
|
||||
const chapters = segmentCues(cues);
|
||||
console.log(`\n== ${chapters.length} chapter(s)`);
|
||||
for (const chapter of chapters) {
|
||||
console.log(` ${stamp(chapter.start)} ${chapter.title}`);
|
||||
}
|
||||
|
||||
// Cues packed to a size worth embedding, each keeping the seconds it spans.
|
||||
const passages = packCues(cues);
|
||||
console.log(`\n== ${passages.length} passage(s)`);
|
||||
for (const passage of passages) {
|
||||
console.log(
|
||||
` [${stamp(passage.start_time ?? 0)}–${stamp(passage.end_time ?? 0)}] ` +
|
||||
`${passage.tokens} tokens`,
|
||||
);
|
||||
console.log(` ${passage.text.slice(0, 92)}…`);
|
||||
}
|
||||
|
||||
// What a player would take: a cue sheet, and a deep link per passage.
|
||||
console.log("\n== what the player gets");
|
||||
for (const chapter of chapters) {
|
||||
console.log(` ${stamp(chapter.start)} ${chapter.title}`);
|
||||
}
|
||||
console.log("\n== what a search result would link to");
|
||||
for (const passage of passages.slice(0, 2)) {
|
||||
const at = Math.floor(passage.start_time ?? 0);
|
||||
console.log(` ?t=${at} ${passage.text.slice(0, 64)}…`);
|
||||
}
|
||||
|
||||
// The same token count the chunker used, so a budget worked out here and a
|
||||
// budget enforced there agree.
|
||||
console.log(`\n ${countTokens(cues.map((c) => c.text).join(" "))} tokens of transcript in all`);
|
||||
|
||||
function stamp(seconds: number): string {
|
||||
const whole = Math.floor(seconds);
|
||||
const mm = String(Math.floor(whole / 60)).padStart(2, "0");
|
||||
const ss = String(whole % 60).padStart(2, "0");
|
||||
return `${String(Math.floor(whole / 3600)).padStart(2, "0")}:${mm}:${ss}`;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Two searches, reconciled — the app-side half of hybrid retrieval.
|
||||
//
|
||||
// Fusion is the one thing in this suite worth calling in process rather than
|
||||
// as a subprocess: by the time a hit reaches your code it is already yours,
|
||||
// and serialising it to hand to a program and back is work for nothing.
|
||||
//
|
||||
// npm install
|
||||
// npm run search
|
||||
//
|
||||
// The hits below stand in for two queries you would run against your own
|
||||
// store — pgvector and a text index, Elasticsearch and a KNN service, or a
|
||||
// vector database and whatever else. What they came from does not matter
|
||||
// here; only where each of them placed does.
|
||||
|
||||
import { fuseHits, type Hit } from "catena";
|
||||
|
||||
// `payload` is whatever you want carried alongside — rrf hands it back
|
||||
// untouched, so a hit can arrive here with the row it came from attached.
|
||||
const hit = (id: string, channel: string, rank: number, score: number): Hit => ({
|
||||
v: 1,
|
||||
id,
|
||||
channel,
|
||||
rank,
|
||||
score,
|
||||
payload: {},
|
||||
});
|
||||
|
||||
// Nearest in the embedding space. Finds the passage about the fall of Rome
|
||||
// for a query about decline, sharing not one word with it.
|
||||
const byMeaning: Hit[] = [
|
||||
hit("p39", "vec", 1, 0.21),
|
||||
hit("p127", "vec", 2, 0.24),
|
||||
hit("p462", "vec", 3, 0.26),
|
||||
hit("p177", "vec", 4, 0.29),
|
||||
];
|
||||
|
||||
// Nearest by the words themselves. Finds the passage that says the word
|
||||
// where the vectors drifted to a paraphrase — and finds the front matter,
|
||||
// which says "Roman Empire" more often than any chapter of the book does.
|
||||
const byWord: Hit[] = [
|
||||
hit("p0", "fts", 1, 0.68),
|
||||
hit("p462", "fts", 2, 0.51),
|
||||
hit("p8", "fts", 3, 0.44),
|
||||
hit("p71", "fts", 4, 0.4),
|
||||
];
|
||||
|
||||
const text: Record<string, string> = {
|
||||
p0: "The Project Gutenberg eBook of History of the Decline and Fall…",
|
||||
p8: "In imitation of his classical models, Gibbon places Rome as the…",
|
||||
p39: "In the second century of the Christian Æra, the empire of Rome…",
|
||||
p71: "Tacit. Annal. iv. 5. Those who fix a regular proportion…",
|
||||
p127: "It was by such institutions that the nations of the empire…",
|
||||
p177: "Among many other passages, see Pliny on the extent of the…",
|
||||
p462: "The received calculation of Varro assigns to the foundation…",
|
||||
};
|
||||
|
||||
for (const [label, hits] of [["by meaning", byMeaning], ["by word", byWord]] as const) {
|
||||
console.log(`\n== ${label}`);
|
||||
for (const hit of hits) {
|
||||
console.log(` ${hit.rank}. [${hit.id}] ${hit.score.toFixed(2)} ${text[hit.id]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The two score columns above mean different things — a cosine distance and
|
||||
// a ts_rank_cd — and no scaling makes them comparable. So the ranks are used
|
||||
// and the scores thrown away.
|
||||
console.log("\n== fused");
|
||||
for (const hit of fuseHits([...byMeaning, ...byWord], undefined, undefined, 5)) {
|
||||
console.log(` ${hit.rank}. [${hit.id}] ${hit.score.toFixed(4)} ${text[hit.id]}`);
|
||||
}
|
||||
|
||||
// One channel can be trusted more than the other where a corpus warrants it.
|
||||
console.log("\n== fused, trusting the vectors twice as much");
|
||||
for (const hit of fuseHits([...byMeaning, ...byWord], undefined, { vec: 2 }, 5)) {
|
||||
console.log(` ${hit.rank}. [${hit.id}] ${hit.score.toFixed(4)} ${text[hit.id]}`);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Reciprocal rank fusion scores a hit by where it placed, not by what it
|
||||
scored: 1/(k + rank) from each list that found it, added up. p462 is
|
||||
third in one list and second in the other and wins, because agreement
|
||||
between two different ways of looking is evidence; p0 is first in one
|
||||
and absent from the other, and drops.
|
||||
|
||||
This is fusion, and it is often called re-ranking, but a second thing
|
||||
wears the same name. Retrieval uses a bi-encoder: the passage and the
|
||||
query are embedded separately, which is what lets passage vectors be
|
||||
computed once and indexed — and is also what limits it, since nothing in
|
||||
a passage's vector knows what was asked.
|
||||
|
||||
A cross-encoder reads the query and the passage together and scores the
|
||||
pair. Far more accurate, far too slow to run over a corpus, so it runs
|
||||
over the twenty or fifty candidates retrieval already found. The two
|
||||
stack: retrieve both ways, fuse as above, then hand the top of the fused
|
||||
list to a cross-encoder for the final order. Nothing here loads a model,
|
||||
so that step is yours — the fused list is what you would hand it.`);
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["es2023"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user