Skip to content

feat: Chinese retrieval enhancements - pluggable BM25 tokenizers, per-doc diversity cap, Gradio Web UI - #2

Closed
hulinming wants to merge 3 commits into
IvenKooLab:mainfrom
hulinming:dev/zh-enhance
Closed

hulinming wants to merge 3 commits into
IvenKooLab:mainfrom
hulinming:dev/zh-enhance

Conversation

@hulinming

Copy link
Copy Markdown
Contributor

Summary

A batch of enhancements focused on Chinese-language retrieval and usability. All changes are backward compatible — the original dependency-free single-char CJK tokenizer stays the default, and every addition ships as an optional extra.

1. Pluggable BM25 tokenizers with jieba Chinese word segmentation

  • src/loci/tokenizers.py: tiny registry (default / jieba); jieba is imported lazily and falls back to default with a one-time install hint (fail-open).
  • Config: [bm25] tokenizer = "default" | "jieba"; unknown names warn and fall back to default.
  • Optional extra: pip install 'loci-rag[zh]' — no new runtime dependency.
  • Effect: the default tokenizer splits Chinese into single characters, so phrase-level BM25 recall is weak (A/B on a real index: a distractor doc outranked the correct one 0.416 vs 0.325); with jieba the correct doc scores 0.595 and the distractor 0.000.

2. Per-document diversity cap

  • Retriever keeps at most max_per_doc chunks (default 2) per source document in one result list, applied after RRF fusion/rerank/filters so surplus chunks give way to other relevant documents.
  • Config: [retrieval] max_per_doc (0 = no cap, preserves the original behavior).
  • Effect: top-5 results spread across 3 documents instead of being dominated by one long file.

3. Gradio Web UI (optional)

  • New loci webui command: chat pane + per-answer evidence panel that reuses build() / make_retriever() (same pipeline as the CLI and HTTP API); evidence shows document name > section.
  • Optional extra: pip install 'loci-rag[ui]'.

4. Smaller items

  • SYSTEM_PROMPT rewritten as strict grounding rules: excerpts only, explicit "not in KB" answer instead of guessing, bullet structure, mandatory Sources: citations.
  • search / ask / bench accept --top_k as a long-form alias of -k (same args.k, no behavior change).
  • Missing optional deps (pdf / docx / ocr / jieba) now warn once per run with the exact pip install command instead of skipping files silently.
  • .gitignore: added .venv/.

Tests

  • 207 passed, 1 skipped — all offline (FakeEmbedder; gradio/jieba tests are importorskip-guarded), +24 new tests in this branch.

The original tokenizer splits CJK into single characters, so phrase-level
keyword recall fails for Chinese: query 检索 matches a document that merely
contains 检 and 索 inside different words, and that decoy can outrank the
genuinely relevant chunk.

- new loci.tokenizers registry: "default" (original behavior, no deps) and
  "jieba" (word-level segmentation, lazy import, fails open to default when
  the optional package is missing)
- BM25 accepts an injected tokenizer; module-level tokenize() kept as a
  backward-compatible alias (conftest FakeEmbedder relies on it)
- [bm25] tokenizer config section wired through Retriever; unknown names
  warn and fall back to default at load time
- bench gains --tokenizer so A/B runs need no config edits

A/B on a decoy corpus (query 检索; doc b contains 检+索 in other words):
default ranks decoy 0.416 > correct 0.325; jieba ranks correct 0.595 and
zeroes the decoy. 10 new tests in tests/test_tokenizers.py.
…extra)

- src/loci/webui.py: chat pane + per-answer evidence panel reusing
  build()/make_retriever() from the CLI pipeline; ask_fn keeps the last
  four turns as history; format_evidence() renders source > section
- cli: new `webui` subcommand with --host/--port overrides
- config: [webui] section (host 127.0.0.1, port 7860)
- pyproject: ui = ["gradio>=4"] optional extra (no new runtime deps)
- tests/test_webui.py: 10 offline tests (evidence formatting, ask
  callback, history pass-through, app construction, config defaults)
…g prompt, --top_k alias, missing-extra guidance

- retriever: max_per_doc (default 2) caps chunks per source document in
  one result list, applied after RRF fusion/rerank so surplus chunks give
  way to other docs; [retrieval] max_per_doc config (0 = no cap)
- retriever: SYSTEM_PROMPT rewritten as 4 strict rules — excerpts only,
  explicit "not in KB" answer, bullet structure, mandatory Sources line
- cli: search/ask/bench accept -k/--top_k alias (dest=k keeps compat)
- webui: evidence panel shows document name > section (full path stays in
  the answer citations)
- loaders/tokenizers: missing pdf/docx/ocr/jieba extras now warn once per
  run with the exact pip install command instead of skipping silently
- tests: +14 (diversity cap, prompt rules, --top_k wiring, dep warnings)
@IvenKooLab

