API reference

The supported Python interface is imported directly from clickhouserag.

clickhouserag.ClickHouseStore

A table of versioned passages using an application-owned ClickHouse client.

Source code in src/clickhouserag/store.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
class ClickHouseStore:
    """A table of versioned passages using an application-owned ClickHouse client."""

    def __init__(
        self,
        client: Client,
        *,
        table: str,
        embedding_model: str,
        dimensions: int,
        database: str = "default",
    ) -> None:
        integer(dimensions, "dimensions", _MAX_DIMENSIONS)
        nonblank(embedding_model, "embedding_model")
        self._client = client
        self._table = f"{identifier(database)}.{identifier(table)}"
        self._location = {"database": database, "table": table}
        self._dimensions = dimensions
        self._embedding_model = embedding_model
        self._manifest = "clickhouserag:" + json.dumps(
            {"schema": 2, "model": embedding_model, "dimensions": dimensions},
            sort_keys=True,
            separators=(",", ":"),
        )
        self._validated = False

    _passage_source = _TABLE_SOURCE

    @property
    def dimensions(self) -> int:
        """Expected number of components in each vector."""
        return self._dimensions

    @property
    def embedding_model(self) -> str:
        """Identifier of the embedding model associated with this table."""
        return self._embedding_model

    def initialize(self) -> None:
        """Create an empty table if absent, then validate its schema identity."""
        # The qualified name uses the validated identifier grammar. The comment
        # and dimension are passed through driver binding.
        self._client.command(
            f"""
            CREATE TABLE IF NOT EXISTS {self._table} (
                namespace String,
                chunk_id String,
                document_id String,
                text String,
                source String,
                revision UInt64,
                metadata Map(String, String),
                embedding Array(Float32),
                lexical_terms Array(String),
                INDEX lexical_idx(lexical_terms) TYPE text(tokenizer = 'array'),
                CONSTRAINT embedding_dimensions CHECK length(embedding) = %(dimensions)s
            )
            ENGINE = ReplacingMergeTree(revision)
            ORDER BY (namespace, chunk_id)
            COMMENT %(manifest)s
            """,
            parameters={"dimensions": self._dimensions, "manifest": self._manifest},
        )
        self.validate_schema()

    def validate_schema(self) -> None:
        """Verify that the existing table belongs to this schema and embedding model."""
        self._validated = False
        result = self._client.query(
            """
            SELECT comment
            FROM system.tables
            WHERE database = {database:String} AND name = {table:String}
            """,
            parameters=self._location,
        )
        if result.result_rows != [(self._manifest,)]:
            raise SchemaError(
                "ClickHouse table is missing or does not match the store schema and embedding model"
            )
        self._validated = True

    def insert(self, chunks: Iterable[EmbeddedChunk], *, namespace: str = "default") -> int:
        """Insert one validated batch of up to 10,000 embedded passages.

        Repeat the same payload at the same revision to retry a failed write. Use
        a strictly greater revision for replacements of a given namespace/chunk ID.
        """
        return self._insert_rows(self._prepare_rows(chunks, namespace))

    def _prepare_rows(self, chunks: Iterable[EmbeddedChunk], namespace: str) -> list[_PassageRow]:
        nonblank(namespace, "namespace")
        rows = []
        ids = set()
        for item in chunks:
            if len(rows) >= _MAX_BATCH:
                raise ValueError(f"insert accepts at most {_MAX_BATCH} chunks per batch")
            chunk = item.chunk
            metadata = chunk_fields(chunk)
            if chunk.chunk_id in ids:
                raise ValueError("chunk IDs must be unique within a batch")
            ids.add(chunk.chunk_id)
            rows.append(
                (
                    namespace,
                    chunk.chunk_id,
                    chunk.document_id,
                    chunk.text,
                    chunk.source,
                    chunk.revision,
                    metadata,
                    unit_vector(item.embedding, self.dimensions),
                    terms(chunk.text),
                )
            )
        return rows

    def _insert_rows(
        self,
        rows: list[_PassageRow],
    ) -> int:
        if not rows:
            return 0
        self._ensure_schema()
        self._client.insert(
            self._table,
            rows,
            column_names=_INSERT_COLUMNS,
            settings={"async_insert": 0},
        )
        return len(rows)

    def search_vector(
        self,
        embedding: Sequence[float],
        *,
        namespace: str = "default",
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
    ) -> list[SearchHit]:
        """Find the nearest passages by exact cosine similarity (higher is closer)."""
        parameters = _scope(namespace, metadata, limit)
        parameters.update(self._location)
        parameters["embedding"] = unit_vector(embedding, self.dimensions)
        return self._search(
            """
            SELECT chunk_id, document_id, text, source, revision, metadata,
                1 - cosineDistance(embedding, {embedding:Array(Float32)}) AS score
            FROM @passages@
            WHERE namespace = {namespace:String}
                AND arrayAll(pair -> mapContains(metadata, pair.1)
                    AND metadata[pair.1] = pair.2, {filters:Array(Tuple(String, String))})
            ORDER BY score DESC, chunk_id ASC
            LIMIT {limit:UInt32}
            """,
            parameters,
        )

    def search_lexical(
        self,
        query: str,
        *,
        namespace: str = "default",
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
    ) -> list[SearchHit]:
        """Rank matching passages by distinct query token coverage."""
        parameters = _scope(namespace, metadata, limit)
        query_terms = _query_terms(query)
        if not query_terms:
            return []
        parameters.update(self._location, terms=query_terms)
        return self._search(
            """
            SELECT chunk_id, document_id, text, source, revision, metadata,
                length(arrayIntersect(lexical_terms, {terms:Array(String)})) AS score
            FROM @passages@
            WHERE namespace = {namespace:String}
                AND arrayAll(pair -> mapContains(metadata, pair.1)
                    AND metadata[pair.1] = pair.2, {filters:Array(Tuple(String, String))})
                AND hasAnyTokens(lexical_terms, {terms:Array(String)})
            ORDER BY score DESC, chunk_id ASC
            LIMIT {limit:UInt32}
            """,
            parameters,
        )

    def search_hybrid(
        self,
        query: str,
        embedding: Sequence[float],
        *,
        namespace: str = "default",
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
        candidates: int | None = None,
    ) -> list[SearchHit]:
        """Fuse independently ranked vector and lexical candidates."""
        integer(limit, "limit", _MAX_LIMIT)
        if candidates is None:
            candidates = min(limit * 4, _MAX_LIMIT)
        integer(candidates, "candidates", _MAX_LIMIT)
        if candidates < limit:
            raise ValueError("candidates must be at least limit")
        parameters = _scope(namespace, metadata, candidates)
        query_terms = _query_terms(query)
        if not query_terms:
            dense = self.search_vector(
                embedding, namespace=namespace, metadata=metadata, limit=limit
            )
            return reciprocal_rank_fusion(dense, [], limit)
        parameters.update(
            self._location, terms=query_terms, embedding=unit_vector(embedding, self.dimensions)
        )
        self._ensure_schema()
        result = self._client.query(
            _HYBRID_SQL.replace("@passages@", self._passage_source),
            parameters=parameters,
            settings=_SELECT_SETTINGS,
        )
        lanes: tuple[list[SearchHit], list[SearchHit]] = ([], [])
        for *row, lane in result.result_rows:
            lanes[lane].append(_hit(row))
        for hits in lanes:
            hits.sort(key=lambda hit: (-hit.score, hit.chunk.chunk_id))
        return reciprocal_rank_fusion(*lanes, limit)

    def _ensure_schema(self) -> None:
        if not self._validated:
            self.validate_schema()

    def _search(self, query: str, parameters: dict[str, Any]) -> list[SearchHit]:
        self._ensure_schema()
        rows = self._client.query(
            query.replace("@passages@", self._passage_source),
            parameters=parameters,
            settings=_SELECT_SETTINGS,
        )
        return [_hit(row) for row in rows.result_rows]

dimensions property

Expected number of components in each vector.

embedding_model property

Identifier of the embedding model associated with this table.

initialize()

Create an empty table if absent, then validate its schema identity.

Source code in src/clickhouserag/store.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
def initialize(self) -> None:
    """Create an empty table if absent, then validate its schema identity."""
    # The qualified name uses the validated identifier grammar. The comment
    # and dimension are passed through driver binding.
    self._client.command(
        f"""
        CREATE TABLE IF NOT EXISTS {self._table} (
            namespace String,
            chunk_id String,
            document_id String,
            text String,
            source String,
            revision UInt64,
            metadata Map(String, String),
            embedding Array(Float32),
            lexical_terms Array(String),
            INDEX lexical_idx(lexical_terms) TYPE text(tokenizer = 'array'),
            CONSTRAINT embedding_dimensions CHECK length(embedding) = %(dimensions)s
        )
        ENGINE = ReplacingMergeTree(revision)
        ORDER BY (namespace, chunk_id)
        COMMENT %(manifest)s
        """,
        parameters={"dimensions": self._dimensions, "manifest": self._manifest},
    )
    self.validate_schema()

insert(chunks, *, namespace='default')

Insert one validated batch of up to 10,000 embedded passages.

Repeat the same payload at the same revision to retry a failed write. Use a strictly greater revision for replacements of a given namespace/chunk ID.

Source code in src/clickhouserag/store.py
155
156
157
158
159
160
161
def insert(self, chunks: Iterable[EmbeddedChunk], *, namespace: str = "default") -> int:
    """Insert one validated batch of up to 10,000 embedded passages.

    Repeat the same payload at the same revision to retry a failed write. Use
    a strictly greater revision for replacements of a given namespace/chunk ID.
    """
    return self._insert_rows(self._prepare_rows(chunks, namespace))

