ค้นหาข้อความทั้งหมดของบทสนทนาด้วย Apache Lucene: บทช่วยสอน
เผยแพร่แล้ว: 2022-03-11Apache Lucene เป็นไลบรารี Java ที่ใช้สำหรับการค้นหาข้อความแบบเต็มของเอกสาร และเป็นแกนหลักของเซิร์ฟเวอร์การค้นหา เช่น Solr และ Elasticsearch นอกจากนี้ยังสามารถฝังลงในแอปพลิเคชัน Java เช่นแอป Android หรือเว็บแบ็กเอนด์
แม้ว่าตัวเลือกการกำหนดค่าของ Lucene นั้นมีมากมาย แต่ก็มีจุดประสงค์เพื่อใช้โดยผู้พัฒนาฐานข้อมูลในคลังข้อความทั่วไป ถ้าเอกสารของคุณมีโครงสร้างหรือประเภทของเนื้อหาเฉพาะ คุณสามารถใช้ประโยชน์จากสิ่งใดสิ่งหนึ่งเพื่อปรับปรุงคุณภาพการค้นหาและความสามารถในการสืบค้น
ตัวอย่างของการปรับแต่งประเภทนี้ ในบทช่วยสอนของ Lucene เราจะจัดทำดัชนีคลังข้อมูลของ Project Gutenberg ซึ่งมี e-book ฟรีนับพันเล่ม เรารู้ว่าหนังสือเหล่านี้เป็นนวนิยายหลายเล่ม สมมติว่าเราสนใจ บทสนทนา ในนวนิยายเหล่านี้เป็นพิเศษ ทั้ง Lucene, Elasticsearch และ Solr ไม่มีเครื่องมือสำเร็จรูปในการระบุเนื้อหาเป็นบทสนทนา ในความเป็นจริง พวกเขาจะละเว้นเครื่องหมายวรรคตอนในช่วงแรกของการวิเคราะห์ข้อความ ซึ่งขัดต่อความสามารถในการระบุส่วนของข้อความที่เป็นบทสนทนา ดังนั้นมันจึงเป็นช่วงเริ่มต้นที่การปรับแต่งของเราจะต้องเริ่มต้นขึ้น
ชิ้นส่วนของท่อวิเคราะห์ Apache Lucene
JavaDoc การวิเคราะห์ Lucene ให้ภาพรวมที่ดีของชิ้นส่วนที่เคลื่อนไหวทั้งหมดในไปป์ไลน์การวิเคราะห์ข้อความ
ในระดับสูง คุณสามารถนึกถึงไปป์ไลน์การวิเคราะห์ว่าใช้สตรีมอักขระแบบดิบๆ ในตอนเริ่มต้น และสร้าง "เงื่อนไข" ที่สัมพันธ์กับคำคร่าวๆ ในตอนท้าย
ไปป์ไลน์การวิเคราะห์มาตรฐานสามารถมองเห็นได้ดังนี้:
เราจะดูวิธีปรับแต่งไปป์ไลน์นี้เพื่อจดจำพื้นที่ของข้อความที่มีเครื่องหมายอัญประกาศคู่ ซึ่งฉันจะเรียกบทสนทนา จากนั้นจึงเพิ่มการจับคู่ที่เกิดขึ้นเมื่อค้นหาในภูมิภาคเหล่านั้น
การอ่านตัวอักษร
เมื่อเริ่มเพิ่มเอกสารลงในดัชนี อักขระจะถูกอ่านจาก Java InputStream และสามารถมาจากไฟล์ ฐานข้อมูล การเรียกใช้บริการเว็บ ฯลฯ ในการสร้างดัชนีสำหรับ Project Gutenberg เราดาวน์โหลด e-book และ สร้างแอปพลิเคชันขนาดเล็กเพื่ออ่านไฟล์เหล่านี้และเขียนลงในดัชนี การสร้างดัชนี Lucene และไฟล์การอ่านเป็นเส้นทางที่ดี ดังนั้นเราจะไม่สำรวจมันมากนัก รหัสที่จำเป็นสำหรับการสร้างดัชนีคือ:
IndexWriter writer = ...; BufferedReader reader = new BufferedReader(new InputStreamReader(... fileInputStream ...)); Document document = new Document(); document.add(new StringField("title", fileName, Store.YES)); document.add(new TextField("body", reader)); writer.addDocument(document);
เราจะเห็นได้ว่า e-book แต่ละเล่มจะสอดคล้องกับ Document
Lucene ฉบับเดียว ดังนั้นในภายหลัง ผลการค้นหาของเราจะเป็นรายการหนังสือที่ตรงกัน Store.YES
ระบุว่าเราเก็บฟิลด์ ชื่อ ซึ่งเป็นเพียงชื่อไฟล์ อย่างไรก็ตาม เราไม่ต้องการเก็บ เนื้อความ ของ ebook เนื่องจากไม่จำเป็นในการค้นหาและจะเปลืองเนื้อที่ดิสก์เท่านั้น
การอ่านสตรีมจริงเริ่มต้นด้วย addDocument
IndexWriter
ดึงโทเค็นจากส่วนท้ายของไปป์ไลน์ การดึงนี้ดำเนินการย้อนกลับผ่านไพพ์จนกระทั่งสเตจแรก Tokenizer
อ่านจาก InputStream
โปรดทราบว่าเราไม่ปิดสตรีม เนื่องจาก Lucene จัดการเรื่องนี้ให้เรา
ตัวละคร Tokenizing
Lucene StandardTokenizer ทิ้งเครื่องหมายวรรคตอน ดังนั้นการปรับแต่งของเราจึงเริ่มต้นที่นี่ เนื่องจากเราจำเป็นต้องรักษาคำพูด
เอกสารประกอบสำหรับ StandardTokenizer
ขอเชิญคุณคัดลอกซอร์สโค้ดและปรับแต่งให้เข้ากับความต้องการของคุณ แต่โซลูชันนี้จะซับซ้อนโดยไม่จำเป็น แต่เราจะขยาย CharTokenizer
ซึ่งอนุญาตให้คุณระบุอักขระเพื่อ "ยอมรับ" โดยที่อักขระที่ไม่ "ยอมรับ" จะถือว่าเป็นตัวคั่นระหว่างโทเค็นและโยนทิ้งไป เนื่องจากเราสนใจคำและใบเสนอราคารอบๆ ตัว Tokenizer แบบกำหนดเองของเราจึงเป็นเพียง:
public class QuotationTokenizer extends CharTokenizer { @Override protected boolean isTokenChar(int c) { return Character.isLetter(c) || c == '"'; } }
เมื่อได้รับกระแสอินพุตของ [He said, "Good day".]
โทเค็นที่ผลิตจะเป็น [He]
, [said]
, ["Good]
, [day"]
สังเกตว่าเครื่องหมายคำพูดจะกระจายอยู่ในโทเค็นอย่างไร เป็นไปได้ที่จะเขียน Tokenizer
ที่สร้างโทเค็นแยกกันสำหรับแต่ละใบเสนอราคา แต่ Tokenizer
ยังเกี่ยวข้องกับรายละเอียดที่ยุ่งเหยิงและง่ายต่อการขัน เช่น การบัฟเฟอร์และการสแกน ดังนั้นจึงควรทำให้ Tokenizer
เรียบง่ายและทำความสะอาด โทเค็นสตรีมต่อไปในไปป์ไลน์
การแยกโทเค็นโดยใช้ตัวกรอง
หลังจาก tokenizer จะมีชุดของออบเจ็กต์ TokenFilter
โปรดทราบว่า ตัวกรอง นั้นมีการเรียกชื่อผิดเล็กน้อย เนื่องจาก TokenFilter
สามารถเพิ่ม ลบ หรือแก้ไขโทเค็นได้
คลาสตัวกรองจำนวนมากที่ Lucene จัดเตรียมไว้ให้คาดหวังคำเดียว ดังนั้นจึงไม่เกิดโทเค่นคำและอัญประกาศผสมกันไหลเข้ามา ดังนั้น การปรับแต่งต่อไปของบทช่วยสอน Lucene จะต้องเป็นการแนะนำตัวกรองที่จะล้างผลลัพธ์ของ QuotationTokenizer
การล้างข้อมูลนี้จะเกี่ยวข้องกับการผลิตโทเค็น ใบเสนอราคาเริ่มต้น เพิ่มเติม หากใบเสนอราคาปรากฏขึ้นที่จุดเริ่มต้นของคำ หรือโทเค็นการ เสนอราคาสิ้นสุด หากใบเสนอราคาปรากฏขึ้นที่ส่วนท้าย เราจะละเว้นการจัดการคำที่ยกมาเพียงคำเดียวเพื่อความเรียบง่าย
การสร้างคลาสย่อย TokenFilter
เกี่ยวข้องกับการใช้เมธอดเดียว: incrementToken
เมธอดนี้ต้องเรียก incrementToken
บนตัวกรองก่อนหน้าในไพพ์ จากนั้นจัดการผลลัพธ์ของการเรียกนั้นเพื่อทำงานใดๆ ก็ตามที่ตัวกรองรับผิดชอบ ผลลัพธ์ของ incrementToken
มีให้ผ่านทางอ็อบเจ็กต์ Attribute
ซึ่งอธิบายสถานะปัจจุบันของการประมวลผลโทเค็น หลังจากที่เราใช้ incrementToken
ส่งคืน คาดว่าแอตทริบิวต์จะได้รับการจัดการเพื่อตั้งค่าโทเค็นสำหรับตัวกรองถัดไป (หรือดัชนีหากเราอยู่ที่ส่วนท้ายของไพพ์)
คุณลักษณะที่เราสนใจ ณ จุดนี้ในไปป์ไลน์คือ:
CharTermAttribute
: ประกอบด้วยบัฟเฟอร์char[]
ที่เก็บอักขระของโทเค็นปัจจุบัน เราจะต้องจัดการสิ่งนี้เพื่อลบใบเสนอราคา หรือเพื่อสร้างโทเค็นของใบเสนอราคาTypeAttribute
: ประกอบด้วย "ประเภท" ของโทเค็นปัจจุบัน เนื่องจากเรากำลังเพิ่มราคาเริ่มต้นและสิ้นสุดให้กับสตรีมโทเค็น เราจะแนะนำประเภทใหม่สองประเภทโดยใช้ตัวกรองของเราOffsetAttribute
: Lucene สามารถเลือกจัดเก็บข้อมูลอ้างอิงไปยังตำแหน่งของเงื่อนไขในเอกสารต้นฉบับได้ การอ้างอิงเหล่านี้เรียกว่า "ออฟเซ็ต" ซึ่งเป็นเพียงดัชนีเริ่มต้นและสิ้นสุดในสตรีมอักขระดั้งเดิม หากเราเปลี่ยนบัฟเฟอร์ในCharTermAttribute
ให้ชี้ไปที่สตริงย่อยของโทเค็น เราต้องปรับการชดเชยเหล่านี้ให้สอดคล้องกัน
คุณอาจสงสัยว่าเหตุใด API สำหรับจัดการสตรีมโทเค็นจึงซับซ้อน และโดยเฉพาะอย่างยิ่ง เหตุใดเราจึงไม่สามารถทำบางอย่างเช่น String#split
บนโทเค็นที่เข้ามาได้ ทั้งนี้เนื่องจาก Lucene ได้รับการออกแบบมาสำหรับการทำดัชนีความเร็วสูงและค่าใช้จ่ายต่ำ โดยที่โทเค็นและตัวกรองในตัวสามารถเคี้ยวข้อความได้อย่างรวดเร็วในขณะที่ใช้หน่วยความจำเพียงเมกะไบต์ เพื่อให้บรรลุเป้าหมายนี้ มีการจัดสรรเพียงเล็กน้อยหรือไม่มีเลยในระหว่างการสร้างโทเค็นและการกรอง ดังนั้นอินสแตนซ์ Attribute
ที่กล่าวถึงข้างต้นมีจุดประสงค์เพื่อจัดสรรเพียงครั้งเดียวและนำกลับมาใช้ใหม่ หากโทเค็นและตัวกรองของคุณถูกเขียนในลักษณะนี้ และลดการจัดสรรให้เหลือน้อยที่สุด คุณสามารถปรับแต่ง Lucene ได้โดยไม่ลดทอนประสิทธิภาพ
จากทั้งหมดนั้น เรามาดูวิธีการใช้ตัวกรองที่ใช้โทเค็นเช่น ["Hello]
และสร้างโทเค็นทั้งสอง ["]
และ [Hello]
:
public class QuotationTokenFilter extends TokenFilter { private static final char QUOTE = '"'; public static final String QUOTE_START_TYPE = "start_quote"; public static final String QUOTE_END_TYPE = "end_quote"; private final OffsetAttribute offsetAttr = addAttribute(OffsetAttribute.class); private final TypeAttribute typeAttr = addAttribute(TypeAttribute.class); private final CharTermAttribute termBufferAttr = addAttribute(CharTermAttribute.class);
เราเริ่มต้นด้วยการรับการอ้างอิงถึงคุณลักษณะบางอย่างที่เราเห็นก่อนหน้านี้ เราต่อท้ายชื่อฟิลด์ด้วย "Attr" เพื่อให้ชัดเจนในภายหลังเมื่อเราอ้างถึงพวกเขา เป็นไปได้ว่าการใช้งาน Tokenizer
บางอย่างไม่มีแอตทริบิวต์เหล่านี้ ดังนั้นเราจึงใช้ addAttribute
เพื่อรับข้อมูลอ้างอิงของเรา addAttribute
จะสร้างอินสแตนซ์ของแอตทริบิวต์หากไม่มี มิฉะนั้นจะดึงข้อมูลอ้างอิงที่ใช้ร่วมกันไปยังแอตทริบิวต์ของประเภทนั้น โปรดทราบว่า Lucene ไม่อนุญาตให้มีแอตทริบิวต์ประเภทเดียวกันหลายอินสแตนซ์ในคราวเดียว
private boolean emitExtraToken; private int extraTokenStartOffset, extraTokenEndOffset; private String extraTokenType;
เนื่องจากตัวกรองของเราจะแนะนำโทเค็นใหม่ที่ไม่ได้อยู่ในสตรีมดั้งเดิม เราจึงต้องมีที่สำหรับบันทึกสถานะของโทเค็นนั้นระหว่างการเรียก incrementToken
เนื่องจากเรากำลังแบ่งโทเค็นที่มีอยู่ออกเป็นสองโทเค็น จึงเพียงพอที่จะทราบเพียงแค่ออฟเซ็ตและประเภทของโทเค็นใหม่ นอกจากนี้เรายังมีแฟล็กที่บอกเราว่าการเรียก incrementToken
ครั้งต่อไปจะเป็นการส่งโทเค็นพิเศษนี้หรือไม่ ที่จริงแล้ว Lucene มีเมธอดอยู่สองวิธีคือ captureState
และ restoreState
ซึ่งจะทำสิ่งนี้ให้คุณ แต่วิธีการเหล่านี้เกี่ยวข้องกับการจัดสรรออบเจกต์ State
และอาจยากกว่าการจัดการสถานะนั้นด้วยตนเอง ดังนั้นเราจะหลีกเลี่ยงการใช้
@Override public void reset() throws IOException { emitExtraToken = false; extraTokenStartOffset = -1; extraTokenEndOffset = -1; extraTokenType = null; super.reset(); }
ในการหลีกเลี่ยงการจัดสรรอย่างเข้มงวด Lucene สามารถใช้อินสแตนซ์ตัวกรองซ้ำได้ ในสถานการณ์นี้ คาดว่าการเรียกเพื่อ reset
จะทำให้ตัวกรองกลับสู่สถานะเริ่มต้น ดังนั้นที่นี่ เราเพียงแค่รีเซ็ตฟิลด์โทเค็นพิเศษของเรา
@Override public boolean incrementToken() throws IOException { if (emitExtraToken) { advanceToExtraToken(); emitExtraToken = false; return true; } ...
ตอนนี้เรากำลังเข้าสู่ส่วนที่น่าสนใจ เมื่อเรียกใช้ incrementToken
ของเรา เรามีโอกาสที่จะ ไม่ เรียก incrementToken
ในขั้นตอนก่อนหน้าของไปป์ไลน์ การทำเช่นนี้ทำให้เราแนะนำโทเค็นใหม่ได้อย่างมีประสิทธิภาพ เนื่องจากเราไม่ได้ดึงโทเค็นจาก Tokenizer
แต่เราเรียก advanceToExtraToken
เพื่อตั้งค่าแอตทริบิวต์สำหรับโทเค็นพิเศษของเรา ตั้งค่า emitExtraToken
เป็น false เพื่อหลีกเลี่ยงสาขานี้ในการโทรครั้งต่อไป จากนั้นคืนค่า true
ซึ่งระบุว่ามีโทเค็นอื่นพร้อมใช้งาน
@Override public boolean incrementToken() throws IOException { ... (emit extra token) ... boolean hasNext = input.incrementToken(); if (hasNext) { char[] buffer = termBufferAttr.buffer(); if (termBuffer.length() > 1) { if (buffer[0] == QUOTE) { splitTermQuoteFirst(); } else if (buffer[termBuffer.length() - 1] == QUOTE) { splitTermWordFirst(); } } else if (termBuffer.length() == 1) { if (buffer[0] == QUOTE) { typeAttr.setType(QUOTE_END_TYPE); } } } return hasNext; }
ส่วนที่เหลือของ incrementToken
จะทำสิ่งใดสิ่งหนึ่งจากสามสิ่งที่แตกต่างกัน จำได้ว่า termBufferAttr
ใช้เพื่อตรวจสอบเนื้อหาของโทเค็นที่มาจากไพพ์:
หากเราถึงจุดสิ้นสุดของสตรีมโทเค็น (เช่น
hasNext
เป็นเท็จ) แสดงว่าเสร็จแล้วและเพียงแค่ส่งคืนหากเรามีโทเค็นมากกว่าหนึ่งอักขระ และหนึ่งในอักขระเหล่านั้นเป็นเครื่องหมายคำพูด เราจะแยกโทเค็นนั้นออก
หากโทเค็นเป็นใบเสนอราคาเดี่ยว เราจะถือว่าโทเค็นนั้นเป็นใบเสนอราคาสิ้นสุด เพื่อให้เข้าใจว่าทำไม ให้สังเกตว่าเครื่องหมายคำพูดเริ่มต้นมักจะปรากฏอยู่ทางด้านซ้ายของคำเสมอ (เช่น ไม่มีเครื่องหมายวรรคตอนกลาง) ในขณะที่เครื่องหมายคำพูดลงท้ายสามารถตามด้วยเครื่องหมายวรรคตอน (เช่น ในประโยค
[He told us to "go back the way we came."]
). ในกรณีเหล่านี้ ใบเสนอราคาสิ้นสุดจะเป็นโทเค็นแยกต่างหาก ดังนั้นเราจึงต้องกำหนดประเภทเท่านั้น
splitTermQuoteFirst
และ splitTermWordFirst
จะตั้งค่าแอตทริบิวต์เพื่อให้โทเค็นปัจจุบันเป็นคำหรือใบเสนอราคา และตั้งค่าฟิลด์ "พิเศษ" เพื่ออนุญาตให้ใช้อีกครึ่งหนึ่งในภายหลัง ทั้งสองวิธีมีความคล้ายคลึงกัน ดังนั้นเราจะดูที่ splitTermQuoteFirst
:
private void splitTermQuoteFirst() { int origStart = offsetAttr.startOffset(); int origEnd = offsetAttr.endOffset(); offsetAttr.setOffset(origStart, origStart + 1); typeAttr.setType(QUOTE_START_TYPE); termBufferAttr.setLength(1); prepareExtraTerm(origStart + 1, origEnd, TypeAttribute.DEFAULT_TYPE); }
เนื่องจากเราต้องการแยกโทเค็นนี้ด้วยเครื่องหมายคำพูดที่ปรากฏในสตรีมก่อน เราจึงตัดทอนบัฟเฟอร์ด้วยการตั้งค่าความยาวเป็นหนึ่ง (กล่าวคือ อักขระหนึ่งตัว กล่าวคือ เครื่องหมายคำพูด) เราปรับออฟเซ็ตตามนั้น (เช่น ชี้ไปที่ใบเสนอราคาในเอกสารต้นฉบับ) และตั้งค่าประเภทเป็นใบเสนอราคาเริ่มต้น
prepareExtraTerm
จะตั้งค่าฟิลด์ extra*
และตั้งค่า emitExtraToken
ให้เป็นจริง มันถูกเรียกด้วยการออฟเซ็ตที่ชี้ไปที่โทเค็น "พิเศษ" (เช่น คำที่ตามหลังเครื่องหมายคำพูด)
QuotationTokenFilter
ทั้งหมดมีอยู่ใน GitHub
ในทางกลับกัน แม้ว่าตัวกรองนี้จะสร้างโทเค็นพิเศษเพียงตัวเดียว แต่วิธีการนี้สามารถขยายเพื่อแนะนำโทเค็นพิเศษจำนวนตามอำเภอใจได้ เพียงแทนที่ฟิลด์ extra*
ด้วยคอลเลกชั่น หรือดีกว่านั้นคืออาร์เรย์ที่มีความยาวคงที่ หากมีการจำกัดจำนวนของโทเค็นพิเศษที่สามารถผลิตได้ ดู SynonymFilter
และคลาสภายใน PendingInput
สำหรับตัวอย่างนี้

การใช้โทเค็นการอ้างสิทธิ์และการทำเครื่องหมายบทสนทนา
ตอนนี้เราได้ใช้ความพยายามอย่างเต็มที่ในการเพิ่มคำพูดเหล่านั้นลงในสตรีมโทเค็นแล้ว เราสามารถใช้คำพูดเหล่านั้นเพื่อกำหนดส่วนต่างๆ ของบทสนทนาในข้อความได้
เนื่องจากเป้าหมายสุดท้ายของเราคือการปรับผลการค้นหาโดยพิจารณาว่าคำเหล่านั้นเป็นส่วนหนึ่งของบทสนทนาหรือไม่ เราจึงต้องแนบข้อมูลเมตากับคำเหล่านั้น Lucene จัดเตรียม PayloadAttribute
เพื่อจุดประสงค์นี้ เพย์โหลดคืออาร์เรย์ไบต์ที่เก็บไว้ข้างๆ เงื่อนไขในดัชนี และสามารถอ่านได้ในภายหลังระหว่างการค้นหา ซึ่งหมายความว่าแฟล็กของเราจะกินพื้นที่ทั้งไบต์อย่างสิ้นเปลือง ดังนั้นเพย์โหลดเพิ่มเติมจึงสามารถนำไปใช้เป็นแฟล็กบิตเพื่อประหยัดพื้นที่ได้
ด้านล่างนี้คือตัวกรองใหม่ DialoguePayloadTokenFilter
ซึ่งถูกเพิ่มไปยังส่วนท้ายสุดของไปป์ไลน์การวิเคราะห์ มันแนบเพย์โหลดที่ระบุว่าโทเค็นเป็นส่วนหนึ่งของบทสนทนาหรือไม่
public class DialoguePayloadTokenFilter extends TokenFilter { private final TypeAttribute typeAttr = getAttribute(TypeAttribute.class); private final PayloadAttribute payloadAttr = addAttribute(PayloadAttribute.class); private static final BytesRef PAYLOAD_DIALOGUE = new BytesRef(new byte[] { 1 }); private static final BytesRef PAYLOAD_NOT_DIALOGUE = new BytesRef(new byte[] { 0 }); private boolean withinDialogue; protected DialoguePayloadTokenFilter(TokenStream input) { super(input); } @Override public void reset() throws IOException { this.withinDialogue = false; super.reset(); } @Override public boolean incrementToken() throws IOException { boolean hasNext = input.incrementToken(); while(hasNext) { boolean isStartQuote = QuotationTokenFilter .QUOTE_START_TYPE.equals(typeAttr.type()); boolean isEndQuote = QuotationTokenFilter .QUOTE_END_TYPE.equals(typeAttr.type()); if (isStartQuote) { withinDialogue = true; hasNext = input.incrementToken(); } else if (isEndQuote) { withinDialogue = false; hasNext = input.incrementToken(); } else { break; } } if (hasNext) { payloadAttr.setPayload(withinDialogue ? PAYLOAD_DIALOGUE : PAYLOAD_NOT_DIALOGUE); } return hasNext; } }
เนื่องจากตัวกรองนี้จำเป็นต้องรักษาสถานะเพียงส่วนเดียว withinDialogue
จึงง่ายกว่ามาก อัญประกาศเริ่มต้นระบุว่าขณะนี้เราอยู่ในส่วนของบทสนทนา ในขณะที่อัญประกาศสิ้นสุดระบุว่าส่วนของบทสนทนาสิ้นสุดลงแล้ว ไม่ว่าในกรณีใด โทเค็นของใบเสนอราคาจะถูกยกเลิกโดยการโทรครั้งที่สองไปที่ incrementToken
ดังนั้น โทเค็นของใบ เสนอราคาเริ่มต้น หรือโทเค็นการ สิ้นสุด จะไม่ไหลผ่านขั้นตอนนี้ในไปป์ไลน์
ตัวอย่างเช่น DialoguePayloadTokenFilter
จะเปลี่ยนสตรีมโทเค็น:
[the], [program], [printed], ["], [hello], [world], ["]`
ในสตรีมใหม่นี้:
[the][0], [program][0], [printed][0], [hello][1], [world][1]
ผูก Tokenizers และตัวกรองเข้าด้วยกัน
ตัว Analyzer
มีหน้าที่ในการประกอบไปป์ไลน์การวิเคราะห์ โดยทั่วไปจะรวม Tokenizer
เข้ากับชุดของ TokenFilter
ตัว Analyzer
ยังสามารถกำหนดวิธีที่ไปป์ไลน์นั้นถูกใช้ซ้ำระหว่างการวิเคราะห์ เราไม่จำเป็นต้องกังวลเรื่องนั้นเนื่องจากส่วนประกอบของเราไม่ต้องการอะไรนอกจากการเรียกเพื่อ reset()
ระหว่างการใช้งาน ซึ่ง Lucene จะทำเสมอ เราแค่ต้องทำแอสเซมบลีโดยใช้ Analyzer#createComponents(String)
:
public class DialogueAnalyzer extends Analyzer { @Override protected TokenStreamComponents createComponents(String fieldName) { QuotationTokenizer tokenizer = new QuotationTokenizer(); TokenFilter filter = new QuotationTokenFilter(tokenizer); filter = new LowerCaseFilter(filter); filter = new StopFilter(filter, StopAnalyzer.ENGLISH_STOP_WORDS_SET); filter = new DialoguePayloadTokenFilter(filter); return new TokenStreamComponents(tokenizer, filter); } }
ดังที่เราเห็นก่อนหน้านี้ ตัวกรองมีการอ้างอิงกลับไปยังขั้นตอนก่อนหน้าในไปป์ไลน์ นั่นคือวิธีที่เราสร้างอินสแตนซ์เหล่านี้ นอกจากนี้เรายังเลื่อนตัวกรองบางตัวจาก StandardAnalyzer
: LowerCaseFilter
และ StopFilter
สองตัวนี้ต้องอยู่หลัง QuotationTokenFilter
เพื่อให้แน่ใจว่ามีการแยกเครื่องหมายคำพูดใดๆ เราสามารถมีความยืดหยุ่นมากขึ้นในการจัดวาง DialoguePayloadTokenFilter
ของเรา เนื่องจากไม่ว่าที่ไหนก็ตามหลังจาก QuotationTokenFilter
จะทำได้ เราใส่ไว้หลัง StopFilter
เพื่อหลีกเลี่ยงไม่ให้เสียเวลาใส่ข้อมูลการสนทนาลงในคำหยุดที่จะถูกลบออกในที่สุด
นี่คือภาพการทำงานของไปป์ไลน์ใหม่ของเรา (ลบด้วยส่วนต่างๆ ของไปป์ไลน์มาตรฐานที่เราได้ลบออกหรือเห็นแล้ว):
ตอนนี้สามารถใช้ DialogueAnalyzer
ได้เหมือนกับตัว Analyzer
สต็อกอื่นๆ และตอนนี้ เราสามารถสร้างดัชนีและดำเนินการค้นหาต่อไปได้
ค้นหาข้อความแบบเต็มของบทสนทนา
หากเราต้องการค้นหาเพียงบทสนทนา เราสามารถทิ้งโทเค็นทั้งหมดที่อยู่นอกใบเสนอราคา และเราก็ทำเสร็จแล้ว โดยปล่อยให้โทเค็นดั้งเดิมทั้งหมดไม่เสียหาย เราได้ให้ความยืดหยุ่นในการดำเนินการค้นหาที่คำนึงถึงบทสนทนา หรือปฏิบัติต่อบทสนทนาเหมือนกับส่วนอื่นๆ ของข้อความ
พื้นฐานของการสืบค้นดัชนี Lucene ได้รับการจัดทำเป็นเอกสารไว้อย่างดี เพื่อจุดประสงค์ของเรา ก็เพียงพอแล้วที่จะรู้ว่าการสืบค้นประกอบด้วยอ็อบเจ็กต์ Term
ที่ติดอยู่กับตัวดำเนินการ เช่น MUST
หรือ SHOULD
พร้อมกับเอกสารที่ตรงกันตามเงื่อนไขเหล่านั้น เอกสารที่ตรงกันจะได้รับคะแนนตามออบเจกต์ Similarity
ที่กำหนดค่าได้ และผลลัพธ์เหล่านั้นสามารถเรียงลำดับตามคะแนน กรอง หรือจำกัด ตัวอย่างเช่น Lucene ช่วยให้เราสามารถค้นหาเอกสารสิบอันดับแรกที่ต้องมีทั้งคำ [hello]
และ [world]
การปรับแต่งผลการค้นหาตามบทสนทนาสามารถทำได้โดยการปรับคะแนนของเอกสารตามส่วนของข้อมูล จุดขยายแรกสำหรับสิ่งนี้จะอยู่ใน Similarity
ซึ่งรับผิดชอบเงื่อนไขการจับคู่การชั่งน้ำหนักและการให้คะแนน
ความเหมือนและการให้คะแนน
ตามค่าเริ่มต้น การสืบค้นข้อมูลจะใช้ DefaultSimilarity
ซึ่งจะให้น้ำหนักเงื่อนไขตามความถี่ที่ปรากฏในเอกสาร เป็นจุดต่อขยายที่ดีสำหรับการปรับน้ำหนัก ดังนั้นเราจึงขยายเพื่อให้คะแนนเอกสารตามน้ำหนักบรรทุกด้วย วิธีการ DefaultSimilarity#scorePayload
มีให้เพื่อจุดประสงค์นี้:
public final class DialogueAwareSimilarity extends DefaultSimilarity { @Override public float scorePayload(int doc, int start, int end, BytesRef payload) { if (payload.bytes[payload.offset] == 0) { return 0.0f; } return 1.0f; } }
DialogueAwareSimilarity
ให้คะแนนเพย์โหลดที่ไม่ใช่การสนทนาเป็นศูนย์ เนื่องจากแต่ละ Term
สามารถจับคู่ได้หลายครั้ง จึงอาจมีคะแนนเพย์โหลดหลายคะแนน การตีความคะแนนเหล่านี้จนถึงการใช้งาน Query
ให้ความสนใจเป็นพิเศษกับ BytesRef
ที่มีเพย์โหลด: เราต้องตรวจสอบไบต์ที่ offset
เนื่องจากเราไม่สามารถสรุปได้ว่าอาร์เรย์ไบต์เป็นเพย์โหลดเดียวกันกับที่เราเก็บไว้ก่อนหน้านี้ เมื่ออ่านดัชนี Lucene จะไม่เปลืองหน่วยความจำในการจัดสรรอาร์เรย์ไบต์แยกต่างหากสำหรับการเรียก scorePayload
ดังนั้นเราจึงได้รับการอ้างอิงไปยังอาร์เรย์ไบต์ที่มีอยู่ เมื่อเขียนโค้ดกับ Lucene API จะต้องคำนึงถึงประสิทธิภาพเป็นหลัก ดีกว่าความสะดวกของนักพัฒนา
ตอนนี้เรามีการใช้งาน Similarity
ใหม่แล้ว จะต้องตั้งค่าใน IndexSearcher
ที่ใช้ในการดำเนินการค้นหา:
IndexSearcher searcher = new IndexSearcher(... reader for index ...); searcher.setSimilarity(new DialogueAwareSimilarity());
แบบสอบถามและข้อกำหนด
ขณะนี้ IndexSearcher
ของเราสามารถให้คะแนนเพย์โหลดได้ เรายังต้องสร้างคิวรีที่รับรู้เพย์โหลดด้วย PayloadTermQuery
สามารถใช้เพื่อจับคู่ Term
เดียวในขณะที่ตรวจสอบ payloads ของการจับคู่เหล่านั้น:
PayloadTermQuery helloQuery = new PayloadTermQuery(new Term("body", "hello"), new AveragePayloadFunction());
แบบสอบถามนี้ตรงกับคำว่า [hello]
ภายในฟิลด์ เนื้อหา (จำได้ว่านี่คือที่ที่เราใส่เนื้อหาของเอกสาร) เราต้องจัดเตรียมฟังก์ชันเพื่อคำนวณคะแนนเพย์โหลดสุดท้ายจากการจับคู่คำศัพท์ทั้งหมด ดังนั้นเราจึงเสียบ AveragePayloadFunction
ซึ่งเฉลี่ยคะแนนเพย์โหลดทั้งหมด ตัวอย่างเช่น หากคำว่า [hello]
เกิดขึ้นภายในบทสนทนาสองครั้งและนอกบทสนทนาหนึ่งครั้ง คะแนนน้ำหนักบรรทุกสุดท้ายจะเป็น ²⁄₃ คะแนนเพย์โหลดสุดท้ายนี้จะถูกคูณด้วยคะแนนที่ได้รับจาก DefaultSimilarity
สำหรับเอกสารทั้งหมด
เราใช้ค่าเฉลี่ยเนื่องจากเราต้องการไม่เน้นผลการค้นหาที่มีคำจำนวนมากปรากฏอยู่นอกบทสนทนา และสร้างคะแนนเป็นศูนย์สำหรับเอกสารที่ไม่มีคำใดๆ ในบทสนทนาเลย
นอกจากนี้เรายังสามารถเขียนออบเจ็กต์ PayloadTermQuery
หลายรายการโดยใช้ BooleanQuery
หากเราต้องการค้นหาคำศัพท์หลายคำที่อยู่ในกล่องโต้ตอบ (โปรดทราบว่าลำดับของคำนั้นไม่เกี่ยวข้องในการสืบค้นนี้ แม้ว่าประเภทการสืบค้นอื่นๆ จะรับรู้ตำแหน่ง):
PayloadTermQuery worldQuery = new PayloadTermQuery(new Term("body", "world"), new AveragePayloadFunction()); BooleanQuery query = new BooleanQuery(); query.add(helloQuery, Occur.MUST); query.add(worldQuery, Occur.MUST);
เมื่อดำเนินการค้นหานี้ เราจะเห็นว่าโครงสร้างการสืบค้นและการใช้งานความคล้ายคลึงกันทำงานร่วมกันอย่างไร:
การดำเนินการแบบสอบถามและคำอธิบาย
ในการดำเนินการค้นหา เราส่งต่อไปยัง IndexSearcher
:
TopScoreDocCollector collector = TopScoreDocCollector.create(10); searcher.search(query, new PositiveScoresOnlyCollector(collector)); TopDocs topDocs = collector.topDocs();
วัตถุ Collector
ใช้เพื่อเตรียมการรวบรวมเอกสารที่ตรงกัน
ตัวสะสมสามารถประกอบขึ้นเพื่อให้เกิดการจัดเรียง การจำกัด และการกรองรวมกันได้ ตัวอย่างเช่น เอกสารการให้คะแนนสิบอันดับแรกที่มีคำศัพท์อย่างน้อยหนึ่งคำในบทสนทนา เราได้รวม TopScoreDocCollector
และ PositiveScoresOnlyCollector
การให้คะแนนในเชิงบวกเท่านั้นทำให้มั่นใจได้ว่าคะแนนศูนย์ที่ตรงกัน (กล่าวคือ คะแนนที่ไม่มีเงื่อนไขในบทสนทนา) จะถูกกรองออก
หากต้องการดูการใช้งานจริงของข้อความค้นหานี้ เราสามารถดำเนินการได้ จากนั้นใช้ IndexSearcher#explain
เพื่อดูว่าเอกสารแต่ละรายการได้รับคะแนนอย่างไร:
for (ScoreDoc result : topDocs.scoreDocs) { Document doc = searcher.doc(result.doc, Collections.singleton("title")); System.out.println("--- document " + doc.getField("title").stringValue() + " ---"); System.out.println(this.searcher.explain(query, result.doc)); }
ที่นี่ เราวนซ้ำ ID เอกสารใน TopDocs
ที่ได้รับจากการค้นหา เรายังใช้ IndexSearcher#doc
เพื่อดึงฟิลด์ชื่อสำหรับแสดงผล สำหรับข้อความค้นหา "hello"
ของเรา ผลลัพธ์ที่ได้คือ:
--- Document whelv10.txt --- 0.072256625 = (MATCH) btq, product of: 0.072256625 = weight(body:hello in 7336) [DialogueAwareSimilarity], result of: 0.072256625 = fieldWeight in 7336, product of: 2.345208 = tf(freq=5.5), with freq of: 5.5 = phraseFreq=5.5 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.009765625 = fieldNorm(doc=7336) 1.0 = AveragePayloadFunction.docScore() --- Document daved10.txt --- 0.061311778 = (MATCH) btq, product of: 0.061311778 = weight(body:hello in 6873) [DialogueAwareSimilarity], result of: 0.061311778 = fieldWeight in 6873, product of: 3.3166249 = tf(freq=11.0), with freq of: 11.0 = phraseFreq=11.0 3.1549776 = idf(docFreq=2873, maxDocs=24796) 0.005859375 = fieldNorm(doc=6873) 1.0 = AveragePayloadFunction.docScore() ...
แม้ว่าผลลัพธ์จะเต็มไปด้วยศัพท์แสง แต่เราสามารถเห็นได้ว่าการนำ Similarity
ที่กำหนดเองของเราไปใช้ในการให้คะแนนอย่างไร และ MaxPayloadFunction
สร้างตัวคูณ 1.0
สำหรับการแข่งขันเหล่านี้อย่างไร นี่แสดงว่าเพย์โหลดโหลดและทำคะแนนแล้ว และการแข่งขันทั้งหมดของ "Hello"
เกิดขึ้นในกล่องโต้ตอบ ดังนั้นผลลัพธ์เหล่านี้จึงอยู่ที่ด้านบนสุดที่เราคาดหวัง
นอกจากนี้ยังควรค่าแก่การชี้ให้เห็นว่าดัชนีสำหรับ Project Gutenberg ที่มีเพย์โหลดมีขนาดเกือบสี่กิกะไบต์ แต่ในเครื่องพัฒนาเจียมเนื้อเจียมตัวของฉัน การสืบค้นจะเกิดขึ้นทันที เราไม่ได้เสียสละความเร็วใดๆ เพื่อให้บรรลุเป้าหมายการค้นหาของเรา
ห่อ
Lucene เป็นไลบรารีค้นหาข้อความแบบเต็มที่มีประสิทธิภาพและสร้างขึ้นเพื่อวัตถุประสงค์ที่นำกระแสอักขระดิบมารวมกลุ่มเป็นโทเค็น และคงไว้เป็นเงื่อนไขในดัชนี สามารถสืบค้นดัชนีนั้นได้อย่างรวดเร็วและให้ผลลัพธ์ที่ได้รับการจัดอันดับ และให้โอกาสเพียงพอสำหรับการขยายในขณะที่ยังคงประสิทธิภาพไว้
ด้วยการใช้ Lucene โดยตรงในแอปพลิเคชันของเรา หรือเป็นส่วนหนึ่งของเซิร์ฟเวอร์ เราสามารถทำการค้นหาข้อความแบบเต็มในแบบเรียลไทม์ผ่านเนื้อหาหลายกิกะไบต์ นอกจากนี้ ด้วยการวิเคราะห์และการให้คะแนนแบบกำหนดเอง เราสามารถใช้ประโยชน์จากคุณลักษณะเฉพาะโดเมนในเอกสารของเรา เพื่อปรับปรุงความเกี่ยวข้องของผลลัพธ์หรือการสืบค้นข้อมูลที่กำหนดเอง
รายการรหัสแบบเต็มสำหรับบทช่วยสอน Lucene นี้มีอยู่ใน GitHub repo มีสองแอปพลิเคชัน: LuceneIndexerApp
สำหรับสร้างดัชนี และ LuceneQueryApp
สำหรับดำเนินการค้นหา
คลังข้อมูลของ Project Gutenberg ซึ่งสามารถรับเป็นภาพดิสก์ผ่าน BitTorrent มีหนังสือน่าอ่านมากมาย (ไม่ว่าจะกับ Lucene หรือแบบเก่า)
มีความสุขในการจัดทำดัชนี!