Copy link
Copy Markdown
Owner

Taking the tokenizer half of this in #4, with your commit intact and co-authored — the registry, config validation, bench --tokenizer and the fail-open behaviour are all unchanged.

One thing I changed, with numbers. jieba segments 全文检索 into a single token, so a 检索 query matches nothing:

correct = "全文检索是知识库的核心能力"
decoy   = "定期检查索引状态并重建"
query   = "检索"

default   → decoy 0.379 > correct 0.351   (the bug you found — confirmed)
jieba     → correct 0.000, no match
bigrams   → correct 0.666, decoy absent

So cjk_bigram is what ships as the recommended option: overlapping bigrams, no dictionary, no dependency, and your 混合检索 case still passes. jieba stays in the registry as an opt-in, and your two tests came over untouched; I added a recall case (检索 inside 全文检索) so the trade-off is pinned by a test instead of by a comment.

Also fixed the same bug in the latin half: default now indexes the joined form of hyphenated compounds, so wifi finds Wi-Fi.

Three things I still want your read on, from the rest of this PR:

  • max_per_doc = 2 as a default changes results for every existing user. Defaulting to 0 plus a loci bench before/after would make it an easy yes.
  • the SYSTEM_PROMPT rewrite changes the answer format for everyone — gating it behind [llm] strict_grounding feels safer.
  • the Gradio UI: I would rather decide the console's future deliberately than add a second one here.

Thanks for the careful commit messages — they are why this took an hour instead of a day.


分词器这部分我接手了,开在 #4,你的 commit 原样保留、co-author 也是你(注册表、配置校验、bench --tokenizer、fail-open 全都没动)。只换了中文的实现:jieba 把「全文检索」切成一个词,查「检索」就一条都不命中,所以推荐项改成重叠双字(不用词典、不用额外依赖),你那个「混合检索」的用例照样成立。jieba 仍在注册表里作为可选项,你的测试原样保留,我补了一个召回用例。缺依赖提示、--top_k、还有那 374 行测试都很好。上面那三个默认值的问题,想听你的意见。

IvenKooLab added a commit that referenced this pull request Oct 26, 2026
…ecision for recall

Ports the tokenizer registry from @hulinming's #2 (`[bm25] tokenizer` in
config, `bench --tokenizer` for A/B runs, fail-open on missing extras), and
changes the CJK implementation that ships as the recommended option.

Word-level segmentation that relies on a dictionary only matches on the
dictionary's terms. jieba splits 全文检索 into one token, so a 检索 query
returns nothing at all — the opposite failure to the single-char tokenizer,
which matches 检 + 索 inside 定期检查索引 and ranks that decoy above the real
document. Overlapping bigrams fix both without a dependency:

  检索      → ['检索']
  全文检索  → ['全文', '文检', '检索']        ← the phrase is still in there
  定期检查索引 → ['定期','期检','检查','查索','索引']  ← no 检索 bigram

Measured on the two-document decoy corpus: single-char puts decoy 0.379 above
correct 0.351, jieba scores correct 0.0 (no match), bigrams score correct
0.666 and drop the decoy entirely. jieba's own advertised case (混合检索)
still passes under bigrams.

`default` also indexes the joined form of hyphenated compounds now, so a
`wifi` query finds `Wi-Fi` — the two spellings were mutually invisible before,
which is the same class of bug in the latin half.

- tokenizers.py: registry + default / cjk_bigram / jieba, all dependency-free
  except jieba (optional, fails open with a one-time install hint)
- bm25.py: accept an injected tokenizer, keep `tokenize()` as the default alias
  so existing callers and conftest's FakeEmbedder are untouched
- retriever.py/config.py/cli.py: wire [bm25] tokenizer, validate the name with
  a stderr fallback, and add `bench --tokenizer` for same-cases A/B
- tests: 21 tokenizer cases — decoy regression, phrase-inside-word recall,
  single-char runs, latin runs, hyphen joins, registry and config fallback

Co-authored-by: hlm <2514266720@qq.com>
IvenKooLab added a commit that referenced this pull request Oct 26, 2026
…ecision for recall

Ports the tokenizer registry from @hulinming's #2 (`[bm25] tokenizer` in
config, `bench --tokenizer` for A/B runs, fail-open on missing extras), and
changes the CJK implementation that ships as the recommended option.

Word-level segmentation that relies on a dictionary only matches on the
dictionary's terms. jieba splits 全文检索 into one token, so a 检索 query
returns nothing at all — the opposite failure to the single-char tokenizer,
which matches 检 + 索 inside 定期检查索引 and ranks that decoy above the real
document. Overlapping bigrams fix both without a dependency:

  检索      → ['检索']
  全文检索  → ['全文', '文检', '检索']        ← the phrase is still in there
  定期检查索引 → ['定期','期检','检查','查索','索引']  ← no 检索 bigram