search_hybrid(query, embedding, *, namespace='default', metadata=None, limit=10, candidates=None)

Fuse independently ranked vector and lexical candidates.

Source code in src/clickhouserag/store.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def search_hybrid(
    self,
    query: str,
    embedding: Sequence[float],
    *,
    namespace: str = "default",
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
    candidates: int | None = None,
) -> list[SearchHit]:
    """Fuse independently ranked vector and lexical candidates."""
    integer(limit, "limit", _MAX_LIMIT)
    if candidates is None:
        candidates = min(limit * 4, _MAX_LIMIT)
    integer(candidates, "candidates", _MAX_LIMIT)
    if candidates < limit:
        raise ValueError("candidates must be at least limit")
    parameters = _scope(namespace, metadata, candidates)
    query_terms = _query_terms(query)
    if not query_terms:
        dense = self.search_vector(
            embedding, namespace=namespace, metadata=metadata, limit=limit
        )
        return reciprocal_rank_fusion(dense, [], limit)
    parameters.update(
        self._location, terms=query_terms, embedding=unit_vector(embedding, self.dimensions)
    )
    self._ensure_schema()
    result = self._client.query(
        _HYBRID_SQL.replace("@passages@", self._passage_source),
        parameters=parameters,
        settings=_SELECT_SETTINGS,
    )
    lanes: tuple[list[SearchHit], list[SearchHit]] = ([], [])
    for *row, lane in result.result_rows:
        lanes[lane].append(_hit(row))
    for hits in lanes:
        hits.sort(key=lambda hit: (-hit.score, hit.chunk.chunk_id))
    return reciprocal_rank_fusion(*lanes, limit)

search_lexical(query, *, namespace='default', metadata=None, limit=10)

Rank matching passages by distinct query token coverage.

Source code in src/clickhouserag/store.py
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def search_lexical(
    self,
    query: str,
    *,
    namespace: str = "default",
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
) -> list[SearchHit]:
    """Rank matching passages by distinct query token coverage."""
    parameters = _scope(namespace, metadata, limit)
    query_terms = _query_terms(query)
    if not query_terms:
        return []
    parameters.update(self._location, terms=query_terms)
    return self._search(
        """
        SELECT chunk_id, document_id, text, source, revision, metadata,
            length(arrayIntersect(lexical_terms, {terms:Array(String)})) AS score
        FROM @passages@
        WHERE namespace = {namespace:String}
            AND arrayAll(pair -> mapContains(metadata, pair.1)
                AND metadata[pair.1] = pair.2, {filters:Array(Tuple(String, String))})
            AND hasAnyTokens(lexical_terms, {terms:Array(String)})
        ORDER BY score DESC, chunk_id ASC
        LIMIT {limit:UInt32}
        """,
        parameters,
    )

search_vector(embedding, *, namespace='default', metadata=None, limit=10)

Find the nearest passages by exact cosine similarity (higher is closer).

Source code in src/clickhouserag/store.py
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def search_vector(
    self,
    embedding: Sequence[float],
    *,
    namespace: str = "default",
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
) -> list[SearchHit]:
    """Find the nearest passages by exact cosine similarity (higher is closer)."""
    parameters = _scope(namespace, metadata, limit)
    parameters.update(self._location)
    parameters["embedding"] = unit_vector(embedding, self.dimensions)
    return self._search(
        """
        SELECT chunk_id, document_id, text, source, revision, metadata,
            1 - cosineDistance(embedding, {embedding:Array(Float32)}) AS score
        FROM @passages@
        WHERE namespace = {namespace:String}
            AND arrayAll(pair -> mapContains(metadata, pair.1)
                AND metadata[pair.1] = pair.2, {filters:Array(Tuple(String, String))})
        ORDER BY score DESC, chunk_id ASC
        LIMIT {limit:UInt32}
        """,
        parameters,
    )

validate_schema()

Verify that the existing table belongs to this schema and embedding model.

Source code in src/clickhouserag/store.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def validate_schema(self) -> None:
    """Verify that the existing table belongs to this schema and embedding model."""
    self._validated = False
    result = self._client.query(
        """
        SELECT comment
        FROM system.tables
        WHERE database = {database:String} AND name = {table:String}
        """,
        parameters=self._location,
    )
    if result.result_rows != [(self._manifest,)]:
        raise SchemaError(
            "ClickHouse table is missing or does not match the store schema and embedding model"
        )
    self._validated = True

clickhouserag.Chunk dataclass

A passage with a stable identity, provenance and a write revision.

Source code in src/clickhouserag/models.py
11
12
13
14
15
16
17
18
19
20
@dataclass(frozen=True, slots=True)
class Chunk:
    """A passage with a stable identity, provenance and a write revision."""

    chunk_id: str
    document_id: str
    text: str
    source: str
    revision: int = 1
    metadata: Mapping[str, str] = field(default_factory=dict)

clickhouserag.EmbeddedChunk dataclass

A passage paired with its document embedding.

Source code in src/clickhouserag/models.py
23
24
25
26
27
28
@dataclass(frozen=True, slots=True)
class EmbeddedChunk:
    """A passage paired with its document embedding."""

    chunk: Chunk
    embedding: Sequence[float]

clickhouserag.SearchHit dataclass

A passage and its score, ordered by the selected search strategy.

Source code in src/clickhouserag/models.py
31
32
33
34
35
36
@dataclass(frozen=True, slots=True)
class SearchHit:
    """A passage and its score, ordered by the selected search strategy."""

    chunk: Chunk
    score: float

clickhouserag.SchemaError

Bases: RuntimeError

The configured ClickHouse table is missing or incompatible.

Source code in src/clickhouserag/models.py
7
8
class SchemaError(RuntimeError):
    """The configured ClickHouse table is missing or incompatible."""

clickhouserag.EmbeddingProvider

Bases: Protocol

Application-supplied encoder with separate document and query methods.

Source code in src/clickhouserag/retriever.py
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class EmbeddingProvider(Protocol):
    """Application-supplied encoder with separate document and query methods."""

    @property
    def model_id(self) -> str:
        """Identity of the model and encoding configuration."""
        ...

    def embed_documents(self, texts: Sequence[str]) -> Sequence[Sequence[float]]:
        """Encode the passages in input order."""
        ...

    def embed_query(self, text: str) -> Sequence[float]:
        """Encode a search query in the same vector space."""
        ...

model_id property

Identity of the model and encoding configuration.

embed_documents(texts)

Encode the passages in input order.

Source code in src/clickhouserag/retriever.py
19
20
21
def embed_documents(self, texts: Sequence[str]) -> Sequence[Sequence[float]]:
    """Encode the passages in input order."""
    ...

embed_query(text)

Encode a search query in the same vector space.

Source code in src/clickhouserag/retriever.py
23
24
25
def embed_query(self, text: str) -> Sequence[float]:
    """Encode a search query in the same vector space."""
    ...

clickhouserag.Retriever

Encode and retrieve passages using a specific embedding provider.

Source code in src/clickhouserag/retriever.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
class Retriever:
    """Encode and retrieve passages using a specific embedding provider."""

    def __init__(self, store: ClickHouseStore, provider: EmbeddingProvider) -> None:
        if store.embedding_model != provider.model_id:
            raise ValueError("provider model_id must match the store embedding_model")
        self._store = store
        self._provider = provider

    def index(self, chunks: Sequence[Chunk], *, namespace: str = "default") -> int:
        """Embed and insert a batch of passages."""
        nonblank(namespace, "namespace")
        return self._store.insert(self._embed(chunks), namespace=namespace)

    def _embed(self, chunks: Sequence[Chunk]) -> list[EmbeddedChunk]:
        if not chunks:
            return []
        if len(chunks) > _MAX_BATCH:
            raise ValueError(f"index accepts at most {_MAX_BATCH} chunks per batch")
        for chunk in chunks:
            chunk_fields(chunk)
        if len({chunk.chunk_id for chunk in chunks}) != len(chunks):
            raise ValueError("chunk IDs must be unique within a batch")
        vectors = self._provider.embed_documents([chunk.text for chunk in chunks])
        if len(vectors) != len(chunks):
            raise ValueError("embedding provider returned the wrong number of document vectors")
        return [EmbeddedChunk(chunk, vector) for chunk, vector in zip(chunks, vectors, strict=True)]

    def search(
        self,
        query: str,
        *,
        namespace: str = "default",
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
        hybrid: bool = True,
    ) -> list[SearchHit]:
        """Embed a query and perform hybrid or exact vector retrieval."""
        nonblank(query, "query")
        embedding = self._provider.embed_query(query)
        if hybrid:
            return self._store.search_hybrid(
                query, embedding, namespace=namespace, metadata=metadata, limit=limit
            )
        return self._store.search_vector(
            embedding, namespace=namespace, metadata=metadata, limit=limit
        )

index(chunks, *, namespace='default')

Embed and insert a batch of passages.

Source code in src/clickhouserag/retriever.py
37
38
39
40
def index(self, chunks: Sequence[Chunk], *, namespace: str = "default") -> int:
    """Embed and insert a batch of passages."""
    nonblank(namespace, "namespace")
    return self._store.insert(self._embed(chunks), namespace=namespace)

search(query, *, namespace='default', metadata=None, limit=10, hybrid=True)

Embed a query and perform hybrid or exact vector retrieval.

Source code in src/clickhouserag/retriever.py
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def search(
    self,
    query: str,
    *,
    namespace: str = "default",
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
    hybrid: bool = True,
) -> list[SearchHit]:
    """Embed a query and perform hybrid or exact vector retrieval."""
    nonblank(query, "query")
    embedding = self._provider.embed_query(query)
    if hybrid:
        return self._store.search_hybrid(
            query, embedding, namespace=namespace, metadata=metadata, limit=limit
        )
    return self._store.search_vector(
        embedding, namespace=namespace, metadata=metadata, limit=limit
    )

Framework and graph

clickhouserag.RAG

Index text documents, retrieve cited evidence and optionally ask a model.

Source code in src/clickhouserag/rag.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
class RAG:
    """Index text documents, retrieve cited evidence and optionally ask a model."""

    def __init__(
        self,
        client: Client,
        provider: EmbeddingProvider,
        *,
        dimensions: int,
        table: str = "rag_passages",
        database: str = "default",
        namespace: str = "default",
        extractor: RelationExtractor | None = None,
        generator: AnswerProvider | None = None,
        graph: bool = False,
        schema: tuple[Predicate, ...] | None = None,
        planner: KnowledgePlanner | None = None,
        search_planner: SearchPlanner | None = None,
        reranker: Reranker | None = None,
        rerank_candidates: int = 32,
        chunker: Chunker | None = None,
        index_revision: str | None = None,
        chunk_size: int = 1600,
        chunk_overlap: int = 160,
    ) -> None:
        nonblank(namespace, "namespace")
        integer(chunk_size, "chunk_size", 100_000)
        integer(rerank_candidates, "rerank_candidates", _MAX_LIMIT)
        if type(chunk_overlap) is not int or not 0 <= chunk_overlap < chunk_size:
            raise ValueError("chunk_overlap must be a nonnegative integer less than chunk_size")
        if chunker is not None and index_revision is None:
            raise ValueError("a custom chunker requires an index_revision")
        if index_revision is not None:
            nonblank(index_revision, "index_revision")
        self.passages = DocumentStore(
            client,
            table=table,
            database=database,
            embedding_model=provider.model_id,
            dimensions=dimensions,
        )
        self.graph = (
            GraphStore(self.passages)
            if graph or extractor is not None or schema is not None
            else None
        )
        self._knowledge = (
            Knowledge(self.graph, schema) if self.graph is not None and schema is not None else None
        )
        self._retriever = Retriever(self.passages, provider)
        self._namespace = namespace
        if planner is not None and schema is None:
            raise ValueError("a knowledge planner requires a schema")
        self._schema = schema or ()
        self._planner = planner
        self._search_planner = search_planner
        self._extractor = extractor
        self._generator = generator
        self._reranker = reranker
        self._rerank_candidates = rerank_candidates
        self._chunker = chunker
        self._index_revision = index_revision
        self._chunk_size = chunk_size
        self._chunk_overlap = chunk_overlap

    def initialize(self) -> None:
        """Provision the managed passage and fact table."""
        self.passages.initialize()

    def index(self, document: Document) -> int:
        """Publish this document's full contents; return the number of staged passages."""
        nonblank(document.document_id, "document_id")
        nonblank(document.source, "source")
        nonblank(document.text, "text")
        metadata = string_map(document.metadata)
        text = document.text.strip()
        config = [
            self._chunk_size,
            self._chunk_overlap,
            self._extractor is not None,
        ]
        if self._index_revision is not None:
            config.extend((self._index_revision, self._chunker is not None))
        identity = hashlib.sha256(
            json.dumps(
                [
                    text,
                    document.source,
                    metadata,
                    *config,
                ],
                sort_keys=True,
                separators=(",", ":"),
            ).encode()
        ).hexdigest()
        revision = self.passages.next_revision(
            self._namespace, document.document_id, identity, document.revision
        )
        if revision is None:
            return 0
        chunks = self._chunks(document, text, metadata, revision)
        rows = self.passages._prepare_rows(self._retriever._embed(chunks), self._namespace)
        facts = [self._facts(chunk) for chunk in chunks]
        self.passages.publish(
            self._namespace, document.document_id, revision, identity, rows, facts
        )
        return len(rows)

    def delete(self, document_id: str, *, revision: int | None = None) -> bool:
        """Hide a document and its facts by publishing a tombstone."""
        nonblank(document_id, "document_id")
        chosen = self.passages.next_revision(self._namespace, document_id, "", revision)
        if chosen is None:
            return False
        self.passages.publish(self._namespace, document_id, chosen, "", [], [])
        return True

    def _facts(self, chunk: Chunk) -> list[_Fact]:
        if self._extractor is None:
            return []
        extracted = self._extractor.extract(chunk)
        if len(extracted) > _MAX_BATCH:
            raise ValueError(f"a passage may contain at most {_MAX_BATCH} relations")
        return sorted(
            _document_fact(
                Relation(
                    item.subject,
                    item.predicate,
                    item.object,
                    chunk.chunk_id,
                    chunk.revision,
                    item.subject_type,
                    item.object_type,
                )
            )
            for item in extracted
        )

    def retrieve(
        self,
        query: str,
        *,
        mode: _Mode = "hybrid",
        metadata: Mapping[str, str] | None = None,
        seeds: Sequence[str] | None = None,
        limit: int = 10,
        depth: int = 2,
    ) -> Context:
        """Retrieve passages and whole cited paths within the passage limit."""
        nonblank(query, "query")
        integer(limit, "limit", _MAX_LIMIT)
        if mode not in (*_PASSAGE_MODES, "global"):
            raise ValueError("unknown retrieval mode")
        if seeds is not None and mode not in ("graph", "mixed"):
            raise ValueError("explicit seeds require graph or mixed mode")
        if mode == "global":
            return self.overview(query, metadata=metadata, limit=limit)
        if mode == "graph" and self.graph is None:
            raise ValueError("graph mode requires graph configuration")
        filters = string_map(metadata) if metadata is not None else None
        scope: _Scope = {"namespace": self._namespace, "metadata": filters}
        candidate_limit = (
            max(limit, self._rerank_candidates) if self._reranker is not None else limit
        )
        hits: list[SearchHit] = []
        if mode == "lexical":
            hits = self.passages.search_lexical(query, limit=candidate_limit, **scope)
        elif mode != "graph":
            hits = self._retriever.search(
                query, limit=candidate_limit, hybrid=mode != "dense", **scope
            )
        paths = []
        if self.graph is not None and mode in ("graph", "mixed"):
            selected = self.graph.seed_entities(query, **scope) if seeds is None else seeds
            paths = self.graph.expand(selected, depth=depth, limit=min(limit * 4, 1000), **scope)
            hits, paths = self._visible_evidence(hits, paths)
        if self._reranker is not None and len(hits) > 1:
            order = tuple(self._reranker.rank(query, tuple(hit.chunk for hit in hits)))
            if (
                len(order) != len(hits)
                or any(type(index) is not int for index in order)
                or set(order) != set(range(len(hits)))
            ):
                raise ValueError("reranker must return a permutation of the candidate indices")
            hits, paths = self._visible_evidence([hits[index] for index in order], paths)
        return _context(hits, paths, limit)

    def retrieve_many(
        self,
        queries: Sequence[SearchQuery],
        *,
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
        candidates_per_query: int = 16,
    ) -> Context:
        """Fuse scoped searches and retain complete graph paths under one passage budget."""
        integer(len(queries), "number of searches", _MAX_QUERY_LANES)
        integer(limit, "limit", _MAX_LIMIT)
        integer(candidates_per_query, "candidates_per_query", _MAX_LIMIT)
        filters = string_map(metadata) if metadata is not None else None
        searches = tuple(dict.fromkeys(queries))
        if any(query.mode not in _PASSAGE_MODES for query in searches):
            raise ValueError("search plans require passage retrieval modes")
        passages: dict[str, Chunk] = {}
        ranks: list[list[str]] = []
        paths: dict[tuple[str, tuple[tuple[Relation, str], ...]], GraphPath] = {}
        for query in searches:
            result = self.retrieve(
                query.text,
                mode=query.mode,
                metadata=filters,
                seeds=query.seeds,
                limit=candidates_per_query,
                depth=query.depth,
            )
            ranks.append([chunk.chunk_id for chunk in result.passages])
            passages.update((chunk.chunk_id, chunk) for chunk in result.passages)
            for path in result.paths:
                key = (
                    path.seed,
                    tuple((step.relation, step.chunk.chunk_id) for step in path.steps),
                )
                paths.setdefault(key, path)
        hits = [SearchHit(passages[key], score) for key, score in fused_ranks(ranks)]
        hits, visible_paths = self._visible_evidence(hits, tuple(paths.values()))
        return _context(hits, visible_paths, limit)

    def ask_many(
        self,
        question: str,
        queries: Sequence[SearchQuery],
        *,
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
        candidates_per_query: int = 16,
    ) -> Answer:
        """Answer the original question using evidence fused from several searches."""
        nonblank(question, "question")
        if self._generator is None:
            raise ValueError("ask_many requires an answer provider")
        context = self.retrieve_many(
            queries, metadata=metadata, limit=limit, candidates_per_query=candidates_per_query
        )
        return self._answer(question, context)

    def ask_planned(
        self,
        question: str,
        *,
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
        candidates_per_query: int = 16,
    ) -> Answer:
        """Plan searches first and answer from their shared scoped evidence."""
        nonblank(question, "question")
        integer(limit, "limit", _MAX_LIMIT)
        integer(candidates_per_query, "candidates_per_query", _MAX_LIMIT)
        filters = string_map(metadata) if metadata is not None else None
        if self._search_planner is None or self._generator is None:
            raise ValueError("ask_planned requires a search planner and an answer provider")
        modes = _PASSAGE_MODES if self.graph is not None else _PASSAGE_MODES[:3]
        queries = self._search_planner.plan_search(question, modes)
        if any(query.mode not in modes for query in queries):
            raise ValueError("search planner returned an unavailable mode")
        return self.ask_many(
            question,
            queries or (SearchQuery(question),),
            metadata=filters,
            limit=limit,
            candidates_per_query=candidates_per_query,
        )

    def overview(
        self,
        query: str,
        *,
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
        max_facts: int = _DEFAULT_EDGE_BUDGET,
    ) -> Context:
        """Return cited representatives of connected groups across the scoped corpus."""
        nonblank(query, "query")
        integer(limit, "limit", _MAX_LIMIT)
        integer(max_facts, "max_facts", _MAX_EDGE_BUDGET)
        if self.graph is None:
            raise ValueError("global mode requires graph configuration")
        facts = self.graph._corpus_facts(self._namespace, metadata, max_facts + 1)
        communities = select_communities(query, facts[:max_facts], limit=limit)
        passages = {
            chunk.chunk_id: chunk
            for row in zip_longest(*(item.passages for item in communities))
            for chunk in row
            if chunk is not None
        }
        return Context(
            tuple(passages.values()), communities=communities, truncated=len(facts) > max_facts
        )

    def reason(
        self,
        query: KnowledgeQuery | KnowledgeIntersection,
        *,
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
    ) -> KnowledgeResult:
        """Resolve a typed knowledge plan in the same collection as text retrieval."""
        if self._knowledge is None:
            raise ValueError("reason requires a knowledge schema")
        result = self._knowledge.solve(
            query, namespace=self._namespace, metadata=metadata, limit=limit
        )
        chunks = [step.chunk for path in result.evidence for step in path.steps]
        visible = self.passages.visible_chunks(self._namespace, chunks)
        evidence = [
            (leg, path)
            for leg, path in zip(result.evidence_legs, result.evidence, strict=True)
            if all((step.chunk.chunk_id, step.chunk.revision) in visible for step in path.steps)
        ]
        proof: defaultdict[str, set[int]] = defaultdict(set)
        for leg, path in evidence:
            proof[path.target].add(leg)
        targets = tuple(
            target for target in result.entities if len(proof[target]) == result.required_legs
        )
        target_set = set(targets)
        return KnowledgeResult(
            targets,
            tuple(path for _, path in evidence if path.target in target_set),
            tuple(leg for leg, path in evidence if path.target in target_set),
            result.required_legs,
        )

    def ask_knowledge(
        self,
        question: str,
        *,
        plan: KnowledgeQuery | KnowledgeIntersection | None = None,
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
    ) -> Answer:
        """Execute typed reasoning and answer with cited proof, optionally using a supplied plan."""
        nonblank(question, "question")
        integer(limit, "limit", _MAX_LIMIT)
        if self._generator is None:
            raise ValueError("ask_knowledge requires an answer provider")
        filters = string_map(metadata) if metadata is not None else None
        if plan is None:
            if self._planner is None:
                raise ValueError("ask_knowledge requires a knowledge planner or an explicit plan")
            proposed = self._planner.plan(question, self._schema)
            try:
                result = self.reason(proposed, metadata=filters, limit=limit) if proposed else None
            except KnowledgePlanError:
                result = None
            if result is None:
                return Answer(None, (), Context(()))
        else:
            result = self.reason(plan, metadata=filters, limit=limit)
        context = _knowledge_context(result, limit)
        answer = self._answer(question, context)
        if answer.text is None:
            return answer
        cited = {chunk.chunk_id for chunk in answer.citations}
        covered: defaultdict[str, set[int]] = defaultdict(set)
        for leg, path in zip(result.evidence_legs, result.evidence, strict=True):
            if all(step.chunk.chunk_id in cited for step in path.steps):
                covered[path.target].add(leg)
        if any(len(proof) == result.required_legs for proof in covered.values()):
            return answer
        return Answer(None, (), context)

    def ask(
        self,
        question: str,
        *,
        mode: _Mode = "hybrid",
        metadata: Mapping[str, str] | None = None,
        seeds: Sequence[str] | None = None,
        limit: int = 10,
        depth: int = 2,
    ) -> Answer:
        """Generate with evidence and reject citation IDs outside the context."""
        if self._generator is None:
            raise ValueError("ask requires an answer provider")
        context = self.retrieve(
            question, mode=mode, metadata=metadata, seeds=seeds, limit=limit, depth=depth
        )
        return self._answer(question, context)

    def _answer(self, question: str, context: Context) -> Answer:
        if self._generator is None:
            raise ValueError("asking a question requires an answer provider")
        if not context.passages:
            return Answer(None, (), context)
        result = self._generator.generate(question, context)
        if result.text is None:
            if result.citation_ids:
                raise ValueError("an abstention must not have citations")
            return Answer(None, (), context)
        nonblank(result.text, "answer")
        known = {chunk.chunk_id: chunk for chunk in context.passages}
        if (
            isinstance(result.citation_ids, str)
            or not result.citation_ids
            or any(key not in known for key in result.citation_ids)
        ):
            raise ValueError("answer provider must cite IDs from the supplied context")
        return Answer(
            result.text, tuple(known[key] for key in dict.fromkeys(result.citation_ids)), context
        )

    def _visible_evidence(
        self, hits: list[SearchHit], paths: Sequence[GraphPath]
    ) -> tuple[list[SearchHit], list[GraphPath]]:
        chunks = [hit.chunk for hit in hits]
        chunks.extend(step.chunk for path in paths for step in path.steps)
        visible = self.passages.visible_chunks(self._namespace, chunks)
        return (
            [hit for hit in hits if (hit.chunk.chunk_id, hit.chunk.revision) in visible],
            [
                path
                for path in paths
                if all((step.chunk.chunk_id, step.chunk.revision) in visible for step in path.steps)
            ],
        )

    def _chunks(
        self, document: Document, text: str, metadata: Mapping[str, str], revision: int
    ) -> list[Chunk]:
        if self._chunker is None:
            located = self._character_chunks(text)
        else:
            parts = self._chunker.split(text)
            if isinstance(parts, (str, bytes)):
                raise ValueError("chunker must return an iterable of passages, not a string")
            located = enumerate(parts)
        chunks = []
        for offset, part in located:
            nonblank(part, "chunk text")
            if len(chunks) == _MAX_BATCH:
                raise ValueError(f"document produces more than {_MAX_BATCH} chunks")
            key = hashlib.sha256(
                f"{document.document_id}\0{revision}\0{offset}".encode()
            ).hexdigest()
            chunks.append(
                Chunk(key, document.document_id, part, document.source, revision, metadata)
            )
        if not chunks:
            raise ValueError("chunker must produce at least one passage")
        return chunks

    def _character_chunks(self, text: str) -> Iterable[tuple[int, str]]:
        for start in range(0, len(text), self._chunk_size - self._chunk_overlap):
            part = text[start : start + self._chunk_size].strip()
            if part:
                yield start, part
            if start + self._chunk_size >= len(text):
                break

ask(question, *, mode='hybrid', metadata=None, seeds=None, limit=10, depth=2)

Generate with evidence and reject citation IDs outside the context.

Source code in src/clickhouserag/rag.py
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
def ask(
    self,
    question: str,
    *,
    mode: _Mode = "hybrid",
    metadata: Mapping[str, str] | None = None,
    seeds: Sequence[str] | None = None,
    limit: int = 10,
    depth: int = 2,
) -> Answer:
    """Generate with evidence and reject citation IDs outside the context."""
    if self._generator is None:
        raise ValueError("ask requires an answer provider")
    context = self.retrieve(
        question, mode=mode, metadata=metadata, seeds=seeds, limit=limit, depth=depth
    )
    return self._answer(question, context)

ask_knowledge(question, *, plan=None, metadata=None, limit=10)

Execute typed reasoning and answer with cited proof, optionally using a supplied plan.

Source code in src/clickhouserag/rag.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
def ask_knowledge(
    self,
    question: str,
    *,
    plan: KnowledgeQuery | KnowledgeIntersection | None = None,
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
) -> Answer:
    """Execute typed reasoning and answer with cited proof, optionally using a supplied plan."""
    nonblank(question, "question")
    integer(limit, "limit", _MAX_LIMIT)
    if self._generator is None:
        raise ValueError("ask_knowledge requires an answer provider")
    filters = string_map(metadata) if metadata is not None else None
    if plan is None:
        if self._planner is None:
            raise ValueError("ask_knowledge requires a knowledge planner or an explicit plan")
        proposed = self._planner.plan(question, self._schema)
        try:
            result = self.reason(proposed, metadata=filters, limit=limit) if proposed else None
        except KnowledgePlanError:
            result = None
        if result is None:
            return Answer(None, (), Context(()))
    else:
        result = self.reason(plan, metadata=filters, limit=limit)
    context = _knowledge_context(result, limit)
    answer = self._answer(question, context)
    if answer.text is None:
        return answer
    cited = {chunk.chunk_id for chunk in answer.citations}
    covered: defaultdict[str, set[int]] = defaultdict(set)
    for leg, path in zip(result.evidence_legs, result.evidence, strict=True):
        if all(step.chunk.chunk_id in cited for step in path.steps):
            covered[path.target].add(leg)
    if any(len(proof) == result.required_legs for proof in covered.values()):
        return answer
    return Answer(None, (), context)

ask_many(question, queries, *, metadata=None, limit=10, candidates_per_query=16)

Answer the original question using evidence fused from several searches.

Source code in src/clickhouserag/rag.py
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
def ask_many(
    self,
    question: str,
    queries: Sequence[SearchQuery],
    *,
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
    candidates_per_query: int = 16,
) -> Answer:
    """Answer the original question using evidence fused from several searches."""
    nonblank(question, "question")
    if self._generator is None:
        raise ValueError("ask_many requires an answer provider")
    context = self.retrieve_many(
        queries, metadata=metadata, limit=limit, candidates_per_query=candidates_per_query
    )
    return self._answer(question, context)

ask_planned(question, *, metadata=None, limit=10, candidates_per_query=16)

Plan searches first and answer from their shared scoped evidence.

Source code in src/clickhouserag/rag.py
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
def ask_planned(
    self,
    question: str,
    *,
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
    candidates_per_query: int = 16,
) -> Answer:
    """Plan searches first and answer from their shared scoped evidence."""
    nonblank(question, "question")
    integer(limit, "limit", _MAX_LIMIT)
    integer(candidates_per_query, "candidates_per_query", _MAX_LIMIT)
    filters = string_map(metadata) if metadata is not None else None
    if self._search_planner is None or self._generator is None:
        raise ValueError("ask_planned requires a search planner and an answer provider")
    modes = _PASSAGE_MODES if self.graph is not None else _PASSAGE_MODES[:3]
    queries = self._search_planner.plan_search(question, modes)
    if any(query.mode not in modes for query in queries):
        raise ValueError("search planner returned an unavailable mode")
    return self.ask_many(
        question,
        queries or (SearchQuery(question),),
        metadata=filters,
        limit=limit,
        candidates_per_query=candidates_per_query,
    )

delete(document_id, *, revision=None)

Hide a document and its facts by publishing a tombstone.

Source code in src/clickhouserag/rag.py
254
255
256
257
258
259
260
261
def delete(self, document_id: str, *, revision: int | None = None) -> bool:
    """Hide a document and its facts by publishing a tombstone."""
    nonblank(document_id, "document_id")
    chosen = self.passages.next_revision(self._namespace, document_id, "", revision)
    if chosen is None:
        return False
    self.passages.publish(self._namespace, document_id, chosen, "", [], [])
    return True

index(document)

Publish this document's full contents; return the number of staged passages.

Source code in src/clickhouserag/rag.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
def index(self, document: Document) -> int:
    """Publish this document's full contents; return the number of staged passages."""
    nonblank(document.document_id, "document_id")
    nonblank(document.source, "source")
    nonblank(document.text, "text")
    metadata = string_map(document.metadata)
    text = document.text.strip()
    config = [
        self._chunk_size,
        self._chunk_overlap,
        self._extractor is not None,
    ]
    if self._index_revision is not None:
        config.extend((self._index_revision, self._chunker is not None))
    identity = hashlib.sha256(
        json.dumps(
            [
                text,
                document.source,
                metadata,
                *config,
            ],
            sort_keys=True,
            separators=(",", ":"),
        ).encode()
    ).hexdigest()
    revision = self.passages.next_revision(
        self._namespace, document.document_id, identity, document.revision
    )
    if revision is None:
        return 0
    chunks = self._chunks(document, text, metadata, revision)
    rows = self.passages._prepare_rows(self._retriever._embed(chunks), self._namespace)
    facts = [self._facts(chunk) for chunk in chunks]
    self.passages.publish(
        self._namespace, document.document_id, revision, identity, rows, facts
    )
    return len(rows)

initialize()

Provision the managed passage and fact table.

Source code in src/clickhouserag/rag.py
211
212
213
def initialize(self) -> None:
    """Provision the managed passage and fact table."""
    self.passages.initialize()

overview(query, *, metadata=None, limit=10, max_facts=_DEFAULT_EDGE_BUDGET)

Return cited representatives of connected groups across the scoped corpus.

Source code in src/clickhouserag/rag.py
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
def overview(
    self,
    query: str,
    *,
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
    max_facts: int = _DEFAULT_EDGE_BUDGET,
) -> Context:
    """Return cited representatives of connected groups across the scoped corpus."""
    nonblank(query, "query")
    integer(limit, "limit", _MAX_LIMIT)
    integer(max_facts, "max_facts", _MAX_EDGE_BUDGET)
    if self.graph is None:
        raise ValueError("global mode requires graph configuration")
    facts = self.graph._corpus_facts(self._namespace, metadata, max_facts + 1)
    communities = select_communities(query, facts[:max_facts], limit=limit)
    passages = {
        chunk.chunk_id: chunk
        for row in zip_longest(*(item.passages for item in communities))
        for chunk in row
        if chunk is not None
    }
    return Context(
        tuple(passages.values()), communities=communities, truncated=len(facts) > max_facts
    )

reason(query, *, metadata=None, limit=10)

Resolve a typed knowledge plan in the same collection as text retrieval.

Source code in src/clickhouserag/rag.py
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
def reason(
    self,
    query: KnowledgeQuery | KnowledgeIntersection,
    *,
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
) -> KnowledgeResult:
    """Resolve a typed knowledge plan in the same collection as text retrieval."""
    if self._knowledge is None:
        raise ValueError("reason requires a knowledge schema")
    result = self._knowledge.solve(
        query, namespace=self._namespace, metadata=metadata, limit=limit
    )
    chunks = [step.chunk for path in result.evidence for step in path.steps]
    visible = self.passages.visible_chunks(self._namespace, chunks)
    evidence = [
        (leg, path)
        for leg, path in zip(result.evidence_legs, result.evidence, strict=True)
        if all((step.chunk.chunk_id, step.chunk.revision) in visible for step in path.steps)
    ]
    proof: defaultdict[str, set[int]] = defaultdict(set)
    for leg, path in evidence:
        proof[path.target].add(leg)
    targets = tuple(
        target for target in result.entities if len(proof[target]) == result.required_legs
    )
    target_set = set(targets)
    return KnowledgeResult(
        targets,
        tuple(path for _, path in evidence if path.target in target_set),
        tuple(leg for leg, path in evidence if path.target in target_set),
        result.required_legs,
    )

retrieve(query, *, mode='hybrid', metadata=None, seeds=None, limit=10, depth=2)

Retrieve passages and whole cited paths within the passage limit.

Source code in src/clickhouserag/rag.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def retrieve(
    self,
    query: str,
    *,
    mode: _Mode = "hybrid",
    metadata: Mapping[str, str] | None = None,
    seeds: Sequence[str] | None = None,
    limit: int = 10,
    depth: int = 2,
) -> Context:
    """Retrieve passages and whole cited paths within the passage limit."""
    nonblank(query, "query")
    integer(limit, "limit", _MAX_LIMIT)
    if mode not in (*_PASSAGE_MODES, "global"):
        raise ValueError("unknown retrieval mode")
    if seeds is not None and mode not in ("graph", "mixed"):
        raise ValueError("explicit seeds require graph or mixed mode")
    if mode == "global":
        return self.overview(query, metadata=metadata, limit=limit)
    if mode == "graph" and self.graph is None:
        raise ValueError("graph mode requires graph configuration")
    filters = string_map(metadata) if metadata is not None else None
    scope: _Scope = {"namespace": self._namespace, "metadata": filters}
    candidate_limit = (
        max(limit, self._rerank_candidates) if self._reranker is not None else limit
    )
    hits: list[SearchHit] = []
    if mode == "lexical":
        hits = self.passages.search_lexical(query, limit=candidate_limit, **scope)
    elif mode != "graph":
        hits = self._retriever.search(
            query, limit=candidate_limit, hybrid=mode != "dense", **scope
        )
    paths = []
    if self.graph is not None and mode in ("graph", "mixed"):
        selected = self.graph.seed_entities(query, **scope) if seeds is None else seeds
        paths = self.graph.expand(selected, depth=depth, limit=min(limit * 4, 1000), **scope)
        hits, paths = self._visible_evidence(hits, paths)
    if self._reranker is not None and len(hits) > 1:
        order = tuple(self._reranker.rank(query, tuple(hit.chunk for hit in hits)))
        if (
            len(order) != len(hits)
            or any(type(index) is not int for index in order)
            or set(order) != set(range(len(hits)))
        ):
            raise ValueError("reranker must return a permutation of the candidate indices")
        hits, paths = self._visible_evidence([hits[index] for index in order], paths)
    return _context(hits, paths, limit)

retrieve_many(queries, *, metadata=None, limit=10, candidates_per_query=16)

Fuse scoped searches and retain complete graph paths under one passage budget.

Source code in src/clickhouserag/rag.py
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
def retrieve_many(
    self,
    queries: Sequence[SearchQuery],
    *,
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
    candidates_per_query: int = 16,
) -> Context:
    """Fuse scoped searches and retain complete graph paths under one passage budget."""
    integer(len(queries), "number of searches", _MAX_QUERY_LANES)
    integer(limit, "limit", _MAX_LIMIT)
    integer(candidates_per_query, "candidates_per_query", _MAX_LIMIT)
    filters = string_map(metadata) if metadata is not None else None
    searches = tuple(dict.fromkeys(queries))
    if any(query.mode not in _PASSAGE_MODES for query in searches):
        raise ValueError("search plans require passage retrieval modes")
    passages: dict[str, Chunk] = {}
    ranks: list[list[str]] = []
    paths: dict[tuple[str, tuple[tuple[Relation, str], ...]], GraphPath] = {}
    for query in searches:
        result = self.retrieve(
            query.text,
            mode=query.mode,
            metadata=filters,
            seeds=query.seeds,
            limit=candidates_per_query,
            depth=query.depth,
        )
        ranks.append([chunk.chunk_id for chunk in result.passages])
        passages.update((chunk.chunk_id, chunk) for chunk in result.passages)
        for path in result.paths:
            key = (
                path.seed,
                tuple((step.relation, step.chunk.chunk_id) for step in path.steps),
            )
            paths.setdefault(key, path)
    hits = [SearchHit(passages[key], score) for key, score in fused_ranks(ranks)]
    hits, visible_paths = self._visible_evidence(hits, tuple(paths.values()))
    return _context(hits, visible_paths, limit)

clickhouserag.SearchPlanner

Bases: Protocol

Propose bounded search terms and retrieval modes for a question.

Source code in src/clickhouserag/rag.py
138
139
140
141
142
143
class SearchPlanner(Protocol):
    """Propose bounded search terms and retrieval modes for a question."""

    def plan_search(self, question: str, modes: tuple[_PassageMode, ...]) -> Sequence[SearchQuery]:
        """Return searches using one or more of the available modes."""
        ...

Return searches using one or more of the available modes.

Source code in src/clickhouserag/rag.py
141
142
143
def plan_search(self, question: str, modes: tuple[_PassageMode, ...]) -> Sequence[SearchQuery]:
    """Return searches using one or more of the available modes."""
    ...

clickhouserag.SearchQuery dataclass

One deterministic search in an application-composed retrieval plan.

Source code in src/clickhouserag/rag.py
128
129
130
131
132
133
134
135
@dataclass(frozen=True, slots=True)
class SearchQuery:
    """One deterministic search in an application-composed retrieval plan."""

    text: str
    mode: _PassageMode = "hybrid"
    seeds: tuple[str, ...] | None = None
    depth: int = 2

clickhouserag.GraphStore

Store cited relationships alongside a ClickHouse passage store.

Source code in src/clickhouserag/graph.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
class GraphStore:
    """Store cited relationships alongside a ClickHouse passage store."""

    def __init__(self, passages: ClickHouseStore, *, table: str | None = None) -> None:
        self._passages = passages
        self._managed = isinstance(passages, DocumentStore)
        if self._managed and table is not None:
            raise ValueError("a managed collection stores facts with its passages")
        self._client = passages._client
        database = passages._location["database"]
        passage_table = passages._location["table"]
        if table is None:
            table = passage_table if self._managed else f"{passage_table}_relations"
        self._qualified_table = f"{identifier(database)}.{identifier(table)}"
        self._location = {"database": database, "table": table, "passage_table": passage_table}
        self._manifest = f"{_GRAPH_SCHEMA}:{passage_table}"
        self._support_query = passages._passage_source.replace(
            "{table:Identifier}", "{passage_table:Identifier}"
        )
        self._edges_query = _DOCUMENT_EDGES if self._managed else _EDGE_TABLE
        self._validated = False

    def initialize(self) -> None:
        """Provision the companion table after the passage table has been created."""
        if self._managed:
            self.validate_schema()
            return
        self._passages.validate_schema()
        self._client.command(
            f"""
            CREATE TABLE IF NOT EXISTS {self._qualified_table} (
                namespace String,
                subject String,
                predicate String,
                object String,
                chunk_id String,
                revision UInt64,
                subject_type String,
                object_type String,
                subject_terms Array(String),
                object_terms Array(String),
                entity_terms Array(String) MATERIALIZED arrayConcat(subject_terms, object_terms),
                INDEX entity_idx(entity_terms) TYPE text(tokenizer = 'array')
            )
            ENGINE = ReplacingMergeTree
            ORDER BY (namespace, subject, predicate, object, chunk_id, revision,
                subject_type, object_type)
            COMMENT %(manifest)s
            """,
            parameters={"manifest": self._manifest},
        )
        self.validate_schema()

    def validate_schema(self) -> None:
        """Verify the companion table belongs to the configured passage table."""
        self._validated = False
        self._passages.validate_schema()
        if self._managed:
            self._validated = True
            return
        result = self._client.query(
            """
            SELECT comment FROM system.tables
            WHERE database = {database:String} AND name = {table:String}
            """,
            parameters=self._location,
        )
        if result.result_rows != [(self._manifest,)]:
            raise SchemaError("ClickHouse graph table is missing or belongs to another store")
        self._validated = True

    def insert(self, relations: Iterable[Relation], *, namespace: str = "default") -> int:
        """Validate and append a batch of up to 10,000 cited relationships."""
        if self._managed:
            raise ValueError("index a Document through RAG to write this managed collection")
        nonblank(namespace, "namespace")
        rows = {}
        for count, relation in enumerate(relations, start=1):
            if count > _MAX_BATCH:
                raise ValueError(f"insert accepts at most {_MAX_BATCH} relations per batch")
            row = _relation_row(namespace, relation)
            rows[row] = None
        if not rows:
            return 0
        self._ensure_schema()
        self._client.insert(
            self._qualified_table,
            [(*row, terms(row[1]), terms(row[3])) for row in rows],
            column_names=(
                "namespace",
                "subject",
                "predicate",
                "object",
                "chunk_id",
                "revision",
                "subject_type",
                "object_type",
                "subject_terms",
                "object_terms",
            ),
            settings={"async_insert": 0},
        )
        return len(rows)

    def expand(
        self,
        seeds: Sequence[str],
        *,
        namespace: str = "default",
        metadata: Mapping[str, str] | None = None,
        depth: int = 2,
        fanout: int = 8,
        limit: int = 64,
        predicates: Sequence[str] | None = None,
        entity_types: Sequence[str] | None = None,
    ) -> list[GraphPath]:
        """Return bounded simple paths, shortest first or matching a full predicate chain."""
        integer(depth, "depth", _MAX_DEPTH)
        integer(fanout, "fanout", _MAX_FANOUT)
        parameters = _scope(namespace, metadata, limit)
        ids = _entities(seeds, "seeds")
        chain = _labels_for_hops(predicates, depth, "predicates")
        types = _labels_for_hops(entity_types, depth + 1, "entity_types")
        frontier = [
            (GraphPath(seed, ()), frozenset(((seed, types[0] if types else ""),))) for seed in ids
        ]
        paths: list[GraphPath] = []
        for hop in range(depth):
            if not frontier:
                break
            constraints = {
                "predicate": chain[hop] if chain else "",
                "subject_type": types[hop] if types else "",
                "object_type": types[hop + 1] if types else "",
            }
            grouped = self._neighbors(
                {path.target for path, _ in frontier}, parameters, constraints, fanout
            )
            next_frontier = []
            for path, visited in frontier:
                for step in grouped[path.target]:
                    reached = (step.relation.object, types[hop + 1] if types else "")
                    if reached in visited or any(
                        previous.chunk.chunk_id == step.chunk.chunk_id
                        and previous.chunk.revision != step.chunk.revision
                        for previous in path.steps
                    ):
                        continue
                    next_path = GraphPath(path.seed, (*path.steps, step))
                    if not chain or hop == depth - 1:
                        paths.append(next_path)
                        if len(paths) == limit:
                            return paths
                    if hop < depth - 1 and len(next_frontier) < _MAX_FRONTIER:
                        next_frontier.append((next_path, visited | {reached}))
            frontier = next_frontier
        return paths

    def seed_entities(
        self,
        query: str,
        *,
        namespace: str = "default",
        metadata: Mapping[str, str] | None = None,
        limit: int = 8,
    ) -> list[str]:
        """Rank entity labels matching the query, using only visible cited facts."""
        integer(limit, "limit", _MAX_FRONTIER)
        params = _scope(namespace, metadata, limit)
        nonblank(query, "query")
        query_terms = terms(query)[:128]
        if not query_terms:
            return []
        self._ensure_schema()
        result = self._client.query(
            _SEED_SQL.replace("@edges@", self._edges_query).replace(
                "@support@", self._support_query
            ),
            parameters={**params, **self._location, "terms": query_terms},
            settings=_SELECT_SETTINGS,
        )
        return [label for (label,) in result.result_rows]

    def _ensure_schema(self) -> None:
        if not self._validated:
            self.validate_schema()

    def _neighbors(
        self, subjects: set[str], scope: dict[str, Any], constraints: dict[str, str], fanout: int
    ) -> defaultdict[str, list[GraphStep]]:
        self._ensure_schema()
        result = self._client.query(
            _NEIGHBORS_SQL.replace("@edges@", self._edges_query).replace(
                "@support@", self._support_query
            ),
            parameters={
                **scope,
                **self._location,
                "subjects": sorted(subjects),
                **constraints,
                "fanout": fanout,
            },
            settings=_SELECT_SETTINGS,
        )
        grouped: defaultdict[str, list[GraphStep]] = defaultdict(list)
        for row in result.result_rows:
            step = _graph_step(row)
            grouped[step.relation.subject].append(step)
        return grouped

    def _corpus_facts(
        self, namespace: str, metadata: Mapping[str, str] | None, edge_limit: int
    ) -> list[GraphStep]:
        params = _scope(namespace, metadata, 1)
        self._ensure_schema()
        result = self._client.query(
            _CORPUS_FACTS_SQL.replace("@edges@", self._edges_query).replace(
                "@support@", self._support_query
            ),
            parameters={**params, **self._location, "edge_limit": edge_limit},
            settings=_SELECT_SETTINGS,
        )
        return [_graph_step(row) for row in result.result_rows]

expand(seeds, *, namespace='default', metadata=None, depth=2, fanout=8, limit=64, predicates=None, entity_types=None)

Return bounded simple paths, shortest first or matching a full predicate chain.

Source code in src/clickhouserag/graph.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def expand(
    self,
    seeds: Sequence[str],
    *,
    namespace: str = "default",
    metadata: Mapping[str, str] | None = None,
    depth: int = 2,
    fanout: int = 8,
    limit: int = 64,
    predicates: Sequence[str] | None = None,
    entity_types: Sequence[str] | None = None,
) -> list[GraphPath]:
    """Return bounded simple paths, shortest first or matching a full predicate chain."""
    integer(depth, "depth", _MAX_DEPTH)
    integer(fanout, "fanout", _MAX_FANOUT)
    parameters = _scope(namespace, metadata, limit)
    ids = _entities(seeds, "seeds")
    chain = _labels_for_hops(predicates, depth, "predicates")
    types = _labels_for_hops(entity_types, depth + 1, "entity_types")
    frontier = [
        (GraphPath(seed, ()), frozenset(((seed, types[0] if types else ""),))) for seed in ids
    ]
    paths: list[GraphPath] = []
    for hop in range(depth):
        if not frontier:
            break
        constraints = {
            "predicate": chain[hop] if chain else "",
            "subject_type": types[hop] if types else "",
            "object_type": types[hop + 1] if types else "",
        }
        grouped = self._neighbors(
            {path.target for path, _ in frontier}, parameters, constraints, fanout
        )
        next_frontier = []
        for path, visited in frontier:
            for step in grouped[path.target]:
                reached = (step.relation.object, types[hop + 1] if types else "")
                if reached in visited or any(
                    previous.chunk.chunk_id == step.chunk.chunk_id
                    and previous.chunk.revision != step.chunk.revision
                    for previous in path.steps
                ):
                    continue
                next_path = GraphPath(path.seed, (*path.steps, step))
                if not chain or hop == depth - 1:
                    paths.append(next_path)
                    if len(paths) == limit:
                        return paths
                if hop < depth - 1 and len(next_frontier) < _MAX_FRONTIER:
                    next_frontier.append((next_path, visited | {reached}))
        frontier = next_frontier
    return paths

initialize()

Provision the companion table after the passage table has been created.

Source code in src/clickhouserag/graph.py
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
def initialize(self) -> None:
    """Provision the companion table after the passage table has been created."""
    if self._managed:
        self.validate_schema()
        return
    self._passages.validate_schema()
    self._client.command(
        f"""
        CREATE TABLE IF NOT EXISTS {self._qualified_table} (
            namespace String,
            subject String,
            predicate String,
            object String,
            chunk_id String,
            revision UInt64,
            subject_type String,
            object_type String,
            subject_terms Array(String),
            object_terms Array(String),
            entity_terms Array(String) MATERIALIZED arrayConcat(subject_terms, object_terms),
            INDEX entity_idx(entity_terms) TYPE text(tokenizer = 'array')
        )
        ENGINE = ReplacingMergeTree
        ORDER BY (namespace, subject, predicate, object, chunk_id, revision,
            subject_type, object_type)
        COMMENT %(manifest)s
        """,
        parameters={"manifest": self._manifest},
    )
    self.validate_schema()

insert(relations, *, namespace='default')

Validate and append a batch of up to 10,000 cited relationships.

Source code in src/clickhouserag/graph.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def insert(self, relations: Iterable[Relation], *, namespace: str = "default") -> int:
    """Validate and append a batch of up to 10,000 cited relationships."""
    if self._managed:
        raise ValueError("index a Document through RAG to write this managed collection")
    nonblank(namespace, "namespace")
    rows = {}
    for count, relation in enumerate(relations, start=1):
        if count > _MAX_BATCH:
            raise ValueError(f"insert accepts at most {_MAX_BATCH} relations per batch")
        row = _relation_row(namespace, relation)
        rows[row] = None
    if not rows:
        return 0
    self._ensure_schema()
    self._client.insert(
        self._qualified_table,
        [(*row, terms(row[1]), terms(row[3])) for row in rows],
        column_names=(
            "namespace",
            "subject",
            "predicate",
            "object",
            "chunk_id",
            "revision",
            "subject_type",
            "object_type",
            "subject_terms",
            "object_terms",
        ),
        settings={"async_insert": 0},
    )
    return len(rows)

seed_entities(query, *, namespace='default', metadata=None, limit=8)

Rank entity labels matching the query, using only visible cited facts.

Source code in src/clickhouserag/graph.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
def seed_entities(
    self,
    query: str,
    *,
    namespace: str = "default",
    metadata: Mapping[str, str] | None = None,
    limit: int = 8,
) -> list[str]:
    """Rank entity labels matching the query, using only visible cited facts."""
    integer(limit, "limit", _MAX_FRONTIER)
    params = _scope(namespace, metadata, limit)
    nonblank(query, "query")
    query_terms = terms(query)[:128]
    if not query_terms:
        return []
    self._ensure_schema()
    result = self._client.query(
        _SEED_SQL.replace("@edges@", self._edges_query).replace(
            "@support@", self._support_query
        ),
        parameters={**params, **self._location, "terms": query_terms},
        settings=_SELECT_SETTINGS,
    )
    return [label for (label,) in result.result_rows]

validate_schema()

Verify the companion table belongs to the configured passage table.

Source code in src/clickhouserag/graph.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def validate_schema(self) -> None:
    """Verify the companion table belongs to the configured passage table."""
    self._validated = False
    self._passages.validate_schema()
    if self._managed:
        self._validated = True
        return
    result = self._client.query(
        """
        SELECT comment FROM system.tables
        WHERE database = {database:String} AND name = {table:String}
        """,
        parameters=self._location,
    )
    if result.result_rows != [(self._manifest,)]:
        raise SchemaError("ClickHouse graph table is missing or belongs to another store")
    self._validated = True

clickhouserag.Knowledge

Resolve bounded conjunctive path queries against a declared relation schema.

Stored assertions must carry endpoint types that agree with the schema.

Source code in src/clickhouserag/knowledge.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
class Knowledge:
    """Resolve bounded conjunctive path queries against a declared relation schema.

    Stored assertions must carry endpoint types that agree with the schema.
    """

    def __init__(self, graph: GraphStore, schema: tuple[Predicate, ...]) -> None:
        self._graph = graph
        self._schema = {}
        for item in schema:
            name = canonical_entity(item.name)
            if name in self._schema:
                raise ValueError(f"duplicate predicate {name!r}")
            self._schema[name] = (
                canonical_entity(item.subject_type),
                canonical_entity(item.object_type),
            )
        if not self._schema:
            raise ValueError("knowledge schema requires at least one predicate")

    def solve(
        self,
        query: KnowledgeQuery | KnowledgeIntersection,
        *,
        namespace: str = "default",
        metadata: Mapping[str, str] | None = None,
        limit: int = 10,
    ) -> KnowledgeResult:
        """Find cited paths matching every predicate in the validated chain."""
        integer(limit, "limit", _MAX_LIMIT)
        queries = query.paths if isinstance(query, KnowledgeIntersection) else (query,)
        if not 1 <= len(queries) <= _MAX_LEGS:
            raise KnowledgePlanError("knowledge intersections require one to eight paths")
        try:
            chains = [self._validate_chain(path) for path in queries]
        except ValueError as exc:
            if isinstance(exc, KnowledgePlanError):
                raise
            raise KnowledgePlanError("knowledge query contains an invalid label") from exc
        if len({entity_types[-1] for _, entity_types in chains}) != 1:
            raise KnowledgePlanError("intersected paths must have the same endpoint type")
        bundles: list[dict[str, list[GraphPath]]] = []
        for path, (predicates, entity_types) in zip(queries, chains, strict=True):
            matches = self._graph.expand(
                [path.entity],
                predicates=predicates,
                entity_types=entity_types,
                depth=len(predicates),
                namespace=namespace,
                metadata=metadata,
                fanout=32,
                limit=_MAX_LIMIT,
            )
            grouped: dict[str, list[GraphPath]] = {}
            for match in matches:
                grouped.setdefault(match.target, []).append(match)
            bundles.append(grouped)
        targets = [name for name in bundles[0] if all(name in group for group in bundles[1:])][
            :limit
        ]
        evidence = [
            (leg, path)
            for name in targets
            for leg, group in enumerate(bundles)
            for path in group[name]
        ]
        return KnowledgeResult(
            tuple(targets),
            tuple(path for _, path in evidence),
            tuple(leg for leg, _ in evidence),
            len(bundles),
        )

    def _validate_chain(self, query: KnowledgeQuery) -> tuple[tuple[str, ...], tuple[str, ...]]:
        canonical_entity(query.entity)
        if not 1 <= len(query.predicates) <= _MAX_CHAIN:
            raise KnowledgePlanError("knowledge queries require one to four predicates")
        entity_type = canonical_entity(query.entity_type)
        types = [entity_type]
        predicates = tuple(map(canonical_entity, query.predicates))
        for name in predicates:
            if name not in self._schema:
                raise KnowledgePlanError(f"unknown predicate {name!r}")
            source, target = self._schema[name]
            if source != entity_type:
                raise KnowledgePlanError(f"predicate {name!r} does not accept type {entity_type!r}")
            entity_type = target
            types.append(target)
        return predicates, tuple(types)

solve(query, *, namespace='default', metadata=None, limit=10)

Find cited paths matching every predicate in the validated chain.

Source code in src/clickhouserag/knowledge.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
def solve(
    self,
    query: KnowledgeQuery | KnowledgeIntersection,
    *,
    namespace: str = "default",
    metadata: Mapping[str, str] | None = None,
    limit: int = 10,
) -> KnowledgeResult:
    """Find cited paths matching every predicate in the validated chain."""
    integer(limit, "limit", _MAX_LIMIT)
    queries = query.paths if isinstance(query, KnowledgeIntersection) else (query,)
    if not 1 <= len(queries) <= _MAX_LEGS:
        raise KnowledgePlanError("knowledge intersections require one to eight paths")
    try:
        chains = [self._validate_chain(path) for path in queries]
    except ValueError as exc:
        if isinstance(exc, KnowledgePlanError):
            raise
        raise KnowledgePlanError("knowledge query contains an invalid label") from exc
    if len({entity_types[-1] for _, entity_types in chains}) != 1:
        raise KnowledgePlanError("intersected paths must have the same endpoint type")
    bundles: list[dict[str, list[GraphPath]]] = []
    for path, (predicates, entity_types) in zip(queries, chains, strict=True):
        matches = self._graph.expand(
            [path.entity],
            predicates=predicates,
            entity_types=entity_types,
            depth=len(predicates),
            namespace=namespace,
            metadata=metadata,
            fanout=32,
            limit=_MAX_LIMIT,
        )
        grouped: dict[str, list[GraphPath]] = {}
        for match in matches:
            grouped.setdefault(match.target, []).append(match)
        bundles.append(grouped)
    targets = [name for name in bundles[0] if all(name in group for group in bundles[1:])][
        :limit
    ]
    evidence = [
        (leg, path)
        for name in targets
        for leg, group in enumerate(bundles)
        for path in group[name]
    ]
    return KnowledgeResult(
        tuple(targets),
        tuple(path for _, path in evidence),
        tuple(leg for leg, _ in evidence),
        len(bundles),
    )

clickhouserag.EvalCase dataclass

Labelled query with relevant passage IDs and optional accepted exact answers.

Source code in src/clickhouserag/evaluation.py
14
15
16
17
18
19
20
21
22
@dataclass(frozen=True, slots=True)
class EvalCase:
    """Labelled query with relevant passage IDs and optional accepted exact answers."""

    case_id: str
    query: str
    relevant: frozenset[str]
    answers: tuple[str, ...] = ()
    category: str = "default"

clickhouserag.evaluate(cases, run, *, k=10)

Run each labelled case once. Empty qrels label queries with no known answer.

Source code in src/clickhouserag/evaluation.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def evaluate(
    cases: Iterable[EvalCase],
    run: Callable[[EvalCase], Context | Answer],
    *,
    k: int = 10,
) -> Evaluation:
    """Run each labelled case once. Empty qrels label queries with no known answer."""
    integer(k, "k", _MAX_LIMIT)
    items = tuple(cases)
    if not items:
        raise ValueError("evaluation requires at least one case")
    if len({case.case_id for case in items}) != len(items):
        raise ValueError("evaluation case IDs must be unique")
    for case in items:
        nonblank(case.case_id, "case ID")
        nonblank(case.category, "category")
        nonblank(case.query, "query")
        if case.answers and not case.relevant:
            raise ValueError("an answerable case must have relevant passage IDs")
    scores = tuple(_run_case(case, run, k) for case in items)
    pairs = list(zip(items, scores, strict=True))
    grouped: defaultdict[str, list[tuple[EvalCase, CaseScore]]] = defaultdict(list)
    for item, score in pairs:
        grouped[item.category].append((item, score))
    aggregate = _aggregate(pairs)
    return Evaluation(
        k=k,
        cases=scores,
        by_category={name: _aggregate(group) for name, group in sorted(grouped.items())},
        recall=aggregate.recall,
        mrr=aggregate.mrr,
        ndcg=aggregate.ndcg,
        exact_match=aggregate.exact_match,
        citation_precision=aggregate.citation_precision,
        citation_recall=aggregate.citation_recall,
        abstention_accuracy=aggregate.abstention_accuracy,
        latency_p95_ms=aggregate.latency_p95_ms,
    )

Model adapters and extension points

clickhouserag.providers.JsonModel

Bases: Protocol

Constrain one completion to JSON for use in any built-in model task.

Source code in src/clickhouserag/_structured.py
228
229
230
231
232
233
class JsonModel(Protocol):
    """Constrain one completion to JSON for use in any built-in model task."""

    def complete_json(self, instructions: str, input_text: str, schema: dict[str, Any]) -> str:
        """Return a JSON object as text, or raise on refusal or incomplete output."""
        ...

complete_json(instructions, input_text, schema)

Return a JSON object as text, or raise on refusal or incomplete output.

Source code in src/clickhouserag/_structured.py
231
232
233
def complete_json(self, instructions: str, input_text: str, schema: dict[str, Any]) -> str:
    """Return a JSON object as text, or raise on refusal or incomplete output."""
    ...

clickhouserag.providers.StructuredLLM

Bases: _StructuredTasks

Run RAG model tasks with an application-owned structured JSON backend.

Source code in src/clickhouserag/_structured.py
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
class StructuredLLM(_StructuredTasks):
    """Run RAG model tasks with an application-owned structured JSON backend."""

    def __init__(
        self, backend: JsonModel, *, schema: tuple[Predicate, ...] = (), max_relations: int = 12
    ) -> None:
        super().__init__(schema, max_relations)
        self._backend = backend

    def plan_search(
        self, question: str, modes: tuple[_PassageMode, ...]
    ) -> tuple[SearchQuery, ...]:
        """Propose read-only ClickHouse searches for one question."""
        result = self._structured(
            "Rewrite the question as one to four short searches that together find its evidence. "
            "Use lexical for exact phrases or identifiers, graph for named relationships, "
            "and hybrid or dense for broader text search when those modes are available. "
            "Keep the original meaning and proper nouns. Do not generate SQL or respond "
            "with an answer. If no useful plan can be formed, return an empty searches list.",
            {"question": question, "available_modes": modes},
            _object(searches=_array(_object(text=_STR, mode={"enum": list(modes)}), 4)),
        )
        rows = result.get("searches")
        if not isinstance(rows, list) or len(rows) > 4:
            raise ModelResponseError("Model returned an invalid search plan")
        searches = []
        for row in rows:
            if not isinstance(row, dict) or row.get("mode") not in modes:
                raise ModelResponseError("Model returned an unavailable search mode")
            term = _text(row.get("text"))
            if len(term) > 4096:
                raise ModelResponseError("Model returned an oversized search query")
            searches.append(SearchQuery(term, cast("_PassageMode", row["mode"])))
        return tuple(searches)

    def _structured(
        self, system: str, data: dict[str, Any], schema: dict[str, Any]
    ) -> dict[str, Any]:
        return _json_object(
            self._backend.complete_json(system, json.dumps(data, ensure_ascii=False), schema)
        )

Propose read-only ClickHouse searches for one question.

Source code in src/clickhouserag/_structured.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
def plan_search(
    self, question: str, modes: tuple[_PassageMode, ...]
) -> tuple[SearchQuery, ...]:
    """Propose read-only ClickHouse searches for one question."""
    result = self._structured(
        "Rewrite the question as one to four short searches that together find its evidence. "
        "Use lexical for exact phrases or identifiers, graph for named relationships, "
        "and hybrid or dense for broader text search when those modes are available. "
        "Keep the original meaning and proper nouns. Do not generate SQL or respond "
        "with an answer. If no useful plan can be formed, return an empty searches list.",
        {"question": question, "available_modes": modes},
        _object(searches=_array(_object(text=_STR, mode={"enum": list(modes)}), 4)),
    )
    rows = result.get("searches")
    if not isinstance(rows, list) or len(rows) > 4:
        raise ModelResponseError("Model returned an invalid search plan")
    searches = []
    for row in rows:
        if not isinstance(row, dict) or row.get("mode") not in modes:
            raise ModelResponseError("Model returned an unavailable search mode")
        term = _text(row.get("text"))
        if len(term) > 4096:
            raise ModelResponseError("Model returned an oversized search query")
        searches.append(SearchQuery(term, cast("_PassageMode", row["mode"])))
    return tuple(searches)

clickhouserag.Reranker

Bases: Protocol

Return a permutation of the ClickHouse candidates, best match first.

Source code in src/clickhouserag/rag.py
100
101
102
103
104
105
class Reranker(Protocol):
    """Return a permutation of the ClickHouse candidates, best match first."""

    def rank(self, query: str, passages: Sequence[Chunk]) -> Sequence[int]:
        """Return only indices into the input sequence, with each index used once."""
        ...

rank(query, passages)

Return only indices into the input sequence, with each index used once.

Source code in src/clickhouserag/rag.py
103
104
105
def rank(self, query: str, passages: Sequence[Chunk]) -> Sequence[int]:
    """Return only indices into the input sequence, with each index used once."""
    ...

See the provider guide for the concrete HTTP adapters.