Measured on the two-document decoy corpus: single-char puts decoy 0.379 above
correct 0.351, jieba scores correct 0.0 (no match), bigrams score correct
0.666 and drop the decoy entirely. jieba's own advertised case (混合检索)
still passes under bigrams.

`default` also indexes the joined form of hyphenated compounds now, so a
`wifi` query finds `Wi-Fi` — the two spellings were mutually invisible before,
which is the same class of bug in the latin half.

- tokenizers.py: registry + default / cjk_bigram / jieba, all dependency-free
  except jieba (optional, fails open with a one-time install hint)
- bm25.py: accept an injected tokenizer, keep `tokenize()` as the default alias
  so existing callers and conftest's FakeEmbedder are untouched
- retriever.py/config.py/cli.py: wire [bm25] tokenizer, validate the name with
  a stderr fallback, and add `bench --tokenizer` for same-cases A/B
- tests: 21 tokenizer cases — decoy regression, phrase-inside-word recall,
  single-char runs, latin runs, hyphen joins, registry and config fallback

Co-authored-by: hlm <2514266720@qq.com>
IvenKooLab added a commit that referenced this pull request Oct 26, 2026
…ecision for recall

Ports the tokenizer registry from @hulinming's #2 (`[bm25] tokenizer` in
config, `bench --tokenizer` for A/B runs, fail-open on missing extras), and
changes the CJK implementation that ships as the recommended option.

Word-level segmentation that relies on a dictionary only matches on the
dictionary's terms. jieba splits 全文检索 into one token, so a 检索 query
returns nothing at all — the opposite failure to the single-char tokenizer,
which matches 检 + 索 inside 定期检查索引 and ranks that decoy above the real
document. Overlapping bigrams fix both without a dependency:

  检索      → ['检索']
  全文检索  → ['全文', '文检', '检索']        ← the phrase is still in there
  定期检查索引 → ['定期','期检','检查','查索','索引']  ← no 检索 bigram

Measured on the two-document decoy corpus: single-char puts decoy 0.379 above
correct 0.351, jieba scores correct 0.0 (no match), bigrams score correct
0.666 and drop the decoy entirely. jieba's own advertised case (混合检索)
still passes under bigrams.

`default` also indexes the joined form of hyphenated compounds now, so a
`wifi` query finds `Wi-Fi` — the two spellings were mutually invisible before,
which is the same class of bug in the latin half.

- tokenizers.py: registry + default / cjk_bigram / jieba, all dependency-free
  except jieba (optional, fails open with a one-time install hint)
- bm25.py: accept an injected tokenizer, keep `tokenize()` as the default alias
  so existing callers and conftest's FakeEmbedder are untouched
- retriever.py/config.py/cli.py: wire [bm25] tokenizer, validate the name with
  a stderr fallback, and add `bench --tokenizer` for same-cases A/B
- tests: 21 tokenizer cases — decoy regression, phrase-inside-word recall,
  single-char runs, latin runs, hyphen joins, registry and config fallback

Co-authored-by: hlm <2514266720@qq.com>
@IvenKooLab

Copy link
Copy Markdown
Owner

Landed in #4 — your commit is on main as 7650a46, authored by you with the message untouched, and the bigram tokenizer sits on top as c034cb7. So the registry, [bm25] tokenizer config validation, bench --tokenizer and the fail-open behaviour are all in, with your tests.

Closing this one as superseded, not rejected. The reason is purely mechanical: the branch's base commit (0365ec6d) no longer exists on main and the two histories never shared an ancestor, so GitHub can only show this PR as a 3,777-line deletion of the Web console and the guides. Nothing about the code in it was wrong.

Three things from the rest of this PR are still worth doing, and I would rather keep them than lose them:

  • max_per_doc — default it to 0 and add a loci bench before/after in the PR, then it is an easy yes
  • SYSTEM_PROMPT — gate the strict rules behind [llm] strict_grounding
  • Gradio webui — needs a decision on the console's future first; two UIs is a real cost

I am happy to open those three, or to hand you the branches if you would rather write them yourself — say which and I will set it up.

Thanks for the care in those commit messages. A PR that sat for three weeks and still ported in an hour is a PR written by someone who thought about the reviewer.


同样的话用中文再说一遍:#4 已合并,你那个 commit 以你的名义在 main 上(7650a46),bigram 是在它上面的一层。这个 PR 关了,但关的原因是「被取代」,不是「被拒」——分支的 base commit 在 main 上已经不存在,GitHub 只能把它显示成删掉 3,777 行。

max_per_doc、SYSTEM_PROMPT、Gradio UI 这三块我留着,想单独开 PR 做。你要自己写的话我先把分支搭好,说一声就行。谢谢你每个 commit 写清楚原因——这种 PR 放三周还能一小时合进去,是因为作者替 reviewer 想过。

@IvenKooLab IvenKooLab closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants